diff --git a/CLAUDE.md b/CLAUDE.md index 3ea32e5..8123925 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ pointage sur site · fonctions d'IA. │ │ ├── api/ # routers (health, imports) │ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── models/ # tables SQLModel -│ │ ├── validation/ # moteur de règles (cœur du produit) +│ │ ├── validation/ # moteur de règles pur (models, engine) │ │ ├── i18n/ │ │ │ ├── __init__.py # t(key, **params) │ │ │ └── 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 | | 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`, > 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. +> +> **À 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. diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 7f8a407..0ee1fca 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.", "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}." } diff --git a/backend/app/validation/__init__.py b/backend/app/validation/__init__.py index ac006b3..2c2e237 100644 --- a/backend/app/validation/__init__.py +++ b/backend/app/validation/__init__.py @@ -1,5 +1,27 @@ """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 -couverte par un cas passant et un cas déclenchant. Voir CLAUDE.md § 3. +Voir CLAUDE.md § 3 pour les règles et la sémantique. """ + +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", +] diff --git a/backend/app/validation/engine.py b/backend/app/validation/engine.py new file mode 100644 index 0000000..bbfd0d9 --- /dev/null +++ b/backend/app/validation/engine.py @@ -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 + ] diff --git a/backend/app/validation/models.py b/backend/app/validation/models.py new file mode 100644 index 0000000..98b729b --- /dev/null +++ b/backend/app/validation/models.py @@ -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: 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, ...] = () diff --git a/backend/tests/test_validation_engine.py b/backend/tests/test_validation_engine.py new file mode 100644 index 0000000..a237001 --- /dev/null +++ b/backend/tests/test_validation_engine.py @@ -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