"""Tests du moteur de validation — un cas passant et un cas déclenchant par règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3).""" import datetime as dt from app.models import AvailabilityStatus from app.validation import ( AvailabilityDeclaration, DayPartWindow, Severity, ShiftInfo, ValidationInput, ViolationCode, has_blocking, validate, ) DAY_PARTS = ( DayPartWindow("MORNING", dt.time(6, 0), dt.time(13, 0), 0), DayPartWindow("AFTERNOON", dt.time(13, 0), dt.time(19, 0), 1), DayPartWindow("EVENING", dt.time(19, 0), dt.time(6, 0), 2), ) def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime: return dt.datetime(2026, 7, day, hour, minute) def _shift(sid: str, start: dt.datetime, end: dt.datetime, skills=()) -> ShiftInfo: return ShiftInfo( id=sid, starts_at=start, ends_at=end, required_skill_codes=frozenset(skills) ) def _codes(violations) -> list[ViolationCode]: return [v.code for v in violations] def _avail(day: int, code: str, status: AvailabilityStatus) -> AvailabilityDeclaration: return AvailabilityDeclaration(dt.date(2026, 7, day), code, status) # --------------------------------------------------------------------------- # # SHIFT_OVERLAP # # --------------------------------------------------------------------------- # def test_overlap_pass_when_shifts_are_adjacent(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),), day_parts=DAY_PARTS, availabilities=( _avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE), ), ) assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data)) def test_overlap_triggers_and_is_blocking(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), ) violations = validate(data) overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP] assert len(overlap) == 1 assert overlap[0].severity is Severity.BLOCKING assert overlap[0].params["other_shift_id"] == "e" assert has_blocking(violations) is True # --------------------------------------------------------------------------- # # OUTSIDE_AVAILABILITY # # --------------------------------------------------------------------------- # def test_availability_pass_when_declared_available(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) assert validate(data) == [] def test_availability_pass_when_declared_if_needed(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.IF_NEEDED),), ) assert validate(data) == [] def test_availability_triggers_when_slot_unavailable(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),), ) violations = validate(data) assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY] assert violations[0].severity is Severity.WARNING assert violations[0].params["status"] == "UNAVAILABLE" assert violations[0].params["day_part_code"] == "AFTERNOON" def test_availability_triggers_when_slot_undeclared(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), day_parts=DAY_PARTS, ) violations = validate(data) assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY] assert violations[0].params["status"] == "UNDECLARED" def test_availability_covers_two_slots_and_flags_only_the_missing_one(): # 18:00 -> 22:00 recoupe AFTERNOON (jusqu'à 19:00) et EVENING (dès 19:00). data = ValidationInput( candidate_shift=_shift("c", _dt(10, 18), _dt(10, 22)), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) violations = validate(data) assert len(violations) == 1 assert violations[0].params["day_part_code"] == "EVENING" def test_availability_midnight_crossing_shift_uses_evening_slot_only(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)), day_parts=DAY_PARTS, availabilities=(_avail(10, "EVENING", AvailabilityStatus.AVAILABLE),), ) assert validate(data) == [] def test_availability_rule_skipped_without_day_parts(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), ) assert ViolationCode.OUTSIDE_AVAILABILITY not in _codes(validate(data)) # --------------------------------------------------------------------------- # # DAILY_HOURS_EXCEEDED # # --------------------------------------------------------------------------- # def test_daily_hours_pass_at_exactly_eight(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile day_parts=DAY_PARTS, availabilities=( _avail(10, "MORNING", AvailabilityStatus.AVAILABLE), _avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE), ), ) assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data)) def test_daily_hours_triggers_above_eight(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min day_parts=DAY_PARTS, ) violations = [ v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED ] assert len(violations) == 1 assert violations[0].params["total_minutes"] == 510 assert violations[0].params["date"] == "2026-07-10" def test_daily_hours_day_starts_at_first_day_part_boundary(): # Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui # commence à 03:00 le 11 appartient à la journée du 10. data = ValidationInput( candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10 existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10 day_parts=DAY_PARTS, ) violations = [ v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED ] assert len(violations) == 1 assert violations[0].params["date"] == "2026-07-10" def test_daily_hours_ignores_other_days(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10 existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9 day_parts=DAY_PARTS, ) assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data)) # --------------------------------------------------------------------------- # # INSUFFICIENT_REST # # --------------------------------------------------------------------------- # def test_rest_pass_with_more_than_eight_hours_gap(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)), existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h day_parts=DAY_PARTS, availabilities=(_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),), ) assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data)) def test_rest_pass_at_exactly_eight_hours(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)), existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile ) assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data)) def test_rest_triggers_for_shift_before(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)), existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h ) violations = [ v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST ] assert len(violations) == 1 assert violations[0].params["direction"] == "BEFORE" assert violations[0].params["gap_minutes"] == 240 def test_rest_triggers_for_shift_after(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)), existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h ) violations = [ v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST ] assert len(violations) == 1 assert violations[0].params["direction"] == "AFTER" def test_back_to_back_shifts_are_rest_violation_not_overlap(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),), ) codes = _codes(validate(data)) assert ViolationCode.SHIFT_OVERLAP not in codes assert ViolationCode.INSUFFICIENT_REST in codes # --------------------------------------------------------------------------- # # MISSING_REQUIRED_SKILL # # --------------------------------------------------------------------------- # def test_skill_pass_when_volunteer_has_all_required(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]), volunteer_skill_codes=frozenset({"SST", "PERMIS_B"}), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) assert validate(data) == [] def test_skill_triggers_for_each_missing_code_sorted(): data = ValidationInput( candidate_shift=_shift( "c", _dt(10, 14), _dt(10, 18), skills=["SOUND", "SST", "PERMIS_B"] ), volunteer_skill_codes=frozenset({"SST"}), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) violations = validate(data) assert [v.params["skill_code"] for v in violations] == ["PERMIS_B", "SOUND"] assert all(v.severity is Severity.WARNING for v in violations) # --------------------------------------------------------------------------- # # Combinaisons # # --------------------------------------------------------------------------- # def test_clean_assignment_has_no_violation(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]), volunteer_skill_codes=frozenset({"SST"}), existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) assert validate(data) == [] def test_multiple_rules_fire_together(): data = ValidationInput( candidate_shift=_shift( "c", _dt(10, 14), _dt(10, 18), skills=["SOUND"] ), volunteer_skill_codes=frozenset(), existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement day_parts=DAY_PARTS, availabilities=( _avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE), ), ) codes = set(_codes(validate(data))) assert { ViolationCode.SHIFT_OVERLAP, ViolationCode.OUTSIDE_AVAILABILITY, ViolationCode.MISSING_REQUIRED_SKILL, } <= codes assert has_blocking(validate(data)) is True def test_only_warnings_is_not_blocking(): data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]), volunteer_skill_codes=frozenset(), day_parts=DAY_PARTS, availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),), ) violations = validate(data) assert _codes(violations) == [ViolationCode.MISSING_REQUIRED_SKILL] assert has_blocking(violations) is False