2026-08-23 12:13:47 +02:00
|
|
|
from datetime import date, time
|
|
|
|
|
|
|
|
|
|
from app_new.core.services.day_planner import DayPlanner, PlanningCandidate, WorkWindow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_proposal_respects_pause_and_is_deterministic(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
DayPlanner,
|
|
|
|
|
"work_windows",
|
|
|
|
|
staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(12)), WorkWindow(time(13, 30), time(17))]),
|
|
|
|
|
)
|
|
|
|
|
candidates = [
|
|
|
|
|
PlanningCandidate("scheduled_task", 2, "Contrôle B", duration_minutes=45),
|
|
|
|
|
PlanningCandidate("scheduled_task", 1, "Contrôle A", duration_minutes=45),
|
|
|
|
|
]
|
|
|
|
|
first = DayPlanner.propose(date(2026, 8, 24), candidates)
|
|
|
|
|
assert [(item.proposed_start, item.proposed_end) for item in first] == [
|
|
|
|
|
(time(8), time(8, 45)), (time(8, 45), time(9, 30))
|
|
|
|
|
]
|
|
|
|
|
assert all(item.proposed_end <= time(12) for item in first)
|
|
|
|
|
second = DayPlanner.propose(date(2026, 8, 24), [
|
|
|
|
|
PlanningCandidate("scheduled_task", 2, "Contrôle B", duration_minutes=45),
|
|
|
|
|
PlanningCandidate("scheduled_task", 1, "Contrôle A", duration_minutes=45),
|
|
|
|
|
])
|
|
|
|
|
assert [(item.proposed_start, item.proposed_end) for item in first] == [
|
|
|
|
|
(item.proposed_start, item.proposed_end) for item in second
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fixed_event_is_preserved(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(17))]))
|
|
|
|
|
fixed = PlanningCandidate("company", 1, "Entreprise", constraint="fixed", fixed_start=time(9), fixed_end=time(10))
|
|
|
|
|
flexible = PlanningCandidate("admin", 2, "Commande", duration_minutes=30)
|
|
|
|
|
result = DayPlanner.propose(date(2026, 8, 24), [flexible, fixed])
|
|
|
|
|
assert next(item for item in result if item.source_id == 1).proposed_start == time(9)
|
|
|
|
|
assert next(item for item in result if item.source_id == 2).proposed_end <= time(9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_task_too_long_is_marked_to_reschedule(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(9))]))
|
|
|
|
|
result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche", duration_minutes=90)])
|
|
|
|
|
assert result[0].status == "à replanifier"
|
|
|
|
|
assert result[0].proposed_start is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_work_window_explains_missing_schedule(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: []))
|
|
|
|
|
result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")])
|
|
|
|
|
assert "horaire de travail" in result[0].explanation
|
2026-08-23 13:05:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _full_day(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(12)), WorkWindow(time(13, 30), time(17))]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_urgent_candidate_is_ordered_before_flexible(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [
|
|
|
|
|
PlanningCandidate("admin", 1, "Flexible", priority=4, duration_minutes=20),
|
|
|
|
|
PlanningCandidate("intervention", 2, "Fuite", task_type="emergency", constraint="urgent", priority=1, duration_minutes=20),
|
|
|
|
|
])
|
|
|
|
|
assert rows[0].source_id == 2
|
|
|
|
|
assert "créneau" in rows[0].explanation or rows[0].status == "proposé"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deadline_candidate_does_not_finish_after_latest_end(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
candidate = PlanningCandidate("admin", 1, "Commande", constraint="deadline", duration_minutes=60, latest_end=time(10))
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
|
|
|
|
|
assert rows[0].proposed_end <= time(10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deadline_candidate_is_marked_when_no_slot_before_deadline(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
candidate = PlanningCandidate("admin", 1, "Commande", constraint="deadline", duration_minutes=30, earliest_start=time(11), latest_end=time(11, 15))
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
|
|
|
|
|
assert rows[0].status == "à replanifier"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fixed_overlap_is_visible_as_conflict(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [
|
|
|
|
|
PlanningCandidate("company", 1, "Entreprise", task_type="external_company", constraint="fixed", fixed_start=time(9), fixed_end=time(10)),
|
|
|
|
|
PlanningCandidate("intervention", 2, "Urgence", task_type="emergency", constraint="fixed", fixed_start=time(9, 30), fixed_end=time(10, 15)),
|
|
|
|
|
])
|
|
|
|
|
assert rows[1].status == "conflit"
|
|
|
|
|
assert "chevauche" in rows[1].explanation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_geographic_labels_are_kept_for_explanation(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [
|
|
|
|
|
PlanningCandidate("scheduled_task", 1, "Salle", building_name="A", zone_name="RDC", room_name="101"),
|
|
|
|
|
PlanningCandidate("scheduled_task", 2, "Zone", building_name="A", zone_name="RDC", room_name="102"),
|
|
|
|
|
])
|
|
|
|
|
assert rows[0].location_label == "A > RDC > 101"
|
|
|
|
|
assert rows[1].location_label == "A > RDC > 102"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_location_is_explicit(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
candidate = PlanningCandidate("admin", 1, "Dossier")
|
|
|
|
|
assert DayPlanner.propose(date(2026, 8, 24), [candidate])[0].location_label == "Localisation non renseignée"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_room_occupation_rejects_slot(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
monkeypatch.setattr("app_new.core.services.day_planner._resolved_room_occupancy_batch", lambda day, room_ids: {12: [(time(8), time(17), object())]})
|
|
|
|
|
candidate = PlanningCandidate("scheduled_task", 1, "Salle occupée", room_id=12, duration_minutes=30)
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
|
|
|
|
|
assert rows[0].status == "à replanifier"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_room_without_id_can_use_work_window(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
candidate = PlanningCandidate("scheduled_task", 1, "Sans salle", duration_minutes=30)
|
|
|
|
|
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
|
|
|
|
|
assert rows[0].status == "proposé"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_alternative_slots_exclude_fixed_event(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
fixed = PlanningCandidate("company", 1, "Fixe", constraint="fixed", fixed_start=time(8), fixed_end=time(9))
|
|
|
|
|
target = PlanningCandidate("admin", 2, "À replacer", duration_minutes=30)
|
|
|
|
|
proposed = DayPlanner.propose(date(2026, 8, 24), [fixed, target])
|
|
|
|
|
slots = DayPlanner.alternative_slots(date(2026, 8, 24), target, proposed)
|
|
|
|
|
assert slots and slots[0][0] >= time(9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_alternative_slots_are_limited_to_three(monkeypatch):
|
|
|
|
|
_full_day(monkeypatch)
|
|
|
|
|
target = PlanningCandidate("admin", 2, "À replacer", duration_minutes=15)
|
|
|
|
|
slots = DayPlanner.alternative_slots(date(2026, 8, 24), target, [target], limit=3)
|
|
|
|
|
assert len(slots) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_user_parameter_is_forwarded(monkeypatch):
|
|
|
|
|
seen = {}
|
|
|
|
|
def windows(day, user_id=None):
|
|
|
|
|
seen["user_id"] = user_id
|
|
|
|
|
return [WorkWindow(time(8), time(9))]
|
|
|
|
|
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(windows))
|
|
|
|
|
DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")], user_id=42)
|
|
|
|
|
assert seen["user_id"] == 42
|