Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service_branches.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

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

695 lines
28 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
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