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:
2026-09-14 07:38:07 +02:00
parent 28c632ca36
commit 249e277696
15 changed files with 4828 additions and 53 deletions
@@ -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
+26 -9
View File
@@ -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:
+14 -14
View File
@@ -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"
)