Files
F-r-ldraledigplannerare/backend/tests/unit/test_swedish_calendar_cache.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

402 lines
17 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_10_years(self, calendar_data: dict[str, Any]) -> None:
"""Cache must contain exactly 10 years."""
assert len(calendar_data["years"]) == 10
def test_cache_year_range(self, calendar_data: dict[str, Any]) -> None:
"""Cache must cover current year + 9 more (10 total)."""
years = [y["year"] for y in calendar_data["years"]]
assert years == list(range(2026, 2036)), f"Expected [2026..2035], 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"