249e277696
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
529 lines
23 KiB
Python
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"
|
|
)
|