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