"""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.validation import ( 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) -> ShiftInfo: return ShiftInfo(id=sid, starts_at=start, ends_at=end) def _codes(violations) -> list[ViolationCode]: return [v.code for v in violations] # --------------------------------------------------------------------------- # # 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)),), ) assert validate(data) == [] def test_overlap_triggers_as_a_warning_only(): 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) assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP] # Deux postes en même temps restent possibles : avertissement seulement. assert violations[0].severity is Severity.WARNING assert violations[0].params["other_shift_id"] == "e" assert has_blocking(violations) is False # --------------------------------------------------------------------------- # # Plus de limite de temps dans la journée # # --------------------------------------------------------------------------- # def test_long_day_and_short_rest_raise_nothing(): """Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les chevauchements sont signalés.""" data = ValidationInput( candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)), existing_shifts=( _shift("matin", _dt(10, 6), _dt(10, 14)), _shift("nuit", _dt(10, 23), _dt(11, 3)), ), ) assert validate(data) == [] def test_clean_assignment_has_no_violation(): data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18))) assert validate(data) == [] assert has_blocking([]) is False # --- Début de « journée de festival » (day_boundary) ------------------------ def test_day_boundary_starts_after_last_day_part_ends(): from app.validation.engine import day_boundary parts = ( DayPartWindow("MATIN", dt.time(8, 0), dt.time(13, 0), 0), DayPartWindow("APRES_MIDI", dt.time(13, 0), dt.time(19, 0), 1), DayPartWindow("SOIREE", dt.time(19, 0), dt.time(2, 0), 2), ) assert day_boundary(parts) == dt.time(2, 0) def test_day_boundary_ignores_order_out_of_step_with_hours(): """Un créneau type d'ordre 0 déplacé à 21 h ne fait plus commencer la journée à 21 h : seul compte le creux entre la fin du dernier et le début du premier.""" from app.validation.engine import day_boundary parts = ( DayPartWindow("SOIREE", dt.time(21, 0), dt.time(3, 0), 0), DayPartWindow("JOUR", dt.time(10, 0), dt.time(21, 0), 1), ) assert day_boundary(parts) == dt.time(3, 0) def test_day_boundary_full_coverage_falls_back_to_first_by_order(): from app.validation.engine import day_boundary assert day_boundary(DAY_PARTS) == dt.time(6, 0) assert day_boundary(()) == dt.time(0, 0)