87b3eb5d0c
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
759 lines
31 KiB
Python
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
|