Retrait des disponibilités et des limites de temps dans la journée
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f671bb85bf
commit
d722a81a87
@@ -4,7 +4,6 @@ import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Artist,
|
||||
Availability,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
@@ -74,34 +73,5 @@ def make_volunteer(
|
||||
return volunteer
|
||||
|
||||
|
||||
def make_availability(
|
||||
session, edition, volunteer, *, date, start_time, end_time
|
||||
):
|
||||
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
|
||||
row = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def declare_available_all_day(session, edition, volunteer, date):
|
||||
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=date,
|
||||
start_time=dt.time(0, 0),
|
||||
end_time=dt.time(23, 59),
|
||||
)
|
||||
|
||||
|
||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import datetime as dt
|
||||
|
||||
from tests.factories import (
|
||||
declare_available_all_day,
|
||||
dt_at,
|
||||
make_position,
|
||||
make_shift,
|
||||
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/assignments{suffix}"
|
||||
|
||||
|
||||
def _setup(session, edition, *, available=True):
|
||||
def _setup(session, edition):
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
if available:
|
||||
declare_available_all_day(
|
||||
session, edition, volunteer, dt.date(2026, 7, 10)
|
||||
)
|
||||
return volunteer, shift
|
||||
|
||||
|
||||
@@ -39,16 +34,21 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
assert body["assignment"]["locked"] is False
|
||||
|
||||
|
||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||
# Bénévole non déclaré disponible sur le créneau.
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
|
||||
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
|
||||
# 18h–04h (14 h d'affilée) passe sans avertissement.
|
||||
volunteer, shift = _setup(session, edition)
|
||||
position = make_position(session, edition, name="Bar")
|
||||
following = make_shift(
|
||||
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||
assert "OUTSIDE_AVAILABILITY" in codes
|
||||
for s in (shift, following):
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
||||
@@ -110,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
resp = client.post(
|
||||
_url(edition, "/validate"),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -118,14 +118,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["blocking"] is False
|
||||
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
|
||||
assert body["violations"] == []
|
||||
assert client.get(_url(edition)).json() == []
|
||||
|
||||
|
||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -134,9 +132,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["assignment"]["locked"] is True
|
||||
assert any(
|
||||
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
|
||||
)
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_delete_assignment(client, edition, session, day_parts):
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
from app.models import Role
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
|
||||
def _url(edition, volunteer, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
|
||||
|
||||
|
||||
def test_put_then_get_returns_windows_sorted(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
|
||||
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
|
||||
assert got == [
|
||||
("2026-07-10", "08:00:00", "13:00:00"),
|
||||
("2026-07-10", "19:00:00", "02:00:00"),
|
||||
("2026-07-11", "14:00:00", "19:00:00"),
|
||||
]
|
||||
assert client.get(_url(edition, v)).json() == resp.json()
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["date"] == "2026-07-11"
|
||||
|
||||
|
||||
def test_put_rejects_date_outside_edition(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
|
||||
|
||||
|
||||
def test_put_rejects_empty_window(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.empty_window"
|
||||
|
||||
|
||||
def test_edition_wide_listing(client, edition, session):
|
||||
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
|
||||
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
|
||||
for v in (v1, v2):
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
body = client.get(f"/editions/{edition.id}/availability").json()
|
||||
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
|
||||
|
||||
|
||||
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
api = as_account(lead)
|
||||
assert api.get(_url(edition, v)).status_code == 200
|
||||
resp = api.put(_url(edition, v), json={"windows": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
|
||||
api = as_account(account)
|
||||
assert api.get(_url(edition, me)).status_code == 200
|
||||
assert api.get(_url(edition, other)).status_code == 403
|
||||
@@ -14,7 +14,6 @@ from app.models import (
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -141,10 +140,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
)
|
||||
alex = make_volunteer(session, edition, first_name="Alex")
|
||||
sam = make_volunteer(session, edition, first_name="Sam")
|
||||
make_availability(
|
||||
session, edition, alex,
|
||||
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
|
||||
)
|
||||
session.add_all([
|
||||
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
||||
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
||||
@@ -159,8 +154,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"shifts": 2,
|
||||
"min_hours": 3.0,
|
||||
"ideal_hours": 5.0,
|
||||
"available_hours": 6.0,
|
||||
"available_volunteers": 1,
|
||||
"assigned_hours": 2.0,
|
||||
}
|
||||
]
|
||||
@@ -176,7 +169,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"wishes": 2,
|
||||
}
|
||||
]
|
||||
assert needs["volunteers_with_availability"] == 1
|
||||
assert needs["volunteers_with_assignment"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
|
||||
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
assert session.exec(select(Assignment)).all() == []
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
# Le créneau subsiste.
|
||||
from app.models import Shift
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import text
|
||||
from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
|
||||
"magic_link_token",
|
||||
"position",
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
|
||||
assert volunteer.assignments[0].id == assignment.id
|
||||
|
||||
|
||||
def test_availability_window_round_trip(session):
|
||||
edition = _edition(session)
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
|
||||
avail = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 9),
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(2, 0), # passe minuit
|
||||
)
|
||||
session.add(avail)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
reloaded = session.get(Availability, avail.id)
|
||||
assert reloaded.start_time == dt.time(19, 0)
|
||||
assert reloaded.end_time == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_volunteer_email_can_be_shared(session):
|
||||
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
|
||||
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
|
||||
|
||||
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
assert summary.assignments > 100
|
||||
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
|
||||
assert summary.blocking == 0 # aucune violation bloquante
|
||||
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
||||
assert len(summary.warnings) >= 2
|
||||
assert sum(summary.warnings.values()) >= 3
|
||||
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
|
||||
assert summary.artists > 0
|
||||
assert summary.position_wishes > 0
|
||||
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
|
||||
)
|
||||
assert second.assignments == first.assignments
|
||||
assert second.understaffed_shifts == first.understaffed_shifts
|
||||
assert dict(second.warnings) == dict(first.warnings)
|
||||
assert second.artists == first.artists
|
||||
assert second.position_wishes == first.position_wishes
|
||||
assert second.artist_wishes == first.artist_wishes
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime as dt
|
||||
|
||||
from app.solver.engine import solve
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
@@ -39,7 +39,6 @@ def _shift(
|
||||
def _volunteer(
|
||||
vid: str,
|
||||
*,
|
||||
availability=(),
|
||||
wished_position_ids=frozenset(),
|
||||
wished_companion_ids=frozenset(),
|
||||
wished_artist_windows=(),
|
||||
@@ -48,7 +47,6 @@ def _volunteer(
|
||||
) -> SolverVolunteer:
|
||||
return SolverVolunteer(
|
||||
id=vid,
|
||||
availability=tuple(availability),
|
||||
wished_position_ids=frozenset(wished_position_ids),
|
||||
wished_companion_ids=frozenset(wished_companion_ids),
|
||||
wished_artist_windows=tuple(wished_artist_windows),
|
||||
@@ -57,10 +55,6 @@ def _volunteer(
|
||||
)
|
||||
|
||||
|
||||
def _full_day(day: int) -> AvailabilityWindow:
|
||||
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
|
||||
|
||||
|
||||
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
|
||||
for s in solution.suggestions:
|
||||
if s.shift_id == shift_id:
|
||||
@@ -75,7 +69,7 @@ def test_no_variables_returns_empty_solution():
|
||||
|
||||
def test_fully_staffed_shift_gets_no_suggestion():
|
||||
shift = _shift(ideal_staff=1, already_assigned=1)
|
||||
volunteer = _volunteer("v1", availability=[_full_day(10)])
|
||||
volunteer = _volunteer("v1")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert solution.suggestions == ()
|
||||
|
||||
@@ -84,105 +78,77 @@ def test_picks_the_only_free_volunteer_over_a_busy_one():
|
||||
shift = _shift()
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
free = _volunteer("free", availability=[_full_day(10)])
|
||||
free = _volunteer("free")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
|
||||
assert _by_shift(solution, "s1") == ("free",)
|
||||
|
||||
|
||||
def test_does_not_exceed_ideal_staff_cap():
|
||||
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
|
||||
a = _volunteer("a", availability=[_full_day(10)])
|
||||
b = _volunteer("b", availability=[_full_day(10)])
|
||||
a = _volunteer("a")
|
||||
b = _volunteer("b")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
|
||||
assert len(_by_shift(solution, "s1")) == 1
|
||||
|
||||
|
||||
def test_prefers_volunteer_with_matching_position_wish():
|
||||
shift = _shift(position_id="regie")
|
||||
plain = _volunteer("plain", availability=[_full_day(10)])
|
||||
plain = _volunteer("plain")
|
||||
wisher = _volunteer(
|
||||
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
|
||||
"wisher", wished_position_ids={"regie"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
|
||||
assert _by_shift(solution, "s1") == ("wisher",)
|
||||
|
||||
|
||||
def test_prefers_fully_available_volunteer_over_partially_available():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full",)
|
||||
|
||||
|
||||
def test_ranks_both_candidates_when_two_slots_are_open():
|
||||
shift = _shift(ideal_staff=2)
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full", "partial")
|
||||
|
||||
|
||||
def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
|
||||
# deux : le solveur ne le propose qu'une fois.
|
||||
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
|
||||
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
|
||||
only = _volunteer("only", availability=[_full_day(10)])
|
||||
only = _volunteer("only")
|
||||
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
|
||||
picked_a = _by_shift(solution, "a")
|
||||
picked_b = _by_shift(solution, "b")
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
def test_short_rest_is_not_penalized():
|
||||
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
|
||||
# bénévole reste à égalité avec un autre.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
|
||||
# pénalise pas le bénévole.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
|
||||
|
||||
|
||||
def test_prefers_volunteer_without_the_immediately_preceding_shift():
|
||||
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
|
||||
# doit différencier les deux bénévoles.
|
||||
# Seul le chaînage doit différencier les deux bénévoles.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -196,10 +162,9 @@ def test_chain_penalty_applies_across_different_positions():
|
||||
)
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -210,7 +175,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
@@ -227,9 +191,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
|
||||
)
|
||||
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
|
||||
almost_chained = _volunteer(
|
||||
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
|
||||
"almost_chained", existing_shifts=[unrelated]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
truly_fresh = _volunteer("truly_fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
@@ -241,9 +205,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
indiff = _volunteer("indiff", availability=[_full_day(10)])
|
||||
indiff = _volunteer("indiff")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
|
||||
assert _by_shift(solution, "s1") == ("indiff",)
|
||||
|
||||
@@ -254,7 +218,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -266,7 +230,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -277,9 +241,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
|
||||
# préféré à « stranger », qui n'a pas ce lien.
|
||||
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
|
||||
buddy = _volunteer(
|
||||
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
|
||||
"buddy", wished_companion_ids={"copain"}
|
||||
)
|
||||
stranger = _volunteer("stranger", availability=[_full_day(10)])
|
||||
stranger = _volunteer("stranger")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
|
||||
assert _by_shift(solution, "s1") == ("buddy",)
|
||||
|
||||
@@ -290,10 +254,10 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
|
||||
)
|
||||
two_friends = _volunteer(
|
||||
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
|
||||
"two_friends", wished_companion_ids={"a", "b"}
|
||||
)
|
||||
one_friend = _volunteer(
|
||||
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
|
||||
"one_friend", wished_companion_ids={"a"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
|
||||
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
|
||||
@@ -302,7 +266,7 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
def test_wished_companion_absent_from_the_shift_has_no_effect():
|
||||
shift = _shift()
|
||||
volunteer = _volunteer(
|
||||
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
|
||||
"v", wished_companion_ids={"not-here"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert _by_shift(solution, "s1") == ("v",)
|
||||
|
||||
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
import datetime as dt
|
||||
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
||||
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
|
||||
`end_h <= start_h` ⇒ se termine le lendemain."""
|
||||
return AvailabilityWindow(
|
||||
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SHIFT_OVERLAP #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -48,233 +39,42 @@ 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)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
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)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
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 _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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# Plus de limite de temps dans la journée #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
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, 18)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
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_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
|
||||
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
||||
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_gap_between_two_windows_is_flagged():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
|
||||
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 120
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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=(_win(10, 8, 18),),
|
||||
)
|
||||
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
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
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)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
codes = _codes(validate(data))
|
||||
assert ViolationCode.SHIFT_OVERLAP not in codes
|
||||
assert ViolationCode.INSUFFICIENT_REST in codes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
} <= 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)),
|
||||
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert has_blocking(violations) is False
|
||||
assert has_blocking([]) is False
|
||||
|
||||
|
||||
# --- Début de « journée de festival » (day_boundary) ------------------------
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Assignment
|
||||
from app.validation import AvailabilityWindow
|
||||
from app.validation.loader import build_validation_input
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
make_availability(
|
||||
session, edition, volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id, volunteer_id=volunteer.id,
|
||||
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
assert data.candidate_shift.id == str(candidate.id)
|
||||
# Toute affectation existante compte, sauf le créneau candidat lui-même.
|
||||
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||
assert len(data.day_parts) == 3
|
||||
assert data.availabilities == (
|
||||
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||
)
|
||||
|
||||
|
||||
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
|
||||
|
||||
Reference in New Issue
Block a user