782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
283 lines
12 KiB
Python
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` report a deliberate removal as a genuinely evicted/displaced
|
|
row instead of an ordinary self-caused one).
|
|
"""
|
|
|
|
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` would
|
|
misreport the removal as a genuinely evicted/displaced row.
|
|
"""
|
|
_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
|
|
]
|