76b1e712b9
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
402 lines
17 KiB
Python
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"
|