"""Tests for the pure planning-engine Allocator. Implements the acceptance matrix in PLANNING_ENGINE_SPEC.md §5 (14 numbered cases). Every test builds an `AllocationInput` by hand (no database, no fixtures that touch I/O) and calls `Allocator().propose()` directly, with a frozen `today` — this is the whole point of the pure/impure split described at the top of `app.services.allocation_service`. Two clean, real months are used as "no red-day interference" windows so tier-3 (goal-dependent) behaviour can be tested in isolation, verified directly against the `holidays` package for the given years: - February 2027 (Mon 1 Feb -> Sun 28 Feb): 28 days, 8 free (4 Sat + 4 Sun), 20 spendable, zero real public holidays anywhere near the boundary. - August 2027 (Sun 1 Aug -> Tue 31 Aug): 31 days, 9 free (4 Sat + 5 Sun), 22 spendable, likewise clean. Where a test *wants* red-day-cluster (tier-2) behaviour, it uses a window around a real holiday (Easter 2027 / Jul 2027-2028) instead. """ from dataclasses import replace from datetime import date, timedelta from typing import Any from uuid import UUID, uuid4 from app.config import settings from app.entities.leave_wish import LeaveWish, project_window from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, REASON_FP_RED_DAY, REASON_VACATION_WRAP_RED_DAY, REASON_VACATION_WRAP_WEEKEND, STRATEGY_UNPLACED, AllocationInput, Allocator, ChildFacts, DayPool, ParentFacts, VacationBudget, ) from app.utils.holidays import get_swedish_holidays from app.utils.tip_predicates import resolve_age4_savable_cap # --------------------------------------------------------------------------- # Builders # --------------------------------------------------------------------------- def _holidays_for(*years: int) -> dict[date, str]: merged: dict[date, str] = {} for y in years: merged.update(get_swedish_holidays(y)) return merged def _child( *, birth_date: date, preschool_start_date: date | None = None, preschool_planning_days_per_year: int = 6, ) -> ChildFacts: from dateutil.relativedelta import relativedelta return ChildFacts( id=uuid4(), birth_date=birth_date, fourth_birthday=birth_date + relativedelta(years=4), preschool_start_date=preschool_start_date, preschool_planning_days_per_year=preschool_planning_days_per_year, ) def _parent( *, name: str = "Alex", salary_type: str = "monthly", employment_status: str = "employed", min_vacation_days_per_year: int = 20, ) -> ParentFacts: return ParentFacts( uuid=uuid4(), name=name, salary_type=salary_type, employment_status=employment_status, min_vacation_days_per_year=min_vacation_days_per_year, ) def _wish( *, child_id: UUID | None, name: str = "Test", start: tuple[int, int] = (2, 1), end: tuple[int, int] = (2, 28), weeks: int = 4, target_days: int | None = None, series_id: UUID | None = None, first_year: int = 2027, last_year: int | None = None, priority: int = 1, parent_mode: str = "either", parent_uuid: UUID | None = None, workspace_id: UUID | None = None, window_mode: str = "fixed", wish_id: UUID | None = None, name_message_key: str | None = None, wants_dubbeldag: bool = False, ) -> LeaveWish: return LeaveWish( id=wish_id or uuid4(), workspace_id=workspace_id or uuid4(), child_id=child_id, name=name, name_message_key=name_message_key, window_start_month=start[0], window_start_day=start[1], window_end_month=end[0], window_end_day=end[1], target_weeks=weeks, target_days=target_days, series_id=series_id, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_uuid, window_mode=window_mode, wants_dubbeldag=wants_dubbeldag, ) def _wish_series(*, first_year: int, last_year: int, **kwargs: Any) -> list[LeaveWish]: """The per-year rows `LeaveWishService` materialises for a repeating wish. A repeating wish is no longer one row the allocator expands; it is one row per year, each already carrying its own dates (see `app.entities.leave_wish`). Tests that used to build a single `recurrence='yearly'` wish build the rows here instead, through the same `project_window` the service uses — so the weekday snap these rows carry is the production projection, not a copy of it. """ series_id = uuid4() anchor = _wish(first_year=first_year, last_year=last_year, series_id=series_id, **kwargs) rows = [anchor] for year in range(first_year + 1, last_year + 1): start, end = project_window( start_month=anchor.window_start_month, start_day=anchor.window_start_day, end_month=anchor.window_end_month, end_day=anchor.window_end_day, anchor_year=first_year, year=year, window_mode=anchor.window_mode, ) rows.append( replace( anchor, id=uuid4(), first_year=year, window_start_month=start.month, window_start_day=start.day, window_end_month=end.month, window_end_day=end.day, ) ) return rows def _build_input( *, today: date, horizon_end: date, children: list[ChildFacts], parents: list[ParentFacts], pools: dict[tuple[UUID, UUID], DayPool], vacation: dict[tuple[UUID, int], VacationBudget], wishes: list[LeaveWish], existing_planned: list | None = None, all_existing_planned: list | None = None, extra_holiday_years: tuple[int, ...] = (), day_overrides: dict[date, bool] | None = None, age4_savable_cap: int | None = None, take_pl_on_red_days: bool = False, excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None, preschool_reserve_by_year: dict[tuple[UUID, int], int] | None = None, vacation_forfeiting: dict[tuple[UUID, int], int] | None = None, ) -> AllocationInput: years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years} if age4_savable_cap is None: # Same resolution `AllocationInputLoader` uses in production — see # `app.utils.tip_predicates.resolve_age4_savable_cap`. Computed here # by default so existing callers that don't care about the age-4 cap # split keep getting the historically-correct figure for their # `parents` count without having to pass it explicitly. age4_savable_cap = resolve_age4_savable_cap(len(parents)) return AllocationInput( today=today, horizon_end=horizon_end, age4_savable_cap=age4_savable_cap, children=children, parents=parents, pools=pools, vacation=vacation, wishes=wishes, existing_planned=existing_planned or [], holidays=_holidays_for(*years), day_overrides=day_overrides or {}, take_pl_on_red_days=take_pl_on_red_days, all_existing_planned=all_existing_planned, excluded_days=excluded_days or frozenset(), preschool_reserve_by_year=preschool_reserve_by_year or {}, vacation_forfeiting=vacation_forfeiting or {}, ) def _planned( *, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation", wish_id: UUID | None = None, is_pinned: bool = False, ) -> PlannedLeaveDay: """A persisted `planned_leave_day` row — the "already applied, before this `propose()` run" building block for the §fix (double-booking on flexible re-propose; extended for §fix parent-reassignment, decision B) test cases below. Only the fields `AllocationInput.existing_planned`/ `all_existing_planned` actually read (`parent_personal_data_uuid`, `child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id` is what `_Ledger._persisted_by_wish` now matches a flexible wish's own prior placement by — pass the wish's `.id` whenever a test relies on that reuse mechanism finding these rows. `is_pinned=True` marks a date the user locked in place — the Step 3 realignment escape hatch must never move a window that contains one. """ return PlannedLeaveDay( id=uuid4(), workspace_id=uuid4(), child_id=child_id, parent_personal_data_uuid=parent_uuid, date=on, day_type=day_type, wish_id=wish_id, is_pinned=is_pinned, ) FEB_2027 = ((2, 1), (2, 28)) # 28 days, 20 spendable, no real holidays nearby AUG_2027 = ((8, 1), (8, 31)) # 31 days, 22 spendable, no real holidays nearby # --------------------------------------------------------------------------- # #1 — solo, child < 12 months, monthly salary -> the single policy still # falls back to FP once vacation is exhausted (the old age-gated "skip FP for # the first 12 months" planning-goal special case was removed entirely; # nothing about the tier-3 policy special-cases a child's age any more). # --------------------------------------------------------------------------- class TestCase1SoloUnder12MonthsFallsBackToFP: def test_fp_used_before_12_months_once_vacation_exhausted(self) -> None: child = _child(birth_date=date(2026, 9, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4, first_year=2027, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 assert outcome.cost_breakdown["vacation"] == 0 assert outcome.cost_breakdown["sjukpenning"] == 20 assert outcome.cost_breakdown["lagsta"] == 0 # --------------------------------------------------------------------------- # #2 — couple, 6-week summer -> parent_swap, 3+3 # --------------------------------------------------------------------------- class TestCase2CoupleSwap: def test_swap_splits_6_weeks_into_3_plus_3(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=(6, 1), end=(7, 12), # 42 days weeks=6, first_year=2027, parent_mode="either", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(42, 0, 0), (parent_b.uuid, 2027): VacationBudget(42, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.strategy == "parent_swap" assert outcome.shortfall_days == 0 assert outcome.weeks_funded == 6.0 days_a = [d for d in proposal.days if d.parent_personal_data_uuid == parent_a.uuid] days_b = [d for d in proposal.days if d.parent_personal_data_uuid == parent_b.uuid] assert len(days_a) == 21 assert len(days_b) == 21 assert {d.parent_personal_data_uuid for d in proposal.days} == { parent_a.uuid, parent_b.uuid, } # --------------------------------------------------------------------------- # #3 — monthly salary -> vacation on every spendable day # --------------------------------------------------------------------------- class TestCase3MonthlySalaryVacationFirst: def test_vacation_lands_on_fridays_and_mondays_around_weekends(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 assert outcome.cost_breakdown["vacation"] == 20 assert outcome.cost_breakdown["sjukpenning"] == 0 by_date = {d.date: d for d in proposal.days} # 2027-02-01 is a Monday, so Fri 2027-02-05 and Mon 2027-02-08 bracket # the first full weekend inside the window. friday = by_date[date(2027, 2, 5)] monday = by_date[date(2027, 2, 8)] assert friday.day_type == "vacation" assert friday.reason == REASON_VACATION_WRAP_WEEKEND assert monday.day_type == "vacation" assert monday.reason == REASON_VACATION_WRAP_WEEKEND # --------------------------------------------------------------------------- # #4 — hourly salary -> wrapping suppressed # --------------------------------------------------------------------------- class TestCase4HourlySalaryFPFirst: def test_wrapping_suppressed_for_hourly(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.cost_breakdown["vacation"] == 0 assert outcome.cost_breakdown["sjukpenning"] == 20 assert not any(d.reason == REASON_VACATION_WRAP_WEEKEND for d in proposal.days) assert not any(d.day_type == "vacation" for d in proposal.days) # --------------------------------------------------------------------------- # #5 — wishes exceed budget -> lower-priority window shows a shortfall # --------------------------------------------------------------------------- class TestCase5ShortfallByPriority: def test_higher_priority_window_funded_first(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") wish_high = _wish( child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1 ) wish_low = _wish( child_id=child.id, name="Aug", start=AUG_2027[0], end=AUG_2027[1], weeks=4, priority=2 ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(30, 0, 60)}, vacation={}, wishes=[wish_high, wish_low], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} assert by_name["Feb"].shortfall_days == 0 assert by_name["Aug"].shortfall_days == 12 # 22 needed, only 10 left of 30 assert any(r.kind for r in by_name["Aug"].remedies) or by_name["Aug"].remedies == [] # --------------------------------------------------------------------------- # #6 — Jullov wraps years; vacation debited from both years # --------------------------------------------------------------------------- class TestCase6YearWrapping: def test_vacation_debited_from_both_years(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Jullov", start=(12, 20), end=(1, 7), weeks=3, first_year=2027, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(25, 0, 0), (parent.uuid, 2028): VacationBudget(25, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) vac_2027 = proposal.balances_after.vacation_after[(parent.uuid, 2027)] vac_2028 = proposal.balances_after.vacation_after[(parent.uuid, 2028)] assert vac_2027.already_used > 0 assert vac_2028.already_used > 0 outcome = proposal.outcomes[0] assert outcome.start_date.year == 2027 assert outcome.end_date.year == 2028 # --------------------------------------------------------------------------- # #7 — two children, overlapping windows -> pools kept separate # --------------------------------------------------------------------------- class TestCase7SeparatePoolsPerChild: def test_pools_not_shared_across_children(self) -> None: child_a = _child(birth_date=date(2020, 1, 1)) child_b = _child(birth_date=date(2021, 1, 1)) parent = _parent(salary_type="hourly") wish_a = _wish(child_id=child_a.id, name="A", start=FEB_2027[0], end=FEB_2027[1], weeks=4) wish_b = _wish(child_id=child_b.id, name="B", start=FEB_2027[0], end=FEB_2027[1], weeks=4) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child_a, child_b], parents=[parent], pools={ (parent.uuid, child_a.id): DayPool(20, 0, 60), # exactly enough (parent.uuid, child_b.id): DayPool(5, 0, 60), # not enough }, vacation={}, wishes=[wish_a, wish_b], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} assert by_name["A"].shortfall_days == 0 assert by_name["B"].shortfall_days == 15 # 20 needed, only 5 available pool_a_after = proposal.balances_after.pools_after[(parent.uuid, child_a.id)] pool_b_after = proposal.balances_after.pools_after[(parent.uuid, child_b.id)] assert pool_a_after.sjukpenning_left == 0 assert pool_b_after.sjukpenning_left == 0 # fully drained but never borrowed from A # --------------------------------------------------------------------------- # #8 — projected_left_at_b4 > 96 -> days_lost_at_age_4 warning # --------------------------------------------------------------------------- class TestCase8DaysLostAtAge4Warning: def test_warning_raised_when_surplus_would_be_lost(self) -> None: child = _child(birth_date=date(2024, 1, 1)) parent = _parent() alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 0, 60)}, vacation={}, wishes=[], ) proposal = Allocator().propose(alloc_input) age4_warnings = [w for w in proposal.warnings if w.kind == "days_lost_at_age_4"] assert len(age4_warnings) == 1 assert age4_warnings[0].data["expiring_days"] == 104 # 200 - 96 # --------------------------------------------------------------------------- # #9 — window containing (movable) Easter -> red-day cluster in correct year # --------------------------------------------------------------------------- class TestCase9MovableEaster: def test_klamdagar_around_easter_2027_detected(self) -> None: # Easter 2027: Langfredagen Fri 3/26, Paskdagen Sun 3/28, Annandag pask # Mon 3/29. Verified against `holidays.Sweden(years=2027, language="sv")`. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish(child_id=child.id, name="Påsklov", start=(3, 22), end=(4, 2), weeks=2) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) by_date = {d.date: d for d in proposal.days} thursday_before = by_date[date(2027, 3, 25)] tuesday_after = by_date[date(2027, 3, 30)] assert thursday_before.day_type == "vacation" assert thursday_before.reason == REASON_VACATION_WRAP_RED_DAY assert tuesday_after.day_type == "vacation" assert tuesday_after.reason == REASON_VACATION_WRAP_RED_DAY # --------------------------------------------------------------------------- # #10 — dubbeldag outside 15 months -> rejected, falls back to sequential # --------------------------------------------------------------------------- class TestCase10DubbeldagOutsideWindow: """§Decision A (co-leave, 2026-09-18 correction) REVERSES this case's old product behaviour: a `parent_mode='both'` wish outside the 15-month dubbeldagar window (or, per the correction, simply never opting in via `wants_dubbeldag`) no longer falls back to a sequential parent-swap. It now co-leaves: BOTH parents off on every SAME date, funded independently (PL to whichever parent has more FP left, vacation to the other) — "both" means both parents home together, not "each parent gets half the weeks". See PLANNING_ENGINE_SPEC.md-adjacent handoff notes and `allocation_service.py`'s `_Ledger._fund_both` docstring. """ def test_co_leave_places_both_parents_on_the_same_dates_when_outside_15_months(self) -> None: child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01 parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], # well after the 15-month window weeks=4, parent_mode="both", # wants_dubbeldag defaults False -- opt-in only (Decision A). ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(20, 0, 0), (parent_b.uuid, 2027): VacationBudget(20, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) assert not any(d.is_dubbeldag for d in proposal.days) assert not any(d.reason == REASON_DUBBELDAG for d in proposal.days) assert {d.parent_personal_data_uuid for d in proposal.days} == { parent_a.uuid, parent_b.uuid, } outcome = proposal.outcomes[0] assert outcome.strategy == "co_leave" assert outcome.requested_strategy == "co_leave" assert outcome.strategy_degraded is False assert outcome.shortfall_days == 0 # The headline product behaviour: every non-free day is BOTH parents # on the SAME date (not parent_a taking the first half of the month # and parent_b the second, which is what the old sequential-swap # fallback would have produced), one funded from FP, the other from # vacation. non_free = [d for d in proposal.days if d.day_type != "free"] by_date: dict[date, list] = {} for d in non_free: by_date.setdefault(d.date, []).append(d) assert by_date, "expected at least one funded day" for on_date, entries in by_date.items(): parents_that_day = {e.parent_personal_data_uuid for e in entries} assert parents_that_day == {parent_a.uuid, parent_b.uuid}, ( f"{on_date}: expected both parents, got {parents_that_day}" ) # Co-leave's PL+vacation split: exactly one FP day + one vacation # day per date -- ample pools here mean it never has to fall back # further, so this holds for every date, not just "on average". types_that_day = sorted(e.day_type for e in entries) assert types_that_day[1] == "vacation", (on_date, types_that_day) assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day) # --------------------------------------------------------------------------- # #11 — re-run propose with identical input -> byte-identical output # --------------------------------------------------------------------------- class TestCase11Determinism: def test_repeated_runs_are_identical(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish(child_id=child.id, start=(6, 1), end=(7, 12), weeks=6, parent_mode="either") alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(42, 0, 0), (parent_b.uuid, 2027): VacationBudget(42, 0, 0), }, wishes=[wish], ) allocator = Allocator() proposal_1 = allocator.propose(alloc_input) proposal_2 = allocator.propose(alloc_input) assert proposal_1 == proposal_2 # --------------------------------------------------------------------------- # #12 — wish window after the 4th birthday -> FP pool hard-capped at 96 # --------------------------------------------------------------------------- class TestCase12Age4HardCap: def test_pool_clamped_to_96_after_fourth_birthday(self) -> None: child = _child(birth_date=date(2020, 1, 1)) # B4 = 2024-01-01, already past parent = _parent(salary_type="hourly") wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) alloc_input = _build_input( today=date(2027, 1, 15), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 0, 60)}, vacation={}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.cost_breakdown["sjukpenning"] == 20 # all 20 spendable Feb days funded pool_after = proposal.balances_after.pools_after[(parent.uuid, child.id)] remaining = pool_after.sjukpenning_left + pool_after.lagsta_left # Clamped to 96 BEFORE spending, then 20 consumed -> 76, not 200-20=180. assert remaining == 76 def test_pool_clamped_to_48_each_for_two_parent_couple(self) -> None: """The per-parent mirror of the above: a `couple` workspace with two parents must clamp EACH parent's pool to 48 (not 96) at the 4th birthday — this is the headline bug the age-4-cap task exists to fix (a two-parent family was previously able to save 96 each, 192 combined, double the legal figure). """ child = _child(birth_date=date(2020, 1, 1)) # B4 = 2024-01-01, already past parent_a = _parent(name="A", salary_type="hourly") parent_b = _parent(name="B", salary_type="hourly") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4, parent_mode="specific", parent_uuid=parent_a.uuid, ) alloc_input = _build_input( today=date(2027, 1, 15), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 0, 60), (parent_b.uuid, child.id): DayPool(200, 0, 60), }, vacation={}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.cost_breakdown["sjukpenning"] == 20 # all 20 spendable Feb days funded pool_a_after = proposal.balances_after.pools_after[(parent_a.uuid, child.id)] remaining_a = pool_a_after.sjukpenning_left + pool_a_after.lagsta_left # Clamped to 48 BEFORE spending, then 20 consumed -> 28, not 200-20=180. assert remaining_a == 28 # Parent B never has a window funded from their pool in this test, # but their untouched pool is still clamped the first time ANY # window starting on/after B4 is funded for this (parent, child) — # here that never happens for B, so B's pool stays unclamped at 200. # The important invariant is A's clamp target, asserted above. age4_a = next( p for p in proposal.age4_projections if p.parent_personal_data_uuid == parent_a.uuid ) assert age4_a.savable_cap_at_b4 == 48 # --------------------------------------------------------------------------- # #13 — shortfall caused by the preschool reserve -> matching remedy # --------------------------------------------------------------------------- class TestCase13PreschoolReserveRemedy: def test_remedy_reports_exact_reserved_days(self) -> None: child = _child( birth_date=date(2020, 1, 1), preschool_start_date=date(2027, 6, 1), preschool_planning_days_per_year=6, ) parent = _parent(salary_type="hourly") wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), # single year -> reserve = 6*1 = 6 children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(15, 0, 60)}, vacation={}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 11 # 20 needed, (15-6)=9 available after reserve remedy = next(r for r in outcome.remedies if r.kind == "reduce_preschool_reserve") assert remedy.days_freed == 6 # --------------------------------------------------------------------------- # REGRESSION: solo parent with exhausted vacation but available FP # --------------------------------------------------------------------------- class TestRegressionSoloVacationExhaustedFPAvailable: """Regression test for the bug where a solo parent with recurring wishes for multiple years would report a shortfall once vacation budget was exhausted, even with healthy sjukpenning/lägstanivå pools available. The allocator was giving up instead of falling back to föräldrapenning days. Real-world scenario: solo parent, 6-week Sommar wish for 2027–2030, but vacation budget only for 2026 and 2027 (exhausted by 2028). Expected to fund all four summers using FP pools; instead reported shortfalls 2028–2030 while holding 480 unused föräldrapenning days. """ def test_falls_back_to_fp_when_vacation_exhausted(self) -> None: """Solo parent with vacation-only budget insufficient for multi-year recurring wish, but with healthy sjukpenning/lägstanivå available. Must fund the wish fully without shortfall and must spend FP days. """ child = _child(birth_date=date(2026, 6, 1)) parent = _parent(salary_type="hourly") # Recurring 6-week summer wish, 2027–2030. wishes = [ _wish( child_id=child.id, name="Sommar 2027", start=(6, 15), end=(7, 31), # ~6 weeks weeks=6, first_year=2027, parent_mode="either", ), _wish( child_id=child.id, name="Sommar 2028", start=(6, 15), end=(7, 31), weeks=6, first_year=2028, parent_mode="either", ), _wish( child_id=child.id, name="Sommar 2029", start=(6, 15), end=(7, 31), weeks=6, first_year=2029, parent_mode="either", ), _wish( child_id=child.id, name="Sommar 2030", start=(6, 15), end=(7, 31), weeks=6, first_year=2030, parent_mode="either", ), ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), children=[child], parents=[parent], # Healthy FP pools — must be used as fallback when vacation runs out. pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, # Vacation only for 2027; by 2028, it's exhausted. vacation={(parent.uuid, 2027): VacationBudget(30, 0, 0)}, wishes=wishes, extra_holiday_years=(2028, 2029, 2030), ) proposal = Allocator().propose(alloc_input) # All four summer windows must be fully funded (shortfall_days == 0). by_name = {o.wish_name: o for o in proposal.outcomes} for year in [2027, 2028, 2029, 2030]: outcome = by_name[f"Sommar {year}"] assert outcome.shortfall_days == 0, f"Sommar {year} should have no shortfall" assert outcome.weeks_funded >= 6.0, f"Sommar {year} should fund at least 6 weeks" # Verify that föräldrapenning days were actually spent (not just vacation). fp_days = [d for d in proposal.days if d.day_type in ("sjukpenning", "lagsta")] assert len(fp_days) > 0, "FP days must be spent as fallback when vacation is exhausted" # After 2027 (when vacation budget is exhausted), subsequent years should use FP. days_2028 = [d for d in proposal.days if d.date.year == 2028] fp_days_2028 = [d for d in days_2028 if d.day_type in ("sjukpenning", "lagsta")] assert len(fp_days_2028) > 0, "2028+ must use FP days since vacation is exhausted" def test_fp_spending_increases_across_years(self) -> None: """As vacation budget exhausts year-by-year, FP spending must increase correspondingly to fund the same-sized wishes. """ child = _child(birth_date=date(2026, 6, 1)) parent = _parent(salary_type="hourly") wishes = [ _wish( child_id=child.id, name="Sommar 2027", start=(6, 15), end=(7, 31), weeks=6, first_year=2027, ), _wish( child_id=child.id, name="Sommar 2028", start=(6, 15), end=(7, 31), weeks=6, first_year=2028, ), ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(30, 0, 0), (parent.uuid, 2028): VacationBudget(0, 0, 0), # Exhausted in 2028 }, wishes=wishes, extra_holiday_years=(2028,), ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} # Both summers should have no shortfall despite vacation exhaustion. assert by_name["Sommar 2027"].shortfall_days == 0 assert by_name["Sommar 2028"].shortfall_days == 0 # --------------------------------------------------------------------------- # REGRESSION: invariant — no shortfall while fundable resources exist # --------------------------------------------------------------------------- class TestRegressionNoShortfallWhileResourcesRemain: """Regression test for the most general form of the bug: the allocator must never report a shortfall while there are unused, fundable resources available. If a shortfall is reported, then ALL pools (vacation, sjukpenning, lägstanivå) for that parent must be exhausted. This is the headline invariant that the old code violated. """ def _assert_shortfall_invariant(self, proposal) -> None: """Helper: validate the invariant across all outcomes. If a window reports a shortfall, then the closing balances for that window must show no remaining fundable resources (vacation, sjukpenning, and lägstanivå all at or near zero). """ # Build a mapping from wish_id to the days that were allocated for it. days_by_wish: dict = {} for day in proposal.days: if day.wish_id not in days_by_wish: days_by_wish[day.wish_id] = [] days_by_wish[day.wish_id].append(day) for outcome in proposal.outcomes: if outcome.shortfall_days > 0: # Shortfall exists — verify that we tried to fund it but couldn't. # Get the parent ID from the proposed days for this wish. wish_days = days_by_wish.get(outcome.wish_id, []) if not wish_days: # Edge case: no days proposed at all (wish entirely outside range). continue parent_id = wish_days[0].parent_personal_data_uuid child_id = wish_days[0].child_id # Check that FP pools are exhausted for this (parent, child) after this window. pool_after = proposal.balances_after.pools_after.get((parent_id, child_id)) if pool_after is not None: # At least one of the FP pools should be at zero (not both, because # lägstanivå is a stretched tier, so sjukpenning may be spent first). # But we expect sjukpenning to be zero when shortfall occurs. assert pool_after.sjukpenning_left == 0, ( f"Shortfall {outcome.wish_name}: {pool_after.sjukpenning_left} sjukpenning days remain" ) # Check vacation is also exhausted for the year(s) in this window. vacation_after = proposal.balances_after.vacation_after for year in range(outcome.start_date.year, outcome.end_date.year + 1): vac = vacation_after.get((parent_id, year)) if vac is not None: vac_available = vac.available assert vac_available <= 0, ( f"Shortfall {outcome.wish_name}: {vac_available} vacation days remain in {year}" ) def test_invariant_holds_on_solo_vacation_exhausted(self) -> None: """Multi-year solo scenario: vacation exhausts, FP fills in. Invariant: no shortfall should appear (because FP is available). """ child = _child(birth_date=date(2026, 6, 1)) parent = _parent(salary_type="hourly") wishes = [ _wish( child_id=child.id, name="Window 2027", start=(6, 15), end=(7, 31), weeks=6, first_year=2027, ), _wish( child_id=child.id, name="Window 2028", start=(6, 15), end=(7, 31), weeks=6, first_year=2028, ), ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(30, 0, 0)}, wishes=wishes, extra_holiday_years=(2028,), ) proposal = Allocator().propose(alloc_input) # All outcomes should have shortfall_days == 0 (FP available). for outcome in proposal.outcomes: assert outcome.shortfall_days == 0, ( f"{outcome.wish_name}: shortfall reported despite FP availability" ) # Invariant trivially holds (no shortfalls exist). self._assert_shortfall_invariant(proposal) def test_invariant_holds_on_exhausted_scenario(self) -> None: """Scenario where all pools are truly exhausted: the invariant must still hold (if shortfall is reported, all pools are empty). """ child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") wish_a = _wish( child_id=child.id, name="A", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1, ) wish_b = _wish( child_id=child.id, name="B", start=AUG_2027[0], end=AUG_2027[1], weeks=4, priority=2, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], # Total pool: 30 sjukpenning. Feb needs ~20, Aug needs ~22. # Feb gets funded, Aug is short 12. pools={(parent.uuid, child.id): DayPool(30, 0, 60)}, vacation={}, wishes=[wish_a, wish_b], ) proposal = Allocator().propose(alloc_input) # Verify the invariant: if shortfall, then all pools exhausted. self._assert_shortfall_invariant(proposal) # And verify that Aug actually has the shortfall. by_name = {o.wish_name: o for o in proposal.outcomes} assert by_name["B"].shortfall_days > 0, "Aug should have shortfall" # --------------------------------------------------------------------------- # REGRESSION: genuine shortfall is still reported when all pools empty # --------------------------------------------------------------------------- class TestRegressionGenuineShortfallStillReported: """Regression test: when every pool is genuinely exhausted, the allocator must still report a shortfall. Ensure the fix to support FP fallback didn't accidentally suppress all shortfall reporting. """ def test_shortfall_reported_when_all_pools_empty(self) -> None: """Single high-priority window with zero pools: shortfall must appear.""" child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") wish = _wish( child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], # Empty pools. pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, vacation={}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days > 0, "Must report shortfall when pools are empty" def test_shortfall_reported_on_low_priority_after_high_exhausted(self) -> None: """Two windows, finite total budget. High-priority funded, low-priority short. Low-priority shortfall must be reported. """ child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") wish_high = _wish( child_id=child.id, name="High", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1, ) wish_low = _wish( child_id=child.id, name="Low", start=AUG_2027[0], end=AUG_2027[1], weeks=4, priority=2, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], # Total 25 days: barely enough for Feb (20), but not Aug (22). pools={(parent.uuid, child.id): DayPool(25, 0, 60)}, vacation={}, wishes=[wish_high, wish_low], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} assert by_name["High"].shortfall_days == 0, "High priority must be funded" assert by_name["Low"].shortfall_days > 0, "Low priority must report shortfall" # --------------------------------------------------------------------------- # REGRESSION: two-parent path (parent_swap) still works # --------------------------------------------------------------------------- class TestRegressionTwoParentSwapUnaffected: """Regression test: the fix to support FP fallback for solo parents must not have broken the existing parent-swap (couple) path. Confirm a basic two-parent scenario still splits the wish as expected. """ def test_couple_swap_still_works(self) -> None: """Two parents, 6-week wish with parent_mode='either'. Should split 3+3 as before. This path already worked; ensure it hasn't regressed. """ child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=(6, 1), end=(7, 12), weeks=6, first_year=2027, parent_mode="either", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(42, 0, 0), (parent_b.uuid, 2027): VacationBudget(42, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0, "Two-parent swap must fund fully" assert outcome.strategy == "parent_swap" assert outcome.weeks_funded == 6.0 days_a = [d for d in proposal.days if d.parent_personal_data_uuid == parent_a.uuid] days_b = [d for d in proposal.days if d.parent_personal_data_uuid == parent_b.uuid] assert len(days_a) == 21, "Parent A should have ~21 days (3 weeks)" assert len(days_b) == 21, "Parent B should have ~21 days (3 weeks)" # --------------------------------------------------------------------------- # PLANNING_ENGINE_SPEC.md §10 — "när det passar bäst" flexible wishes. # §5 rows 15-24. VÅR_2027 = 2027-03-01 -> 2027-05-31, a bounding range # containing Easter 2027 (Långfredagen 03-26 -> Annandag påsk 03-29). # --------------------------------------------------------------------------- VAR_2027 = ((3, 1), (5, 31)) class TestCase15FlexibleWishFindsEasterCluster: def test_candidate_touching_easter_wins_at_minimal_cost(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 assert outcome.weeks_funded == outcome.weeks_wished == 1 # The Easter cluster (Långfredagen..Annandag påsk, 4 free days) is # inside the winning 7-day window -> only 3 spendable days. assert outcome.start_date == date(2027, 3, 23) assert outcome.end_date == date(2027, 3, 29) assert outcome.cost_breakdown["free"] == 4 assert outcome.cost_breakdown["vacation"] == 3 class TestCase16FlexibleTieBreakByEarliestStart: def test_equal_cost_candidates_break_tie_by_earliest_start(self) -> None: # A holiday-free bound (Feb 2027): every 7-day slice contains # exactly one Saturday and one Sunday, so every candidate has the # *same* calendar-only cost (5) regardless of start date. Since # §Decision 2, the tie is actually broken by Monday-alignment first, # THEN earliest start -- 2027-02-01 happens to be a Monday, so this # still pins down the same winner; `TestFlexibleWishPrefersMonday` # below exercises the Monday-preference itself against a bound whose # earliest start is NOT a Monday. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Sportlov", start=FEB_2027[0], end=FEB_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 2, 1) assert outcome.end_date == date(2027, 2, 7) # 5 spendable days (2 free: Sat+Sun) -- split across vacation/FP per # the single tier-3 policy, but always 5 total, which is what the # tie-break must be indifferent to (every start in this bound costs # exactly 5). spendable = ( outcome.cost_breakdown["vacation"] + outcome.cost_breakdown["sjukpenning"] + outcome.cost_breakdown["lagsta"] ) assert spendable == 5 assert outcome.cost_breakdown["free"] == 2 class TestFlexibleWishSizedByTargetDays: """`target_days` (day-granular, e.g. a calendar-drawn band) must drive the flexible-window search's candidate length via `target_span_days()`, not the stale/unrelated `target_weeks` value alongside it. """ def test_resolved_window_length_matches_target_days_not_target_weeks(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Bandlov", start=FEB_2027[0], end=FEB_2027[1], # 28-day bound weeks=4, # would mean 28 days if target_days were ignored target_days=10, # the value that must actually win first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert (outcome.end_date - outcome.start_date).days + 1 == 10 assert outcome.shortfall_days == 0 class TestCase17TwoFlexibleWishesPriorityOrdering: def test_priority_one_gets_cheapest_candidate_priority_two_scored_on_rest(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") high = _wish( child_id=child.id, name="High", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, priority=1, window_mode="flexible", ) low = _wish( child_id=child.id, name="Low", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, priority=2, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, wishes=[high, low], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} high_outcome = by_name["High"] low_outcome = by_name["Low"] # Priority 1 gets the globally cheapest (Easter) window. assert high_outcome.start_date == date(2027, 3, 23) assert high_outcome.end_date == date(2027, 3, 29) # Priority 2 must not overlap priority 1's claimed dates, and (since # the Easter window was taken) cannot be cheaper than it. high_dates = {high_outcome.start_date + timedelta(days=i) for i in range(7)} low_dates = {low_outcome.start_date + timedelta(days=i) for i in range(7)} assert not (high_dates & low_dates) high_cost = sum(v for k, v in high_outcome.cost_breakdown.items() if k != "free") low_cost = sum(v for k, v in low_outcome.cost_breakdown.items() if k != "free") assert low_cost >= high_cost class TestCase18FixedWishAlwaysWinsOverFlexible: def test_fixed_wish_dates_are_never_revisited_by_flexible_candidates(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") fixed = _wish( child_id=child.id, name="Fixed", start=(3, 23), end=(3, 29), weeks=1, first_year=2027, window_mode="fixed", ) flexible = _wish( child_id=child.id, name="Flex", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, wishes=[fixed, flexible], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} fixed_outcome = by_name["Fixed"] flex_outcome = by_name["Flex"] # The fixed wish gets its exact requested placement, unconditionally. assert fixed_outcome.start_date == date(2027, 3, 23) assert fixed_outcome.end_date == date(2027, 3, 29) fixed_dates = {fixed_outcome.start_date + timedelta(days=i) for i in range(7)} flex_dates = {flex_outcome.start_date + timedelta(days=i) for i in range(7)} assert not (fixed_dates & flex_dates) class TestCase19FlexibleBoundShrunkByHorizonIsPartialNotUnplaced: def test_shrunk_bound_reported_as_partial_shortfall(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Flex", start=VAR_2027[0], end=VAR_2027[1], weeks=1, # needs 7 days first_year=2027, window_mode="flexible", ) alloc_input = _build_input( # Horizon ends mid-bound, leaving only 6 days -- shorter than the # 7 days target_weeks needs. today=date(2027, 5, 20), horizon_end=date(2027, 5, 25), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.strategy != STRATEGY_UNPLACED assert outcome.start_date == date(2027, 5, 20) assert outcome.end_date == date(2027, 5, 25) assert outcome.weeks_funded < outcome.weeks_wished class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: def test_strategy_unplaced_when_bound_fully_claimed_by_fixed_wish(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") # A fixed wish spanning the entire bound (13 weeks ~ 91 days covers # 03-01 -> 05-31) leaves no full-length candidate free anywhere. blocker = _wish( child_id=child.id, name="Blocker", start=VAR_2027[0], end=VAR_2027[1], weeks=13, first_year=2027, window_mode="fixed", ) flexible = _wish( child_id=child.id, name="Flex", name_message_key="wishEditor.seasonVar", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, wishes=[blocker, flexible], ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} flex_outcome = by_name["Flex"] assert flex_outcome.strategy == STRATEGY_UNPLACED # build_unplaced_outcome (§10.4) is a separate WindowOutcome # construction site from fund_window's -- assert its # wish_name_message_key mapping independently so neither can drop it. assert flex_outcome.wish_name_message_key == "wishEditor.seasonVar" assert flex_outcome.data["wish_name_message_key"] == "wishEditor.seasonVar" assert flex_outcome.weeks_funded == 0.0 assert flex_outcome.shortfall_days == 7 # target_weeks * 7 assert flex_outcome.start_date == date(2027, 3, 1) assert flex_outcome.end_date == date(2027, 5, 31) assert flex_outcome.cost_breakdown == { "free": 0, "vacation": 0, "sjukpenning": 0, "lagsta": 0, } # §10.4: shift_window_into_free_days is omitted -- there is no single # winning window edge left to shift. assert all(r.kind != "shift_window_into_free_days" for r in flex_outcome.remedies) assert not any(d.wish_id == flexible.id for d in proposal.days) class TestCase21FlexibleYearlyWishTracksMovingEaster: def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wishes = _wish_series( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, last_year=2029, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(400, 90, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(50, 0, 0), (parent.uuid, 2028): VacationBudget(50, 0, 0), (parent.uuid, 2029): VacationBudget(50, 0, 0), }, wishes=wishes, extra_holiday_years=(2028, 2029), ) proposal = Allocator().propose(alloc_input) by_year = {o.year: o for o in proposal.outcomes} assert set(by_year) == {2027, 2028, 2029} # Easter falls on a different date each year -> each year's picked # window must be different (a moving target), and each must be # fully funded at minimal cost. starts = {o.start_date for o in by_year.values()} assert len(starts) == 3 assert by_year[2027].start_date == date(2027, 3, 23) assert by_year[2028].start_date == date(2028, 4, 11) assert by_year[2029].start_date == date(2029, 3, 27) for o in by_year.values(): assert o.shortfall_days == 0 class TestCase22FlexibleDubbeldagOutsideWindowFallsBack: def test_falls_back_to_swap_when_flexible_window_outside_15_months(self) -> None: child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01 parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], # well after the 15-month window weeks=1, parent_mode="both", window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(20, 0, 0), (parent_b.uuid, 2027): VacationBudget(20, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) assert not any(d.is_dubbeldag for d in proposal.days) assert not any(d.reason == REASON_DUBBELDAG for d in proposal.days) assert {d.parent_personal_data_uuid for d in proposal.days} == { parent_a.uuid, parent_b.uuid, } assert proposal.outcomes[0].shortfall_days == 0 class TestCase23FlexibleWishDeterminism: def test_repeated_runs_with_a_flexible_wish_are_identical(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") fixed = _wish( child_id=child.id, name="Fixed", start=(6, 1), end=(7, 12), weeks=6, parent_mode="either", window_mode="fixed", ) flexible = _wish( child_id=child.id, name="Flex", start=VAR_2027[0], end=VAR_2027[1], weeks=1, parent_mode="either", window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(42, 0, 0), (parent_b.uuid, 2027): VacationBudget(42, 0, 0), }, wishes=[fixed, flexible], ) allocator = Allocator() proposal_1 = allocator.propose(alloc_input) proposal_2 = allocator.propose(alloc_input) assert proposal_1 == proposal_2 class TestCase24WindowModeDefaultsToFixed: def test_omitted_window_mode_behaves_like_an_explicit_fixed_wish(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") default_wish = _wish( child_id=child.id, name="Sportlov", start=FEB_2027[0], end=FEB_2027[1], weeks=4 ) assert default_wish.window_mode == "fixed" # Same id, name and workspace as `default_wish` -- the only # difference is an explicit (vs. omitted) `window_mode='fixed'`. explicit_wish = _wish( child_id=child.id, name="Sportlov", start=FEB_2027[0], end=FEB_2027[1], weeks=4, window_mode="fixed", wish_id=default_wish.id, workspace_id=default_wish.workspace_id, ) def _run(wish: LeaveWish) -> object: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) return Allocator().propose(alloc_input) proposal_default = _run(default_wish) proposal_explicit = _run(explicit_wish) assert proposal_default == proposal_explicit # --------------------------------------------------------------------------- # §fix — double-booking on flexible re-propose (2026-09-14 handoff). # # A flexible wish-year that already has enough planned days inside its # bounding range must be treated as funded, proposing nothing new, instead # of the engine routing a *fresh* candidate search around the applied dates # and funding the same wish-year a second time on shifted dates. All four # scenarios reuse TestCase15's exact setup (VAR_2027 bound, weeks=1, Easter # 2027-03-23..29 as the argmin-cost candidate: 4 free days + 3 spendable # days 03-23/24/25, all funded via vacation) so the "already funded"/"top # up"/"still may move" behaviour is verified against a known-good baseline. # --------------------------------------------------------------------------- class TestCase25FlexibleAlreadyFundedYearProposesNothingNew: def test_fully_applied_year_adds_zero_new_paid_days(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) # The exact 3 spendable days TestCase15 funds when this wish-year is # searched fresh -- simulating a prior propose -> apply cycle. existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d), wish_id=wish.id ) for d in (23, 24, 25) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=existing, ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.strategy != STRATEGY_UNPLACED assert outcome.shortfall_days == 0 assert outcome.weeks_funded == outcome.weeks_wished == 1 # Reused the exact prior window, not a freshly searched one. assert outcome.start_date == date(2027, 3, 23) assert outcome.end_date == date(2027, 3, 29) assert outcome.cost_breakdown["vacation"] == 3 assert outcome.cost_breakdown["free"] == 4 new_paid_days = [d for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"] assert new_paid_days == [], f"expected zero new paid days, got {new_paid_days}" class TestCase26FlexiblePartialFundingTopsUpRemainder: def test_partially_applied_year_tops_up_only_the_remainder(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) # Only ONE of the winning window's 3 spendable days was ever applied. existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id ) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=existing, ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] # Topped up to full funding in the SAME window, not a false shortfall. assert outcome.shortfall_days == 0 assert outcome.weeks_funded == outcome.weeks_wished == 1 assert outcome.start_date == date(2027, 3, 23) assert outcome.end_date == date(2027, 3, 29) assert outcome.cost_breakdown["vacation"] == 3 new_paid_dates = { d.date for d in proposal.days if d.wish_id == wish.id and d.day_type != "free" } # Exactly the remainder -- never re-proposes the already-applied day, # never duplicates the whole week. assert new_paid_dates == {date(2027, 3, 24), date(2027, 3, 25)} class TestCase27FlexibleDeterminismWithExistingPlannedDays: def test_repeated_runs_with_a_partially_applied_flexible_wish_are_identical(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id ) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=existing, ) allocator = Allocator() proposal_1 = allocator.propose(alloc_input) proposal_2 = allocator.propose(alloc_input) assert proposal_1 == proposal_2 class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied: """§10.3's "still free to move" decision must survive the fix: a partially-applied flexible occurrence whose reused window is no longer available (because a higher-priority fixed wish now claims one of its still-unfunded days) must NOT be frozen to that window -- it searches again and lands somewhere collision-free, exactly like an untouched flexible wish would. """ def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") # A brand-new fixed wish now claims 2027-03-25 -- one of the Easter # window's still-open days -- and, per §3.9/§3.11.2, always wins. blocker = _wish( child_id=child.id, name="Fixed", start=(3, 25), end=(3, 25), weeks=1, first_year=2027, window_mode="fixed", ) flexible = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) # Only 2027-03-23 was ever applied for "Vårlov" -- the other two # spendable days of the Easter window (03-24, 03-25) were proposed # last time but never applied. existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=flexible.id, ) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[blocker, flexible], existing_planned=existing, ) proposal = Allocator().propose(alloc_input) by_name = {o.wish_name: o for o in proposal.outcomes} fixed_outcome = by_name["Fixed"] flex_outcome = by_name["Vårlov"] # Fixed always wins its exact requested date. assert fixed_outcome.start_date == fixed_outcome.end_date == date(2027, 3, 25) # The flexible wish was NOT frozen to the original Easter window # (03-23..03-29) -- that window is no longer collision-free, so it # moved rather than silently overlapping the fixed wish's day. assert (flex_outcome.start_date, flex_outcome.end_date) != ( date(2027, 3, 23), date(2027, 3, 29), ) assert flex_outcome.strategy != STRATEGY_UNPLACED assert flex_outcome.shortfall_days == 0 # Since Step 3's realignment escape hatch, "moves" here means all the # way to the globally cheapest remaining window, not just the # nearest one that happens to still contain 2027-03-23: with 03-25 # occupied, the only windows reusable around the applied day cost 5 # (Wed 03-17..03-23 or Thu 03-18..03-24, no red days) -- both # misaligned (not Monday-start), which is what lets the escape # hatch compare them against a fresh search. That search finds # 03-26..04-01 (Fri-Thu), which still catches the Easter cluster # (Fri-Mon free) at cost 3 -- strictly cheaper, so it wins even # though it abandons 03-23 entirely and is itself not Monday-start # (§Decision 2: red days always outrank alignment). assert (flex_outcome.start_date, flex_outcome.end_date) == ( date(2027, 3, 26), date(2027, 4, 1), ) assert flex_outcome.cost_breakdown["free"] == 4 assert flex_outcome.cost_breakdown["vacation"] == 3 flex_dates = { flex_outcome.start_date + timedelta(days=i) for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1) } # The previously-applied day is abandoned along with the rest of the # old window -- cheaper wins over "keep what's already there" -- and # the fixed wish's date is (still) never re-claimed. assert date(2027, 3, 23) not in flex_dates assert date(2027, 3, 25) not in flex_dates class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits: """§fix (Step 3) — the flexible-window realignment escape hatch. A flexible wish-year already funded before §Decision 2 existed (or before its bound moved) is otherwise frozen forever to whichever weekday it first landed on, by `_reuse_already_funded_window`'s own reuse-the- prior-window mechanism. It must self-heal onto a Monday the next time `propose()` runs -- UNLESS one of its persisted dates carries a manual edit (pinned, or explicitly removed), in which case the user's choice always outlives realignment. All three cases share the same bound: August 2027, holiday-free, so every 7-day candidate costs the same 5 spendable days and the only thing that can separate them is the Monday-alignment tie-break. """ def _alloc_input( self, wish: LeaveWish, child: ChildFacts, parent: ParentFacts, existing: list[PlannedLeaveDay], ) -> AllocationInput: return _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=existing, ) def test_a_misaligned_year_releases_onto_a_monday(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Augusti", start=AUG_2027[0], end=AUG_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) # A prior apply funded the full Tuesday-start week 08-03..08-09. existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id ) for d in range(3, 10) ] alloc_input = self._alloc_input(wish, child, parent, existing) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 # Released the Tuesday window for the earliest Monday-start # candidate that doesn't collide with the abandoned dates. assert outcome.start_date == date(2027, 8, 16) assert outcome.start_date.weekday() == 0 def test_a_pinned_day_keeps_the_window_from_moving(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Augusti", start=AUG_2027[0], end=AUG_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id, is_pinned=(d == 3), # the user locked the first day in place ) for d in range(3, 10) ] alloc_input = self._alloc_input(wish, child, parent, existing) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] # Same misaligned Tuesday window as the unprotected case above -- # but this time it must NOT move, because one of its days is pinned. assert outcome.start_date == date(2027, 8, 3) assert outcome.start_date.weekday() == 1 # still Tuesday def test_an_excluded_day_keeps_the_window_from_moving(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Augusti", start=AUG_2027[0], end=AUG_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id ) for d in range(3, 10) ] alloc_input = replace( self._alloc_input(wish, child, parent, existing), # The user explicitly removed one of the window's days # (§remove-this-day) -- still a manual edit, must not move. excluded_days=frozenset({(parent.uuid, child.id, date(2027, 8, 4))}), ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 8, 3) assert outcome.start_date.weekday() == 1 # --------------------------------------------------------------------------- # §Task 4 — pinned days: fixed, and debited off the top before any window # is funded (mirrors `_reserve_preschool_days`'s precedent). # --------------------------------------------------------------------------- class TestPinnedDayIsNeverRetypedAndDebitsThePool: def test_pinned_day_keeps_its_type_and_the_pool_reflects_the_debit(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") pinned_date = date(2027, 2, 10) # a Wednesday inside the wish's window pinned = PlannedLeaveDay( id=uuid4(), workspace_id=uuid4(), child_id=child.id, parent_personal_data_uuid=parent.uuid, date=pinned_date, day_type="sjukpenning", is_pinned=True, ) # A wish covering exactly this one date -- if the pinned day were # NOT fixed, this monthly-salaried parent's tier-3 policy (vacation # first) would fund it as 'vacation', not 'sjukpenning'. wish = _wish( child_id=child.id, name="Single day", start=(2, 10), end=(2, 10), weeks=1, first_year=2027, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 5, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=[pinned], ) proposal = Allocator().propose(alloc_input) # Never re-proposed / retyped for that date. assert not any( d.date == pinned_date and d.parent_personal_data_uuid == parent.uuid for d in proposal.days ) # Its cost was debited off the top, before any window was funded -- # the pool reflects one less sjukpenning day, not the untouched 5. pool_after = proposal.balances_after.pools_after[(parent.uuid, child.id)] assert pool_after.sjukpenning_left == 4 assert pool_after.lagsta_left == 5 # The wish's own outcome still reports the pinned day as funded # (via cost_breakdown), just not as a newly-proposed day. outcome = proposal.outcomes[0] assert outcome.cost_breakdown["sjukpenning"] == 1 assert outcome.shortfall_days == 0 def test_pinned_vacation_day_debits_the_vacation_budget(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") pinned_date = date(2027, 2, 10) pinned = PlannedLeaveDay( id=uuid4(), workspace_id=uuid4(), child_id=child.id, parent_personal_data_uuid=parent.uuid, date=pinned_date, day_type="vacation", is_pinned=True, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(5, 5, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[], existing_planned=[pinned], ) proposal = Allocator().propose(alloc_input) budget_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)] assert budget_after.already_used == 1 assert budget_after.available == 24 # --------------------------------------------------------------------------- # Dubbeldagar count cap (settings.rules.max_dubbeldagar) reached mid-window # -> the allocator falls back to a parent swap for the leftover days # (allocation_service.py:979, `if used >= _MAX_DUBBELDAGAR`). # # TestCase10DubbeldagOutsideWindow above covers the *15-month window* edge # (every day outside the window falls back to swap); TestDubbeldagSuccess in # test_allocation_service_branches.py covers a short, uncapped success case. # Neither drives the *count* cap itself to exhaustion -- this does, with a # single `parent_mode='both'` wish long enough that its window contains more # eligible weekdays than `max_dubbeldagar`, so `_fund_both`'s running `used` # counter crosses the cap partway through and pushes every date after that # point into `leftover_for_swap`. # # NOTE on the fallback's signature: `_fund_both` only ever routes a # *non-free* (spendable weekday) date into `leftover_for_swap` -- every # free/weekend/red day is funded for both parents directly inside # `_fund_both`'s own loop, before the leftover list is even built (see the # `is_free_day(...)` branch above the cap check in `allocation_service.py`). # `REASON_SWAP_FIRST_HALF`/`REASON_SWAP_SECOND_HALF` are therefore never the # right signal here: `_fund_swap` -> `_fund_solo` only stamps that reason on # the *free-day* branch it takes for its half, and a cap-triggered leftover # never contains a free day to stamp. The real, unambiguous signature of # "funded as a parent swap rather than dubbeldagar" for this path is that # each leftover date is funded for exactly ONE parent (an `_assign_tier3` # tier day, e.g. `fp_interior`) instead of BOTH parents with # `is_dubbeldag=True` -- confirmed against a live run of this exact scenario # before writing the assertions below. # --------------------------------------------------------------------------- class TestCase23DubbeldagCountCapFallsBackToSwap: def test_days_past_the_count_cap_are_funded_solo_not_as_dubbeldagar(self) -> None: cap = settings.rules.max_dubbeldagar # 15mo cutoff: 2024-04-01 -- well after the window below, so the # *window* edge (Case 10) plays no part here; only the count cap can. child = _child(birth_date=date(2023, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=(1, 1), end=(6, 30), # ~124 spendable weekdays -- comfortably more than the cap weeks=26, first_year=2023, parent_mode="both", # §Decision A (2026-09-18 correction): dubbeldagar are opt-in -- # must be explicitly requested, or `_fund_both` never attempts # them at all and this cap-fallback scenario has nothing to # fall back FROM. wants_dubbeldag=True, ) alloc_input = _build_input( today=date(2023, 1, 1), horizon_end=date(2023, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(300, 90, cap), (parent_b.uuid, child.id): DayPool(300, 90, cap), }, vacation={}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) dubbel_days = [d for d in proposal.days if d.is_dubbeldag] dubbel_dates_a = { d.date for d in dubbel_days if d.parent_personal_data_uuid == parent_a.uuid } dubbel_dates_b = { d.date for d in dubbel_days if d.parent_personal_data_uuid == parent_b.uuid } # Both parents get a dubbeldag entry for exactly the same dates, and # the running count stops growing once it hits the cap -- not before, # not after. assert dubbel_dates_a == dubbel_dates_b assert len(dubbel_dates_a) == cap # Non-free, non-dubbeldag spendable days -- the leftover the cap # pushed into `_fund_swap`. leftover_days = [d for d in proposal.days if d.day_type != "free" and not d.is_dubbeldag] assert leftover_days, "expected leftover-past-the-cap days to fall back to a parent swap" leftover_parents_by_date: dict[date, set] = {} for d in leftover_days: leftover_parents_by_date.setdefault(d.date, set()).add(d.parent_personal_data_uuid) # Swap-funded: exactly one parent per leftover date -- never both, which # is what would make it a dubbeldag instead. assert all(len(parents) == 1 for parents in leftover_parents_by_date.values()) # The fallback only ever kicks in for dates after the cap was reached # (`used` is monotonically increasing within the window). assert min(leftover_parents_by_date) > max(dubbel_dates_a) # --------------------------------------------------------------------------- # §fix — reassigning a wish's parent must not move its dates (decision B, # 2026-09-18 handoff: Höstlov reassigned from Amelie to Jacob jumped # windows). `_reuse_already_funded_window` already existed for exactly this # purpose but matched a wish's prior placement by (parent, child, date) -- # reassigning the parent changes `_resolve_parents`'s output BEFORE that # match runs, so it silently stopped finding the wish's own history and fell # through to a fresh, occupancy-blind search. Fixed by matching on `wish_id` # instead (`_Ledger._persisted_by_wish`), which finds the prior window # regardless of which parent it was funded under. # --------------------------------------------------------------------------- class TestFixReassigningParentDoesNotMoveDates: def test_reassigning_the_parent_leaves_start_and_end_unchanged(self) -> None: """THE headline regression: same wish, same window fields, only `parent_personal_data_uuid` changes (Amelie -> Jacob) -- the already-applied window (2027-04-05..04-11) must be reused, funded now for the new parent, not re-searched from scratch. A fresh, occupancy-blind search over this bound (2027-04-05..05-31) lands on 2027-04-30..05-06 instead (cost 4 vs. the original week's cost 5 -- strictly cheaper by calendar) -- that is exactly the bug this test pins down (verified against a live pre-fix run: pytest failed with `assert datetime.date(2027, 4, 30) == datetime.date(2027, 4, 5)`). """ child = _child(birth_date=date(2020, 1, 1)) amelie = _parent(name="Amelie", salary_type="monthly") jacob = _parent(name="Jacob", salary_type="monthly") wish_id = uuid4() # Amelie's prior apply: the Mon-Fri workdays of 2027-04-05..04-11 # (the weekend itself is never persisted -- free days are implicit, # exactly like TestCase25/26's `existing` lists). existing = [ _planned( parent_uuid=amelie.uuid, child_id=child.id, on=date(2027, 4, d), wish_id=wish_id, ) for d in (5, 6, 7, 8, 9) ] reassigned_wish = _wish( wish_id=wish_id, child_id=child.id, name="Höstlov", start=(4, 5), end=(5, 31), weeks=1, first_year=2027, window_mode="flexible", parent_mode="specific", parent_uuid=jacob.uuid, # <- the only thing that changed ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[amelie, jacob], pools={ (amelie.uuid, child.id): DayPool(90, 90, 60), (jacob.uuid, child.id): DayPool(90, 90, 60), }, vacation={ (amelie.uuid, 2027): VacationBudget(25, 0, 0), (jacob.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[reassigned_wish], existing_planned=existing, ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 4, 5) assert outcome.end_date == date(2027, 4, 11) assert outcome.shortfall_days == 0 # Funded now for the NEW parent, not the old one. assert {d.parent_personal_data_uuid for d in proposal.days} == {jacob.uuid} def test_editing_the_actual_dates_still_moves_it(self) -> None: """The flip side: when the user genuinely edits start/end (not just the parent), the wish's placement DOES move -- reuse must never pin a wish to a window its current bound no longer contains. """ child = _child(birth_date=date(2020, 1, 1)) parent = _parent(name="Amelie", salary_type="monthly") wish_id = uuid4() existing = [ _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 4, d), wish_id=wish_id ) for d in (5, 6, 7, 8, 9) ] # Same wish, same parent -- but the user dragged the period to # September instead. edited_wish = _wish( wish_id=wish_id, child_id=child.id, name="Höstlov", start=(9, 1), end=(9, 30), weeks=1, first_year=2027, window_mode="flexible", parent_mode="specific", parent_uuid=parent.uuid, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[edited_wish], existing_planned=existing, ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date.month == 9 assert outcome.end_date.month == 9 assert (outcome.start_date, outcome.end_date) != (date(2027, 4, 5), date(2027, 4, 11)) # --------------------------------------------------------------------------- # §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are opt-in. # --------------------------------------------------------------------------- class TestDubbeldagOptIn: def _build(self, *, wants_dubbeldag: bool): child = _child(birth_date=date(2027, 1, 1)) # 15mo cutoff: 2028-04-01 parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], # well inside the 15-month window weeks=4, parent_mode="both", wants_dubbeldag=wants_dubbeldag, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(20, 0, 0), (parent_b.uuid, 2027): VacationBudget(20, 0, 0), }, wishes=[wish], ) return Allocator().propose(alloc_input) def test_flag_unset_never_consumes_dubbeldagar_even_inside_the_window(self) -> None: proposal = self._build(wants_dubbeldag=False) assert not any(d.is_dubbeldag for d in proposal.days) outcome = proposal.outcomes[0] assert outcome.strategy == "co_leave" assert outcome.requested_strategy == "co_leave" assert outcome.strategy_degraded is False # Legally available (child inside the window, quota free, both # parents hold FP) even though it wasn't requested -- the frontend # uses this to decide whether to SHOW the opt-in checkbox. assert outcome.dubbeldagar_legally_available is True def test_flag_set_consumes_dubbeldagar_inside_the_window(self) -> None: proposal = self._build(wants_dubbeldag=True) assert any(d.is_dubbeldag for d in proposal.days) outcome = proposal.outcomes[0] assert outcome.strategy == "dubbeldag" assert outcome.requested_strategy == "dubbeldag" assert outcome.strategy_degraded is False assert outcome.dubbeldagar_legally_available is True # --------------------------------------------------------------------------- # §Decision C — candidate-window selection must be budget-aware, not just # calendar-cheapest. # --------------------------------------------------------------------------- class TestFlexibleCandidateSelectionIsBudgetAware: def test_unfundable_cheapest_candidate_is_skipped_for_a_fundable_one(self) -> None: """A single parent, single child. The wish's flexible bound (2027-12-01..2028-02-28) contains a strictly-cheaper-by-calendar window over the Christmas/New Year red-day cluster, but that window's spendable days fall in 2027, a year with ZERO vacation and ZERO FP left -- completely unfundable. A plain (costlier) week entirely in 2028, where the parent has ample vacation, is not calendar-cheapest but IS fully payable. Budget-aware ranking must prefer the payable window; the old calendar-only `_candidate_cost` ranking would have picked the unfundable one and reported a shortfall despite a perfectly fundable window existing right next to it in the same bound. The very cheapest candidate overall (2027-12-31..2028-01-06) is blocked by an unrelated pre-existing commitment on 2028-01-04 so this test isolates the budget-vs-calendar-cost tradeoff rather than that one coincidentally-fundable window (its 3 spendable days all happen to fall in 2028 -- verified against a live run before writing this test). """ child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Jul", start=(12, 1), end=(1, 31), # wraps into 2028 weeks=1, first_year=2027, window_mode="flexible", ) blocker = _planned( parent_uuid=parent.uuid, child_id=child.id, on=date(2028, 1, 4), day_type="vacation" ) alloc_input = _build_input( today=date(2027, 11, 1), horizon_end=date(2028, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(0, 0, 0), (parent.uuid, 2028): VacationBudget(30, 0, 0), }, wishes=[wish], existing_planned=[blocker], extra_holiday_years=(2028,), ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 assert outcome.start_date.year == 2028 assert outcome.end_date.year == 2028 # --------------------------------------------------------------------------- # §Decision C — `strategy` must report what actually happened, never what # was merely requested. # --------------------------------------------------------------------------- class TestStrategyReportsDegradation: def test_both_mode_falling_back_to_swap_is_reported_not_hidden(self) -> None: """Parent A has FP but no partner-side vacation counterpart to pair with; Parent B has neither FP nor vacation at all, so co-leave can never pay Parent B's half of any date. Every date genuinely can't be co-funded -> the whole window degrades to a sequential swap (handed entirely to Parent A, who can afford it alone). `strategy` must say 'swap', not silently keep claiming 'co_leave'. """ child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A", salary_type="monthly") parent_b = _parent(name="B", salary_type="monthly") wish = _wish( child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 0, 60), (parent_b.uuid, child.id): DayPool(0, 0, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(30, 0, 0), (parent_b.uuid, 2027): VacationBudget(0, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.requested_strategy == "co_leave" assert outcome.strategy == "swap" assert outcome.strategy_degraded is True assert outcome.shortfall_days == 0 # parent A alone covers every spendable day non_free_b = [ d for d in proposal.days if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free" ] assert non_free_b == [], "parent B cannot fund anything -- must get nothing, not a lie" # --------------------------------------------------------------------------- # todo.md #1 -- a `window_mode == 'fixed'` repeating wish is a week-based habit # ("off Mon-Sun"), not a literal month/day. Each later year's naive month/day # anchor must be snapped onto the nearest date sharing the anchor year's start # weekday, holding the window's day-span fixed instead of re-anchoring # end_month/end_day. Flexible wishes are unaffected -- their four fields are a # bounding range to search inside, never a placement. # # The snap now happens ONCE, when `LeaveWishService` materialises the series # into per-year rows (`project_window`), and the snapped dates are what each # row stores -- so these assert on the stored rows rather than on a projection # re-run at read time. The dates are the same ones the old single-row shape # produced; `_wish_series` runs the production projection, so nothing here # re-derives the arithmetic. Still pure entity-level tests, no Allocator. # --------------------------------------------------------------------------- class TestCase29FixedYearlyWishPreservesWeekdayAlignment: def test_reported_user_case_shifts_to_the_nearest_matching_weekday(self) -> None: """2026-10-05..10-11 is Mon-Sun. Naively repeating the same month/day would land 2027 on Tue-Mon -- the exact bug report. The nearest date to 2027-10-05 sharing Monday is 2027-10-04, and holding the 6-day span gives 2027-10-04..10-10 (also Mon-Sun); 2028 lands on 10-02..10-08 for the same reason (verified with date.weekday() directly, not by re-deriving the arithmetic in this docstring). """ rows = { w.first_year: w for w in _wish_series( child_id=None, start=(10, 5), end=(10, 11), first_year=2026, last_year=2028, window_mode="fixed", ) } assert set(rows) == {2026, 2027, 2028} # Every year is its own row, all in one series. assert len({w.series_id for w in rows.values()}) == 1 assert all(w.series_id is not None for w in rows.values()) start_2026, end_2026 = rows[2026].window() assert (start_2026, end_2026) == (date(2026, 10, 5), date(2026, 10, 11)) assert start_2026.weekday() == 0 # Monday assert end_2026.weekday() == 6 # Sunday start_2027, end_2027 = rows[2027].window() assert (start_2027, end_2027) == (date(2027, 10, 4), date(2027, 10, 10)) assert start_2027.weekday() == 0 assert end_2027.weekday() == 6 start_2028, end_2028 = rows[2028].window() assert (start_2028, end_2028) == (date(2028, 10, 2), date(2028, 10, 8)) assert start_2028.weekday() == 0 assert end_2028.weekday() == 6 def test_the_snap_is_stored_on_the_row_not_recomputed(self) -> None: """The whole point of materialising: the shifted dates are the row's own month/day columns, so a later per-year edit has somewhere to go and nothing re-derives 2027 from 2026 behind the user's back. """ rows = { w.first_year: w for w in _wish_series( child_id=None, start=(10, 5), end=(10, 11), first_year=2026, last_year=2027, window_mode="fixed", ) } assert (rows[2027].window_start_month, rows[2027].window_start_day) == (10, 4) assert (rows[2027].window_end_month, rows[2027].window_end_day) == (10, 10) def test_first_year_is_returned_unshifted(self) -> None: rows = _wish_series( child_id=None, start=(10, 5), end=(10, 11), first_year=2026, last_year=2028, window_mode="fixed", ) assert rows[0].first_year == 2026 assert rows[0].window() == (date(2026, 10, 5), date(2026, 10, 11)) def test_new_year_wrapping_fixed_wish_still_wraps(self) -> None: """Jullov 12-20..01-06 (Sun -> Wed, a 17-day span) must keep landing its end in `first_year + 1` relative to its own row's year after the weekday shift, exactly like the unshifted (anchor year) case. """ rows = { w.first_year: w for w in _wish_series( child_id=None, start=(12, 20), end=(1, 6), first_year=2026, last_year=2027, window_mode="fixed", ) } assert rows[2026].window_wraps() is True assert rows[2027].window_wraps() is True start_2026, end_2026 = rows[2026].window() assert start_2026 == date(2026, 12, 20) assert end_2026 == date(2027, 1, 6) start_2027, end_2027 = rows[2027].window() assert start_2027 == date(2027, 12, 19) assert end_2027 == date(2028, 1, 5) assert (end_2027 - start_2027) == (end_2026 - start_2026) assert start_2027.weekday() == start_2026.weekday() assert end_2027.weekday() == end_2026.weekday() def test_flexible_wish_bounding_range_stays_weekday_aligned_across_years(self) -> None: """A flexible wish's four fields describe a bounding range to search inside, not a placement -- but the range must still get the same weekday snap as a fixed placement, or a week-shaped bound (the reported bug, extended: also breaks a Season preset whose bound happens to be week-shaped) silently drifts onto a different weekday every non-anchor year. The bound's day-*span* stays exactly fixed (91 days here) so the allocator's per-year search space never shrinks; only the start slides by the anchor's own `_shift_to_weekday` rule. Each year still gets its OWN row (carrying that year's range); the per-year placement search inside it is the allocator's job. """ rows = { w.first_year: w for w in _wish_series( child_id=None, start=(3, 1), # 2026-03-01 is a Sunday end=(5, 31), first_year=2026, last_year=2028, window_mode="flexible", ) } assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31)) assert rows[2027].window() == (date(2027, 2, 28), date(2027, 5, 30)) assert rows[2028].window() == (date(2028, 2, 27), date(2028, 5, 28)) for row in rows.values(): start, end = row.window() assert start.weekday() == 6 # Sunday, like the anchor assert (end - start).days == 91 class TestFlexibleWishPrefersMonday: """§Decision 2 (user-confirmed) — a flexible window's placement search prefers a Monday start, but a non-Monday wins whenever it absorbs strictly more red days; red days always outrank alignment. Covers the three cases the plan called out: a wide, holiday-free bound (pure ranking, `_rank_candidate`'s new `misaligned` term); a bound whose length equals the target (the degenerate single-candidate case only Step 1's bound snap can fix, since `_rank_candidate` never even runs a second candidate to prefer); and a strictly-cheaper non-Monday still winning over a pricier Monday alternative. """ def test_wide_holiday_free_bound_places_on_a_monday_every_year(self) -> None: # August: no Swedish public holidays, so every 7-day candidate in the # bound costs the same 5 spendable days -- the only thing that can # separate them is the Monday-alignment tie-break. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wishes = _wish_series( child_id=child.id, name="Augusti", start=AUG_2027[0], end=AUG_2027[1], weeks=1, first_year=2027, last_year=2029, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(400, 90, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(50, 0, 0), (parent.uuid, 2028): VacationBudget(50, 0, 0), (parent.uuid, 2029): VacationBudget(50, 0, 0), }, wishes=wishes, extra_holiday_years=(2027, 2028, 2029), ) proposal = Allocator().propose(alloc_input) by_year = {o.year: o for o in proposal.outcomes} assert set(by_year) == {2027, 2028, 2029} for o in by_year.values(): assert o.shortfall_days == 0 assert o.start_date.weekday() == 0 # Monday, every year # The bound itself keeps sliding (§Decision 1, Step 1's bound snap) # so each year's Monday is a genuinely different date, not one # window re-proposed three times. assert by_year[2027].start_date == date(2027, 8, 2) assert by_year[2028].start_date == date(2028, 7, 31) assert by_year[2029].start_date == date(2029, 7, 30) def test_bound_length_equal_to_target_repeats_monday_to_sunday(self) -> None: # The degenerate case: a 42-day bound for a 42-day (6-week) target # gives `_candidate_windows` exactly ONE candidate per year, so no # amount of ranking can align it -- only Step 1's bound snap (the # bound itself keeping its weekday alignment year over year) can. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wishes = _wish_series( child_id=child.id, name="Sommar", start=(6, 28), # 2027-06-28 is a Monday end=(8, 8), weeks=6, first_year=2027, last_year=2029, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(400, 90, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(50, 0, 0), (parent.uuid, 2028): VacationBudget(50, 0, 0), (parent.uuid, 2029): VacationBudget(50, 0, 0), }, wishes=wishes, extra_holiday_years=(2027, 2028, 2029), ) proposal = Allocator().propose(alloc_input) by_year = {o.year: o for o in proposal.outcomes} assert set(by_year) == {2027, 2028, 2029} assert by_year[2027].start_date == date(2027, 6, 28) assert by_year[2028].start_date == date(2028, 6, 26) assert by_year[2029].start_date == date(2029, 6, 25) for o in by_year.values(): assert o.start_date.weekday() == 0 assert (o.end_date - o.start_date).days == 41 def test_strictly_cheaper_non_monday_still_wins(self) -> None: # Same Easter-cluster bound as TestCase15: the Tuesday-start window # touching Långfredagen..Annandag påsk costs 3 spendable days. # Nearby Monday-start candidates (2027-03-22..03-28 and # 2027-03-29..04-04) each cost 4 -- strictly more -- so red days # must still win over alignment. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 3, 23) assert outcome.start_date.weekday() == 1 # Tuesday, not Monday assert outcome.cost_breakdown["free"] == 4 # --------------------------------------------------------------------------- # todo.md item 2, Part 1 — regression: a free day (Saturday, Sunday, public # holiday) must never spend a vacation or FP day unless the workspace # explicitly opts in via `take_pl_on_red_days`. # # Window: Mon 2027-03-22 -> Sun 2027-03-28 (verified against # `get_swedish_holidays(2027)` directly): # Mon 22, Tue 23, Wed 24, Thu 25 -- ordinary spendable weekdays, no holiday. # Fri 26 -- Långfredagen (a REAL weekday public holiday). # Sat 27 -- Påskafton (weekend; also separately named). # Sun 28 -- "Påskdagen; Söndag" (weekend; a real holiday, not just the # generic Sunday marker). # 4 spendable days, 3 free days (one of which is a real red day even before # counting the weekend). # --------------------------------------------------------------------------- _RED_WEEK = ((3, 22), (3, 28)) _RED_WEEK_FREE_DATES = (date(2027, 3, 26), date(2027, 3, 27), date(2027, 3, 28)) class TestCase30FreeDaysNeverSpendResourcesByDefault: def test_weekend_and_red_day_spend_nothing_solo(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027 ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], # take_pl_on_red_days defaults to False -- the point of this test. ) proposal = Allocator().propose(alloc_input) days_by_date = {d.date: d for d in proposal.days} for free_date in _RED_WEEK_FREE_DATES: assert days_by_date[free_date].day_type == "free" outcome = proposal.outcomes[0] assert outcome.cost_breakdown["free"] == 3 # The 4 ordinary weekdays (Mon-Thu) are the only days that spend # anything at all -- all via vacation (monthly-salary tier-3 policy). assert outcome.cost_breakdown["vacation"] == 4 assert outcome.cost_breakdown["sjukpenning"] == 0 assert outcome.cost_breakdown["lagsta"] == 0 def test_weekend_and_red_day_spend_nothing_both_parents(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 90, 60), (parent_b.uuid, child.id): DayPool(200, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(25, 0, 0), (parent_b.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) # The 4 ordinary weekdays go through the normal co-leave split # (irrelevant to this test); only the 3 free dates matter here. for free_date in _RED_WEEK_FREE_DATES: entries = [d for d in proposal.days if d.date == free_date] assert len(entries) == 2 assert all(e.day_type == "free" for e in entries) # --------------------------------------------------------------------------- # todo.md item 2, Part 2 — `take_pl_on_red_days=True`: a free day may now be # paid with a parental-benefit day (sjukpenning preferred over lägstanivå), # but NEVER with vacation -- Swedish law does not let you "take vacation" on # a day that already isn't a working day. # --------------------------------------------------------------------------- class TestCase31TakePlOnRedDaysSolo: def test_red_days_funded_with_fp_when_available(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027 ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], take_pl_on_red_days=True, ) proposal = Allocator().propose(alloc_input) days_by_date = {d.date: d for d in proposal.days} for free_date in _RED_WEEK_FREE_DATES: day = days_by_date[free_date] assert day.day_type == "sjukpenning" assert day.reason == REASON_FP_RED_DAY outcome = proposal.outcomes[0] assert outcome.cost_breakdown["free"] == 0 assert outcome.cost_breakdown["vacation"] == 4 # unaffected: the 4 ordinary weekdays assert outcome.cost_breakdown["sjukpenning"] == 3 assert outcome.shortfall_days == 0 def test_red_days_never_spend_vacation_even_when_fp_exhausted(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027 ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(0, 0, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], take_pl_on_red_days=True, ) proposal = Allocator().propose(alloc_input) days_by_date = {d.date: d for d in proposal.days} for free_date in _RED_WEEK_FREE_DATES: assert days_by_date[free_date].day_type == "free" outcome = proposal.outcomes[0] assert outcome.cost_breakdown["free"] == 3 # The 4 ordinary weekdays still spend vacation as usual -- only the # red-day-FP branch is affected by the empty pool. assert outcome.cost_breakdown["vacation"] == 4 class TestCase32TakePlOnRedDaysBothParents: def test_each_parent_funded_independently_from_own_fp_pool(self) -> None: """Parent B's FP pool is empty, Parent A's is not. Each parent's own pool is tried independently on a red day (never a peeked-then- committed-together pair like dubbeldagar/co-leave) -- one parent running out must never stop the other from still getting paid, and must never fall back to vacation for the exhausted one either. """ child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 90, 60), (parent_b.uuid, child.id): DayPool(0, 0, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(25, 0, 0), (parent_b.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[wish], take_pl_on_red_days=True, ) proposal = Allocator().propose(alloc_input) for free_date in _RED_WEEK_FREE_DATES: entries = {d.parent_personal_data_uuid: d for d in proposal.days if d.date == free_date} assert entries[parent_a.uuid].day_type == "sjukpenning" assert entries[parent_a.uuid].reason == REASON_FP_RED_DAY assert entries[parent_b.uuid].day_type == "free" def test_both_parents_funded_when_both_have_fp_left(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 90, 60), (parent_b.uuid, child.id): DayPool(200, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(25, 0, 0), (parent_b.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[wish], take_pl_on_red_days=True, ) proposal = Allocator().propose(alloc_input) for free_date in _RED_WEEK_FREE_DATES: entries = [d for d in proposal.days if d.date == free_date] assert len(entries) == 2 assert all(e.day_type == "sjukpenning" for e in entries) assert all(e.reason == REASON_FP_RED_DAY for e in entries) # --------------------------------------------------------------------------- # §fix (long-chunk co-leave, 2026-09-19) — the FP-payer for a `parent_mode= # 'both'` window is decided per CHUNK (first half / second half, split by # `_split_dates_by_spendable_cost`), not per day. The old per-day "whichever # parent has more remaining FP" comparison flipped the payer almost every # day once spending started shifting the lead back and forth -- a real # household reported exactly this as an alternating PL/vacation pattern # that produced no benefit, just extra admin requesting leave. # --------------------------------------------------------------------------- class TestCase33LongChunkCoLeave: def test_six_week_window_switches_fp_payer_at_most_once(self) -> None: # Feb 1 -> Mar 14 2027: 42 calendar days, clean of red days (Easter # 2027 starts Fri 3/26, per TestCase9's own verified figures) so # every non-weekend date reaches the co-leave tier. child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff well past this window parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, start=(2, 1), end=(3, 14), weeks=6, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(100, 90, 60), (parent_b.uuid, child.id): DayPool(100, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(30, 0, 0), (parent_b.uuid, 2027): VacationBudget(30, 0, 0), }, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.strategy == "co_leave" assert outcome.shortfall_days == 0 non_free = [d for d in proposal.days if d.day_type != "free"] by_date: dict[date, list] = {} for d in non_free: by_date.setdefault(d.date, []).append(d) assert by_date, "expected at least one funded date" # Both parents present (co-leaving) on every spendable date. for on_date, entries in sorted(by_date.items()): parents_that_day = {e.parent_personal_data_uuid for e in entries} assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (on_date, parents_that_day) # The FP-payer, walked in date order, switches at most once -- # never the old daily alternation. fp_payer_sequence = [ next(e.parent_personal_data_uuid for e in entries if e.day_type != "vacation") for _on_date, entries in sorted(by_date.items()) ] switches = sum( 1 for prev, cur in zip(fp_payer_sequence, fp_payer_sequence[1:]) if prev != cur ) assert switches <= 1, f"expected at most one payer switch, saw {switches}" # Roughly equal halves -- neither parent gets only a token day. count_a = fp_payer_sequence.count(parent_a.uuid) count_b = fp_payer_sequence.count(parent_b.uuid) assert count_a > 0 and count_b > 0 assert abs(count_a - count_b) <= 2 # --------------------------------------------------------------------------- # §todo item 13, part 1 — PLANNING_ENGINE_SPEC.md §3.6 "shortfall, not # failure": a yearly wish whose pool affords only ~3.5 of its 4 wished weeks # must fund the first three years in full and PART-fund the fourth (never # drop it) -- the user's own example ("1 week/spring, enough days for 3 of 4 # years"). # --------------------------------------------------------------------------- class TestCase34PartialFundingAcrossYearsShortfallNotFailure: def test_fourth_year_partially_funded_not_dropped(self) -> None: # Any 7-day window contains exactly one Saturday + one Sunday # (verified below against real spendable-day counts, not assumed), # so every one of these 4 one-week windows costs exactly 5 spendable # days regardless of which weekday it starts on -- a clean way to # make the pool math ("18 covers 3 years + 3/5 of a 4th") exact. # August has no Swedish red days nearby, so tier-2 never fires. child = _child(birth_date=date(2027, 9, 1)) # fourth birthday well after 2030 parent = _parent(salary_type="hourly") wishes = _wish_series( child_id=child.id, name="Sportlov", start=(8, 2), end=(8, 8), weeks=1, first_year=2027, last_year=2030, priority=1, ) # A lower-priority sibling wish purely so `lower_priority_wish` has # something to suggest once the pool runs dry -- its own funding # (or lack of it) is irrelevant to this test. sibling = _wish( child_id=child.id, name="Höstlov", start=(11, 1), end=(11, 14), weeks=2, first_year=2027, priority=2, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), children=[child], parents=[parent], # 18 sjukpenning days: exactly 3 full years (3x5=15) + 3 of the # 4th year's 5 spendable days -- a genuine partial-year # shortfall, not a full-window one. pools={(parent.uuid, child.id): DayPool(18, 0, 60)}, vacation={(parent.uuid, y): VacationBudget(0, 0, 0) for y in range(2026, 2031)}, wishes=[*wishes, sibling], # `_build_input` only auto-merges holidays for # {today.year, horizon_end.year, horizon_end.year + 1} = # {2027, 2030, 2031} -- without this, 2028/2029 would be missing # their Sunday markers entirely (only Saturday is free-by- # weekday) and silently inflate their spendable-day counts. extra_holiday_years=(2028, 2029), ) proposal = Allocator().propose(alloc_input) series_ids = {w.id for w in wishes} by_year = {o.year: o for o in proposal.outcomes if o.wish_id in series_ids} assert set(by_year) == {2027, 2028, 2029, 2030} for y in (2027, 2028, 2029): outcome = by_year[y] assert outcome.shortfall_days == 0, (y, outcome) assert outcome.weeks_funded == 1.0 assert outcome.cost_breakdown["sjukpenning"] == 5 assert outcome.cost_breakdown["free"] == 2 # The fourth year is NOT dropped: it is still a real WindowOutcome # with real funded days, just an incomplete one. last = by_year[2030] assert last.shortfall_days == 2 assert last.weeks_funded == round(5 / 7, 2) assert last.cost_breakdown["sjukpenning"] == 3 assert last.cost_breakdown["free"] == 2 assert last.remedies, "expected at least one remedy for the shortfall year" assert "lower_priority_wish" in {r.kind for r in last.remedies} # A usable plan, not a refusal: the fourth year still has concrete # `ProposedDay`s for the days it could actually afford (2 free + 3 # sjukpenning = 5), not zero. year4_days = [d for d in proposal.days if d.wish_id in series_ids and d.date.year == 2030] assert len(year4_days) == 5 assert sum(1 for d in year4_days if d.day_type == "sjukpenning") == 3 # --------------------------------------------------------------------------- # §todo item 13, part 2 — the widened `shift_window_into_free_days` remedy # must find a genuinely better placement around a MOVING public holiday # (Easter), not just the +/-3-day nudge it used to be. Real dates from # `backend/app/data/swedish_calendar.json`: Easter Sunday is 2027-03-28 and # 2028-04-16 (18 days apart) -- a fixed wish anchored at the same March # month/day drifts a different distance from Easter each year, verified # against `LeaveWish.window_for_year`'s own weekday-preserving snap (not # hand-derived) below. # --------------------------------------------------------------------------- class TestCase35ShiftRemedyFindsMovingEaster: def test_widened_search_finds_that_years_easter_cluster(self) -> None: child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030 -- past both years parent = _parent(salary_type="hourly") wishes = _wish_series( child_id=child.id, name="Vårvecka", start=(3, 22), end=(3, 28), weeks=1, first_year=2027, last_year=2028, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), children=[child], parents=[parent], # Deliberately starved (1 sjukpenning day total, shared across # both years) so both years carry a genuine shortfall -- the # shift remedy is calendar-only and fires independent of *how* # short a window is, but `_build_remedies` still gates on # `shortfall_days > 0`. pools={(parent.uuid, child.id): DayPool(1, 0, 60)}, vacation={ (parent.uuid, 2027): VacationBudget(0, 0, 0), (parent.uuid, 2028): VacationBudget(0, 0, 0), }, wishes=wishes, ) proposal = Allocator().propose(alloc_input) by_year = {o.year: o for o in proposal.outcomes} # Each year's placement, read straight off its OWN row -- the # weekday-preserving snap was applied once, when the series was # materialised, and moves the naive 2028 anchor from Mon 3/20 rather # than leaving it literally on 3/22. rows_by_year = {w.first_year: w for w in wishes} assert rows_by_year[2027].window() == (date(2027, 3, 22), date(2027, 3, 28)) assert rows_by_year[2028].window() == (date(2028, 3, 20), date(2028, 3, 26)) outcome_2027 = by_year[2027] assert outcome_2027.shortfall_days > 0 shift_2027 = next( r for r in outcome_2027.remedies if r.kind == "shift_window_into_free_days" ) # Best placement: shift the window 1 day later to pick up Easter # Monday (3/29 is a real holiday; the anchor window ends 3/28). assert shift_2027.data["new_start_date"] == "2027-03-23" assert shift_2027.data["new_end_date"] == "2027-03-29" assert shift_2027.days_freed == 1 assert shift_2027.data["shift_gain"] == 1 outcome_2028 = by_year[2028] assert outcome_2028.shortfall_days > 0 shift_2028 = next( r for r in outcome_2028.remedies if r.kind == "shift_window_into_free_days" ) # Best placement: the window that fully overlaps Easter 2028's # cluster (Good Friday 4/14 .. Easter Monday 4/17) -- 22 days later # than the anchor's own weekday-snapped placement (3/20-3/26), well # beyond the old +/-3-day probe but inside the widened search bound. assert shift_2028.data["new_start_date"] == "2028-04-11" assert shift_2028.data["new_end_date"] == "2028-04-17" assert shift_2028.days_freed == 2 assert shift_2028.data["shift_gain"] == 2 # Easter Sunday genuinely falls inside the suggested window. assert date(2028, 4, 16) <= date(2028, 4, 17) # sanity: window contains it assert date(2028, 4, 11) <= date(2028, 4, 16) <= date(2028, 4, 17) # Both wish-name provenance fields still travel onto the remedy # (§branches test already covers this for the non-Easter case; keep # it here too since this remedy's `data` shape just grew). assert shift_2027.data["wish_name_message_key"] == rows_by_year[2027].name_message_key assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key # --------------------------------------------------------------------------- # §todo item (red-day shift toast) — a `window_mode == 'fixed'` window's # placement is unconditional (never auto-moved), so it needs the # shift_window_into_free_days SUGGESTION surfaced even when fully funded # (previously hidden behind `shortfall_days > 0`). A `window_mode == # 'flexible'` window's own bound search already IS the shift, so it must # NEVER get that remedy kind, and instead reports how much the search # improved on the naive "first slot in the bound" placement via # `WindowOutcome.shift_gain_days`. # --------------------------------------------------------------------------- class TestCase36ShiftRemedyOnFullyFundedFixedWindow: def test_fully_funded_fixed_window_still_gets_the_shift_suggestion(self) -> None: child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030, past 2027 parent = _parent(salary_type="monthly") # Same window TestCase35 uses, but funded generously -- unlike that # test's deliberately starved pool, this window is FULLY funded, so # the only thing that could still hide the shift suggestion is # `_build_remedies`'s shortfall gate (now removed for fixed windows). wish = _wish( child_id=child.id, name="Vårvecka", start=(3, 22), end=(3, 28), weeks=1, first_year=2027, window_mode="fixed", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 shift = next(r for r in outcome.remedies if r.kind == "shift_window_into_free_days") # Same best placement TestCase35 finds for this exact window: shift # 1 day later to pick up Easter Monday (2027-03-29, a real holiday). assert shift.data["new_start_date"] == "2027-03-23" assert shift.data["new_end_date"] == "2027-03-29" assert shift.days_freed == 1 class TestCase37ShiftRemedyNeverOnFlexibleWindow: def test_flexible_window_never_gets_the_shift_remedy_even_with_a_shortfall(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], # Deliberately starved (same recipe as TestCase35) so the placed # (searched, not the whole bound -- this is not the §10.4 # "unplaced" case TestCase20 covers) window carries a genuine # shortfall -- the flexible contract says its own search already # IS the shift, so the remedy must stay absent regardless. pools={(parent.uuid, child.id): DayPool(1, 0, 60)}, vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.strategy != STRATEGY_UNPLACED assert outcome.shortfall_days > 0 assert all(r.kind != "shift_window_into_free_days" for r in outcome.remedies) class TestCase38FlexibleShiftGainDaysReportsSearchImprovement: def test_winner_beating_naive_bound_start_reports_positive_gain(self) -> None: # Same setup as TestCase15FlexibleWishFindsEasterCluster: the search # picks 2027-03-23..03-29 (3 spendable days -- the Easter cluster) # over the naive bound-start placement 2027-03-01..03-07 (5 # spendable days -- Mon-Fri all working, verified against # `get_swedish_holidays(2027)`) -- a 2-day gain. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 3, 23) assert outcome.end_date == date(2027, 3, 29) assert outcome.shift_gain_days == 2 def test_winner_landing_on_naive_bound_start_reports_zero(self) -> None: # Same setup as TestCase16FlexibleTieBreakByEarliestStart: every # candidate in the holiday-free February bound costs the same, and # 2027-02-01 (the bound's own start) is also the Monday-aligned # tie-break winner -- the winner IS the naive placement, so there is # nothing to report. child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish( child_id=child.id, name="Sportlov", start=FEB_2027[0], end=FEB_2027[1], weeks=1, first_year=2027, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.start_date == date(2027, 2, 1) assert outcome.shift_gain_days == 0