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:
@@ -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
|
||||
Reference in New Issue
Block a user