Keep "both parents" wishes both-off, and warn when they can't be

A lower-priority 'both' wish (e.g. a yearly autumn week) quietly fell back
to one parent per day once summer/Jul had used both parents' vacation, and
still counted as funded, so nothing warned.

- Allocator rescue pass: frees a vacation day by switching one parent's
  vacation to FP on another date (interior v+v, then Mon/Fri bridge days
  without creating an FP weekend wrap, then solo days) and spends it on the
  one-parent date as co-leave. Pinned days count as present.
- WindowOutcome.unmet_both_dates + new both_parents_unmet plan warning.
- Frontend maps the new fields; Dashboard warning card, year-row flag and a
  Calendar save-toast line list the affected years (sv + en).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:24:28 +02:00
parent 1a02bdcdbf
commit 906cf70a98
20 changed files with 1265 additions and 14 deletions
@@ -0,0 +1,461 @@
"""Tests for §both-parents-rescue: `_Ledger.rescue_degraded_both_windows`.
Context: `_fund_both` falls back to `_fund_swap` for any 'both'-mode date it
can't co-leave/dubbeldag on — typically because BOTH parents' vacation
budget for that year is already exhausted by a higher-priority 'both'
window (summer, Jul, ...). A swap date still counts as "funded" (one parent
got a day), so `shortfall_days` stays 0 and nothing warns — the only trace
used to be `WindowOutcome.strategy_degraded`, which the frontend dropped.
This module's real-world motivating case: two monthly-salaried parents, each
with a full vacation budget consumed by fixed, high-priority "both" wishes,
then a lower-priority "both" wish (here fixed, for a deterministic test —
the production case is flexible, but the rescue pass itself doesn't care
which pass placed the window) that would otherwise degrade to a sequential
swap for every one of its dates.
Builders (`_build_input`, `_child`, `_parent`, `_wish`) and `FEB_2027` are
imported from `test_allocation_service.py` rather than reinvented — see that
module's docstring for the harness convention.
"""
from datetime import date, timedelta
from uuid import uuid4
from app.entities.planned_leave import PlannedLeaveDay
from app.services.allocation_service import (
Allocator,
DayPool,
VacationBudget,
_bridge_far_side_dates,
)
from tests.unit.test_services.test_allocation_service import (
FEB_2027,
_build_input,
_child,
_parent,
_wish,
)
# Mon 2027-09-06 .. Fri 2027-09-10: five clean spendable weekdays, no red
# days nearby (verified against `get_swedish_holidays(2027)`).
_AUTUMN_WEEK = ((9, 6), (9, 10))
_AUTUMN_DATES = (
date(2027, 9, 6),
date(2027, 9, 7),
date(2027, 9, 8),
date(2027, 9, 9),
date(2027, 9, 10),
)
class TestBothParentsRescueRepairsDegradedWindow:
def test_donates_vacation_from_summer_to_fund_autumn_as_co_leave(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
# Two 'specific'-mode wishes covering the SAME dates (Feb 2027 — 20
# spendable weekdays, no red days) — each parent independently and
# entirely exhausts their own 20-day vacation budget for 2027,
# producing a genuine "vacation+vacation" date for every spendable
# Feb weekday (the donor pool this test's rescue draws from).
summer_a = _wish(
child_id=child.id,
name="Summer A",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
priority=1,
parent_mode="specific",
parent_uuid=parent_a.uuid,
)
summer_b = _wish(
child_id=child.id,
name="Summer B",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
priority=1,
parent_mode="specific",
parent_uuid=parent_b.uuid,
)
autumn = _wish(
child_id=child.id,
name="Autumn",
start=_AUTUMN_WEEK[0],
end=_AUTUMN_WEEK[1],
weeks=1,
priority=9,
parent_mode="both",
)
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(300, 90, 60),
(parent_b.uuid, child.id): DayPool(300, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(20, 0, 0),
(parent_b.uuid, 2027): VacationBudget(20, 0, 0),
},
wishes=[summer_a, summer_b, autumn],
)
proposal = Allocator().propose(alloc_input)
outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn")
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy == "co_leave"
assert outcome.strategy_degraded is False
assert outcome.unmet_both_dates == []
assert outcome.shortfall_days == 0
by_date: dict[date, list] = {}
for d in proposal.days:
if d.wish_id == autumn.id and d.day_type != "free":
by_date.setdefault(d.date, []).append(d)
for autumn_date in _AUTUMN_DATES:
entries = by_date.get(autumn_date, [])
parents_present = {e.parent_personal_data_uuid for e in entries}
assert parents_present == {parent_a.uuid, parent_b.uuid}, (
autumn_date,
entries,
)
# Summer dates are still both-off — the rescue only ever changes a
# donor date's TYPE (vacation -> FP), never removes it.
summer_days: dict[date, set] = {}
for d in proposal.days:
if d.wish_id in (summer_a.id, summer_b.id) and d.day_type != "free":
summer_days.setdefault(d.date, set()).add(d.parent_personal_data_uuid)
spendable_feb_dates = [
date(2027, 2, 1) + timedelta(days=i)
for i in range(28)
if (date(2027, 2, 1) + timedelta(days=i)).weekday() < 5
]
assert len(spendable_feb_dates) == 20
for feb_date in spendable_feb_dates:
assert summer_days.get(feb_date) == {parent_a.uuid, parent_b.uuid}, feb_date
# Every donated vacation day was exactly replaced by a spent one on
# the autumn window -- net vacation usage per parent is unchanged
# (still fully exhausted), just re-timed.
vacation_after = proposal.balances_after.vacation_after
assert vacation_after[(parent_a.uuid, 2027)].available == 0
assert vacation_after[(parent_b.uuid, 2027)].available == 0
assert not any(w.kind == "both_parents_unmet" for w in proposal.warnings)
class TestBothParentsRescueLeavesUnmetWhenNoDonorExists:
def test_no_vacation_anywhere_reports_unmet_and_warns(self) -> None:
"""No vacation budget at all for either parent -- the rescue pass has
nothing to donate, so every autumn date stays swap-funded,
`unmet_both_dates` reports all of them, and a `both_parents_unmet`
warning is emitted.
"""
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
autumn = _wish(
child_id=child.id,
name="Autumn",
start=_AUTUMN_WEEK[0],
end=_AUTUMN_WEEK[1],
weeks=1,
priority=1,
parent_mode="both",
)
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(300, 90, 60),
(parent_b.uuid, child.id): DayPool(300, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(0, 0, 0),
(parent_b.uuid, 2027): VacationBudget(0, 0, 0),
},
wishes=[autumn],
)
proposal = Allocator().propose(alloc_input)
outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn")
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy_degraded is True
assert outcome.shortfall_days == 0 # still the silent-success bug's own signature
assert sorted(outcome.unmet_both_dates) == list(_AUTUMN_DATES)
warning = next(w for w in proposal.warnings if w.kind == "both_parents_unmet")
assert warning.data["year"] == 2027
assert warning.data["count"] == 5
assert warning.data["total_days"] == 5
assert warning.data["wish_id"] == autumn.id
class TestBothParentsRescueUsesBridgeDonors:
"""Real-data follow-up: a genuine 'both'-mode window's co-leave tier
NEVER produces a vacation+vacation date on an ordinary interior day (it
always splits one parent FP / the other vacation) — only its bridge tier
(`_try_bridge_vacation_pair`, Fri/Mon around a weekend or a klämdag
around a red-day run) can. So on a real plan where the high-priority
'both' wishes are genuinely 'both'-mode (not two solo per-parent wishes,
unlike `TestBothParentsRescueRepairsDegradedWindow` above), EVERY
donor candidate is a bridge date, and an earlier version of the rescue
that excluded bridge donors outright was a complete no-op.
"""
def test_donates_from_bridge_only_vacation_and_never_wraps_a_weekend(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
# An 8-week 'both' window (Feb 1 - Mar 21 2027, clear of Easter,
# which lands 2027-03-26) -- long enough that a 24-day vacation
# budget is EXACTLY its natural (unconstrained) vacation appetite:
# confirmed empirically to fund with `strategy_degraded == False`
# (no swap fallback inside Summer itself, so nothing here is
# confounded by `reallocate_vacation_by_value`'s own, unrelated,
# pre-existing handling of swap-fallback bridge days) while leaving
# both parents at exactly 0 available afterwards.
summer = _wish(
child_id=child.id,
name="Summer",
start=(2, 1),
end=(3, 21),
weeks=8,
priority=1,
parent_mode="both",
)
autumn = _wish(
child_id=child.id,
name="Autumn",
start=_AUTUMN_WEEK[0],
end=_AUTUMN_WEEK[1],
weeks=1,
priority=9,
parent_mode="both",
)
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(300, 90, 60),
(parent_b.uuid, child.id): DayPool(300, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(24, 0, 0),
(parent_b.uuid, 2027): VacationBudget(24, 0, 0),
},
wishes=[summer, autumn],
)
proposal = Allocator().propose(alloc_input)
summer_outcome = next(o for o in proposal.outcomes if o.wish_name == "Summer")
assert summer_outcome.strategy_degraded is False, "fixture assumption broken"
# Fixture assumption: Summer's own vacation+vacation dates are ALL
# bridge days -- never an ordinary interior day (co-leave always
# splits FP/vacation on those). If this ever stops holding the test
# itself is no longer exercising the bridge-donor path.
by_date: dict[date, list] = {}
for d in proposal.days:
if d.wish_id == summer.id and d.day_type != "free":
by_date.setdefault(d.date, []).append(d)
for dt, entries in by_date.items():
if len(entries) == 2 and all(e.day_type == "vacation" for e in entries):
assert entries[0].reason in (
"vacation_wrap_weekend",
"vacation_wrap_red_day",
), (dt, entries)
outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn")
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy == "co_leave"
assert outcome.strategy_degraded is False
assert outcome.unmet_both_dates == []
assert outcome.shortfall_days == 0
autumn_days: dict[date, set] = {}
for d in proposal.days:
if d.wish_id == autumn.id and d.day_type != "free":
autumn_days.setdefault(d.date, set()).add(d.parent_personal_data_uuid)
for autumn_date in _AUTUMN_DATES:
assert autumn_days.get(autumn_date) == {parent_a.uuid, parent_b.uuid}, autumn_date
# Vacation really was the binding constraint -- fully exhausted for
# both parents, exactly as the real plan's summer + Jul do.
vacation_after = proposal.balances_after.vacation_after
assert vacation_after[(parent_a.uuid, 2027)].available == 0
assert vacation_after[(parent_b.uuid, 2027)].available == 0
# No weekend/red-day run ever ends up "wrapped" by FP on both edges
# for the same parent -- the one thing bridge-donor conversion must
# never create (§both-parents-rescue real-data follow-up).
fp_dates_by_parent: dict[str, set] = {parent_a.uuid: set(), parent_b.uuid: set()}
for d in proposal.days:
if d.day_type in ("sjukpenning", "lagsta"):
fp_dates_by_parent.setdefault(d.parent_personal_data_uuid, set()).add(d.date)
for parent_uuid, fp_dates in fp_dates_by_parent.items():
for fp_date in fp_dates:
for far_side in _bridge_far_side_dates(
fp_date, alloc_input.holidays, alloc_input.day_overrides
):
assert far_side not in fp_dates, (
"weekend wrapped by FP on both edges",
parent_uuid,
fp_date,
far_side,
)
class TestBothParentsRescueDeterminism:
def test_repeated_runs_are_identical(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
summer_a = _wish(
child_id=child.id,
name="Summer A",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
priority=1,
parent_mode="specific",
parent_uuid=parent_a.uuid,
)
summer_b = _wish(
child_id=child.id,
name="Summer B",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
priority=1,
parent_mode="specific",
parent_uuid=parent_b.uuid,
)
autumn = _wish(
child_id=child.id,
name="Autumn",
start=_AUTUMN_WEEK[0],
end=_AUTUMN_WEEK[1],
weeks=1,
priority=9,
parent_mode="both",
)
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(300, 90, 60),
(parent_b.uuid, child.id): DayPool(300, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(20, 0, 0),
(parent_b.uuid, 2027): VacationBudget(20, 0, 0),
},
wishes=[summer_a, summer_b, autumn],
)
allocator = Allocator()
proposal_1 = allocator.propose(alloc_input)
proposal_2 = allocator.propose(alloc_input)
assert proposal_1 == proposal_2
class TestBothParentsRescueIgnoresPinnedDays:
"""§false-positive-pin-warning — real-data regression.
A 'both'-mode wish where one parent is pinned (`day_assertion` vacation)
on every date and the other is funded normally. `_fund_both` sends every
such date straight to `_fund_swap` (`existing_a is not None`, checked
before dubbeldag/bridge/co-leave even run), and the pinned parent's row
is deliberately never re-proposed as a `ProposedDay` (`_fund_solo` skips
it — the row already exists and is correct). Before this fix,
`_find_swap_dates` only ever looked at `all_days`, so it saw the pinned
parent as flat-out missing and reported every one of these dates in
`unmet_both_dates` plus a `both_parents_unmet` warning — even though
`apply_proposed_days` leaves the pinned rows standing and the persisted
plan genuinely has both parents off every day.
"""
def test_pinned_parent_never_counts_as_missing(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
jacob = _parent(name="Jacob")
amelie = _parent(name="Amelie")
dates = [date(2026, 10, d) for d in (5, 6, 7, 8, 9)] # Mon-Fri, clean
wish = _wish(
child_id=child.id,
name="Manuellt tillagd period",
start=(10, 5),
end=(10, 9),
weeks=1,
priority=1,
parent_mode="both",
first_year=2026,
)
pinned = [
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=child.id,
parent_personal_data_uuid=jacob.uuid,
date=d,
day_type="vacation",
wish_id=wish.id,
)
for d in dates
]
alloc_input = _build_input(
today=date(2026, 1, 1),
horizon_end=date(2026, 12, 31),
children=[child],
parents=[jacob, amelie],
pools={
(jacob.uuid, child.id): DayPool(300, 90, 60),
(amelie.uuid, child.id): DayPool(300, 90, 60),
},
vacation={
(jacob.uuid, 2026): VacationBudget(0, 0, 5), # all 5 already spent (pinned)
(amelie.uuid, 2026): VacationBudget(25, 0, 0),
},
wishes=[wish],
existing_planned=pinned,
pinned_days=frozenset((jacob.uuid, child.id, d) for d in dates),
)
proposal = Allocator().propose(alloc_input)
outcome = next(o for o in proposal.outcomes if o.wish_name == "Manuellt tillagd period")
assert outcome.unmet_both_dates == [], outcome.unmet_both_dates
assert outcome.strategy_degraded is False
assert outcome.strategy == "co_leave"
assert not any(w.kind == "both_parents_unmet" for w in proposal.warnings)
# Jacob's pinned rows are never re-proposed (they're already correct
# and `apply_proposed_days` leaves them standing)...
assert not any(
d.parent_personal_data_uuid == jacob.uuid and d.date in dates for d in proposal.days
)
# ...while Amelie genuinely gets funded on every one of those dates
# from this run's own placement -- the persisted result IS both-off.
amelie_dates = {d.date for d in proposal.days if d.parent_personal_data_uuid == amelie.uuid}
assert set(dates) <= amelie_dates, amelie_dates