É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
+7 -2
View File
@@ -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.
+7 -1
View File
@@ -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}."
} }
+24 -2
View File
@@ -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",
]
+192
View File
@@ -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
]
+88
View File
@@ -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, ...] = ()
+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