Files
F-r-ldraledigplannerare/backend/tests/unit/test_swedish_calendar_cache.py
T
jacobh2 249e277696 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
2026-09-14 07:38:07 +02:00

529 lines
23 KiB
Python

"""Tests for Swedish calendar cache.
Validates that the cached swedish_calendar.json file is complete,
correct, and matches independent sources (Python holidays library).
"""
import datetime
import json
from pathlib import Path
from typing import Any
import holidays
import pytest
CACHE_FILE = Path(__file__).parent.parent.parent / "app" / "data" / "swedish_calendar.json"
@pytest.fixture
def calendar_data() -> dict[str, Any]:
"""Load the Swedish calendar cache."""
if not CACHE_FILE.exists():
pytest.skip(f"Calendar cache not found at {CACHE_FILE}")
with open(CACHE_FILE) as f:
return json.load(f)
class TestCacheStructure:
"""Validate the structure of the cache file."""
def test_cache_file_exists(self) -> None:
"""Cache file must exist."""
assert CACHE_FILE.exists(), f"Cache file not found at {CACHE_FILE}"
def test_cache_has_years(self, calendar_data: dict[str, Any]) -> None:
"""Cache must have 'years' field."""
assert "years" in calendar_data
assert isinstance(calendar_data["years"], list)
assert len(calendar_data["years"]) > 0
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 + 29 more (30 total)."""
years = [y["year"] for y in calendar_data["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."""
for year_data in calendar_data["years"]:
assert "year" in year_data
assert "days" in year_data
assert isinstance(year_data["days"], list)
assert len(year_data["days"]) > 0
def test_each_day_has_required_fields(self, calendar_data: dict[str, Any]) -> None:
"""Each day record must have required fields."""
required_fields = {"date", "name_sv", "name_en", "category", "working_day"}
for year_data in calendar_data["years"]:
for day in year_data["days"]:
assert set(day.keys()) == required_fields, f"Day {day} has wrong fields"
assert isinstance(day["date"], str)
assert isinstance(day["name_sv"], str)
assert isinstance(day["name_en"], str)
assert isinstance(day["category"], str)
assert isinstance(day["working_day"], bool)
def test_valid_category_values(self, calendar_data: dict[str, Any]) -> None:
"""Categories must be exactly one of: public, de_facto, bridge_day."""
valid_categories = {"public", "de_facto", "bridge_day"}
for year_data in calendar_data["years"]:
for day in year_data["days"]:
assert day["category"] in valid_categories, (
f"Invalid category '{day['category']}' for {day['date']}"
)
def test_dates_are_sorted(self, calendar_data: dict[str, Any]) -> None:
"""Days within each year must be sorted by date."""
for year_data in calendar_data["years"]:
dates = [d["date"] for d in year_data["days"]]
assert dates == sorted(dates), f"Days not sorted for year {year_data['year']}"
def test_names_are_lowercase(self, calendar_data: dict[str, Any]) -> None:
"""All names must be lowercase for consistency."""
for year_data in calendar_data["years"]:
for day in year_data["days"]:
assert day["name_sv"] == day["name_sv"].lower(), (
f"name_sv '{day['name_sv']}' not lowercase for {day['date']}"
)
assert day["name_en"] == day["name_en"].lower(), (
f"name_en '{day['name_en']}' not lowercase for {day['date']}"
)
class TestPublicHolidaysMatch:
"""Verify public holidays match Python's holidays library."""
@staticmethod
def get_library_public_holidays(
year: int,
) -> dict[datetime.date, str]:
"""Get public holidays from Python holidays library.
Filters out pure Sundays (but keeps holidays that happen to be
on Sunday, like 'Påskdagen; Söndag').
Args:
year: Calendar year
Returns:
Dict mapping date to holiday name
"""
se_holidays = holidays.Sweden(years=year, language="sv")
result = {}
for holiday_date, holiday_name in se_holidays.items():
# Filter out entries that are ONLY "Söndag" (plain Sunday)
# This is a Sunday with no holiday, e.g. "2027-01-03: Söndag"
if holiday_name == "Söndag":
continue
result[holiday_date] = holiday_name
return result
def test_public_holidays_match_for_2027(self, calendar_data: dict[str, Any]) -> None:
"""Public holidays in cache must match library for 2027."""
library_holidays = self.get_library_public_holidays(2027)
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2027)
cache_public = {
d["date"]: d["name_sv"] for d in cache_year["days"] if d["category"] == "public"
}
library_dates = {d.isoformat() for d in library_holidays.keys()}
cache_dates = set(cache_public.keys())
assert library_dates == cache_dates, (
f"Public holidays mismatch for 2027.\n"
f"Missing from cache: {library_dates - cache_dates}\n"
f"Extra in cache: {cache_dates - library_dates}"
)
def test_public_holidays_match_for_2026(self, calendar_data: dict[str, Any]) -> None:
"""Public holidays in cache must match library for 2026."""
library_holidays = self.get_library_public_holidays(2026)
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2026)
cache_public = {
d["date"]: d["name_sv"] for d in cache_year["days"] if d["category"] == "public"
}
library_dates = {d.isoformat() for d in library_holidays.keys()}
cache_dates = set(cache_public.keys())
assert library_dates == cache_dates, (
f"Public holidays mismatch for 2026.\n"
f"Missing from cache: {library_dates - cache_dates}\n"
f"Extra in cache: {cache_dates - library_dates}"
)
def test_public_holidays_match_for_2035(self, calendar_data: dict[str, Any]) -> None:
"""Public holidays in cache must match library for 2035."""
library_holidays = self.get_library_public_holidays(2035)
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2035)
cache_public = {
d["date"]: d["name_sv"] for d in cache_year["days"] if d["category"] == "public"
}
library_dates = {d.isoformat() for d in library_holidays.keys()}
cache_dates = set(cache_public.keys())
assert library_dates == cache_dates, (
f"Public holidays mismatch for 2035.\n"
f"Missing from cache: {library_dates - cache_dates}\n"
f"Extra in cache: {cache_dates - library_dates}"
)
class TestDefactuDays:
"""Verify that de facto days are present."""
def test_julafton_present_all_years(self, calendar_data: dict[str, Any]) -> None:
"""Julafton (Dec 24) must be present in every year."""
for year_data in calendar_data["years"]:
year = year_data["year"]
dates = {d["date"] for d in year_data["days"]}
julafton = f"{year:04d}-12-24"
assert julafton in dates, f"Julafton missing for {year}"
def test_nyarsafton_present_all_years(self, calendar_data: dict[str, Any]) -> None:
"""Nyårsafton (Dec 31) must be present in every year."""
for year_data in calendar_data["years"]:
year = year_data["year"]
dates = {d["date"] for d in year_data["days"]}
nyarsafton = f"{year:04d}-12-31"
assert nyarsafton in dates, f"Nyårsafton missing for {year}"
def test_midsommarafton_present_all_years(self, calendar_data: dict[str, Any]) -> None:
"""Midsommarafton (Friday before Midsommardagen) must be present."""
for year_data in calendar_data["years"]:
year = year_data["year"]
midsommardagen = None
for day in year_data["days"]:
if "midsommardagen" in day["name_sv"].lower():
midsommardagen = day["date"]
break
assert midsommardagen is not None, f"Midsommardagen not found for {year}"
expected_midsommarafton = (
datetime.date.fromisoformat(midsommardagen) - datetime.timedelta(days=1)
).isoformat()
dates = {d["date"] for d in year_data["days"]}
assert expected_midsommarafton in dates, (
f"Midsommarafton ({expected_midsommarafton}) missing for {year}"
)
class TestMovableFeasts:
"""Verify movable feasts are correctly calculated."""
def test_easter_2026(self, calendar_data: dict[str, Any]) -> None:
"""Easter 2026: Apr 5 (Sunday). Good Friday should be Apr 3."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2026)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2026-04-05" in dates_to_names
assert "påskdagen" in dates_to_names["2026-04-05"].lower()
assert "2026-04-03" in dates_to_names
assert "långfredagen" in dates_to_names["2026-04-03"].lower()
def test_easter_2027(self, calendar_data: dict[str, Any]) -> None:
"""Easter 2027: Mar 28 (Sunday). Good Friday should be Mar 26."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2027)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2027-03-28" in dates_to_names
assert "påskdagen" in dates_to_names["2027-03-28"].lower()
assert "2027-03-26" in dates_to_names
assert "långfredagen" in dates_to_names["2027-03-26"].lower()
def test_midsummer_2026(self, calendar_data: dict[str, Any]) -> None:
"""Midsommardagen 2026 should be Saturday, June 20."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2026)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2026-06-20" in dates_to_names
assert "midsommardagen" in dates_to_names["2026-06-20"].lower()
def test_midsummer_2027(self, calendar_data: dict[str, Any]) -> None:
"""Midsommardagen 2027 should be Saturday, June 26."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2027)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2027-06-26" in dates_to_names
assert "midsommardagen" in dates_to_names["2027-06-26"].lower()
def test_midsummer_always_saturday(self, calendar_data: dict[str, Any]) -> None:
"""Midsommardagen is always a Saturday."""
for year_data in calendar_data["years"]:
year = year_data["year"]
midsommardagen = None
for day in year_data["days"]:
if "midsommardagen" in day["name_sv"].lower():
midsommardagen = day["date"]
break
assert midsommardagen is not None, f"Midsommardagen not found for {year}"
date_obj = datetime.date.fromisoformat(midsommardagen)
assert date_obj.weekday() == 5, f"Midsommardagen {midsommardagen} is not a Saturday"
assert 20 <= date_obj.day <= 26, f"Midsommardagen {midsommardagen} is not in June 20-26"
def test_pentecost_2026(self, calendar_data: dict[str, Any]) -> None:
"""Pentecost (Pingstdagen) 2026 should be May 24."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2026)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2026-05-24" in dates_to_names
assert "pingstdagen" in dates_to_names["2026-05-24"].lower()
def test_pentecost_2027(self, calendar_data: dict[str, Any]) -> None:
"""Pentecost (Pingstdagen) 2027 should be May 16."""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2027)
dates_to_names = {d["date"]: d["name_sv"] for d in cache_year["days"]}
assert "2027-05-16" in dates_to_names
assert "pingstdagen" in dates_to_names["2027-05-16"].lower()
class TestBridgeDays:
"""Verify bridge days (klämdagar) are correctly computed."""
def test_bridge_days_exist(self, calendar_data: dict[str, Any]) -> None:
"""Cache must contain bridge days."""
all_days = []
for year_data in calendar_data["years"]:
all_days.extend(year_data["days"])
bridge_days = [d for d in all_days if d["category"] == "bridge_day"]
assert len(bridge_days) > 0, "No bridge days found in cache"
def test_no_bridge_day_overlaps(self, calendar_data: dict[str, Any]) -> None:
"""Bridge days must not overlap with public or de_facto days."""
for year_data in calendar_data["years"]:
other_days = {
d["date"] for d in year_data["days"] if d["category"] in ("public", "de_facto")
}
bridge_days = [d for d in year_data["days"] if d["category"] == "bridge_day"]
for bridge_day in bridge_days:
assert bridge_day["date"] not in other_days, (
f"Bridge day {bridge_day['date']} overlaps with another day"
)
def test_bridge_days_are_weekdays(self, calendar_data: dict[str, Any]) -> None:
"""Bridge days must be Monday-Friday."""
for year_data in calendar_data["years"]:
bridge_days = [d for d in year_data["days"] if d["category"] == "bridge_day"]
for bridge_day in bridge_days:
date_obj = datetime.date.fromisoformat(bridge_day["date"])
assert date_obj.weekday() < 5, f"Bridge day {bridge_day['date']} is not a weekday"
def test_bridge_days_are_working_days(self, calendar_data: dict[str, Any]) -> None:
"""Bridge days must have working_day=true."""
for year_data in calendar_data["years"]:
bridge_days = [d for d in year_data["days"] if d["category"] == "bridge_day"]
for bridge_day in bridge_days:
assert bridge_day["working_day"] is True, (
f"Bridge day {bridge_day['date']} has working_day=false"
)
def test_known_bridge_day_2027(self, calendar_data: dict[str, Any]) -> None:
"""Verify a known klämdag for 2027: 2027-05-07.
Reasoning: 2027-05-06 (Thursday) is Kristi himmelsfärdsdag (public
holiday). 2027-05-07 (Friday) is an ordinary working day. 2027-05-08
(Saturday) is the weekend. Friday May 7 is surrounded by non-working
days, making it a klämdag.
"""
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2027)
dates_to_info = {d["date"]: d for d in cache_year["days"]}
assert "2027-05-07" in dates_to_info, "Known klämdag 2027-05-07 not in cache"
bridge_day = dates_to_info["2027-05-07"]
assert bridge_day["category"] == "bridge_day", (
f"2027-05-07 category: expected 'bridge_day', got '{bridge_day['category']}'"
)
assert bridge_day["working_day"] is True
class TestCacheConsistency:
"""Verify cache file is consistent and clean."""
def test_no_duplicate_dates_per_year(self, calendar_data: dict[str, Any]) -> None:
"""No duplicate dates within a single year."""
for year_data in calendar_data["years"]:
dates = [d["date"] for d in year_data["days"]]
assert len(dates) == len(set(dates)), (
f"Duplicate dates found in year {year_data['year']}"
)
def test_no_null_or_empty_strings(self, calendar_data: dict[str, Any]) -> None:
"""No null values or empty strings in name fields."""
for year_data in calendar_data["years"]:
for day in year_data["days"]:
assert day["name_sv"] and day["name_sv"].strip(), f"Empty name_sv for {day['date']}"
assert day["name_en"] and day["name_en"].strip(), f"Empty name_en for {day['date']}"
def test_cache_file_is_pretty_printed(self) -> None:
"""Cache file must be pretty-printed (2-space indent)."""
with open(CACHE_FILE) as f:
content = f.read()
assert " " in content, "Cache file should be pretty-printed"
lines = content.split("\n")
long_lines = [line for line in lines if len(line) > 120]
assert len(long_lines) < 5, "Cache file appears to not be pretty-printed"
def test_cache_ends_with_newline(self) -> None:
"""Cache file must end with a newline for clean git diffs."""
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"
)