La « journée de festival » commence désormais au début du plus long creux laissé par les créneaux types sur 24 h (l'heure où le dernier se termine), et non plus au début de celui de plus petit `order` : un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles à 21:00, jusqu'à 03:00 le lendemain. Même règle côté backend (validation.engine.day_boundary, réutilisée par la génération de créneaux) et frontend (dayBoundaryFromDayParts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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 (
|
|
AvailabilityWindow,
|
|
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]
|
|
|
|
|
|
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
|
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
|
|
`end_h <= start_h` ⇒ se termine le lendemain."""
|
|
return AvailabilityWindow(
|
|
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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)),),
|
|
availabilities=(_win(10, 8, 23),),
|
|
)
|
|
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)),),
|
|
availabilities=(_win(10, 8, 23),),
|
|
)
|
|
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_shift_within_declared_window():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
|
availabilities=(_win(10, 13, 19),),
|
|
)
|
|
assert validate(data) == []
|
|
|
|
|
|
def test_availability_pass_when_window_exactly_matches_shift():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
|
availabilities=(_win(10, 14, 18),),
|
|
)
|
|
assert validate(data) == []
|
|
|
|
|
|
def test_availability_triggers_when_nothing_declared():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
|
)
|
|
violations = validate(data)
|
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
|
assert violations[0].severity is Severity.WARNING
|
|
assert violations[0].params["date"] == "2026-07-10"
|
|
assert violations[0].params["uncovered_minutes"] == 240
|
|
|
|
|
|
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
|
|
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
|
availabilities=(_win(10, 13, 19),),
|
|
)
|
|
violations = validate(data)
|
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
|
assert violations[0].params["uncovered_minutes"] == 60
|
|
|
|
|
|
def test_availability_union_of_several_windows_covers_the_shift():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
|
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
|
)
|
|
assert validate(data) == []
|
|
|
|
|
|
def test_availability_midnight_crossing_window_covers_night_shift():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
|
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
|
)
|
|
assert validate(data) == []
|
|
|
|
|
|
def test_availability_gap_between_two_windows_is_flagged():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
|
|
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
|
|
)
|
|
violations = validate(data)
|
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
|
assert violations[0].params["uncovered_minutes"] == 120
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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=(_win(10, 8, 18),),
|
|
)
|
|
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
|
|
availabilities=(_win(10, 8, 12),),
|
|
)
|
|
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)),),
|
|
availabilities=(_win(10, 8, 23),),
|
|
)
|
|
codes = _codes(validate(data))
|
|
assert ViolationCode.SHIFT_OVERLAP not in codes
|
|
assert ViolationCode.INSUFFICIENT_REST in codes
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Combinaisons #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_clean_assignment_has_no_violation():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
|
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
|
day_parts=DAY_PARTS,
|
|
availabilities=(_win(10, 13, 19),),
|
|
)
|
|
assert validate(data) == []
|
|
|
|
|
|
def test_multiple_rules_fire_together():
|
|
data = ValidationInput(
|
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
|
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
|
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
|
)
|
|
codes = set(_codes(validate(data)))
|
|
assert {
|
|
ViolationCode.SHIFT_OVERLAP,
|
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
|
} <= 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)),
|
|
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
|
)
|
|
violations = validate(data)
|
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
|
assert has_blocking(violations) 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)
|