Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_service_branches.py
T
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:22:07 +02:00

860 lines
36 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.
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
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