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
+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"
)