b76fb7417e
Warnings such as "vacation days at risk of being lost" kept showing after the planner had already resolved them. The planner itself was firing correctly -- PlanSyncProvider syncs on every plan-input change -- so the fault was in the warnings, in two independent places. The vacation-expiry warning was computed before allocation. It read `vacation_forfeiting`, which the loader derives from already-committed usage, while `_assign_tier3` deliberately spends at-risk vacation days first precisely to avoid that forfeiture. The figure therefore counted days the plan was in the middle of saving. `_vacation_expiry_warnings` now subtracts the vacation days this run actually placed for that parent and year, clamped at zero -- sound because the bank is FIFO, so a day spent in year Y retires one expiring day one-for-one. The full carry-forward simulation is still not duplicated inside the ledger. The Dashboard then never refreshed them. Its proposal came from a one-shot effect guarded by `proposal === null`, with no planVersion dependency, so the card kept rendering the pre-replan warning until remount -- while `syncPlan` was already returning a fresh proposal that both call sites discarded. PlanSyncProvider now exposes that proposal behind a monotonic counter, and usePlanProposal prefers it once any sync has completed, keeping `propose()` for the first load. Also adds a manual recompute button to Settings, which only bumps planVersion and reports what the resulting sync did, and fixes partner add/remove never bumping planVersion at all -- that gap meant gaining or losing a parent never re-ran the allocator, though it changes who days can be assigned to. Rename stays cosmetic. Verified in the browser: a 2031 warning of 15 days dropped to 12 after three vacation days were planned, without a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
955 lines
40 KiB
Python
955 lines
40 KiB
Python
"""Additional branch-coverage tests for the pure Allocator.
|
|
|
|
The §5 test matrix (test_allocation_service.py) covers the 14 numbered
|
|
acceptance scenarios. This file covers the remaining tier-3 fallback paths,
|
|
`parent_mode='specific'`, a successful dubbeldag, family-wide (child-less)
|
|
wishes, and the warning/remedy branches not already exercised by the matrix.
|
|
"""
|
|
|
|
from datetime import date
|
|
|
|
from app.services.allocation_service import (
|
|
REASON_DUBBELDAG,
|
|
REASON_VACATION_WRAP_RED_DAY,
|
|
REASON_VACATION_WRAP_WEEKEND,
|
|
SURPLUS_FRONT_LOAD_EARLY_YEARS,
|
|
SURPLUS_SHIFT_MIX_TO_FP,
|
|
SURPLUS_USE_LESS_VACATION,
|
|
SURPLUS_WISH_MORE_LEAVE,
|
|
Allocator,
|
|
DayPool,
|
|
VacationBudget,
|
|
_candidate_cost,
|
|
_candidate_windows,
|
|
)
|
|
from app.utils.holidays import get_swedish_holidays
|
|
from tests.unit.test_services.test_allocation_service import (
|
|
AUG_2027,
|
|
FEB_2027,
|
|
_build_input,
|
|
_child,
|
|
_parent,
|
|
_wish,
|
|
)
|
|
|
|
|
|
class TestParentModeSpecific:
|
|
def test_specific_parent_handles_whole_window_solo(self) -> None:
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
parent_a = _parent(name="A")
|
|
parent_b = _parent(name="B")
|
|
wish = _wish(
|
|
child_id=child.id,
|
|
start=FEB_2027[0],
|
|
end=FEB_2027[1],
|
|
weeks=4,
|
|
parent_mode="specific",
|
|
parent_uuid=parent_b.uuid,
|
|
)
|
|
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(90, 90, 60),
|
|
(parent_b.uuid, child.id): DayPool(90, 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)
|
|
|
|
assert {d.parent_personal_data_uuid for d in proposal.days} == {parent_b.uuid}
|
|
|
|
|
|
class TestFamilyWideWish:
|
|
def test_family_wide_wish_uses_vacation_only(self) -> None:
|
|
parent = _parent(salary_type="monthly")
|
|
wish = _wish(child_id=None, 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=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
assert all(d.day_type in ("free", "vacation") for d in proposal.days)
|
|
assert any(d.reason == REASON_VACATION_WRAP_WEEKEND for d in proposal.days)
|
|
|
|
def test_family_wide_wish_unfunded_without_vacation(self) -> None:
|
|
parent = _parent(salary_type="monthly")
|
|
wish = _wish(child_id=None, 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=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
assert not any(d.day_type == "vacation" for d in proposal.days)
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.shortfall_days == 20 # nothing at all could be funded
|
|
|
|
|
|
class TestTier3Fallbacks:
|
|
def test_monthly_falls_back_to_fp_once_vacation_exhausted(self) -> None:
|
|
child = _child(birth_date=date(2025, 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(90, 0, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(5, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["vacation"] == 5
|
|
assert outcome.cost_breakdown["sjukpenning"] == 15
|
|
assert outcome.shortfall_days == 0
|
|
|
|
def test_hourly_falls_back_to_vacation_once_fp_exhausted(self) -> None:
|
|
child = _child(birth_date=date(2025, 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(2, 0, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["sjukpenning"] == 2
|
|
assert outcome.cost_breakdown["vacation"] == 18
|
|
assert outcome.shortfall_days == 0
|
|
|
|
def test_monthly_under_12_months_falls_back_to_fp_without_vacation(self) -> None:
|
|
"""The removed goal-dependent "stretch time at home" policy used to
|
|
skip FP entirely for a child's first 12 months (skip_fp_first_12_months)
|
|
to stretch the pool for later. The single policy has no such age gate:
|
|
with no vacation available, a monthly parent still funds the window
|
|
from FP even though the child is under 12 months old.
|
|
"""
|
|
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)
|
|
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={},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["sjukpenning"] == 20
|
|
assert outcome.cost_breakdown["lagsta"] == 0
|
|
assert outcome.cost_breakdown["vacation"] == 0
|
|
assert outcome.shortfall_days == 0
|
|
|
|
def test_monthly_fp_exhausted_falls_back_to_vacation_then_unfunded(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)
|
|
|
|
# First: FP exhausted, vacation available -> falls back to vacation.
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
children=[child],
|
|
parents=[parent],
|
|
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(20, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
proposal = Allocator().propose(alloc_input)
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["vacation"] == 20
|
|
assert outcome.shortfall_days == 0
|
|
|
|
# Second: FP AND vacation both exhausted -> unfunded.
|
|
alloc_input_empty = _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={},
|
|
wishes=[wish],
|
|
)
|
|
proposal_empty = Allocator().propose(alloc_input_empty)
|
|
outcome_empty = proposal_empty.outcomes[0]
|
|
assert outcome_empty.shortfall_days == 20
|
|
|
|
def test_monthly_vacation_scarce_falls_back_to_fp(self) -> None:
|
|
child = _child(birth_date=date(2025, 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(90, 0, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(1, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["vacation"] == 1
|
|
assert outcome.cost_breakdown["sjukpenning"] == 19
|
|
assert outcome.shortfall_days == 0
|
|
|
|
def test_monthly_fp_exhausted_uses_vacation_for_all_days(self) -> None:
|
|
child = _child(birth_date=date(2025, 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(0, 0, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(20, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.cost_breakdown["sjukpenning"] == 0
|
|
assert outcome.cost_breakdown["vacation"] == 20
|
|
assert outcome.shortfall_days == 0
|
|
|
|
|
|
class TestDubbeldagSuccess:
|
|
def test_dubbeldag_fires_within_15_month_window(self) -> None:
|
|
birth = date(2027, 1, 1) # 15mo cutoff: 2028-04-01
|
|
child = _child(birth_date=birth)
|
|
parent_a = _parent(name="A")
|
|
parent_b = _parent(name="B")
|
|
wish = _wish(
|
|
child_id=child.id,
|
|
start=(2, 1),
|
|
end=(2, 5), # Mon 1 Feb 2027 -> Fri 5 Feb 2027, well within 15mo
|
|
weeks=1,
|
|
parent_mode="both",
|
|
# §Decision A (2026-09-18 correction): dubbeldagar are opt-in --
|
|
# without this flag `_fund_both` would co-leave (PL+vacation)
|
|
# instead, never touching the dubbeldagar quota at all.
|
|
wants_dubbeldag=True,
|
|
)
|
|
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(90, 90, 60),
|
|
(parent_b.uuid, child.id): DayPool(90, 90, 60),
|
|
},
|
|
vacation={},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
dubbel_days = [d for d in proposal.days if d.is_dubbeldag]
|
|
assert len(dubbel_days) > 0
|
|
assert all(d.reason == REASON_DUBBELDAG for d in dubbel_days)
|
|
# Both parents get a dubbeldag entry for the same dates.
|
|
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
|
|
}
|
|
assert dubbel_dates_a == dubbel_dates_b
|
|
|
|
|
|
class TestWarnings:
|
|
def test_sgi_risk_warning_for_non_working_parent(self) -> None:
|
|
child = _child(birth_date=date(2020, 1, 1)) # well over 1 year old
|
|
parent = _parent(salary_type="hourly", employment_status="none")
|
|
# Wed-Fri only: 3 FP days in one ISO week, well under the 5/week floor.
|
|
wish = _wish(child_id=child.id, start=(2, 3), end=(2, 5), weeks=1)
|
|
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={},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
sgi_warnings = [w for w in proposal.warnings if w.kind == "sgi_risk"]
|
|
assert len(sgi_warnings) == 1
|
|
assert sgi_warnings[0].data["days_planned"] == 3
|
|
|
|
def test_vacation_expiring_warning(self) -> None:
|
|
# §Task 2 — `_vacation_expiry_warnings` now surfaces the real,
|
|
# loader-computed `vacation_forfeiting` figure (same FIFO
|
|
# carry-forward simulation as the Analytics tab) instead of the old
|
|
# `has_vacation_near_expiry` heuristic, so a hand-built pure
|
|
# `AllocationInput` supplies that figure directly rather than a
|
|
# `VacationBudget` shape the heuristic used to infer it from.
|
|
#
|
|
# Also the "structurally unresolvable" regression case for the
|
|
# post-allocation subtraction added below: no wishes means this run
|
|
# places zero vacation days anywhere, so nothing can reduce the
|
|
# forecast and the warning must still fire for the full figure.
|
|
parent = _parent()
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
children=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
|
|
wishes=[],
|
|
vacation_forfeiting={(parent.uuid, 2027): 5},
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
|
assert len(expiry_warnings) == 1
|
|
assert expiry_warnings[0].data["expiring_days"] == 5
|
|
assert expiry_warnings[0].data["year"] == 2027
|
|
|
|
def test_vacation_expiring_warning_reduced_by_placed_vacation_days(self) -> None:
|
|
# §Task 2 follow-up — `_assign_tier3` (family-wide, 0-children wishes
|
|
# are vacation-only regardless of its bias) spends vacation to fund
|
|
# this window, so the pre-placement forecast must be reduced by
|
|
# however many vacation days THIS run actually placed in that same
|
|
# year before the warning is shown — the FIFO argument in
|
|
# `_vacation_expiry_warnings`'s docstring.
|
|
parent = _parent()
|
|
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1)
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
children=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
|
|
wishes=[wish],
|
|
vacation_forfeiting={(parent.uuid, 2027): 8},
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
placed_vacation_2027 = sum(
|
|
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
|
)
|
|
assert placed_vacation_2027 == 5 # Mon 1 Feb - Fri 5 Feb, the whole eligible window
|
|
|
|
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
|
assert len(expiry_warnings) == 1
|
|
assert expiry_warnings[0].data["expiring_days"] == 3 # 8 forecast - 5 placed
|
|
assert expiry_warnings[0].data["year"] == 2027
|
|
|
|
def test_vacation_expiring_warning_suppressed_when_fully_covered(self) -> None:
|
|
# M >= N: the plan places at least as many vacation days in the
|
|
# forfeiting year as were forecast to expire, so no warning at all.
|
|
parent = _parent()
|
|
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1)
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
children=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 5, 0)},
|
|
wishes=[wish],
|
|
vacation_forfeiting={(parent.uuid, 2027): 5},
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
placed_vacation_2027 = sum(
|
|
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
|
)
|
|
assert placed_vacation_2027 == 5
|
|
|
|
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
|
assert expiry_warnings == []
|
|
|
|
def test_vacation_expiring_warning_unaffected_by_placements_in_other_year(self) -> None:
|
|
# The plan places vacation only in 2027; the forfeiture forecast is
|
|
# for 2028, a year this run places no vacation in at all, so the
|
|
# figure must pass through unchanged.
|
|
parent = _parent()
|
|
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1, first_year=2027)
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
children=[],
|
|
parents=[parent],
|
|
pools={},
|
|
vacation={
|
|
(parent.uuid, 2027): VacationBudget(25, 0, 0),
|
|
(parent.uuid, 2028): VacationBudget(25, 0, 0),
|
|
},
|
|
wishes=[wish],
|
|
vacation_forfeiting={(parent.uuid, 2028): 4},
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
placed_vacation_2027 = sum(
|
|
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
|
)
|
|
assert placed_vacation_2027 == 5
|
|
|
|
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
|
assert len(expiry_warnings) == 1
|
|
assert expiry_warnings[0].data["expiring_days"] == 4
|
|
assert expiry_warnings[0].data["year"] == 2028
|
|
|
|
|
|
class TestVacationForfeitureBias:
|
|
"""§Task 2 — `_assign_tier3` spends vacation before FP in a year where
|
|
|
|
`AllocationInput.vacation_forfeiting` says the vacation would otherwise
|
|
be lost anyway, even for an hourly-salaried parent (whose default
|
|
tier-3 order is FP-first, since a vacation day normally costs them
|
|
pay). February 2027 is used as the funded window: a clean month (no
|
|
real red days near its edges, per this file's module docstring), so
|
|
every non-free day goes through tier-3 with nothing else interfering.
|
|
"""
|
|
|
|
def test_forfeiting_year_spends_vacation_before_fp(self) -> None:
|
|
child = _child(birth_date=date(2025, 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(90, 90, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
|
wishes=[wish],
|
|
vacation_forfeiting={(parent.uuid, 2027): 5},
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
interior_days = [
|
|
d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta")
|
|
]
|
|
assert interior_days, "expected some funded interior (non-free) days"
|
|
assert all(d.day_type == "vacation" for d in interior_days)
|
|
# The FP pool must be untouched: every interior day was paid from
|
|
# vacation instead, exactly the point of the bias (bank the FP days
|
|
# for a later year rather than let this year's vacation go to waste).
|
|
balances_after = proposal.balances_after.pools_after[(parent.uuid, child.id)]
|
|
assert balances_after.sjukpenning_left == 90
|
|
assert balances_after.lagsta_left == 90
|
|
|
|
def test_non_forfeiting_year_is_unchanged_fp_first_for_hourly_parent(self) -> None:
|
|
"""Regression guard: absent a forecast forfeiture, an hourly parent's
|
|
|
|
tier-3 order must stay exactly what it was before §Task 2 — FP
|
|
first, vacation only backstopping what FP can't cover.
|
|
"""
|
|
child = _child(birth_date=date(2025, 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(90, 90, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
interior_days = [
|
|
d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta")
|
|
]
|
|
assert interior_days, "expected some funded interior (non-free) days"
|
|
assert all(d.day_type == "sjukpenning" for d in interior_days)
|
|
balances_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
|
|
assert balances_after.already_used == 0
|
|
|
|
|
|
class TestRemedies:
|
|
def test_carry_vacation_forward_and_lower_priority_wish_remedies(self) -> None:
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
# 25 total - 15 already_used = 10 "available", but only days above the
|
|
# semesterlagen minimum (default 20/year, PLANNING_ENGINE_SPEC §9.2)
|
|
# may be carried forward: max(25 - 20, 0) = 5 bankable, so the
|
|
# remedy is capped at 5, not the full 10 available.
|
|
parent = _parent(salary_type="hourly", min_vacation_days_per_year=20)
|
|
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(5, 0, 60)},
|
|
vacation={(parent.uuid, 2026): VacationBudget(25, 0, 15)}, # 10 available, prior year
|
|
wishes=[wish_high, wish_low],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Feb")
|
|
assert outcome_high.shortfall_days > 0
|
|
kinds = {r.kind for r in outcome_high.remedies}
|
|
assert "carry_vacation_forward" in kinds
|
|
assert "lower_priority_wish" in kinds
|
|
carry = next(r for r in outcome_high.remedies if r.kind == "carry_vacation_forward")
|
|
assert carry.days_freed == 5
|
|
|
|
def test_carry_vacation_forward_capped_by_custom_minimum(self) -> None:
|
|
"""A parent with a higher employer-set minimum (Task 1) gets a
|
|
correspondingly lower bankable ceiling: 25 - 12 = 13, still below
|
|
the 20 "available" so the cap (not `available`) is what binds."""
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
parent = _parent(salary_type="hourly", min_vacation_days_per_year=12)
|
|
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],
|
|
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
|
|
vacation={(parent.uuid, 2026): VacationBudget(25, 0, 5)}, # 20 available, prior year
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.shortfall_days > 0
|
|
carry = next(r for r in outcome.remedies if r.kind == "carry_vacation_forward")
|
|
assert carry.days_freed == 13
|
|
|
|
def test_lower_priority_wish_suppressed_when_target_is_already_one_week(self) -> None:
|
|
"""Bug B regression: a 1-week sibling wish has no smaller whole-week
|
|
target to suggest (`target_weeks` floors at 1 — see
|
|
`LeaveWishCreate.target_weeks`). Halving used to clamp back up to 1,
|
|
producing a nonsensical "lower Vår from 1 to 1 weeks" remedy. It must
|
|
be suppressed instead of claiming a change that isn't one."""
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
parent = _parent(salary_type="hourly", min_vacation_days_per_year=20)
|
|
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="Vår", start=AUG_2027[0], end=AUG_2027[1], weeks=1, 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(5, 0, 60)},
|
|
vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)}, # no carry-forward remedy
|
|
wishes=[wish_high, wish_low],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Feb")
|
|
assert outcome_high.shortfall_days > 0
|
|
assert "lower_priority_wish" not in {r.kind for r in outcome_high.remedies}
|
|
|
|
def test_lower_priority_wish_reports_a_genuine_reduction(self) -> None:
|
|
"""A sibling wish of >= 2 weeks halves to a strictly smaller whole
|
|
week count, so the remedy fires with a real (not no-op) suggestion."""
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
parent = _parent(salary_type="hourly", min_vacation_days_per_year=20)
|
|
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=2, 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(5, 0, 60)},
|
|
vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)},
|
|
wishes=[wish_high, wish_low],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Feb")
|
|
remedy = next(r for r in outcome_high.remedies if r.kind == "lower_priority_wish")
|
|
assert remedy.data["from_weeks"] == 2
|
|
assert remedy.data["to_weeks"] == 1
|
|
assert remedy.data["from_weeks"] != remedy.data["to_weeks"]
|
|
assert remedy.days_freed > 0
|
|
|
|
def test_wish_name_message_key_propagates_to_outcome_and_remedies(self) -> None:
|
|
"""`WindowOutcome.wish_name_message_key` (and its `data` mirror) must
|
|
|
|
carry the *outcome's own* wish's i18n key, `lower_priority_wish`'s
|
|
`data.wish_name_message_key` must carry the *sibling* wish's key (not
|
|
the outcome's own -- they can differ), and
|
|
`shift_window_into_free_days`'s `data.wish_name_message_key` must
|
|
again carry the outcome's own wish's key, so `PlanResults.tsx` can
|
|
re-translate a preset-created name in every surface, not just the
|
|
top-level field.
|
|
"""
|
|
child = _child(birth_date=date(2025, 1, 1))
|
|
parent = _parent(salary_type="hourly", min_vacation_days_per_year=20)
|
|
wish_high = _wish(
|
|
child_id=child.id,
|
|
name="Vinter",
|
|
name_message_key="wishEditor.seasonJul",
|
|
start=FEB_2027[0],
|
|
end=FEB_2027[1],
|
|
weeks=4,
|
|
priority=1,
|
|
)
|
|
wish_low = _wish(
|
|
child_id=child.id,
|
|
name="Sommar",
|
|
name_message_key="wishEditor.presetSommar",
|
|
start=AUG_2027[0],
|
|
end=AUG_2027[1],
|
|
weeks=2,
|
|
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(5, 0, 60)},
|
|
vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)},
|
|
wishes=[wish_high, wish_low],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Vinter")
|
|
assert outcome_high.wish_name_message_key == "wishEditor.seasonJul"
|
|
assert outcome_high.data["wish_name_message_key"] == "wishEditor.seasonJul"
|
|
|
|
lower_priority = next(r for r in outcome_high.remedies if r.kind == "lower_priority_wish")
|
|
assert lower_priority.data["wish_name_message_key"] == "wishEditor.presetSommar"
|
|
|
|
shift_window = next(
|
|
r for r in outcome_high.remedies if r.kind == "shift_window_into_free_days"
|
|
)
|
|
assert shift_window.data["wish_name_message_key"] == "wishEditor.seasonJul"
|
|
|
|
|
|
class TestDayOverrides:
|
|
"""`AllocationInput.day_overrides` — see `DayOverride` / `is_free_day`.
|
|
|
|
A workspace-level override must win over the calendar in both
|
|
directions: it can turn a normally-free calendar day into a working day
|
|
(must clock in on julafton) or a normally-working day into a day off
|
|
(employer gives a klämdag).
|
|
"""
|
|
|
|
def test_override_forces_work_on_normally_free_julafton(self) -> None:
|
|
"""Without an override, julafton (2027-12-24) is free (de facto day)
|
|
and the 25th/26th are the weekend + Juldagen/Annandag jul. Overriding
|
|
the 24th to `is_working_day=True` turns it into an ordinary spendable
|
|
day — but it's still adjacent to the Dec 25-26 red-day cluster, so
|
|
tier-2 (vacation-wrap-red-day) fires for it rather than leaving it
|
|
unfunded."""
|
|
child = _child(birth_date=date(2020, 1, 1))
|
|
parent = _parent(salary_type="monthly")
|
|
wish = _wish(
|
|
child_id=child.id,
|
|
name="Jul",
|
|
start=(12, 24),
|
|
end=(12, 26),
|
|
weeks=1,
|
|
first_year=2027,
|
|
)
|
|
base_kwargs = dict(
|
|
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],
|
|
)
|
|
|
|
without_override = Allocator().propose(_build_input(**base_kwargs))
|
|
assert without_override.outcomes[0].cost_breakdown["free"] == 3
|
|
assert without_override.outcomes[0].cost_breakdown["vacation"] == 0
|
|
|
|
with_override = Allocator().propose(
|
|
_build_input(**base_kwargs, day_overrides={date(2027, 12, 24): True})
|
|
)
|
|
outcome = with_override.outcomes[0]
|
|
assert outcome.cost_breakdown["free"] == 2
|
|
assert outcome.cost_breakdown["vacation"] == 1
|
|
|
|
overridden_day = next(d for d in with_override.days if d.date == date(2027, 12, 24))
|
|
assert overridden_day.day_type == "vacation"
|
|
assert overridden_day.reason == REASON_VACATION_WRAP_RED_DAY
|
|
|
|
def test_override_grants_a_day_off_on_a_normally_working_day(self) -> None:
|
|
"""A klämdag the employer gives off: 2027-02-02 (an ordinary Tuesday
|
|
deep inside the Feb window) is overridden to `is_working_day=False`.
|
|
It must show up as a free day and NOT draw from vacation."""
|
|
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)
|
|
base_kwargs = dict(
|
|
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],
|
|
)
|
|
|
|
without_override = Allocator().propose(_build_input(**base_kwargs))
|
|
assert without_override.outcomes[0].cost_breakdown["vacation"] == 20
|
|
|
|
with_override = Allocator().propose(
|
|
_build_input(**base_kwargs, day_overrides={date(2027, 2, 2): False})
|
|
)
|
|
outcome = with_override.outcomes[0]
|
|
assert outcome.cost_breakdown["vacation"] == 19
|
|
assert outcome.cost_breakdown["free"] == 9
|
|
|
|
overridden_day = next(d for d in with_override.days if d.date == date(2027, 2, 2))
|
|
assert overridden_day.day_type == "free"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §9.2 — surplus advice: the mirror of a shortfall remedy.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _big_surplus_input(*, min_vacation_days_per_year: int = 10):
|
|
"""A parent who funds a whole 20-day interior window entirely from
|
|
vacation (monthly salary, no red days nearby -> tier-3 picks vacation
|
|
first) while sitting on a huge, untouched FP pool. By the child's
|
|
4th birthday the untouched pool massively exceeds the 96-day ceiling, so
|
|
every surplus-advice kind should have something to say:
|
|
|
|
- wish_more_leave: 384 expiring FP days -> 76 affordable weeks
|
|
- shift_mix_to_fp: 20 vacation days spent in the interior could
|
|
have been FP instead (min(20, 384) == 20)
|
|
- front_load_early_years: 384 >= the "large surplus" threshold, and the
|
|
child is < 2 years old at `today`
|
|
- use_less_vacation: already_used (20) - min (10) == 10 days could
|
|
have stayed banked instead of being spent
|
|
"""
|
|
child = _child(birth_date=date(2026, 3, 1)) # B4 = 2030-03-01
|
|
parent = _parent(salary_type="monthly", min_vacation_days_per_year=min_vacation_days_per_year)
|
|
wish = _wish(
|
|
child_id=child.id,
|
|
name="Feb",
|
|
start=FEB_2027[0],
|
|
end=FEB_2027[1],
|
|
weeks=4,
|
|
)
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2030, 12, 31),
|
|
children=[child],
|
|
parents=[parent],
|
|
pools={(parent.uuid, child.id): DayPool(390, 90, 60)}, # 480 total, untouched
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, # covers all 20 interior days
|
|
wishes=[wish],
|
|
)
|
|
return alloc_input, parent, child
|
|
|
|
|
|
class TestSurplusAdvice:
|
|
def test_large_surplus_emits_all_four_kinds_with_correct_day_counts(self) -> None:
|
|
alloc_input, parent, child = _big_surplus_input(min_vacation_days_per_year=10)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
# Sanity: the window really was funded entirely from vacation, and
|
|
# the age-4 warning fired (surplus advice mirrors that warning).
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.shortfall_days == 0
|
|
assert outcome.cost_breakdown["vacation"] == 20
|
|
assert outcome.cost_breakdown["sjukpenning"] == 0
|
|
assert outcome.cost_breakdown["lagsta"] == 0
|
|
assert any(w.kind == "days_lost_at_age_4" for w in proposal.warnings)
|
|
|
|
by_kind = {a.kind: a for a in proposal.surplus_advice}
|
|
assert by_kind.keys() == {
|
|
SURPLUS_WISH_MORE_LEAVE,
|
|
SURPLUS_SHIFT_MIX_TO_FP,
|
|
SURPLUS_FRONT_LOAD_EARLY_YEARS,
|
|
SURPLUS_USE_LESS_VACATION,
|
|
}
|
|
|
|
assert by_kind[SURPLUS_WISH_MORE_LEAVE].days_involved == 384
|
|
assert by_kind[SURPLUS_SHIFT_MIX_TO_FP].days_involved == 20
|
|
assert by_kind[SURPLUS_FRONT_LOAD_EARLY_YEARS].days_involved == 384
|
|
assert by_kind[SURPLUS_USE_LESS_VACATION].days_involved == 10
|
|
|
|
for advice in proposal.surplus_advice:
|
|
assert advice.data["parent_personal_data_uuid"] == parent.uuid
|
|
assert advice.data["child_id"] == child.id
|
|
assert advice.days_involved > 0
|
|
|
|
def test_use_less_vacation_not_emitted_below_the_minimum(self) -> None:
|
|
"""Same big-surplus scenario, but the parent's minimum (20) equals
|
|
the vacation actually used (20) -> nothing reducible, so
|
|
`use_less_vacation` must not appear even though other kinds do."""
|
|
alloc_input, _parent_facts, _child_facts = _big_surplus_input(min_vacation_days_per_year=20)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
kinds = {a.kind for a in proposal.surplus_advice}
|
|
assert SURPLUS_USE_LESS_VACATION not in kinds
|
|
# The other three don't depend on the vacation minimum and must still fire.
|
|
assert SURPLUS_WISH_MORE_LEAVE in kinds
|
|
assert SURPLUS_SHIFT_MIX_TO_FP in kinds
|
|
assert SURPLUS_FRONT_LOAD_EARLY_YEARS in kinds
|
|
|
|
def test_no_surplus_advice_for_a_well_fitted_plan(self) -> None:
|
|
"""A plan that doesn't leave anything behind at the age-4 boundary
|
|
must not nag the user with suggestions."""
|
|
child = _child(birth_date=date(2020, 1, 1)) # B4 already passed (2024-01-01)
|
|
parent = _parent()
|
|
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],
|
|
# Pool already at/under the age-4 ceiling -> nothing "expiring".
|
|
pools={(parent.uuid, child.id): DayPool(60, 36, 60)},
|
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
|
wishes=[wish],
|
|
)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
assert proposal.surplus_advice == []
|
|
|
|
def test_surplus_advice_is_deterministic(self) -> None:
|
|
alloc_input, _parent_facts, _child_facts = _big_surplus_input()
|
|
|
|
allocator = Allocator()
|
|
proposal_1 = allocator.propose(alloc_input)
|
|
proposal_2 = allocator.propose(alloc_input)
|
|
|
|
assert proposal_1.surplus_advice == proposal_2.surplus_advice
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PLANNING_ENGINE_SPEC.md §10.2 — pure helpers for the flexible-wish
|
|
# candidate search: `_candidate_windows` (step 5) and `_candidate_cost`
|
|
# (step 6). Direct unit tests, independent of the full acceptance-matrix
|
|
# scenarios in test_allocation_service.py.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCandidateWindows:
|
|
def test_slides_one_day_at_a_time_across_the_full_bound(self) -> None:
|
|
bound_start = date(2027, 3, 1)
|
|
bound_end = date(2027, 3, 10) # 10-day bound, 7-day windows -> 4 candidates
|
|
candidates = _candidate_windows(bound_start, bound_end, length_days=7)
|
|
|
|
assert candidates == [
|
|
(date(2027, 3, 1), date(2027, 3, 7)),
|
|
(date(2027, 3, 2), date(2027, 3, 8)),
|
|
(date(2027, 3, 3), date(2027, 3, 9)),
|
|
(date(2027, 3, 4), date(2027, 3, 10)),
|
|
]
|
|
|
|
def test_single_candidate_when_bound_exactly_matches_length(self) -> None:
|
|
bound_start = date(2027, 3, 1)
|
|
bound_end = date(2027, 3, 7)
|
|
candidates = _candidate_windows(bound_start, bound_end, length_days=7)
|
|
|
|
assert candidates == [(date(2027, 3, 1), date(2027, 3, 7))]
|
|
|
|
def test_no_candidates_when_bound_shorter_than_length(self) -> None:
|
|
# Guarded by the caller (§10.2 step 4) before this is ever invoked,
|
|
# but the pure helper itself must not raise or return a garbage
|
|
# negative-length window.
|
|
candidates = _candidate_windows(date(2027, 3, 1), date(2027, 3, 5), length_days=7)
|
|
assert candidates == []
|
|
|
|
|
|
class TestCandidateCost:
|
|
def test_counts_only_spendable_days(self) -> None:
|
|
holidays = get_swedish_holidays(2027)
|
|
# 2027-03-23 (Tue) -> 2027-03-29 (Mon): Långfredagen/Påskafton/
|
|
# Påskdagen/Annandag påsk (Fri-Mon) are free -> 3 spendable days.
|
|
cost = _candidate_cost(date(2027, 3, 23), date(2027, 3, 29), holidays, {})
|
|
assert cost == 3
|
|
|
|
def test_day_override_can_make_a_weekday_free(self) -> None:
|
|
holidays = get_swedish_holidays(2027)
|
|
base_cost = _candidate_cost(date(2027, 2, 1), date(2027, 2, 7), holidays, {})
|
|
overridden_cost = _candidate_cost(
|
|
date(2027, 2, 1), date(2027, 2, 7), holidays, {date(2027, 2, 3): False}
|
|
)
|
|
assert overridden_cost == base_cost - 1
|
|
|
|
def test_day_override_can_make_a_saturday_spendable(self) -> None:
|
|
holidays = get_swedish_holidays(2027)
|
|
base_cost = _candidate_cost(date(2027, 2, 1), date(2027, 2, 7), holidays, {})
|
|
# 2027-02-06 is a Saturday (normally free); overriding it to a
|
|
# working day makes it spendable.
|
|
overridden_cost = _candidate_cost(
|
|
date(2027, 2, 1), date(2027, 2, 7), holidays, {date(2027, 2, 6): True}
|
|
)
|
|
assert overridden_cost == base_cost + 1
|