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
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""Tests for Swedish holiday utilities."""
|
|
|
|
import datetime
|
|
|
|
|
|
from app.utils.holidays import (
|
|
get_holiday_name,
|
|
get_red_days,
|
|
get_swedish_holidays,
|
|
is_klamdag,
|
|
is_non_working_day,
|
|
is_swedish_holiday,
|
|
)
|
|
|
|
|
|
class TestGetSwedishHolidays:
|
|
"""Test get_swedish_holidays function."""
|
|
|
|
def test_returns_dict(self) -> None:
|
|
"""get_swedish_holidays returns a dictionary."""
|
|
result = get_swedish_holidays(2026)
|
|
assert isinstance(result, dict)
|
|
assert len(result) > 0
|
|
|
|
def test_keys_are_dates(self) -> None:
|
|
"""All keys are datetime.date objects."""
|
|
result = get_swedish_holidays(2026)
|
|
for key in result.keys():
|
|
assert isinstance(key, datetime.date)
|
|
|
|
def test_values_are_strings(self) -> None:
|
|
"""All values are strings."""
|
|
result = get_swedish_holidays(2026)
|
|
for value in result.values():
|
|
assert isinstance(value, str)
|
|
assert len(value) > 0
|
|
|
|
def test_christmas_2026(self) -> None:
|
|
"""Christmas 2026 (Dec 25) is in the holidays."""
|
|
result = get_swedish_holidays(2026)
|
|
christmas = datetime.date(2026, 12, 25)
|
|
assert christmas in result
|
|
assert "Jul" in result[christmas] # Holiday name contains "Jul"
|
|
|
|
def test_new_year_2026(self) -> None:
|
|
"""New Year's Day 2026 (Jan 1) is in the holidays."""
|
|
result = get_swedish_holidays(2026)
|
|
new_year = datetime.date(2026, 1, 1)
|
|
assert new_year in result
|
|
|
|
def test_midsummer_2026(self) -> None:
|
|
"""Midsummer 2026 (Midsommar) is in the holidays."""
|
|
result = get_swedish_holidays(2026)
|
|
# Midsummer is always a Saturday in mid-June
|
|
midsummer_2026 = datetime.date(2026, 6, 20)
|
|
assert midsummer_2026 in result
|
|
assert "Midsommar" in result[midsummer_2026]
|
|
|
|
def test_easter_2026(self) -> None:
|
|
"""Easter-related days (Långfredagen, Påskdagen) in 2026."""
|
|
result = get_swedish_holidays(2026)
|
|
# Easter 2026 is April 5; Good Friday would be April 3
|
|
good_friday = datetime.date(2026, 4, 3)
|
|
easter_sunday = datetime.date(2026, 4, 5)
|
|
assert good_friday in result
|
|
assert easter_sunday in result
|
|
|
|
def test_different_years(self) -> None:
|
|
"""Different years have different holiday sets."""
|
|
holidays_2025 = get_swedish_holidays(2025)
|
|
holidays_2026 = get_swedish_holidays(2026)
|
|
holidays_2027 = get_swedish_holidays(2027)
|
|
|
|
# All should have holidays
|
|
assert len(holidays_2025) > 0
|
|
assert len(holidays_2026) > 0
|
|
assert len(holidays_2027) > 0
|
|
|
|
def test_caching(self) -> None:
|
|
"""Calling get_swedish_holidays twice returns same object (cached)."""
|
|
result1 = get_swedish_holidays(2026)
|
|
result2 = get_swedish_holidays(2026)
|
|
# Should be the same object due to caching
|
|
assert result1 is result2
|
|
|
|
def test_random_day_not_holiday(self) -> None:
|
|
"""Random weekday is not a holiday."""
|
|
result = get_swedish_holidays(2026)
|
|
random_day = datetime.date(2026, 6, 8) # Random Monday
|
|
assert random_day not in result
|
|
|
|
|
|
class TestIsSwedishHoliday:
|
|
"""Test is_swedish_holiday function."""
|
|
|
|
def test_christmas_is_holiday(self) -> None:
|
|
"""Christmas is recognized as a holiday."""
|
|
assert is_swedish_holiday(datetime.date(2026, 12, 25))
|
|
|
|
def test_new_year_is_holiday(self) -> None:
|
|
"""New Year's Day is recognized as a holiday."""
|
|
assert is_swedish_holiday(datetime.date(2026, 1, 1))
|
|
|
|
def test_random_day_not_holiday(self) -> None:
|
|
"""Random day is not a holiday."""
|
|
assert not is_swedish_holiday(datetime.date(2026, 6, 8))
|
|
|
|
def test_monday_not_holiday(self) -> None:
|
|
"""Random Monday is not a holiday."""
|
|
assert not is_swedish_holiday(datetime.date(2026, 6, 1))
|
|
|
|
|
|
class TestGetHolidayName:
|
|
"""Test get_holiday_name function."""
|
|
|
|
def test_christmas_name(self) -> None:
|
|
"""Get name of Christmas."""
|
|
name = get_holiday_name(datetime.date(2026, 12, 25))
|
|
assert name is not None
|
|
assert "Jul" in name
|
|
|
|
def test_non_holiday_returns_none(self) -> None:
|
|
"""Non-holiday date returns None."""
|
|
name = get_holiday_name(datetime.date(2026, 6, 8))
|
|
assert name is None
|
|
|
|
def test_new_year_name(self) -> None:
|
|
"""Get name of New Year's Day."""
|
|
name = get_holiday_name(datetime.date(2026, 1, 1))
|
|
assert name is not None
|
|
|
|
def test_midsummer_name(self) -> None:
|
|
"""Get name of Midsummer."""
|
|
name = get_holiday_name(datetime.date(2026, 6, 20))
|
|
assert name is not None
|
|
assert "Midsommar" in name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# De facto days (julafton, midsommarafton, nyårsafton) — cached-year source
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeFactoDaysAreNonWorking:
|
|
"""The calendar cache adds de facto days the `holidays` library doesn't
|
|
know about — these must count as free, closing the bug where the
|
|
allocator would spend a vacation day on julafton."""
|
|
|
|
def test_julafton_is_non_working(self) -> None:
|
|
assert is_non_working_day(datetime.date(2027, 12, 24))
|
|
assert is_swedish_holiday(datetime.date(2027, 12, 24))
|
|
name = get_holiday_name(datetime.date(2027, 12, 24))
|
|
assert name is not None
|
|
assert "Julafton" in name
|
|
|
|
def test_nyarsafton_is_non_working(self) -> None:
|
|
assert is_non_working_day(datetime.date(2027, 12, 31))
|
|
name = get_holiday_name(datetime.date(2027, 12, 31))
|
|
assert name is not None
|
|
assert "Nyårsafton" in name
|
|
|
|
def test_midsommarafton_is_non_working(self) -> None:
|
|
# Midsommardagen 2027 is Sat 2027-06-26 -> midsommarafton is Fri 6/25.
|
|
assert is_non_working_day(datetime.date(2027, 6, 25))
|
|
name = get_holiday_name(datetime.date(2027, 6, 25))
|
|
assert name is not None
|
|
assert "Midsommarafton" in name
|
|
|
|
def test_ordinary_weekday_is_still_working(self) -> None:
|
|
assert not is_non_working_day(datetime.date(2027, 3, 2)) # a plain Tuesday
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Klämdagar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestKlamdag:
|
|
def test_known_klamdag_2027_is_detected(self) -> None:
|
|
"""2027-05-06 (Thu) is Kristi himmelsfärdsdag; 2027-05-08 (Sat) is the
|
|
weekend; 2027-05-07 (Fri) is squeezed between them."""
|
|
assert is_klamdag(datetime.date(2027, 5, 7))
|
|
|
|
def test_julafton_itself_is_not_a_klamdag(self) -> None:
|
|
"""Julafton is a free day in its own right, not a working day
|
|
squeezed between two others."""
|
|
assert not is_klamdag(datetime.date(2027, 12, 24))
|
|
|
|
def test_ordinary_weekday_is_not_a_klamdag(self) -> None:
|
|
assert not is_klamdag(datetime.date(2027, 3, 2))
|
|
|
|
def test_klamdag_detected_for_uncached_fallback_year(self) -> None:
|
|
"""2040 is outside the cached range (2026-2035): 2040-04-30 (Mon) is
|
|
squeezed between Sunday 4/29 and Första maj (Tue 5/1). `is_klamdag`
|
|
is computed generically from `is_non_working_day`, so this works even
|
|
without cached klämdag data for the year."""
|
|
assert is_klamdag(datetime.date(2040, 4, 30))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_red_days — real red days, excluding ordinary Sundays
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetRedDays:
|
|
def test_bare_sunday_excluded(self) -> None:
|
|
"""2027-01-03 is a plain Sunday with no holiday -> excluded."""
|
|
red_days = get_red_days(2027)
|
|
assert datetime.date(2027, 1, 3) not in red_days
|
|
|
|
def test_easter_sunday_survives_the_filter(self) -> None:
|
|
"""Påskdagen 2027 (2027-03-28) IS a Sunday, but must survive: the
|
|
filter matches the exact string "Söndag" only, not `"Söndag" in name`.
|
|
A substring filter would silently delete this (and Pingstdagen,
|
|
Nationaldagen, Annandag jul whenever they land on a Sunday)."""
|
|
red_days = get_red_days(2027)
|
|
easter_sunday = datetime.date(2027, 3, 28)
|
|
assert easter_sunday in red_days
|
|
assert "Påskdagen" in red_days[easter_sunday]
|
|
|
|
def test_julafton_included_as_a_real_red_day(self) -> None:
|
|
red_days = get_red_days(2027)
|
|
assert datetime.date(2027, 12, 24) in red_days
|
|
|
|
def test_only_bare_sundays_are_removed(self) -> None:
|
|
all_holidays = get_swedish_holidays(2027)
|
|
red_days = get_red_days(2027)
|
|
removed = set(all_holidays) - set(red_days)
|
|
assert all(all_holidays[d] == "Söndag" for d in removed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fallback path for years outside the cached range (2026-2035)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFallbackForUncachedYear:
|
|
def test_uncached_year_still_returns_formal_holidays(self) -> None:
|
|
"""2040 is outside the cache -> falls back to the `holidays` library:
|
|
formal röda dagar are still known."""
|
|
result = get_swedish_holidays(2040)
|
|
assert datetime.date(2040, 12, 25) in result # Juldagen
|
|
assert datetime.date(2040, 1, 1) in result # Nyårsdagen (+ Söndag combo)
|
|
|
|
def test_uncached_year_does_not_know_de_facto_days(self) -> None:
|
|
"""Documents the fallback's known limitation: no de facto-day data
|
|
exists outside the cached range, so julafton is NOT marked free."""
|
|
assert not is_non_working_day(datetime.date(2040, 12, 24))
|
|
|
|
def test_uncached_year_easter_sunday_survives_sondag_filter(self) -> None:
|
|
"""The exact-match trap applies to the fallback path too."""
|
|
red_days = get_red_days(2040)
|
|
# Easter 2040: verified against `holidays.Sweden(years=2040)`.
|
|
easter_sunday = next(
|
|
d for d, name in get_swedish_holidays(2040).items() if "Påskdagen" in name
|
|
)
|
|
assert easter_sunday in red_days
|