Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service_branches.py
T
jacobh2 b76fb7417e Keep plan warnings in step with the plan they describe
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
2026-09-20 14:26:50 +02:00

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