Étape 4 : moteur de validation des affectations

- Module pur app/validation/ : aucune dépendance à la base, entrée
  ValidationInput (dataclasses), sortie list[Violation] (code + params, pas
  de phrase)
- 5 règles conformes à CLAUDE.md § 3 :
  SHIFT_OVERLAP (bloquante), OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED,
  INSUFFICIENT_REST, MISSING_REQUIRED_SKILL (avertissements)
- Sémantique figée : bornes strictes (créneaux jointifs non chevauchants),
  frontière de journée au 1er DayPart, cumul > 8 h strict, repos < 8 h strict,
  statuts PROPOSED/CONFIRMED côté appelant
- has_blocking() pour l'appelant
- TDD : 23 tests, un cas passant + un cas déclenchant par règle + cas limites
  (minuit, frontière de journée, multi-créneaux, combinaisons) ; couverture 99 %
- Clés de traduction validation.<CODE> dans locales/fr.json

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-09 22:44:09 +02:00
co-authored by Claude Sonnet 5
parent bb919554ff
commit c96e85af37
6 changed files with 641 additions and 5 deletions
+323
View File
@@ -0,0 +1,323 @@
"""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