Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service_branches.py
T
jacobh2 cc3ced905e Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
2026-09-15 20:28:06 +02:00

800 lines
33 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),
planning_mode="couple",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
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),
planning_mode="solo",
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:
parent = _parent()
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[],
parents=[parent],
pools={},
vacation={(parent.uuid, 2026): VacationBudget(25, 8, 0)},
wishes=[],
)
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["saved_days_in"] == 8
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
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),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)},
wishes=[wish],
)
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),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
)
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),
planning_mode="solo",
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),
planning_mode="solo",
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