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 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