É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:
co-authored by
Claude Sonnet 5
parent
bb919554ff
commit
c96e85af37
@@ -190,7 +190,7 @@ pointage sur site · fonctions d'IA.
|
|||||||
│ │ ├── api/ # routers (health, imports)
|
│ │ ├── api/ # routers (health, imports)
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
||||||
│ │ ├── models/ # tables SQLModel
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur de règles (cœur du produit)
|
│ │ ├── validation/ # moteur de règles pur (models, engine)
|
||||||
│ │ ├── i18n/
|
│ │ ├── i18n/
|
||||||
│ │ │ ├── __init__.py # t(key, **params)
|
│ │ │ ├── __init__.py # t(key, **params)
|
||||||
│ │ │ └── locales/fr.json
|
│ │ │ └── locales/fr.json
|
||||||
@@ -262,8 +262,13 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
|-------|-------------------------------------------------------|----------|
|
|-------|-------------------------------------------------------|----------|
|
||||||
| 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée |
|
| 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée |
|
||||||
| 2 | Modèle de données complet + migration Alembic initiale | validée |
|
| 2 | Modèle de données complet + migration Alembic initiale | validée |
|
||||||
| 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | à valider |
|
| 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée |
|
||||||
|
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | à valider |
|
||||||
|
|
||||||
> **En attente** : la couche d'authentification (lien magique, `Account`,
|
> **En attente** : la couche d'authentification (lien magique, `Account`,
|
||||||
> JWT, dépendances de rôle) fera l'objet d'une étape dédiée. Les endpoints
|
> JWT, dépendances de rôle) fera l'objet d'une étape dédiée. Les endpoints
|
||||||
> actuels ne sont pas encore protégés.
|
> actuels ne sont pas encore protégés.
|
||||||
|
>
|
||||||
|
> **À brancher plus tard** : `app.validation.validate()` sera appelé par l'API
|
||||||
|
> CRUD des affectations (étape 5) via un chargeur qui construit la
|
||||||
|
> `ValidationInput` depuis la base.
|
||||||
|
|||||||
@@ -25,5 +25,11 @@
|
|||||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||||
"import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.",
|
"import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.",
|
||||||
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
||||||
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »)."
|
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
|
||||||
|
|
||||||
|
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||||
|
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
|
||||||
|
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||||
|
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||||
|
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
"""Moteur de validation des affectations — cœur du produit.
|
"""Moteur de validation des affectations — cœur du produit.
|
||||||
|
|
||||||
Implémenté à l'étape 4, en TDD : les tests précèdent le code, chaque règle est
|
Voir CLAUDE.md § 3 pour les règles et la sémantique.
|
||||||
couverte par un cas passant et un cas déclenchant. Voir CLAUDE.md § 3.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.validation.engine import has_blocking, validate
|
||||||
|
from app.validation.models import (
|
||||||
|
AvailabilityDeclaration,
|
||||||
|
DayPartWindow,
|
||||||
|
Severity,
|
||||||
|
ShiftInfo,
|
||||||
|
ValidationInput,
|
||||||
|
Violation,
|
||||||
|
ViolationCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AvailabilityDeclaration",
|
||||||
|
"DayPartWindow",
|
||||||
|
"Severity",
|
||||||
|
"ShiftInfo",
|
||||||
|
"ValidationInput",
|
||||||
|
"Violation",
|
||||||
|
"ViolationCode",
|
||||||
|
"has_blocking",
|
||||||
|
"validate",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Moteur de validation des affectations — cœur du produit.
|
||||||
|
|
||||||
|
Fonction pure : une `ValidationInput` en entrée, une liste de `Violation` en
|
||||||
|
sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.models import AvailabilityStatus
|
||||||
|
from app.validation.models import (
|
||||||
|
Severity,
|
||||||
|
ShiftInfo,
|
||||||
|
ValidationInput,
|
||||||
|
Violation,
|
||||||
|
ViolationCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
_EIGHT_HOURS = dt.timedelta(hours=8)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(data: ValidationInput) -> list[Violation]:
|
||||||
|
violations: list[Violation] = []
|
||||||
|
violations.extend(_check_overlap(data))
|
||||||
|
violations.extend(_check_outside_availability(data))
|
||||||
|
violations.extend(_check_daily_hours(data))
|
||||||
|
violations.extend(_check_insufficient_rest(data))
|
||||||
|
violations.extend(_check_missing_skills(data))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def has_blocking(violations: list[Violation]) -> bool:
|
||||||
|
return any(v.severity is Severity.BLOCKING for v in violations)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# SHIFT_OVERLAP (bloquante) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||||
|
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
|
||||||
|
# pas.
|
||||||
|
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||||
|
|
||||||
|
|
||||||
|
def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||||||
|
candidate = data.candidate_shift
|
||||||
|
return [
|
||||||
|
Violation.of(
|
||||||
|
ViolationCode.SHIFT_OVERLAP,
|
||||||
|
other_shift_id=other.id,
|
||||||
|
other_starts_at=other.starts_at.isoformat(),
|
||||||
|
other_ends_at=other.ends_at.isoformat(),
|
||||||
|
)
|
||||||
|
for other in data.existing_shifts
|
||||||
|
if _overlaps(candidate, other)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _covered_slots(
|
||||||
|
shift: ShiftInfo, day_parts: tuple
|
||||||
|
) -> list[tuple[dt.date, str]]:
|
||||||
|
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
|
||||||
|
if not day_parts:
|
||||||
|
return []
|
||||||
|
slots: list[tuple[dt.date, str]] = []
|
||||||
|
day = shift.starts_at.date() - dt.timedelta(days=1)
|
||||||
|
last = shift.ends_at.date()
|
||||||
|
while day <= last:
|
||||||
|
for part in day_parts:
|
||||||
|
window_start = dt.datetime.combine(day, part.start_time)
|
||||||
|
if part.end_time > part.start_time:
|
||||||
|
window_end = dt.datetime.combine(day, part.end_time)
|
||||||
|
else:
|
||||||
|
window_end = dt.datetime.combine(
|
||||||
|
day + dt.timedelta(days=1), part.end_time
|
||||||
|
)
|
||||||
|
if shift.starts_at < window_end and window_start < shift.ends_at:
|
||||||
|
slots.append((day, part.code))
|
||||||
|
day += dt.timedelta(days=1)
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||||
|
slots = _covered_slots(data.candidate_shift, data.day_parts)
|
||||||
|
if not slots:
|
||||||
|
return []
|
||||||
|
declared = {
|
||||||
|
(a.date, a.day_part_code): a.status for a in data.availabilities
|
||||||
|
}
|
||||||
|
out: list[Violation] = []
|
||||||
|
for day, code in slots:
|
||||||
|
status = declared.get((day, code))
|
||||||
|
if status is None:
|
||||||
|
out.append(
|
||||||
|
Violation.of(
|
||||||
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||||
|
date=day.isoformat(),
|
||||||
|
day_part_code=code,
|
||||||
|
status="UNDECLARED",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif status is AvailabilityStatus.UNAVAILABLE:
|
||||||
|
out.append(
|
||||||
|
Violation.of(
|
||||||
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||||
|
date=day.isoformat(),
|
||||||
|
day_part_code=code,
|
||||||
|
status="UNAVAILABLE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# DAILY_HOURS_EXCEEDED (avertissement) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _day_boundary(day_parts: tuple) -> dt.time:
|
||||||
|
if not day_parts:
|
||||||
|
return dt.time(0, 0)
|
||||||
|
return min(day_parts, key=lambda part: part.order).start_time
|
||||||
|
|
||||||
|
|
||||||
|
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||||
|
if moment.time() >= boundary:
|
||||||
|
return moment.date()
|
||||||
|
return moment.date() - dt.timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
||||||
|
boundary = _day_boundary(data.day_parts)
|
||||||
|
candidate = data.candidate_shift
|
||||||
|
key = _day_key(candidate.starts_at, boundary)
|
||||||
|
|
||||||
|
total = candidate.ends_at - candidate.starts_at
|
||||||
|
for other in data.existing_shifts:
|
||||||
|
if _day_key(other.starts_at, boundary) == key:
|
||||||
|
total += other.ends_at - other.starts_at
|
||||||
|
|
||||||
|
if total > _EIGHT_HOURS:
|
||||||
|
return [
|
||||||
|
Violation.of(
|
||||||
|
ViolationCode.DAILY_HOURS_EXCEEDED,
|
||||||
|
date=key.isoformat(),
|
||||||
|
total_minutes=int(total.total_seconds() // 60),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# INSUFFICIENT_REST (avertissement) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
||||||
|
candidate = data.candidate_shift
|
||||||
|
out: list[Violation] = []
|
||||||
|
for other in data.existing_shifts:
|
||||||
|
if _overlaps(candidate, other):
|
||||||
|
continue
|
||||||
|
if other.ends_at <= candidate.starts_at:
|
||||||
|
gap = candidate.starts_at - other.ends_at
|
||||||
|
direction = "BEFORE"
|
||||||
|
elif candidate.ends_at <= other.starts_at:
|
||||||
|
gap = other.starts_at - candidate.ends_at
|
||||||
|
direction = "AFTER"
|
||||||
|
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
|
||||||
|
continue
|
||||||
|
if gap < _EIGHT_HOURS:
|
||||||
|
out.append(
|
||||||
|
Violation.of(
|
||||||
|
ViolationCode.INSUFFICIENT_REST,
|
||||||
|
other_shift_id=other.id,
|
||||||
|
direction=direction,
|
||||||
|
gap_minutes=int(gap.total_seconds() // 60),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MISSING_REQUIRED_SKILL (avertissement) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _check_missing_skills(data: ValidationInput) -> list[Violation]:
|
||||||
|
missing = sorted(
|
||||||
|
data.candidate_shift.required_skill_codes
|
||||||
|
- data.volunteer_skill_codes
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
Violation.of(ViolationCode.MISSING_REQUIRED_SKILL, skill_code=code)
|
||||||
|
for code in missing
|
||||||
|
]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Types d'entrée et de sortie du moteur de validation.
|
||||||
|
|
||||||
|
Découplé de la base : le moteur ne connaît que ces structures simples, jamais
|
||||||
|
les modèles SQLModel. La couche appelante construit l'entrée depuis la base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models import AvailabilityStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(StrEnum):
|
||||||
|
BLOCKING = "BLOCKING"
|
||||||
|
WARNING = "WARNING"
|
||||||
|
|
||||||
|
|
||||||
|
class ViolationCode(StrEnum):
|
||||||
|
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
||||||
|
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
||||||
|
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
||||||
|
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
||||||
|
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
|
||||||
|
|
||||||
|
|
||||||
|
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||||
|
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
|
||||||
|
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
||||||
|
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
||||||
|
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
||||||
|
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Violation(BaseModel):
|
||||||
|
"""Une violation : un **code** + des paramètres structurés, jamais de phrase
|
||||||
|
(traduction via `locales/fr.json`, clés `validation.<CODE>`)."""
|
||||||
|
|
||||||
|
code: ViolationCode
|
||||||
|
severity: Severity
|
||||||
|
params: dict[str, str | int] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, code: ViolationCode, **params: str | int) -> "Violation":
|
||||||
|
return cls(code=code, severity=SEVERITY_BY_CODE[code], params=params)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ShiftInfo:
|
||||||
|
id: str
|
||||||
|
starts_at: dt.datetime
|
||||||
|
ends_at: dt.datetime
|
||||||
|
required_skill_codes: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DayPartWindow:
|
||||||
|
code: str
|
||||||
|
start_time: dt.time
|
||||||
|
# end_time <= start_time ⇒ la demi-journée passe minuit.
|
||||||
|
end_time: dt.time
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AvailabilityDeclaration:
|
||||||
|
date: dt.date
|
||||||
|
day_part_code: str
|
||||||
|
status: AvailabilityStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationInput:
|
||||||
|
"""Tout ce qu'il faut pour valider une affectation candidate.
|
||||||
|
|
||||||
|
`existing_shifts` = les créneaux des autres affectations **actives**
|
||||||
|
(`PROPOSED` / `CONFIRMED`) du même bénévole ; le filtrage par statut est à
|
||||||
|
la charge de l'appelant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate_shift: ShiftInfo
|
||||||
|
volunteer_skill_codes: frozenset[str] = frozenset()
|
||||||
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||||
|
availabilities: tuple[AvailabilityDeclaration, ...] = ()
|
||||||
|
day_parts: tuple[DayPartWindow, ...] = ()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user