Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service.py
T
jacobh2 6cd60887d5 Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
2026-09-17 17:58:47 +02:00

1979 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_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