"""Tests du solveur (suggestions, jamais d'affectation automatique).""" import datetime as dt from app.solver.engine import solve from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.validation.models import AvailabilityWindow, ShiftInfo def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime: return dt.datetime(2026, 7, day, hour, minute) def _shift( sid: str = "s1", *, position_id: str = "p1", start: dt.datetime | None = None, end: dt.datetime | None = None, min_staff=1, ideal_staff=1, already_assigned=0, ) -> SolverShift: return SolverShift( id=sid, position_id=position_id, starts_at=start or _dt(10, 14), ends_at=end or _dt(10, 18), min_staff=min_staff, ideal_staff=ideal_staff, already_assigned=already_assigned, ) def _volunteer( vid: str, *, availability=(), wished_position_ids=frozenset(), existing_shifts=(), existing_minutes=0, ) -> SolverVolunteer: return SolverVolunteer( id=vid, availability=tuple(availability), wished_position_ids=frozenset(wished_position_ids), existing_shifts=tuple(existing_shifts), existing_minutes=existing_minutes, ) def _full_day(day: int) -> AvailabilityWindow: return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0)) def _by_shift(solution, shift_id: str) -> tuple[str, ...]: for s in solution.suggestions: if s.shift_id == shift_id: return s.volunteer_ids return () def test_no_variables_returns_empty_solution(): data = SolverInput(shifts=(), volunteers=()) assert solve(data).suggestions == () def test_fully_staffed_shift_gets_no_suggestion(): shift = _shift(ideal_staff=1, already_assigned=1) volunteer = _volunteer("v1", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,))) assert solution.suggestions == () def test_picks_the_only_free_volunteer_over_a_busy_one(): shift = _shift() busy = _volunteer( "busy", availability=[_full_day(10)], existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))], ) free = _volunteer("free", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free))) assert _by_shift(solution, "s1") == ("free",) def test_does_not_exceed_ideal_staff_cap(): shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place a = _volunteer("a", availability=[_full_day(10)]) b = _volunteer("b", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b))) assert len(_by_shift(solution, "s1")) == 1 def test_prefers_volunteer_with_matching_position_wish(): shift = _shift(position_id="regie") plain = _volunteer("plain", availability=[_full_day(10)]) wisher = _volunteer( "wisher", availability=[_full_day(10)], wished_position_ids={"regie"} ) solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher))) assert _by_shift(solution, "s1") == ("wisher",) def test_prefers_fully_available_volunteer_over_partially_available(): shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) full = _volunteer("full", availability=[_full_day(10)]) partial = _volunteer( "partial", availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))], ) solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial))) assert _by_shift(solution, "s1") == ("full",) def test_ranks_both_candidates_when_two_slots_are_open(): shift = _shift(ideal_staff=2) full = _volunteer("full", availability=[_full_day(10)]) partial = _volunteer( "partial", availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))], ) solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial))) assert _by_shift(solution, "s1") == ("full", "partial") def test_never_double_books_a_volunteer_across_two_new_suggestions(): # Deux créneaux qui se chevauchent, un seul bénévole disponible pour les # deux : le solveur ne le propose qu'une fois. shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18)) shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20)) only = _volunteer("only", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,))) picked_a = _by_shift(solution, "a") picked_b = _by_shift(solution, "b") assert len(picked_a) + len(picked_b) == 1 def test_avoids_volunteer_who_would_exceed_eight_hours_that_day(): shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h tired = _volunteer( "tired", availability=[_full_day(10)], existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 12))], # 6 h déjà ) fresh = _volunteer("fresh", availability=[_full_day(10)]) solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh))) assert _by_shift(solution, "s1") == ("fresh",)