b87ad0e7ad
Asking for a full week off never spent anything on the Saturday, the Sunday or a holiday, and a test now says so outright rather than leaving it to be rediscovered. Those days are free because vacation cannot be taken on a day that is not a working day. Parental benefit is a payment rather than a schedule, though, so it can be drawn on a red day to turn an unpaid day off into a paid one — at the cost of a day the family would otherwise keep. That is now a workspace setting, off by default, and it never reaches for a vacation day. With it off the planner behaves exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2768 lines
108 KiB
Python
2768 lines
108 KiB
Python
"""Tests for the pure planning-engine Allocator.
|
||
|
||
Implements the acceptance matrix in PLANNING_ENGINE_SPEC.md §5 (14 numbered
|
||
cases). Every test builds an `AllocationInput` by hand (no database, no
|
||
fixtures that touch I/O) and calls `Allocator().propose()` directly, with a
|
||
frozen `today` — this is the whole point of the pure/impure split described at
|
||
the top of `app.services.allocation_service`.
|
||
|
||
Two clean, real months are used as "no red-day interference" windows so tier-3
|
||
(goal-dependent) behaviour can be tested in isolation, verified directly
|
||
against the `holidays` package for the given years:
|
||
- February 2027 (Mon 1 Feb -> Sun 28 Feb): 28 days, 8 free (4 Sat + 4 Sun),
|
||
20 spendable, zero real public holidays anywhere near the boundary.
|
||
- August 2027 (Sun 1 Aug -> Tue 31 Aug): 31 days, 9 free (4 Sat + 5 Sun),
|
||
22 spendable, likewise clean.
|
||
Where a test *wants* red-day-cluster (tier-2) behaviour, it uses a window
|
||
around a real holiday (Easter 2027 / Jul 2027-2028) instead.
|
||
"""
|
||
|
||
from datetime import date, timedelta
|
||
from uuid import UUID, uuid4
|
||
|
||
from app.config import settings
|
||
from app.entities.leave_wish import LeaveWish
|
||
from app.entities.planned_leave import PlannedLeaveDay
|
||
from app.services.allocation_service import (
|
||
REASON_DUBBELDAG,
|
||
REASON_FP_RED_DAY,
|
||
REASON_VACATION_WRAP_RED_DAY,
|
||
REASON_VACATION_WRAP_WEEKEND,
|
||
STRATEGY_UNPLACED,
|
||
AllocationInput,
|
||
Allocator,
|
||
ChildFacts,
|
||
DayPool,
|
||
ParentFacts,
|
||
VacationBudget,
|
||
)
|
||
from app.utils.holidays import get_swedish_holidays
|
||
from app.utils.tip_predicates import resolve_age4_savable_cap
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Builders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _holidays_for(*years: int) -> dict[date, str]:
|
||
merged: dict[date, str] = {}
|
||
for y in years:
|
||
merged.update(get_swedish_holidays(y))
|
||
return merged
|
||
|
||
|
||
def _child(
|
||
*,
|
||
birth_date: date,
|
||
preschool_start_date: date | None = None,
|
||
preschool_planning_days_per_year: int = 6,
|
||
) -> ChildFacts:
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
return ChildFacts(
|
||
id=uuid4(),
|
||
birth_date=birth_date,
|
||
fourth_birthday=birth_date + relativedelta(years=4),
|
||
preschool_start_date=preschool_start_date,
|
||
preschool_planning_days_per_year=preschool_planning_days_per_year,
|
||
)
|
||
|
||
|
||
def _parent(
|
||
*,
|
||
name: str = "Alex",
|
||
salary_type: str = "monthly",
|
||
employment_status: str = "employed",
|
||
min_vacation_days_per_year: int = 20,
|
||
) -> ParentFacts:
|
||
return ParentFacts(
|
||
uuid=uuid4(),
|
||
name=name,
|
||
salary_type=salary_type,
|
||
employment_status=employment_status,
|
||
min_vacation_days_per_year=min_vacation_days_per_year,
|
||
)
|
||
|
||
|
||
def _wish(
|
||
*,
|
||
child_id: UUID | None,
|
||
name: str = "Test",
|
||
start: tuple[int, int] = (2, 1),
|
||
end: tuple[int, int] = (2, 28),
|
||
weeks: int = 4,
|
||
target_days: int | None = None,
|
||
recurrence: str = "once",
|
||
first_year: int = 2027,
|
||
last_year: int | None = None,
|
||
priority: int = 1,
|
||
parent_mode: str = "either",
|
||
parent_uuid: UUID | None = None,
|
||
workspace_id: UUID | None = None,
|
||
window_mode: str = "fixed",
|
||
wish_id: UUID | None = None,
|
||
name_message_key: str | None = None,
|
||
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,
|
||
recurrence=recurrence,
|
||
first_year=first_year,
|
||
last_year=last_year,
|
||
priority=priority,
|
||
parent_mode=parent_mode,
|
||
parent_personal_data_uuid=parent_uuid,
|
||
window_mode=window_mode,
|
||
wants_dubbeldag=wants_dubbeldag,
|
||
)
|
||
|
||
|
||
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,
|
||
) -> 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,
|
||
)
|
||
|
||
|
||
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.
|
||
"""
|
||
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"
|
||
assert friday.reason == REASON_VACATION_WRAP_WEEKEND
|
||
assert monday.day_type == "vacation"
|
||
assert monday.reason == REASON_VACATION_WRAP_WEEKEND
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #4 — hourly salary -> wrapping suppressed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCase4HourlySalaryFPFirst:
|
||
def test_wrapping_suppressed_for_hourly(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="hourly")
|
||
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
children=[child],
|
||
parents=[parent],
|
||
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
|
||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||
wishes=[wish],
|
||
)
|
||
|
||
proposal = Allocator().propose(alloc_input)
|
||
|
||
outcome = proposal.outcomes[0]
|
||
assert outcome.cost_breakdown["vacation"] == 0
|
||
assert outcome.cost_breakdown["sjukpenning"] == 20
|
||
assert not any(d.reason == REASON_VACATION_WRAP_WEEKEND for d in proposal.days)
|
||
assert not any(d.day_type == "vacation" for d in proposal.days)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #5 — wishes exceed budget -> lower-priority window shows a shortfall
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCase5ShortfallByPriority:
|
||
def test_higher_priority_window_funded_first(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="hourly")
|
||
wish_high = _wish(
|
||
child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1
|
||
)
|
||
wish_low = _wish(
|
||
child_id=child.id, name="Aug", start=AUG_2027[0], end=AUG_2027[1], weeks=4, priority=2
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
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.
|
||
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}"
|
||
)
|
||
# Co-leave's PL+vacation split: 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 date, not just "on average".
|
||
types_that_day = sorted(e.day_type for e in entries)
|
||
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))
|
||
parent_a = _parent(name="A")
|
||
parent_b = _parent(name="B")
|
||
wish = _wish(child_id=child.id, start=(6, 1), end=(7, 12), weeks=6, parent_mode="either")
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
children=[child],
|
||
parents=[parent_a, parent_b],
|
||
pools={
|
||
(parent_a.uuid, child.id): DayPool(100, 90, 60),
|
||
(parent_b.uuid, child.id): DayPool(100, 90, 60),
|
||
},
|
||
vacation={
|
||
(parent_a.uuid, 2027): VacationBudget(42, 0, 0),
|
||
(parent_b.uuid, 2027): VacationBudget(42, 0, 0),
|
||
},
|
||
wishes=[wish],
|
||
)
|
||
|
||
allocator = Allocator()
|
||
proposal_1 = allocator.propose(alloc_input)
|
||
proposal_2 = allocator.propose(alloc_input)
|
||
|
||
assert proposal_1 == proposal_2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #12 — wish window after the 4th birthday -> FP pool hard-capped at 96
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCase12Age4HardCap:
|
||
def test_pool_clamped_to_96_after_fourth_birthday(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1)) # B4 = 2024-01-01, already past
|
||
parent = _parent(salary_type="hourly")
|
||
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 15),
|
||
horizon_end=date(2027, 12, 31),
|
||
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 -- the
|
||
# only thing that can break the tie is `candidate.start`, never
|
||
# dict/set iteration order.
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="monthly")
|
||
wish = _wish(
|
||
child_id=child.id,
|
||
name="Sportlov",
|
||
start=FEB_2027[0],
|
||
end=FEB_2027[1],
|
||
weeks=1,
|
||
first_year=2027,
|
||
window_mode="flexible",
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
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 TestCase21FlexibleYearlyWishTracksMovingEaster:
|
||
def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="monthly")
|
||
wish = _wish(
|
||
child_id=child.id,
|
||
name="Vårlov",
|
||
start=VAR_2027[0],
|
||
end=VAR_2027[1],
|
||
weeks=1,
|
||
recurrence="yearly",
|
||
first_year=2027,
|
||
last_year=2029,
|
||
window_mode="flexible",
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2029, 12, 31),
|
||
children=[child],
|
||
parents=[parent],
|
||
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
|
||
vacation={
|
||
(parent.uuid, 2027): VacationBudget(50, 0, 0),
|
||
(parent.uuid, 2028): VacationBudget(50, 0, 0),
|
||
(parent.uuid, 2029): VacationBudget(50, 0, 0),
|
||
},
|
||
wishes=[wish],
|
||
extra_holiday_years=(2028, 2029),
|
||
)
|
||
|
||
proposal = Allocator().propose(alloc_input)
|
||
|
||
by_year = {o.year: o for o in proposal.outcomes}
|
||
assert set(by_year) == {2027, 2028, 2029}
|
||
# Easter falls on a different date each year -> each year's picked
|
||
# window must be different (a moving target), and each must be
|
||
# fully funded at minimal cost.
|
||
starts = {o.start_date for o in by_year.values()}
|
||
assert len(starts) == 3
|
||
assert by_year[2027].start_date == date(2027, 3, 23)
|
||
assert by_year[2028].start_date == date(2028, 4, 11)
|
||
assert by_year[2029].start_date == date(2029, 3, 27)
|
||
for o in by_year.values():
|
||
assert o.shortfall_days == 0
|
||
|
||
|
||
class TestCase22FlexibleDubbeldagOutsideWindowFallsBack:
|
||
def test_falls_back_to_swap_when_flexible_window_outside_15_months(self) -> None:
|
||
child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01
|
||
parent_a = _parent(name="A")
|
||
parent_b = _parent(name="B")
|
||
wish = _wish(
|
||
child_id=child.id,
|
||
start=FEB_2027[0],
|
||
end=FEB_2027[1], # well after the 15-month window
|
||
weeks=1,
|
||
parent_mode="both",
|
||
window_mode="flexible",
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
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
|
||
|
||
flex_dates = {
|
||
flex_outcome.start_date + timedelta(days=i)
|
||
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
|
||
}
|
||
# Still honours the previously-applied day (it's inside the new
|
||
# window's span) but never re-claims the fixed wish's date.
|
||
assert date(2027, 3, 23) in flex_dates
|
||
assert date(2027, 3, 25) not in flex_dates
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §Task 4 — pinned days: fixed, and debited off the top before any window
|
||
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||
def test_pinned_day_keeps_its_type_and_the_pool_reflects_the_debit(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="monthly")
|
||
pinned_date = date(2027, 2, 10) # a Wednesday inside the wish's window
|
||
pinned = PlannedLeaveDay(
|
||
id=uuid4(),
|
||
workspace_id=uuid4(),
|
||
child_id=child.id,
|
||
parent_personal_data_uuid=parent.uuid,
|
||
date=pinned_date,
|
||
day_type="sjukpenning",
|
||
is_pinned=True,
|
||
)
|
||
# A wish covering exactly this one date -- if the pinned day were
|
||
# NOT fixed, this monthly-salaried parent's tier-3 policy (vacation
|
||
# first) would fund it as 'vacation', not 'sjukpenning'.
|
||
wish = _wish(
|
||
child_id=child.id,
|
||
name="Single day",
|
||
start=(2, 10),
|
||
end=(2, 10),
|
||
weeks=1,
|
||
first_year=2027,
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
children=[child],
|
||
parents=[parent],
|
||
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
|
||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||
wishes=[wish],
|
||
existing_planned=[pinned],
|
||
)
|
||
|
||
proposal = Allocator().propose(alloc_input)
|
||
|
||
# Never re-proposed / retyped for that date.
|
||
assert not any(
|
||
d.date == pinned_date and d.parent_personal_data_uuid == parent.uuid
|
||
for d in proposal.days
|
||
)
|
||
# Its cost was debited off the top, before any window was funded --
|
||
# the pool reflects one less sjukpenning day, not the untouched 5.
|
||
pool_after = proposal.balances_after.pools_after[(parent.uuid, child.id)]
|
||
assert pool_after.sjukpenning_left == 4
|
||
assert pool_after.lagsta_left == 5
|
||
|
||
# The wish's own outcome still reports the pinned day as funded
|
||
# (via cost_breakdown), just not as a newly-proposed day.
|
||
outcome = proposal.outcomes[0]
|
||
assert outcome.cost_breakdown["sjukpenning"] == 1
|
||
assert outcome.shortfall_days == 0
|
||
|
||
def test_pinned_vacation_day_debits_the_vacation_budget(self) -> None:
|
||
child = _child(birth_date=date(2020, 1, 1))
|
||
parent = _parent(salary_type="monthly")
|
||
pinned_date = date(2027, 2, 10)
|
||
pinned = PlannedLeaveDay(
|
||
id=uuid4(),
|
||
workspace_id=uuid4(),
|
||
child_id=child.id,
|
||
parent_personal_data_uuid=parent.uuid,
|
||
date=pinned_date,
|
||
day_type="vacation",
|
||
is_pinned=True,
|
||
)
|
||
alloc_input = _build_input(
|
||
today=date(2027, 1, 1),
|
||
horizon_end=date(2027, 12, 31),
|
||
children=[child],
|
||
parents=[parent],
|
||
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
|
||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||
wishes=[],
|
||
existing_planned=[pinned],
|
||
)
|
||
|
||
proposal = Allocator().propose(alloc_input)
|
||
|
||
budget_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
|
||
assert budget_after.already_used == 1
|
||
assert budget_after.available == 24
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dubbeldagar count cap (settings.rules.max_dubbeldagar) reached mid-window
|
||
# -> the allocator falls back to a parent swap for the leftover days
|
||
# (allocation_service.py:979, `if used >= _MAX_DUBBELDAGAR`).
|
||
#
|
||
# TestCase10DubbeldagOutsideWindow above covers the *15-month window* edge
|
||
# (every day outside the window falls back to swap); TestDubbeldagSuccess in
|
||
# test_allocation_service_branches.py covers a short, uncapped success case.
|
||
# Neither drives the *count* cap itself to exhaustion -- this does, with a
|
||
# single `parent_mode='both'` wish long enough that its window contains more
|
||
# eligible weekdays than `max_dubbeldagar`, so `_fund_both`'s running `used`
|
||
# counter crosses the cap partway through and pushes every date after that
|
||
# point into `leftover_for_swap`.
|
||
#
|
||
# NOTE on the fallback's signature: `_fund_both` only ever routes a
|
||
# *non-free* (spendable weekday) date into `leftover_for_swap` -- every
|
||
# free/weekend/red day is funded for both parents directly inside
|
||
# `_fund_both`'s own loop, before the leftover list is even built (see the
|
||
# `is_free_day(...)` branch above the cap check in `allocation_service.py`).
|
||
# `REASON_SWAP_FIRST_HALF`/`REASON_SWAP_SECOND_HALF` are therefore never the
|
||
# right signal here: `_fund_swap` -> `_fund_solo` only stamps that reason on
|
||
# the *free-day* branch it takes for its half, and a cap-triggered leftover
|
||
# never contains a free day to stamp. The real, unambiguous signature of
|
||
# "funded as a parent swap rather than dubbeldagar" for this path is that
|
||
# each leftover date is funded for exactly ONE parent (an `_assign_tier3`
|
||
# tier day, e.g. `fp_interior`) instead of BOTH parents with
|
||
# `is_dubbeldag=True` -- confirmed against a live run of this exact scenario
|
||
# before writing the assertions below.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCase23DubbeldagCountCapFallsBackToSwap:
|
||
def test_days_past_the_count_cap_are_funded_solo_not_as_dubbeldagar(self) -> None:
|
||
cap = settings.rules.max_dubbeldagar
|
||
# 15mo cutoff: 2024-04-01 -- well after the window below, so the
|
||
# *window* edge (Case 10) plays no part here; only the count cap can.
|
||
child = _child(birth_date=date(2023, 1, 1))
|
||
parent_a = _parent(name="A")
|
||
parent_b = _parent(name="B")
|
||
wish = _wish(
|
||
child_id=child.id,
|
||
start=(1, 1),
|
||
end=(6, 30), # ~124 spendable weekdays -- comfortably more than the cap
|
||
weeks=26,
|
||
first_year=2023,
|
||
parent_mode="both",
|
||
# §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'` wish's recurrence is a week-based
|
||
# habit ("off Mon-Sun"), not a literal month/day. `window_for_year` must snap
|
||
# later years' naive month/day anchor onto the nearest date sharing
|
||
# `first_year`'s start weekday, and hold 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.
|
||
# These are pure entity-level tests (no Allocator needed) since the rule
|
||
# lives entirely in `LeaveWish.window_for_year`.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
||
def test_reported_user_case_shifts_to_the_nearest_matching_weekday(self) -> None:
|
||
"""2026-10-05..10-11 is Mon-Sun. Naively recurring 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).
|
||
"""
|
||
wish = _wish(
|
||
child_id=None,
|
||
start=(10, 5),
|
||
end=(10, 11),
|
||
recurrence="yearly",
|
||
first_year=2026,
|
||
window_mode="fixed",
|
||
)
|
||
|
||
start_2026, end_2026 = wish.window_for_year(2026)
|
||
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 = wish.window_for_year(2027)
|
||
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 = wish.window_for_year(2028)
|
||
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_first_year_is_returned_unshifted(self) -> None:
|
||
wish = _wish(
|
||
child_id=None,
|
||
start=(10, 5),
|
||
end=(10, 11),
|
||
recurrence="yearly",
|
||
first_year=2026,
|
||
window_mode="fixed",
|
||
)
|
||
assert wish.window_for_year(2026) == (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 `year + 1` relative to its own anchor year after the
|
||
weekday shift, exactly like the unshifted (first_year) case.
|
||
"""
|
||
wish = _wish(
|
||
child_id=None,
|
||
start=(12, 20),
|
||
end=(1, 6),
|
||
recurrence="yearly",
|
||
first_year=2026,
|
||
window_mode="fixed",
|
||
)
|
||
assert wish.window_wraps() is True
|
||
|
||
start_2026, end_2026 = wish.window_for_year(2026)
|
||
assert start_2026 == date(2026, 12, 20)
|
||
assert end_2026 == date(2027, 1, 6)
|
||
|
||
start_2027, end_2027 = wish.window_for_year(2027)
|
||
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_is_unchanged_across_years(self) -> None:
|
||
"""A flexible wish's four fields describe a bounding range to search
|
||
inside, not a placement -- no weekday shift, ever, including for
|
||
Season presets which must stay byte-identical after this change.
|
||
"""
|
||
wish = _wish(
|
||
child_id=None,
|
||
start=(3, 1),
|
||
end=(5, 31),
|
||
recurrence="yearly",
|
||
first_year=2026,
|
||
window_mode="flexible",
|
||
)
|
||
assert wish.window_for_year(2026) == (date(2026, 3, 1), date(2026, 5, 31))
|
||
assert wish.window_for_year(2027) == (date(2027, 3, 1), date(2027, 5, 31))
|
||
assert wish.window_for_year(2028) == (date(2028, 3, 1), date(2028, 5, 31))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|