e4719cb9ae
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and dubbeldagar left (plus optional reserved days) and derives the rest in build_snapshot, shared with the scraper. The scraper also reads the family panel and exposes a partner deduction; with two parents it is saved as a 'deduced' snapshot (migration 002). Parent count is the single source of entitlement: one member gets 480 days and 96 savable at age 4, two get 240 and 48 each. Workspaces are capped at two parents. Also lands the in-flight squash of migrations 002–011 into the initial schema and the removal of planning_mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
782 lines
32 KiB
Python
782 lines
32 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:
|
|
parent = _parent()
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2027, 12, 31),
|
|
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),
|
|
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
|