Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service.py
T
jacobh2 249e277696 Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green.

Flexible wishes were funded twice across propose -> apply -> re-propose. Because
planned_leave_day carries no wish_id, the engine could not tell that applied days
were that wish's own prior occurrence; it treated them as occupied space, routed
the candidate search around them, and funded the year again on shifted dates while
reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18
paid days instead of 9. Fixed wishes were unaffected and stay unaffected.

Fix (allocation_service.py), per the approved approach of counting already-applied
days inside the bounding range rather than adding a wish_id column:
- _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its
  own freshly-computed windows, so a fixed wish's new placement is never mistaken
  for a flexible wish's history
- _reuse_already_funded_window finds this wish's persisted dates within its bound
  and reuses the window containing them, via the ordinary fund_window path
- full funding therefore yields zero new days and partial funding tops up exactly
  the remainder, with no duplication and no false shortfall
- with no persisted dates, or if the reused window is no longer collision-free, it
  falls through to the original search, so an unsatisfied year is never frozen

Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator
switched to strict, so an uncached year raises UncachedYearError -> ValidationError
-> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as
working days. Also fixed normalize_day dropping the second name when two holidays
share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red
days, not 18: Pingstdagen coincides with Nationaldagen.

New backend/tests/integration/test_user_journey.py drives the real app over HTTP
across seven journeys: the double-booking guard, the fixed-wish control, apply
idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter
per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish
text reaches the wire now that everything is message_key + data.

New backend/scripts/seed_demo.py seeds a realistic workspace through the real
service layer for manual testing: demo@example.com / DemoPass123!

Backend 414 -> 435 tests. Frontend unchanged at 705.

Still not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 07:38:07 +02:00

1895 lines
71 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_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,
) -> LeaveWish:
return LeaveWish(
id=wish_id or uuid4(),
workspace_id=workspace_id or uuid4(),
child_id=child_id,
name=name,
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",
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
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