Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service_branches.py
T
jacobh2 87b3eb5d0c Fix four bugs found by browser verification
First real browser walkthrough of the app, against seeded demo data. All 1140
tests were passing; none of these four were visible to them.

Age-4 entitlement (high severity, domain bug). The no-snapshot fallback gave a
single parent rules.sjukpenning_days + rules.lagsta_niva_days = 390 + 90 = 480
days, but those config values are the whole CHILD's combined tier totals across
both parents, not one parent's share. The correct fallback is days_per_parent
(240 = 195 + 45), which only AnalyticsService used. So a parent without an FK
snapshot was credited with roughly double their real entitlement, and the
Dashboard and Plan tab disagreed about expiring_at_b4 for the same committed
state. The wrong default existed in three places; allocation_service.py and
planning_service.py are corrected, and analytics_service.py now calls the shared
pure days_left_at_age_4_projection instead of re-deriving the arithmetic, so
there is one source of truth. A test that had encoded the buggy 390/90 default
as expected behaviour is corrected too.

The Dashboard stat is workspace-wide while the Plan tab breaks down per parent,
which is legitimate but was unlabelled and contributed to the confusion; the
dashboard label now says it is a combined all-parents total.

Nonsensical remedy text. lower_priority_wish computed
max(other.target_weeks // 2, 1), which for a 1-week wish clamped back to 1 and
rendered "Lowering Var from 1 to 1 weeks frees up 32 days". target_weeks has a
domain floor of 1 and the planner works in whole days, so there is no smaller
whole-week suggestion to make: the remedy is now suppressed for 1-week wishes.
Wishes of 2+ weeks still get a genuine, strictly smaller suggestion.

Day override invisible until reload. Calendar refetched the overrides list but
not GET /api/calendar/days, which backs the grid's shading and badges, so a
just-applied override did not render. useCalendarDays now exposes refetch and
Calendar calls it after a successful upsert or remove.

Dashboard showed the previous user's data after a login swap. WorkspaceContext
sits above the router and outlives login/logout, and its refetch was gated on
!workspaceId || children.length === 0 - so precisely because the old session's
data was still present, the guard blocked the refetch. It now keys off an
explicit isAuthenticated transition and clears state on the logout edge itself,
including the persisted localStorage workspaceId, so one account's figures can
never be briefly visible under another's session. useAnalytics, useParentalLeave
and usePlannedDays clear cached data on identity loss as defence in depth.

Backend 435 -> 439 tests, frontend 705 -> 710.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 08:21:18 +02:00

759 lines
31 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_goal="max_money",
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_goal="max_money",
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_goal="max_money",
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_max_money_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_goal="max_money",
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_max_money_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_goal="max_money",
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_max_time_under_12_months_unfunded_without_vacation(self) -> None:
child = _child(birth_date=date(2026, 9, 1))
parent = _parent(salary_type="monthly")
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="max_time",
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"] == 0
assert outcome.cost_breakdown["lagsta"] == 0
assert outcome.cost_breakdown["vacation"] == 0
assert outcome.shortfall_days == 20
def test_max_time_over_12_months_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_goal="max_time",
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_goal="max_time",
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_balanced_early_falls_back_to_fp_when_vacation_scarce(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_goal="balanced",
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_balanced_late_falls_back_to_vacation_when_fp_scarce(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_goal="balanced",
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_goal="balanced",
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_goal="max_money",
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_goal="balanced",
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_goal="max_money",
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_goal="max_money",
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_goal="max_money",
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_goal="max_money",
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
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_goal="balanced",
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_goal="max_money",
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 (max_money + monthly, 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_goal="max_money",
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_goal="balanced",
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