"""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 datetime import date, timedelta from uuid import UUID, uuid4 from app.config import settings from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, 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, recurrence: str = "once", 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, ) -> 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, recurrence=recurrence, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_uuid, window_mode=window_mode, ) def _build_input( *, today: date, horizon_end: date, planning_mode: str = "solo", 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, extra_holiday_years: tuple[int, ...] = (), day_overrides: dict[date, bool] | None = None, age4_savable_cap: 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 # `planning_mode`/`parents` without having to pass it explicitly. age4_savable_cap = resolve_age4_savable_cap(planning_mode, len(parents)) return AllocationInput( today=today, horizon_end=horizon_end, planning_mode=planning_mode, 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 {}, ) def _planned( *, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation" ) -> 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) test cases below. Only the fields `AllocationInput.existing_planned` actually reads (`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter. """ return PlannedLeaveDay( id=uuid4(), workspace_id=uuid4(), child_id=child_id, parent_personal_data_uuid=parent_uuid, date=on, day_type=day_type, ) 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), planning_mode="solo", 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), planning_mode="couple", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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: def test_falls_back_to_swap_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", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_mode="couple", 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, } # --------------------------------------------------------------------------- # #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), planning_mode="couple", 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), planning_mode="solo", 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), planning_mode="couple", 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 planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="couple", 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), planning_mode="solo", 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 -- the # only thing that can break the tie is `candidate.start`, never # dict/set iteration order. 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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), planning_mode="solo", 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") wish = _wish( child_id=child.id, name="Vårlov", start=VAR_2027[0], end=VAR_2027[1], weeks=1, recurrence="yearly", first_year=2027, last_year=2029, window_mode="flexible", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), planning_mode="solo", 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=[wish], 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), planning_mode="couple", 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), planning_mode="couple", 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), planning_mode="solo", 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)) for d in (23, 24, 25) ] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_mode="solo", 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))] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_mode="solo", 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))] alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_mode="solo", 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") # 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))] # 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", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_mode="solo", 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 flex_dates = { flex_outcome.start_date + timedelta(days=i) for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1) } # Still honours the previously-applied day (it's inside the new # window's span) but never re-claims the fixed wish's date. assert date(2027, 3, 23) in flex_dates assert date(2027, 3, 25) not in flex_dates # --------------------------------------------------------------------------- # §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), planning_mode="solo", 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), planning_mode="solo", 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", ) alloc_input = _build_input( today=date(2023, 1, 1), horizon_end=date(2023, 12, 31), planning_mode="couple", 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)