Files
F-r-ldraledigplannerare/backend/tests/unit/test_calendar_service.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

184 lines
7.2 KiB
Python

"""Tests for `CalendarService` (§3.4 + §3.8: röda dagar / klämdagar API).
Covers: year-window pagination (fixed 4-year page, out-of-range rejection),
override merging (workspace DayOverrides layered on top of the statutory
calendar), and the exact-name holiday-matching trap (§4.2: Easter Sunday
comes back from the `holidays` library as the combined string
"Påskdagen; Söndag" — a naive `"Söndag" in name` filter would misclassify it
as a bare weekend).
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from app.entities.day_override import DayOverride
from app.services.calendar_service import MAX_WINDOW_YEARS, CalendarService
from app.services.day_override_service import DayOverrideService
from app.utils.errors import ValidationError
from app.utils.holidays import cached_year_bounds
from tests.conftest import SeededIds
@pytest.fixture
def calendar_service(day_override_service: DayOverrideService) -> CalendarService:
return CalendarService(day_override_service)
class TestYearWindowPagination:
async def test_default_window_is_four_years(self, calendar_service: CalendarService) -> None:
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
assert window.start_year == 2026
assert window.end_year == 2029
years_seen = {d.date.year for d in window.days}
assert years_seen == {2026, 2027, 2028, 2029}
async def test_second_page_starts_where_first_left_off(
self, calendar_service: CalendarService
) -> None:
first = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
second = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=first.end_year + 1
)
assert second.start_year == 2030
assert second.end_year == 2033
async def test_years_over_max_window_is_rejected(
self, calendar_service: CalendarService
) -> None:
with pytest.raises(ValidationError):
await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=2026, years=MAX_WINDOW_YEARS + 1
)
async def test_start_year_outside_cache_is_rejected(
self, calendar_service: CalendarService
) -> None:
bounds = cached_year_bounds()
assert bounds is not None, "cache file must exist for this repo's test suite"
_, max_year = bounds
with pytest.raises(ValidationError):
await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=max_year + 1
)
async def test_full_10_year_cache_is_never_returned_in_one_call(
self, calendar_service: CalendarService
) -> None:
bounds = cached_year_bounds()
assert bounds is not None
min_year, _ = bounds
window = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=min_year
)
years_seen = {d.date.year for d in window.days}
assert len(years_seen) == MAX_WINDOW_YEARS
class TestOverrideMerging:
async def test_worked_override_flips_a_statutory_red_day_to_not_free(
self,
calendar_service: CalendarService,
day_override_repo,
seeded_ids: SeededIds,
) -> None:
# 2026-12-25 (Juldagen) is a statutory red day; the workspace marks it
# a working day via an override.
christmas = date(2026, 12, 25)
await day_override_repo.create(
DayOverride(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
date=christmas,
is_working_day=True,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
by_date = {d.date: d for d in window.days}
christmas_day = by_date[christmas]
assert christmas_day.day_type == "red_day", "statutory classification must not change"
assert christmas_day.is_override is True
assert christmas_day.override_is_working_day is True
assert christmas_day.is_free is False, "the override must win over the statutory calendar"
async def test_off_override_flips_an_ordinary_working_day_to_free(
self,
calendar_service: CalendarService,
day_override_repo,
seeded_ids: SeededIds,
) -> None:
# 2026-01-02 is a bridge_day (klämdag) - an ordinary working day the
# employer happens to give off here.
ordinary_bridge_day = date(2026, 1, 2)
await day_override_repo.create(
DayOverride(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
date=ordinary_bridge_day,
is_working_day=False,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
by_date = {d.date: d for d in window.days}
day = by_date[ordinary_bridge_day]
assert day.day_type == "klamdag"
assert day.is_override is True
assert day.override_is_working_day is False
assert day.is_free is True
async def test_days_without_an_override_are_unaffected(
self, calendar_service: CalendarService, seeded_ids: SeededIds
) -> None:
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
assert all(not d.is_override and d.override_is_working_day is None for d in window.days)
class TestExactNameHolidayMatching:
"""§4.2: the `holidays` library combines a fixed holiday with "Söndag"
when it lands on a Sunday, e.g. "Påskdagen; Söndag". A naive
`"Söndag" in name` filter would wrongly classify these as bare weekends.
"""
async def test_easter_sunday_is_classified_as_red_day_even_when_it_reads_sondag(
self, calendar_service: CalendarService
) -> None:
# Easter Sunday always falls on a Sunday by definition, so in every
# cached year its cache/library name is combined with "Söndag".
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
easter_sundays = [d for d in window.days if d.name and "påskdagen" in d.name.lower()]
assert easter_sundays, "expected an Easter Sunday in the 2026 window"
for day in easter_sundays:
assert day.day_type == "red_day"
assert day.name != "Söndag", "must carry the real holiday name, not the bare marker"
async def test_bare_sundays_are_never_classified_as_red_days(
self, calendar_service: CalendarService
) -> None:
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
# 2026-01-04 is an ordinary Sunday with no coinciding holiday.
plain_sunday = date(2026, 1, 4)
by_date = {d.date: d for d in window.days}
day = by_date[plain_sunday]
assert day.day_type == "weekend"
assert day.name is None
assert day.is_free is True