Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service.py
T
jacobh2 6d91f153d7 Merge branch 'vacation-rescue-and-wrap'
Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:48:33 +02:00

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