Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green. Flexible wishes were funded twice across propose -> apply -> re-propose. Because planned_leave_day carries no wish_id, the engine could not tell that applied days were that wish's own prior occurrence; it treated them as occupied space, routed the candidate search around them, and funded the year again on shifted dates while reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18 paid days instead of 9. Fixed wishes were unaffected and stay unaffected. Fix (allocation_service.py), per the approved approach of counting already-applied days inside the bounding range rather than adding a wish_id column: - _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its own freshly-computed windows, so a fixed wish's new placement is never mistaken for a flexible wish's history - _reuse_already_funded_window finds this wish's persisted dates within its bound and reuses the window containing them, via the ordinary fund_window path - full funding therefore yields zero new days and partial funding tops up exactly the remainder, with no duplication and no false shortfall - with no persisted dates, or if the reused window is no longer collision-free, it falls through to the original search, so an unsatisfied year is never frozen Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator switched to strict, so an uncached year raises UncachedYearError -> ValidationError -> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as working days. Also fixed normalize_day dropping the second name when two holidays share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red days, not 18: Pingstdagen coincides with Nationaldagen. New backend/tests/integration/test_user_journey.py drives the real app over HTTP across seven journeys: the double-booking guard, the fixed-wish control, apply idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish text reaches the wire now that everything is message_key + data. New backend/scripts/seed_demo.py seeds a realistic workspace through the real service layer for manual testing: demo@example.com / DemoPass123! Backend 414 -> 435 tests. Frontend unchanged at 705. Still not verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -0,0 +1,702 @@
|
||||
"""API-level end-to-end journey tests.
|
||||
|
||||
Two real bugs were found in one session by driving the *running* app through
|
||||
a browser while 1,119 unit/service/router tests sat green. Both were
|
||||
whole-journey bugs: every existing test exercised a single layer (a service
|
||||
in isolation, or a router with hand-built repository rows), so nothing ever
|
||||
walked register -> child -> vacation days -> wishes -> propose -> apply ->
|
||||
re-propose the way a real user (and the browser session that found the
|
||||
bugs) does.
|
||||
|
||||
This file closes that gap. Every test here drives the real FastAPI app
|
||||
(`app.main.app`) over HTTP with `httpx.ASGITransport`, authenticating with a
|
||||
real register -> bearer-token flow (see `_register`), and only the DB
|
||||
connection is swapped for the in-memory `connection` fixture from
|
||||
`tests/conftest.py` (via the `get_connection` dependency override — the same
|
||||
technique `tests/unit/test_plan_router.py` uses).
|
||||
|
||||
Dates are always derived from `date.today()` (`next_year`,
|
||||
`relativedelta(months=...)`, ...) or from `cached_year_bounds()` /
|
||||
`dateutil.easter.easter()` — never a hardcoded year — so these tests stay
|
||||
meaningful next year and as the Swedish-calendar cache is regenerated with a
|
||||
different range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiosqlite
|
||||
import pytest_asyncio
|
||||
from dateutil.easter import easter
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.dependencies import get_connection
|
||||
from app.main import app
|
||||
from app.utils.holidays import cached_year_bounds
|
||||
|
||||
_PASSWORD = "correct horse battery staple"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(connection: aiosqlite.Connection) -> AsyncClient:
|
||||
"""An httpx client against the real app, with `get_connection` overridden
|
||||
|
||||
to hand back the test's in-memory connection instead of opening the real
|
||||
on-disk database via the (never-run) lifespan. Same technique as
|
||||
`tests/unit/test_plan_router.py`.
|
||||
"""
|
||||
app.dependency_overrides[get_connection] = lambda: connection
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.pop(get_connection, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journey helpers — each wraps one HTTP call the real app exposes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _register(
|
||||
client: AsyncClient, email: str, display_name: str
|
||||
) -> tuple[dict[str, str], UUID, UUID]:
|
||||
"""Register a brand-new user and return (auth headers, workspace_id,
|
||||
|
||||
personal_data_uuid) — `AuthService.register` creates the workspace and
|
||||
the registering parent's `PersonalData` row as a side effect, exactly as
|
||||
the real onboarding flow does.
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": _PASSWORD, "display_name": display_name},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
ctx = await client.get("/api/auth/context", headers=headers)
|
||||
assert ctx.status_code == 200, ctx.text
|
||||
body = ctx.json()
|
||||
return headers, UUID(body["workspace_id"]), UUID(body["personal_data_uuid"])
|
||||
|
||||
|
||||
async def _add_child(
|
||||
client: AsyncClient, headers: dict[str, str], workspace_id: UUID, name: str, birth_date: date
|
||||
) -> UUID:
|
||||
resp = await client.post(
|
||||
"/api/children",
|
||||
headers=headers,
|
||||
json={
|
||||
"workspace_id": str(workspace_id),
|
||||
"name": name,
|
||||
"birth_date": birth_date.isoformat(),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return UUID(resp.json()["id"])
|
||||
|
||||
|
||||
async def _set_vacation(
|
||||
client: AsyncClient,
|
||||
headers: dict[str, str],
|
||||
personal_data_uuid: UUID,
|
||||
workspace_id: UUID,
|
||||
year: int,
|
||||
total_days: int = 25,
|
||||
saved_days_in: int = 0,
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/vacation-days",
|
||||
headers=headers,
|
||||
json={
|
||||
"personal_data_uuid": str(personal_data_uuid),
|
||||
"workspace_id": str(workspace_id),
|
||||
"year": year,
|
||||
"total_days": total_days,
|
||||
"saved_days_in": saved_days_in,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
async def _set_full_parental_leave(
|
||||
client: AsyncClient,
|
||||
headers: dict[str, str],
|
||||
personal_data_uuid: UUID,
|
||||
workspace_id: UUID,
|
||||
child_id: UUID,
|
||||
sjukpenning_used: int = 0,
|
||||
lagsta_niva_used: int = 0,
|
||||
) -> None:
|
||||
"""Record a manual FK snapshot via the real `/api/parental-leave/manual`
|
||||
|
||||
endpoint (the always-available alternative to the scraper), defaulting
|
||||
to a full, untouched 390+90 pool.
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/parental-leave/manual",
|
||||
headers=headers,
|
||||
json={
|
||||
"personal_data_uuid": str(personal_data_uuid),
|
||||
"workspace_id": str(workspace_id),
|
||||
"child_id": str(child_id),
|
||||
"total_days": 480,
|
||||
"days_used": sjukpenning_used + lagsta_niva_used,
|
||||
"reserved_days_total": 90,
|
||||
"reserved_days_left": 90,
|
||||
"transferable_days_left": 150,
|
||||
"dubbeldagar_left": 60,
|
||||
"sjukpenning_total": 390,
|
||||
"sjukpenning_used": sjukpenning_used,
|
||||
"lagsta_niva_total": 90,
|
||||
"lagsta_niva_used": lagsta_niva_used,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
async def _create_wish(
|
||||
client: AsyncClient, headers: dict[str, str], **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
resp = await client.post("/api/leave-wishes", headers=headers, json=kwargs)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _propose(
|
||||
client: AsyncClient, headers: dict[str, str], workspace_id: UUID
|
||||
) -> dict[str, Any]:
|
||||
resp = await client.post(
|
||||
"/api/plan/propose", headers=headers, json={"workspace_id": str(workspace_id)}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _apply(
|
||||
client: AsyncClient,
|
||||
headers: dict[str, str],
|
||||
workspace_id: UUID,
|
||||
days: list[dict[str, Any]],
|
||||
replace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
resp = await client.post(
|
||||
"/api/plan/apply",
|
||||
headers=headers,
|
||||
json={"workspace_id": str(workspace_id), "days": days, "replace": replace},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]:
|
||||
"""Proposed days belonging to `wish_id` that would actually cost a
|
||||
|
||||
semesterdag/föräldrapenningdag — excludes the automatic 'free'
|
||||
(weekend/red-day) filler entries a window also reports.
|
||||
"""
|
||||
return [d for d in proposal["days"] if d["wish_id"] == wish_id and d["day_type"] != "free"]
|
||||
|
||||
|
||||
def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"date": d["date"],
|
||||
"parent_personal_data_uuid": d["parent_personal_data_uuid"],
|
||||
"child_id": d["child_id"],
|
||||
"day_type": d["day_type"],
|
||||
"is_dubbeldag": d["is_dubbeldag"],
|
||||
}
|
||||
|
||||
|
||||
def _outcome_for_wish(
|
||||
proposal: dict[str, Any], wish_id: str, year: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
matches = [
|
||||
o
|
||||
for o in proposal["outcomes"]
|
||||
if o["wish_id"] == wish_id and (year is None or o["year"] == year)
|
||||
]
|
||||
assert matches, f"no outcome for wish {wish_id} (year={year}) in {proposal['outcomes']}"
|
||||
assert len(matches) == 1, f"expected exactly one outcome, got {matches}"
|
||||
return matches[0]
|
||||
|
||||
|
||||
_SWEDISH_CHARS = set("åäöÅÄÖ")
|
||||
|
||||
|
||||
def _find_swedish_strings(obj: object, path: str = "$") -> list[str]:
|
||||
"""Recursively walk a decoded JSON response and collect every string
|
||||
|
||||
leaf containing å/ä/ö — §3.5 requires every user-facing surface to be a
|
||||
`message_key` + `data` pair instead of a pre-rendered Swedish string.
|
||||
"""
|
||||
found: list[str] = []
|
||||
if isinstance(obj, str):
|
||||
if any(ch in _SWEDISH_CHARS for ch in obj):
|
||||
found.append(f"{path} = {obj!r}")
|
||||
elif isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
found.extend(_find_swedish_strings(v, f"{path}.{k}"))
|
||||
elif isinstance(obj, list):
|
||||
for i, v in enumerate(obj):
|
||||
found.extend(_find_swedish_strings(v, f"{path}[{i}]"))
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) THE DOUBLE-BOOKING REGRESSION (confirmed bug, currently unfixed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlexibleWishDoubleBookingOnRepropose:
|
||||
"""Guards the double-booking bug found live on 2026-09-14.
|
||||
|
||||
Observed in the running app: propose -> apply -> propose again for a
|
||||
*flexible* wish returns a full second week of brand-new paid days on
|
||||
shifted dates (9 paid days become 18 if applied twice), while reporting
|
||||
`weeks_funded: 1.0` for the "new" window. Root cause: once a flexible
|
||||
window's dates are persisted, `_Ledger.resolve_flexible_window`'s
|
||||
`_candidate_occupied` check sees them as occupied and searches for a
|
||||
*different* full-length candidate elsewhere in the bounding range,
|
||||
instead of recognising the wish-year as already funded.
|
||||
|
||||
The approved fix (product-owner decision, landing separately): if a
|
||||
flexible wish-year already has enough planned days inside its bounding
|
||||
range, treat that year as funded and propose nothing new.
|
||||
|
||||
This test asserts the CORRECT (post-fix) behaviour and is EXPECTED TO
|
||||
FAIL until that fix lands — do not skip/xfail/weaken it; a red result
|
||||
here is the point.
|
||||
"""
|
||||
|
||||
async def test_repropose_after_apply_adds_no_new_paid_days(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "double-booking@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Sommar",
|
||||
window_start_month=6,
|
||||
window_start_day=1,
|
||||
window_end_month=8,
|
||||
window_end_day=31,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
window_mode="flexible",
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
proposal_1 = await _propose(client, headers, ws_id)
|
||||
outcome_1 = _outcome_for_wish(proposal_1, wish_id)
|
||||
assert outcome_1["strategy"] != "unplaced"
|
||||
paid_days_1 = _paid_days_for_wish(proposal_1, wish_id)
|
||||
assert paid_days_1, "expected the flexible wish to be funded with at least one paid day"
|
||||
applied_dates = {d["date"] for d in paid_days_1}
|
||||
|
||||
all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id]
|
||||
apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1])
|
||||
assert apply_result["accepted"] is True, apply_result
|
||||
assert apply_result["created_count"] == len(paid_days_1)
|
||||
|
||||
proposal_2 = await _propose(client, headers, ws_id)
|
||||
paid_days_2 = _paid_days_for_wish(proposal_2, wish_id)
|
||||
new_dates = {d["date"] for d in paid_days_2} - applied_dates
|
||||
|
||||
assert new_dates == set(), (
|
||||
"Double-booking regression: re-proposing after applying a flexible "
|
||||
"wish's window must add ZERO new paid days for that wish-year (the "
|
||||
"already-applied dates should be recognised as funding it), but "
|
||||
f"got {sorted(new_dates)} brand-new paid day(s) beyond the "
|
||||
f"already-applied {sorted(applied_dates)}. This is the confirmed, "
|
||||
"not-yet-fixed bug from the 2026-09-14 handoff."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) FIXED-WISH CONTROL CASE — locks in currently-correct behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixedWishReproposeAddsNothingNew:
|
||||
"""Control case for the double-booking bug above: the SAME propose ->
|
||||
|
||||
apply -> propose cycle for a `window_mode='fixed'` wish must return zero
|
||||
new paid days on re-propose. `_fund_solo`'s `existing_lookup` check
|
||||
already prevents this for fixed windows (unlike the flexible resolver's
|
||||
`_candidate_occupied` check, which excludes rather than recognises
|
||||
already-planned dates) — this test locks that in so a future change to
|
||||
the shared funding path can't regress it.
|
||||
"""
|
||||
|
||||
async def test_repropose_after_apply_adds_no_new_paid_days(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "fixed-control@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Fixed Week",
|
||||
window_start_month=6,
|
||||
window_start_day=1,
|
||||
window_end_month=6,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
window_mode="fixed",
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
proposal_1 = await _propose(client, headers, ws_id)
|
||||
outcome_1 = _outcome_for_wish(proposal_1, wish_id)
|
||||
assert outcome_1["strategy"] != "unplaced"
|
||||
paid_days_1 = _paid_days_for_wish(proposal_1, wish_id)
|
||||
assert paid_days_1
|
||||
applied_dates = {d["date"] for d in paid_days_1}
|
||||
|
||||
all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id]
|
||||
apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1])
|
||||
assert apply_result["accepted"] is True, apply_result
|
||||
|
||||
proposal_2 = await _propose(client, headers, ws_id)
|
||||
paid_days_2 = _paid_days_for_wish(proposal_2, wish_id)
|
||||
new_dates = {d["date"] for d in paid_days_2} - applied_dates
|
||||
|
||||
assert new_dates == set(), (
|
||||
f"a fixed wish must never be re-funded with new dates on re-propose, "
|
||||
f"got {sorted(new_dates)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) APPLY IDEMPOTENCY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyIdempotency:
|
||||
"""Applying the exact same day set twice must skip, not duplicate."""
|
||||
|
||||
async def test_reapplying_identical_days_skips_instead_of_duplicating(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "idempotency@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Idempotency Week",
|
||||
window_start_month=9,
|
||||
window_start_day=1,
|
||||
window_end_month=9,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
proposal = await _propose(client, headers, ws_id)
|
||||
paid_days = _paid_days_for_wish(proposal, wish_id)
|
||||
assert paid_days, "expected at least one paid day to apply"
|
||||
payload = [_to_apply_item(d) for d in paid_days]
|
||||
|
||||
first = await _apply(client, headers, ws_id, payload)
|
||||
assert first["accepted"] is True
|
||||
assert first["created_count"] == len(paid_days)
|
||||
assert first["skipped_identical_count"] == 0
|
||||
|
||||
second = await _apply(client, headers, ws_id, payload)
|
||||
assert second["accepted"] is True, second
|
||||
assert second["created_count"] == 0
|
||||
assert second["skipped_identical_count"] == len(paid_days)
|
||||
assert second["replaced_count"] == 0
|
||||
assert second["collisions"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) FIXED BEATS FLEXIBLE (§3.11.2 — fixed wishes always win)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFixedWishNeverDisplacedByFlexible:
|
||||
"""A fixed wish that consumes a flexible wish's whole bounding range must
|
||||
|
||||
leave the flexible one `strategy='unplaced'`, `weeks_funded=0.0` — a
|
||||
flexible wish can never displace a fixed one, driven end-to-end through
|
||||
the real onboarding flow (register/child/leave/vacation/wishes) rather
|
||||
than hand-built repository rows.
|
||||
"""
|
||||
|
||||
async def test_flexible_wish_is_unplaced_when_fixed_wish_claims_its_bound(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "fixed-beats-flex@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Fixed",
|
||||
window_start_month=1,
|
||||
window_start_day=1,
|
||||
window_end_month=1,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
window_mode="fixed",
|
||||
)
|
||||
flexible = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Flex",
|
||||
window_start_month=1,
|
||||
window_start_day=1,
|
||||
window_end_month=1,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
window_mode="flexible",
|
||||
)
|
||||
flexible_id = flexible["id"]
|
||||
|
||||
proposal = await _propose(client, headers, ws_id)
|
||||
outcome = _outcome_for_wish(proposal, flexible_id)
|
||||
|
||||
assert outcome["strategy"] == "unplaced"
|
||||
assert outcome["weeks_funded"] == 0.0
|
||||
assert outcome["shortfall_days"] == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5) FLEXIBLE WISHES RE-PICK PER YEAR, TRACKING EASTER
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlexibleYearlyWishTracksEasterEachYear:
|
||||
"""A yearly flexible spring wish (bounding range 03-01..05-31) must land
|
||||
|
||||
on a different window each year, tracking Easter (verified live: 2027
|
||||
Mar 23-29, 2028 Apr 11-17, 2029 Mar 27-Apr 2 — the calendar-only cost
|
||||
function in `resolve_flexible_window` favours whichever full-length
|
||||
candidate contains the most red days, and Easter's Fri-Mon cluster is
|
||||
the densest one in that range). Easter is derived via `dateutil.easter`
|
||||
and the three years come from `cached_year_bounds()`, not hardcoded, so
|
||||
this stays meaningful as the calendar cache is regenerated.
|
||||
"""
|
||||
|
||||
async def test_each_years_window_is_distinct_and_easter_adjacent(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
bounds = cached_year_bounds()
|
||||
assert bounds is not None, "Swedish calendar cache is missing"
|
||||
min_year, max_year = bounds
|
||||
base_year = min_year + 1 # skip the partially-elapsed current year
|
||||
years = [base_year, base_year + 1, base_year + 2]
|
||||
assert years[-1] <= max_year, (
|
||||
f"calendar cache ({min_year}-{max_year}) too short to run this test; "
|
||||
"it needs 3 full years starting the year after the cache's first year"
|
||||
)
|
||||
|
||||
headers, ws_id, pd_uuid = await _register(client, "easter-tracking@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(years=1)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
for year in years:
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, year, total_days=25)
|
||||
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Varlov",
|
||||
window_start_month=3,
|
||||
window_start_day=1,
|
||||
window_end_month=5,
|
||||
window_end_day=31,
|
||||
target_weeks=1,
|
||||
recurrence="yearly",
|
||||
first_year=years[0],
|
||||
last_year=years[-1],
|
||||
window_mode="flexible",
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
proposal = await _propose(client, headers, ws_id)
|
||||
|
||||
windows: dict[int, tuple[date, date]] = {}
|
||||
for year in years:
|
||||
outcome = _outcome_for_wish(proposal, wish_id, year=year)
|
||||
assert outcome["strategy"] != "unplaced", outcome
|
||||
assert outcome["shortfall_days"] == 0, outcome
|
||||
start = date.fromisoformat(outcome["start_date"])
|
||||
end = date.fromisoformat(outcome["end_date"])
|
||||
assert (end - start).days == 6 # a full 1-week (7-day) placement
|
||||
windows[year] = (start, end)
|
||||
|
||||
easter_sunday = easter(year)
|
||||
assert start <= easter_sunday <= end, (
|
||||
f"{year}'s window {start}..{end} is not Easter-adjacent "
|
||||
f"(Easter Sunday is {easter_sunday})"
|
||||
)
|
||||
|
||||
start_month_days = {year: (w[0].month, w[0].day) for year, w in windows.items()}
|
||||
assert len(set(start_month_days.values())) == len(years), (
|
||||
f"expected a distinct window each year, got {start_month_days}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) NO SWEDISH ON THE WIRE (§3.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoSwedishOnTheWire:
|
||||
"""Every user-facing string in the propose/apply responses must be a
|
||||
|
||||
`message_key` + `data` pair, never a pre-rendered Swedish string. Builds
|
||||
a scenario that exercises outcomes, remedies, warnings and an apply
|
||||
rejection (the message-bearing surfaces §3.5 converted), then walks the
|
||||
full decoded JSON body looking for å/ä/ö in any string leaf. All wish
|
||||
names created here are plain ASCII, so no field needs to be excluded
|
||||
from the walk.
|
||||
"""
|
||||
|
||||
async def test_propose_and_apply_responses_carry_no_swedish_strings(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "no-swedish@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
|
||||
# Exhaust the FP pool entirely -> guaranteed shortfall + remedies.
|
||||
await _set_full_parental_leave(
|
||||
client, headers, pd_uuid, ws_id, child_id, sjukpenning_used=390, lagsta_niva_used=90
|
||||
)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
# No vacation in the wish's own year -> fully unfunded window.
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=0)
|
||||
# Spare vacation this year -> carry_vacation_forward remedy fires.
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, date.today().year, total_days=25)
|
||||
|
||||
await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Winter",
|
||||
window_start_month=12,
|
||||
window_start_day=1,
|
||||
window_end_month=12,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
)
|
||||
|
||||
proposal = await _propose(client, headers, ws_id)
|
||||
outcomes_with_shortfall = [o for o in proposal["outcomes"] if o["shortfall_days"] > 0]
|
||||
assert outcomes_with_shortfall, "expected the wish's window to be underfunded"
|
||||
assert outcomes_with_shortfall[0]["remedies"], "expected at least one shortfall remedy"
|
||||
# A fully-drained FP pool means nothing is left to forecast as
|
||||
# expiring at age-4, so `warnings` is legitimately empty here — the
|
||||
# remedies/outcomes/data surfaces above already exercise the
|
||||
# message_key + data i18n path; walking `warnings` too (even if
|
||||
# empty) costs nothing and still guards it if it ever fires.
|
||||
violations = _find_swedish_strings(proposal)
|
||||
assert not violations, f"Swedish text leaked into /api/plan/propose response: {violations}"
|
||||
|
||||
# Trigger a structured apply rejection (insufficient balance) too.
|
||||
apply_result = await _apply(
|
||||
client,
|
||||
headers,
|
||||
ws_id,
|
||||
[
|
||||
{
|
||||
"date": date(next_year, 6, 15).isoformat(),
|
||||
"parent_personal_data_uuid": str(pd_uuid),
|
||||
"child_id": str(child_id),
|
||||
"day_type": "sjukpenning",
|
||||
"is_dubbeldag": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert apply_result["accepted"] is False
|
||||
assert apply_result["rejections"], "expected the balance violation to be reported"
|
||||
|
||||
violations = _find_swedish_strings(apply_result)
|
||||
assert not violations, f"Swedish text leaked into /api/plan/apply response: {violations}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7) PLAN-AWARE AGE-4 PROJECTION (§3.3) IS SELF-CONSISTENT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAge4ProjectionArithmeticIsSelfConsistent:
|
||||
"""The propose response's `age4_projections` must satisfy
|
||||
|
||||
`expiring_at_b4 == max(projected_left_at_b4 - 96, 0)` — after the
|
||||
child's 4th birthday only 96 saved days may carry forward; anything
|
||||
above that in the projected balance is what's forecast to expire.
|
||||
"""
|
||||
|
||||
async def test_expiring_equals_projected_minus_ceiling_clamped_at_zero(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
proposal = await _propose(client, headers, ws_id)
|
||||
|
||||
assert proposal["age4_projections"], "expected an age4 projection for the (parent, child)"
|
||||
for projection in proposal["age4_projections"]:
|
||||
assert projection["child_id"] == str(child_id)
|
||||
expected = max(projection["projected_left_at_b4"] - 96, 0)
|
||||
assert projection["expiring_at_b4"] == expected, projection
|
||||
@@ -102,9 +102,9 @@ class TestYearWindowPagination:
|
||||
async def test_a_middle_page_that_would_overshoot_is_also_clamped(
|
||||
self, calendar_service: CalendarService
|
||||
) -> None:
|
||||
"""Same clamp, exercised one page before the very last year (the
|
||||
`start_year=2034, years=4` case from real paging: 2034+3=2037 would
|
||||
overshoot the 2026-2035 cache by two years)."""
|
||||
"""Same clamp, exercised one page before the very last year (e.g. the
|
||||
`start_year=max_year-1, years=4` case from real paging, which would
|
||||
overshoot the cache's end by two years without the clamp)."""
|
||||
bounds = cached_year_bounds()
|
||||
assert bounds is not None
|
||||
_, max_year = bounds
|
||||
@@ -178,13 +178,30 @@ class TestNoDegradedDataAcrossTheServedRange:
|
||||
async def test_red_day_count_is_consistent_across_every_served_year(
|
||||
self, calendar_service: CalendarService
|
||||
) -> None:
|
||||
"""No year may be silently poorer than its neighbours (the fallback's
|
||||
signature: 18 real red days in-cache vs. 13 once it kicks in).
|
||||
|
||||
The one legitimate exception in a ~30-year range: Pingstdagen
|
||||
(Easter + 49 days) occasionally lands on Sveriges nationaldag (fixed
|
||||
June 6), e.g. 2049 and 2055. That's 17 *distinct* red dates, not a
|
||||
degradation — both holidays genuinely share one calendar date, and
|
||||
the API itself reports 17 for that year. Assert the exception is
|
||||
exactly this coincidence, by name, rather than loosely allowing any
|
||||
two counts.
|
||||
"""
|
||||
by_year = await self._all_cached_years_red_days(calendar_service)
|
||||
counts = {year: len(red_days) for year, red_days in by_year.items()}
|
||||
# No year may be silently poorer than its neighbours (the fallback's
|
||||
# signature: 18 real red days in-cache vs. 13 once it kicks in).
|
||||
assert len(set(counts.values())) == 1, (
|
||||
f"red-day counts differ across years, one may be degraded: {counts}"
|
||||
)
|
||||
for year, red_days in by_year.items():
|
||||
names_by_date = {d: name.lower() for d, name in red_days.items()}
|
||||
june_6_name = next(
|
||||
(n for d, n in names_by_date.items() if d.month == 6 and d.day == 6), ""
|
||||
)
|
||||
pingst_meets_nationaldag = "pingstdagen" in june_6_name and "nationaldag" in june_6_name
|
||||
expected = 17 if pingst_meets_nationaldag else 18
|
||||
assert len(red_days) == expected, (
|
||||
f"{year}: expected {expected} red dates "
|
||||
f"({'Pingstdagen/Nationaldagen coincide' if pingst_meets_nationaldag else 'normal year'}), "
|
||||
f"got {len(red_days)} — a year may be degraded: {sorted(red_days)}"
|
||||
)
|
||||
|
||||
|
||||
class TestOverrideMerging:
|
||||
|
||||
@@ -194,11 +194,11 @@ class TestKlamdag:
|
||||
assert not is_klamdag(datetime.date(2027, 3, 2))
|
||||
|
||||
def test_klamdag_detected_for_uncached_fallback_year(self) -> None:
|
||||
"""2040 is outside the cached range (2026-2035): 2040-04-30 (Mon) is
|
||||
squeezed between Sunday 4/29 and Första maj (Tue 5/1). `is_klamdag`
|
||||
is computed generically from `is_non_working_day`, so this works even
|
||||
without cached klämdag data for the year."""
|
||||
assert is_klamdag(datetime.date(2040, 4, 30))
|
||||
"""2060 is outside the cached range (2026-2055): 2060-01-02 (Fri) is
|
||||
squeezed between Nyårsdagen (Thu 1/1) and the weekend (Sat 1/3).
|
||||
`is_klamdag` is computed generically from `is_non_working_day`, so
|
||||
this works even without cached klämdag data for the year."""
|
||||
assert is_klamdag(datetime.date(2060, 1, 2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,29 +234,29 @@ class TestGetRedDays:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback path for years outside the cached range (2026-2035)
|
||||
# Fallback path for years outside the cached range (2026-2055)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFallbackForUncachedYear:
|
||||
def test_uncached_year_still_returns_formal_holidays(self) -> None:
|
||||
"""2040 is outside the cache -> falls back to the `holidays` library:
|
||||
"""2060 is outside the cache -> falls back to the `holidays` library:
|
||||
formal röda dagar are still known."""
|
||||
result = get_swedish_holidays(2040)
|
||||
assert datetime.date(2040, 12, 25) in result # Juldagen
|
||||
assert datetime.date(2040, 1, 1) in result # Nyårsdagen (+ Söndag combo)
|
||||
result = get_swedish_holidays(2060)
|
||||
assert datetime.date(2060, 12, 25) in result # Juldagen
|
||||
assert datetime.date(2060, 1, 1) in result # Nyårsdagen (+ Söndag combo)
|
||||
|
||||
def test_uncached_year_does_not_know_de_facto_days(self) -> None:
|
||||
"""Documents the fallback's known limitation: no de facto-day data
|
||||
exists outside the cached range, so julafton is NOT marked free."""
|
||||
assert not is_non_working_day(datetime.date(2040, 12, 24))
|
||||
assert not is_non_working_day(datetime.date(2060, 12, 24))
|
||||
|
||||
def test_uncached_year_easter_sunday_survives_sondag_filter(self) -> None:
|
||||
"""The exact-match trap applies to the fallback path too."""
|
||||
red_days = get_red_days(2040)
|
||||
# Easter 2040: verified against `holidays.Sweden(years=2040)`.
|
||||
red_days = get_red_days(2060)
|
||||
# Easter 2060: verified against `holidays.Sweden(years=2060)`.
|
||||
easter_sunday = next(
|
||||
d for d, name in get_swedish_holidays(2040).items() if "Påskdagen" in name
|
||||
d for d, name in get_swedish_holidays(2060).items() if "Påskdagen" in name
|
||||
)
|
||||
assert easter_sunday in red_days
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from app.entities.child import Child
|
||||
from app.entities.day_override import DayOverride
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
@@ -29,7 +30,7 @@ from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedL
|
||||
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
||||
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
|
||||
from app.services.allocation_service import AllocationInputLoader, Allocator, PlanningEngineService
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
|
||||
def _make_loader(
|
||||
@@ -391,3 +392,118 @@ class TestAllocationInputLoader:
|
||||
date(2027, 12, 24): True,
|
||||
date(2027, 2, 2): False,
|
||||
}
|
||||
|
||||
async def test_load_costs_julafton_correctly_for_a_far_future_horizon(
|
||||
self,
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
child_repo: SQLiteChildRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
) -> None:
|
||||
"""Before the cache was extended to ~30 years, a horizon reaching the
|
||||
late 2040s ran past the old 10-year cache (2026-2035) and the loader
|
||||
silently fell back to the live `holidays` library, which reports
|
||||
Julafton/Nyårsafton/Midsommarafton as ordinary working days. A child
|
||||
born in 2044 pushes the horizon (their 4th birthday) to 2048 — assert
|
||||
the resulting `holidays` map still carries all three as real red
|
||||
days that far out, proving the extended cache (not a silent
|
||||
fallback) served it, and that the allocator would cost them
|
||||
correctly rather than charging a needless semesterdag."""
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Future", created_at=now))
|
||||
await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
|
||||
)
|
||||
await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date(2044, 1, 1),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
loader = _make_loader(
|
||||
workspace_repo,
|
||||
child_repo,
|
||||
personal_data_repo,
|
||||
parental_leave_repo,
|
||||
vacation_repo,
|
||||
planned_leave_repo,
|
||||
leave_wish_repo,
|
||||
)
|
||||
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
|
||||
|
||||
assert alloc_input.horizon_end.year == 2048
|
||||
|
||||
julafton = alloc_input.holidays.get(date(2048, 12, 24))
|
||||
assert julafton is not None and "julafton" in julafton.lower()
|
||||
|
||||
nyarsafton = alloc_input.holidays.get(date(2048, 12, 31))
|
||||
assert nyarsafton is not None and "nyårsafton" in nyarsafton.lower()
|
||||
|
||||
midsommarafton = [
|
||||
n for d, n in alloc_input.holidays.items() if "midsommarafton" in n.lower()
|
||||
]
|
||||
assert midsommarafton, "Midsommarafton must still be a real free day in 2048"
|
||||
|
||||
async def test_load_raises_validation_error_when_horizon_exceeds_the_cache(
|
||||
self,
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
child_repo: SQLiteChildRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
) -> None:
|
||||
"""A wish with a far-out `last_year` and no children yet (so there's
|
||||
no age-12 hard cap to save it) can still in principle push the
|
||||
horizon past even a ~30-year cache. Rather than silently falling
|
||||
back to the live `holidays` library (the original bug) or silently
|
||||
clamping the horizon (which would contradict the §6 decision to plan
|
||||
past B4 and show the squeeze), the loader must raise a
|
||||
`ValidationError` — the same domain error `CalendarService` uses for
|
||||
an out-of-range `start_year` — so the router can turn it into a 422
|
||||
the UI can explain."""
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="FarFuture", created_at=now))
|
||||
await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
|
||||
)
|
||||
await leave_wish_repo.create(
|
||||
LeaveWish(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
child_id=None,
|
||||
name="Sommar",
|
||||
window_start_month=6,
|
||||
window_start_day=1,
|
||||
window_end_month=6,
|
||||
window_end_day=30,
|
||||
target_weeks=4,
|
||||
recurrence="yearly",
|
||||
first_year=2027,
|
||||
last_year=2200,
|
||||
priority=1,
|
||||
parent_mode="either",
|
||||
parent_personal_data_uuid=None,
|
||||
)
|
||||
)
|
||||
|
||||
loader = _make_loader(
|
||||
workspace_repo,
|
||||
child_repo,
|
||||
personal_data_repo,
|
||||
parental_leave_repo,
|
||||
vacation_repo,
|
||||
planned_leave_repo,
|
||||
leave_wish_repo,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await loader.load(ws.id, today=date(2027, 1, 1))
|
||||
|
||||
@@ -21,6 +21,7 @@ from datetime import date, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.services.allocation_service import (
|
||||
REASON_DUBBELDAG,
|
||||
REASON_FP_LAGSTA_STRETCH,
|
||||
@@ -152,6 +153,26 @@ def _build_input(
|
||||
)
|
||||
|
||||
|
||||
def _planned(
|
||||
*, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation"
|
||||
) -> PlannedLeaveDay:
|
||||
"""A persisted `planned_leave_day` row — the "already applied, before
|
||||
|
||||
this `propose()` run" building block for the §fix (double-booking on
|
||||
flexible re-propose) test cases below. Only the fields
|
||||
`AllocationInput.existing_planned` actually reads
|
||||
(`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter.
|
||||
"""
|
||||
return PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
child_id=child_id,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
date=on,
|
||||
day_type=day_type,
|
||||
)
|
||||
|
||||
|
||||
FEB_2027 = ((2, 1), (2, 28)) # 28 days, 20 spendable, no real holidays nearby
|
||||
AUG_2027 = ((8, 1), (8, 31)) # 31 days, 22 spendable, no real holidays nearby
|
||||
|
||||
@@ -1650,3 +1671,224 @@ class TestCase24WindowModeDefaultsToFixed:
|
||||
proposal_explicit = _run(explicit_wish)
|
||||
|
||||
assert proposal_default == proposal_explicit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §fix — double-booking on flexible re-propose (2026-09-14 handoff).
|
||||
#
|
||||
# A flexible wish-year that already has enough planned days inside its
|
||||
# bounding range must be treated as funded, proposing nothing new, instead
|
||||
# of the engine routing a *fresh* candidate search around the applied dates
|
||||
# and funding the same wish-year a second time on shifted dates. All four
|
||||
# scenarios reuse TestCase15's exact setup (VAR_2027 bound, weeks=1, Easter
|
||||
# 2027-03-23..29 as the argmin-cost candidate: 4 free days + 3 spendable
|
||||
# days 03-23/24/25, all funded via vacation) so the "already funded"/"top
|
||||
# up"/"still may move" behaviour is verified against a known-good baseline.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCase25FlexibleAlreadyFundedYearProposesNothingNew:
|
||||
def test_fully_applied_year_adds_zero_new_paid_days(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
# The exact 3 spendable days TestCase15 funds when this wish-year is
|
||||
# searched fresh -- simulating a prior propose -> apply cycle.
|
||||
existing = [
|
||||
_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d))
|
||||
for d in (23, 24, 25)
|
||||
]
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_goal="balanced",
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.strategy != STRATEGY_UNPLACED
|
||||
assert outcome.shortfall_days == 0
|
||||
assert outcome.weeks_funded == outcome.weeks_wished == 1
|
||||
# Reused the exact prior window, not a freshly searched one.
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.end_date == date(2027, 3, 29)
|
||||
assert outcome.cost_breakdown["vacation"] == 3
|
||||
assert outcome.cost_breakdown["free"] == 4
|
||||
|
||||
new_paid_days = [d for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"]
|
||||
assert new_paid_days == [], f"expected zero new paid days, got {new_paid_days}"
|
||||
|
||||
|
||||
class TestCase26FlexiblePartialFundingTopsUpRemainder:
|
||||
def test_partially_applied_year_tops_up_only_the_remainder(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
# Only ONE of the winning window's 3 spendable days was ever applied.
|
||||
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_goal="balanced",
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
# Topped up to full funding in the SAME window, not a false shortfall.
|
||||
assert outcome.shortfall_days == 0
|
||||
assert outcome.weeks_funded == outcome.weeks_wished == 1
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.end_date == date(2027, 3, 29)
|
||||
assert outcome.cost_breakdown["vacation"] == 3
|
||||
|
||||
new_paid_dates = {
|
||||
d.date for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"
|
||||
}
|
||||
# Exactly the remainder -- never re-proposes the already-applied day,
|
||||
# never duplicates the whole week.
|
||||
assert new_paid_dates == {date(2027, 3, 24), date(2027, 3, 25)}
|
||||
|
||||
|
||||
class TestCase27FlexibleDeterminismWithExistingPlannedDays:
|
||||
def test_repeated_runs_with_a_partially_applied_flexible_wish_are_identical(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_goal="balanced",
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
allocator = Allocator()
|
||||
proposal_1 = allocator.propose(alloc_input)
|
||||
proposal_2 = allocator.propose(alloc_input)
|
||||
|
||||
assert proposal_1 == proposal_2
|
||||
|
||||
|
||||
class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
|
||||
"""§10.3's "still free to move" decision must survive the fix: a
|
||||
|
||||
partially-applied flexible occurrence whose reused window is no longer
|
||||
available (because a higher-priority fixed wish now claims one of its
|
||||
still-unfunded days) must NOT be frozen to that window -- it searches
|
||||
again and lands somewhere collision-free, exactly like an untouched
|
||||
flexible wish would.
|
||||
"""
|
||||
|
||||
def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
|
||||
# spendable days of the Easter window (03-24, 03-25) were proposed
|
||||
# last time but never applied.
|
||||
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
|
||||
# A brand-new fixed wish now claims 2027-03-25 -- one of the Easter
|
||||
# window's still-open days -- and, per §3.9/§3.11.2, always wins.
|
||||
blocker = _wish(
|
||||
child_id=child.id,
|
||||
name="Fixed",
|
||||
start=(3, 25),
|
||||
end=(3, 25),
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="fixed",
|
||||
)
|
||||
flexible = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_goal="balanced",
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[blocker, flexible],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
by_name = {o.wish_name: o for o in proposal.outcomes}
|
||||
fixed_outcome = by_name["Fixed"]
|
||||
flex_outcome = by_name["Vårlov"]
|
||||
|
||||
# Fixed always wins its exact requested date.
|
||||
assert fixed_outcome.start_date == fixed_outcome.end_date == date(2027, 3, 25)
|
||||
|
||||
# The flexible wish was NOT frozen to the original Easter window
|
||||
# (03-23..03-29) -- that window is no longer collision-free, so it
|
||||
# moved rather than silently overlapping the fixed wish's day.
|
||||
assert (flex_outcome.start_date, flex_outcome.end_date) != (
|
||||
date(2027, 3, 23),
|
||||
date(2027, 3, 29),
|
||||
)
|
||||
assert flex_outcome.strategy != STRATEGY_UNPLACED
|
||||
assert flex_outcome.shortfall_days == 0
|
||||
|
||||
flex_dates = {
|
||||
flex_outcome.start_date + timedelta(days=i)
|
||||
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
|
||||
}
|
||||
# Still honours the previously-applied day (it's inside the new
|
||||
# window's span) but never re-claims the fixed wish's date.
|
||||
assert date(2027, 3, 23) in flex_dates
|
||||
assert date(2027, 3, 25) not in flex_dates
|
||||
|
||||
@@ -39,14 +39,17 @@ class TestCacheStructure:
|
||||
assert isinstance(calendar_data["years"], list)
|
||||
assert len(calendar_data["years"]) > 0
|
||||
|
||||
def test_cache_covers_10_years(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must contain exactly 10 years."""
|
||||
assert len(calendar_data["years"]) == 10
|
||||
def test_cache_covers_30_years(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must contain exactly 30 years (extended from the original
|
||||
10 so the planning engine's horizon can never realistically run past
|
||||
the cache — see `AllocationInputLoader.load`'s `strict=True` switch
|
||||
in `app.services.allocation_service`)."""
|
||||
assert len(calendar_data["years"]) == 30
|
||||
|
||||
def test_cache_year_range(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must cover current year + 9 more (10 total)."""
|
||||
"""Cache must cover current year + 29 more (30 total)."""
|
||||
years = [y["year"] for y in calendar_data["years"]]
|
||||
assert years == list(range(2026, 2036)), f"Expected [2026..2035], got {years}"
|
||||
assert years == list(range(2026, 2056)), f"Expected [2026..2055], got {years}"
|
||||
|
||||
def test_each_year_has_days(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Each year must have a 'days' list."""
|
||||
@@ -399,3 +402,127 @@ class TestCacheConsistency:
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
content = f.read()
|
||||
assert content.endswith(b"\n"), "Cache file must end with newline"
|
||||
|
||||
|
||||
class TestRedDayCountConsistency:
|
||||
"""No year in the extended range may be silently poorer than its
|
||||
neighbours — this is exactly the shape of the bug this cache extension
|
||||
fixes (years past the old cache lost Julafton/Nyårsafton/Midsommarafton
|
||||
to the `holidays`-library fallback, 13 red days instead of 18)."""
|
||||
|
||||
def test_red_day_count_consistent_with_one_documented_exception(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Every year has 18 red (public + de_facto) dates, EXCEPT a year
|
||||
where Pingstdagen (Easter + 49 days) coincides with Sveriges
|
||||
nationaldag (fixed June 6) — then the two holidays share a single
|
||||
calendar date, giving 17 distinct dates. That happens for 2049 and
|
||||
2055 in this cache. Verified by name (not just assumed from the
|
||||
count), so a genuinely degraded year can't hide behind it.
|
||||
"""
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
red_days = [d for d in year_data["days"] if d["category"] in ("public", "de_facto")]
|
||||
june_6 = next(
|
||||
(d for d in red_days if d["date"] == f"{year:04d}-06-06"),
|
||||
None,
|
||||
)
|
||||
pingst_meets_nationaldag = june_6 is not None and (
|
||||
"pingstdagen" in june_6["name_sv"] and "nationaldag" in june_6["name_sv"]
|
||||
)
|
||||
expected = 17 if pingst_meets_nationaldag else 18
|
||||
assert len(red_days) == expected, (
|
||||
f"{year}: expected {expected} red dates, got {len(red_days)} "
|
||||
f"({sorted(d['date'] for d in red_days)})"
|
||||
)
|
||||
|
||||
def test_the_known_coincidence_years_are_exactly_2049_and_2055(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pin down which years hit the 17-count exception, so a future cache
|
||||
regeneration that shifts the covered range doesn't silently change
|
||||
this test's assumptions without notice."""
|
||||
coincidence_years = set()
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
for day in year_data["days"]:
|
||||
if (
|
||||
day["date"] == f"{year:04d}-06-06"
|
||||
and "pingstdagen" in day["name_sv"]
|
||||
and "nationaldag" in day["name_sv"]
|
||||
):
|
||||
coincidence_years.add(year)
|
||||
assert coincidence_years == {2049, 2055}
|
||||
|
||||
|
||||
class TestMultiObservanceNamesArePreserved:
|
||||
"""A date can carry more than one `observances` entry from the Dagsmart
|
||||
API when two distinct holidays coincide (see `normalize_day` in
|
||||
`scripts/fetch_swedish_calendar.py`). Taking only the first observance
|
||||
would silently drop the second holiday's name — verify both survive."""
|
||||
|
||||
def test_pingstdagen_and_nationaldagen_both_named_in_2049(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2049)
|
||||
day = next(d for d in cache_year["days"] if d["date"] == "2049-06-06")
|
||||
assert "pingstdagen" in day["name_sv"]
|
||||
assert "nationaldag" in day["name_sv"]
|
||||
assert day["category"] == "public"
|
||||
assert day["working_day"] is False
|
||||
|
||||
|
||||
class TestEasterAcrossTheExtendedRange:
|
||||
"""Easter moves every year (computed, not fixed) — sample a few years
|
||||
spread across the new 30-year range to confirm Långfredagen/Påskdagen/
|
||||
Annandag påsk keep landing on the correct relative weekdays this far out,
|
||||
not just near the start of the cache."""
|
||||
|
||||
@pytest.mark.parametrize("year", [2026, 2038, 2049, 2055])
|
||||
def test_easter_triduum_lands_correctly(self, calendar_data: dict[str, Any], year: int) -> None:
|
||||
cache_year = next(y for y in calendar_data["years"] if y["year"] == year)
|
||||
by_date = {d["date"]: d for d in cache_year["days"]}
|
||||
|
||||
good_friday = next(d for d, info in by_date.items() if "långfredagen" in info["name_sv"])
|
||||
easter_sunday = next(d for d, info in by_date.items() if "påskdagen" in info["name_sv"])
|
||||
easter_monday = next(d for d, info in by_date.items() if "annandag påsk" in info["name_sv"])
|
||||
|
||||
gf = datetime.date.fromisoformat(good_friday)
|
||||
es = datetime.date.fromisoformat(easter_sunday)
|
||||
em = datetime.date.fromisoformat(easter_monday)
|
||||
|
||||
assert gf.weekday() == 4, f"{year}: Långfredagen must be a Friday"
|
||||
assert es.weekday() == 6, f"{year}: Påskdagen must be a Sunday"
|
||||
assert em.weekday() == 0, f"{year}: Annandag påsk must be a Monday"
|
||||
assert es - gf == datetime.timedelta(days=2), (
|
||||
f"{year}: Good Friday must be 2 days before Easter Sunday"
|
||||
)
|
||||
assert em - es == datetime.timedelta(days=1), (
|
||||
f"{year}: Easter Monday must be the day after Easter Sunday"
|
||||
)
|
||||
|
||||
|
||||
class TestValborgIsNotADefaultDayOff:
|
||||
"""§6 product decision: Valborgsmässoafton (30 April) is deliberately NOT
|
||||
a statutory/de-facto day off — it was added, then removed, because most
|
||||
Swedes work on it. Confirm the extended cache still treats it as an
|
||||
ordinary day (or, when it happens to be a genuine klämdag, that it's
|
||||
filed as `bridge_day` — still `working_day: true` — never `public`/
|
||||
`de_facto`)."""
|
||||
|
||||
def test_valborg_never_public_or_de_facto_in_any_cached_year(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
valborg = next(
|
||||
(d for d in year_data["days"] if d["date"] == f"{year:04d}-04-30"),
|
||||
None,
|
||||
)
|
||||
if valborg is None:
|
||||
continue # not even a bridge day this year — also fine
|
||||
assert valborg["category"] != "public", f"{year}: Valborg wrongly marked public"
|
||||
assert valborg["category"] != "de_facto", f"{year}: Valborg wrongly marked de_facto"
|
||||
assert valborg["working_day"] is True, (
|
||||
f"{year}: Valborg must remain an ordinary working day"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user