Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_allocation_excluded_days.py
T
jacobh2 ad0e3c0b33 Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 12:04:20 +02:00

283 lines
12 KiB
Python

"""The allocator must never re-place a day the user removed by hand.
User's requirement, verbatim: "removing a single day should stay permanent.
Even if it's an auto added period day." The wish still says "a full week", so
without `AllocationInput.excluded_days` the next `POST /api/plan/sync` simply
re-derives the removed date — which is exactly what these tests pin down.
Two decisions are asserted here, both explained at length on
`_Ledger._excluded_window_dates`:
1. **The parent is part of the key.** A both-parents window places one day
per parent on the same date; removing one parent's day leaves the
partner's standing. A date only leaves the window entirely when no
responsible parent may take it.
2. **An excluded date shrinks the window's target; it is not a shortfall.**
A day the user removed is not a day the plan failed to fund, so
`shortfall_days` must not grow (which would make the new shortfall
remedies advise freeing resources for a day nobody wants, and would make
`sync_plan`'s eviction preview treat a deliberate removal as a lost
priority race).
"""
from datetime import date
from app.services.allocation_service import Allocator, DayPool, VacationBudget
from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish
# Feb 2027 is the suite's clean "no red-day interference" month; Feb 1-7 2027
# is Mon-Sun, so 5 spendable days + Sat 6 + Sun 7.
_WINDOW_START = (2, 1)
_WINDOW_END = (2, 7)
_REMOVED = date(2027, 2, 3) # a Wednesday in the middle of the window
def _solo_scenario(*, excluded: frozenset | None = None, reuse=None):
"""A one-parent, one-week fixed window.
`reuse` takes a `(child, parent)` pair from an earlier call so a test can
build two inputs that are identical down to the uuids — needed both for a
before/after comparison and for the determinism check, since `_Ledger`
mutates its copies of the pool/vacation dicts in place and every
`propose()` run therefore needs its own fresh input.
"""
if reuse is not None:
child, parent = reuse
else:
child, parent = _child(birth_date=date(2025, 6, 1)), _parent(name="Solo")
wish = _wish(
child_id=child.id,
name="Sportlov",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
parent_mode="specific",
parent_uuid=parent.uuid,
)
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(200, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
excluded_days=excluded,
)
return alloc_input, parent, child
def _both_scenario(*, excluded: frozenset | None = None, reuse=None):
"""A both-parents ("co-leave") one-week fixed window; `reuse` as above."""
if reuse is not None:
child, parent_a, parent_b = reuse
else:
child = _child(birth_date=date(2025, 6, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
name="Tillsammans",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
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(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
excluded_days=excluded,
)
return alloc_input, parent_a, parent_b, child
class TestExcludedDayIsNeverPlaced:
def test_solo_window_skips_the_removed_date(self) -> None:
baseline_input, parent, child = _solo_scenario()
baseline = Allocator().propose(baseline_input)
assert _REMOVED in {d.date for d in baseline.days if d.day_type != "free"}
alloc_input, parent, child = _solo_scenario(
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
# The rest of the window is untouched.
assert {d.date for d in proposal.days if d.day_type != "free"} == {
date(2027, 2, 1),
date(2027, 2, 2),
date(2027, 2, 4),
date(2027, 2, 5),
}
def test_removed_date_is_not_reported_as_a_shortfall(self) -> None:
"""Decision: an excluded date reduces the window's target.
The user removed it; the plan did not fail to fund it. If this
counted as a gap, `_build_remedies` would start proposing ways to
free up resources for a day nobody wants, and `sync_plan`'s eviction
preview would block the sync that carries out the removal.
"""
_seed, parent, child = _solo_scenario()
alloc_input, _p, _c = _solo_scenario(
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
# None of the three genuinely shortfall-gated remedies fire for a
# zero-shortfall window -- the exclusion did not create a gap to
# remedy. This used to be `remedies == []` outright, but the
# `shift_window_into_free_days` suggestion (§todo item, red-day
# shift toast) is deliberately NOT shortfall-gated for a `fixed`
# window any more: a fully-funded fixed window can still surface
# "moving this frees N days", and this Feb 1-7 window genuinely has
# a cheaper nearby placement. See `_Ledger._build_remedies`.
assert {r.kind for r in outcome.remedies} <= {"shift_window_into_free_days"}
assert not any(
r.kind in {"reduce_preschool_reserve", "carry_vacation_forward", "lower_priority_wish"}
for r in outcome.remedies
)
# `weeks_funded` still tells the truth about what was actually
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
assert outcome.weeks_funded == round(6 / 7, 2)
def test_an_unrelated_parents_exclusion_does_not_touch_this_window(self) -> None:
_seed, parent, child = _solo_scenario()
alloc_input, _p, _c = _solo_scenario(
excluded=frozenset({(_parent(name="Stranger").uuid, child.id, _REMOVED)}),
reuse=(child, parent),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED in {d.date for d in proposal.days if d.day_type != "free"}
assert proposal.outcomes[0].shortfall_days == 0
class TestExclusionIsKeyedPerParent:
def test_both_parents_window_keeps_the_partners_day(self) -> None:
"""The parent-keying decision, stated as a test.
Removing "this day" from A's band must not silently cancel B's leave
on the same date — B never asked for that, and on a shared plan they
would only discover it by noticing the hole.
"""
alloc_input, parent_a, parent_b, child = _both_scenario()
baseline = Allocator().propose(alloc_input)
baseline_on_date = [d for d in baseline.days if d.date == _REMOVED]
assert {d.parent_personal_data_uuid for d in baseline_on_date} == {
parent_a.uuid,
parent_b.uuid,
}
alloc_input, parent_a, parent_b, child = _both_scenario(
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
on_date = [d for d in proposal.days if d.date == _REMOVED]
assert [d.parent_personal_data_uuid for d in on_date] == [parent_b.uuid]
assert on_date[0].day_type != "free"
def test_the_date_leaves_the_window_only_when_both_parents_are_removed(self) -> None:
_seed, parent_a, parent_b, child = _both_scenario()
alloc_input, _a, _b, _c = _both_scenario(
excluded=frozenset(
{
(parent_a.uuid, child.id, _REMOVED),
(parent_b.uuid, child.id, _REMOVED),
}
),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
assert proposal.outcomes[0].shortfall_days == 0
def test_one_parents_exclusion_is_not_a_shortfall_for_the_window(self) -> None:
_seed, parent_a, parent_b, child = _both_scenario()
alloc_input, _a, _b, _c = _both_scenario(
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
assert proposal.outcomes[0].shortfall_days == 0
class TestSwapWindowDoesNotRefillTheHole:
def test_the_removed_date_is_not_handed_to_the_other_parent(self) -> None:
"""`_fund_swap`'s leftover hand-off must not undo the removal.
In a swap window exactly one parent is on leave per date, so a date
one parent is removed from would otherwise fall through as "unfunded
leftover" and be offered to the partner — the day would visibly come
back, just in the other parent's colour.
"""
child = _child(birth_date=date(2025, 6, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
name="Sommar",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
parent_mode="either",
)
# `parent_mode='either'` with two parents resolves to a swap window.
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(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
excluded_days=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
assert proposal.outcomes[0].shortfall_days == 0
class TestDeterminismIsPreserved:
def test_same_input_same_output_with_exclusions(self) -> None:
"""§5 test #11 — the purity guarantee still holds with exclusions."""
_seed_input, parent, child = _solo_scenario()
excluded = frozenset({(parent.uuid, child.id, _REMOVED)})
first_input, _p, _c = _solo_scenario(excluded=excluded, reuse=(child, parent))
second_input, _p2, _c2 = _solo_scenario(excluded=excluded, reuse=(child, parent))
first = Allocator().propose(first_input)
second = Allocator().propose(second_input)
assert [(d.date, d.day_type, d.reason) for d in first.days] == [
(d.date, d.day_type, d.reason) for d in second.days
]