"""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.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, REASON_FP_LAGSTA_STRETCH, 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 # --------------------------------------------------------------------------- # 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, recurrence: str = "once", first_year: int = 2027, last_year: int | None = None, priority: int = 1, parent_mode: str = "either", parent_uuid: UUID | None = None, goal_override: str | 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, recurrence=recurrence, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_uuid, goal_override=goal_override, window_mode=window_mode, ) def _build_input( *, today: date, horizon_end: date, planning_goal: str = "balanced", 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, ) -> AllocationInput: years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years} return AllocationInput( today=today, horizon_end=horizon_end, planning_goal=planning_goal, planning_mode=planning_mode, 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, max_time, 6-week summer, child < 12 months -> zero FP days # --------------------------------------------------------------------------- class TestCase1SoloMaxTimeUnder12Months: def test_zero_fp_days_assigned(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_goal="max_time", 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) assert all(d.day_type not in ("sjukpenning", "lagsta") for d in proposal.days) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 assert outcome.cost_breakdown["vacation"] == 20 assert outcome.cost_breakdown["sjukpenning"] == 0 assert outcome.cost_breakdown["lagsta"] == 0 # --------------------------------------------------------------------------- # #2 — couple, balanced, 6-week summer -> parent_swap, 3+3 # --------------------------------------------------------------------------- class TestCase2CoupleBalancedSwap: 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_goal="balanced", 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 — max_money + monthly salary -> vacation on every spendable day # --------------------------------------------------------------------------- class TestCase3MaxMoneyMonthly: 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_goal="max_money", 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 — max_money + hourly salary -> wrapping suppressed # --------------------------------------------------------------------------- class TestCase4MaxMoneyHourly: 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_goal="max_money", 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_goal="max_money", 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_goal="max_money", 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_goal="max_money", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="max_money", 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 # --------------------------------------------------------------------------- # #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_goal="max_money", 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 # --------------------------------------------------------------------------- # #14 — wish.goal_override applies per-window; other windows use workspace goal # --------------------------------------------------------------------------- class TestCase14GoalOverride: def test_override_applies_only_to_its_own_window(self) -> None: # Born recently enough that the 4th birthday falls well after the # horizon, so the §9.1 age-4 hard cap (tested separately in #12) # cannot interfere with this test's FP pool numbers. child = _child(birth_date=date(2025, 1, 1)) parent = _parent(salary_type="monthly") wish_override = _wish( child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, goal_override="max_money", ) wish_default = _wish( child_id=child.id, name="Aug", start=AUG_2027[0], end=AUG_2027[1], weeks=4 ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_goal="max_time", # workspace default planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(90, 30, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish_override, wish_default], ) proposal = Allocator().propose(alloc_input) feb_days = [d for d in proposal.days if d.wish_id == wish_override.id] aug_days = [d for d in proposal.days if d.wish_id == wish_default.id] # Feb window used its own override (max_money + monthly -> vacation). assert all(d.day_type == "vacation" for d in feb_days if d.day_type != "free") # Aug window used the workspace default (max_time, child >= 12mo -> # stretch via the lagsta tier). assert all(d.day_type == "lagsta" for d in aug_days if d.day_type != "free") assert any(d.reason == REASON_FP_LAGSTA_STRETCH for d in aug_days) def test_convergence_corollary_red_day_dense_window(self) -> None: """§3.3 corollary: on a window where every spendable day is a tier-2 klämdag, goal_override='max_money' produces the same allocation as 'max_time' would, since tiers 1-2 are goal-independent.""" child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") def _run(goal: str, override: str | None) -> list: wish = _wish( child_id=child.id, name="Klämdagar", start=(12, 24), end=(12, 27), weeks=1, goal_override=override, ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), planning_goal=goal, 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) return [(d.date, d.day_type, d.reason) for d in proposal.days] max_time_result = _run("max_time", None) max_money_result = _run("balanced", "max_money") assert max_time_result == max_money_result assert any(day_type == "vacation" for _, day_type, _ in max_time_result) # --------------------------------------------------------------------------- # 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_goal="balanced", 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_goal="max_money", 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_goal="balanced", 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_goal="max_money", 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_goal="max_money", 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_goal="max_money", 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_balanced_swap_still_works(self) -> None: """Two parents, balanced goal, 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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 'balanced' goal's early/late rule, 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 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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_goal="balanced", 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