Files
F-r-ldraledigplannerare/backend/tests/unit/test_holidays.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
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
2026-09-14 02:33:03 +02:00

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