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