Disponibilités déclarées supprimées (table `availability`, migration 0022, API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) ainsi que les règles OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus de disponibilité et de repos du solveur et le filtre « Surchargés » de la vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
3.8 KiB
Python
112 lines
3.8 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_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)
|
|
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
|
|
assert violations[0].severity is Severity.BLOCKING
|
|
assert violations[0].params["other_shift_id"] == "e"
|
|
assert has_blocking(violations) is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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)
|