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
184 lines
7.2 KiB
Python
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
|