Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il remonte en avertissement et l'affectation est enregistrée. Le solveur ne l'exclut plus mais applique un fort malus au bénévole déjà affecté ailleurs au même moment (proposé seulement sous l'effectif minimal, faute de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà. Même malus dans le classement par défaut du panneau et dans le mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
3.9 KiB
Python
113 lines
3.9 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 (
|
|
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)
|