"""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