gmao/tests/unit/test_day_planner.py

145 lines
6.9 KiB
Python
Raw Normal View History

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