Persist the wish and pinned flag a planned day is created with

The create and update handlers for a planned day accepted both fields in
their request models and passed neither to the service, so a day hand-edited
in the calendar was never marked as pinned and never kept its link back to
the period that produced it. The allocator would then feel free to retype it
on the next sync, which is precisely what pinning exists to prevent.

Two investigations alongside this found no defect. A period that occurs once
rather than yearly does get placed, and a window that wraps past New Year
keeps the half that falls after today; both are now covered by tests, since
neither case had any. Where those looked broken, the wish had simply run out
of budget for that year, which the interface does not yet explain.
This commit is contained in:
2026-09-17 21:04:44 +02:00
parent 9e27fef78a
commit 5e96c352fb
3 changed files with 345 additions and 0 deletions
@@ -0,0 +1,185 @@
"""Regression tests for two behaviours investigated after a live bug report:
1. `recurrence: 'once'` wishes — reported as producing *zero* planned days
for a drag-created period, with no error/rejection surfaced anywhere.
Investigation (see the task write-up) traced the live symptom to the demo
seed data: the new wish's default (lowest) priority + `parent_mode:
'specific'` bound to a parent whose vacation budget for that particular
year is deliberately zeroed by `scripts/seed_demo.py`. `LeaveWish
.applies_to_year`/`_candidate_years`/`_expand_all_windows` all handle
`recurrence == 'once'` correctly — proven here with ample resources (the
window is produced and fully funded) and with a past window (correctly
produces nothing, the *good* kind of "nothing"). These tests exist so
that conclusion has a permanent regression guard instead of living only
in an investigation transcript.
2. A wrapping window (e.g. Jullov 12-20 -> 01-16) whose anchor year's start
is before `today` but whose end is after `today` — does `window_for_year`
+ the horizon-clip in `_expand_all_windows` partially clip it (correct)
or drop the whole window (bug)? Proven here: partial clip, future part
funded, past part silently dropped from the window's *dates* — a
distinct, correct mechanism from a *funding* shortfall (which shows up as
`shortfall_days`, not as a shorter window).
"""
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,
)
class TestOnceRecurrenceProducesDays:
def test_once_wish_with_ample_resources_is_fully_funded_on_the_right_dates(self) -> None:
"""A `once` wish is not, by itself, broken: given a parent with an
untouched pool/vacation budget, it funds every day in its single
occurrence — exactly like a `yearly` wish would for that same year.
"""
child = _child(birth_date=date(2026, 3, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Drag-created period",
start=(10, 5),
end=(10, 9), # Mon-Fri, 5 days
weeks=1,
target_days=5,
recurrence="once",
first_year=2026,
parent_mode="specific",
parent_uuid=parent.uuid,
)
alloc_input = _build_input(
today=date(2026, 9, 17),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(390, 90, 30)},
vacation={(parent.uuid, 2026): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
assert len(proposal.outcomes) == 1
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2026, 10, 5)
assert outcome.end_date == date(2026, 10, 9)
assert outcome.shortfall_days == 0
funded_dates = {d.date for d in proposal.days if d.day_type != "free"}
assert funded_dates == {
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
date(2026, 10, 9),
}
assert all(d.day_type == "vacation" for d in proposal.days if d.date in funded_dates)
def test_once_wish_entirely_in_the_past_produces_nothing(self) -> None:
"""The correct-clipping counterpart: a `once` wish whose single
occurrence is entirely before `today` is dropped, not funded — the
same rule that applies to a `yearly` wish's already-elapsed years.
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Long gone",
start=(3, 1),
end=(3, 5),
weeks=1,
recurrence="once",
first_year=2021,
parent_mode="specific",
parent_uuid=parent.uuid,
)
alloc_input = _build_input(
today=date(2026, 9, 17),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(390, 90, 30)},
vacation={(parent.uuid, 2021): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
assert proposal.outcomes == []
assert proposal.days == []
class TestWrappingWindowStraddlingToday:
def test_wrapping_window_straddling_today_clips_past_and_funds_future(self) -> None:
"""A yearly wrapping window (Jullov-style, 12-20 -> 01-16) whose
anchor year's start already elapsed relative to `today`, but whose
(year+1) end has not: `_expand_all_windows` must clip the window down
to `[today, end]`, not drop the whole thing. This is the specific
mechanism the "wrapping window loses its head" bug report asked to
be checked — proven here to be correct: only the already-past
Dec 20-Jan 4 stub disappears from the window's *dates*; the
still-future Jan 5-16 part is present and gets funded.
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Jullov",
start=(12, 20),
end=(1, 16), # wraps into January
weeks=4,
recurrence="yearly",
first_year=2025,
last_year=2026, # explicit, so this doesn't depend on the child's 4th birthday
parent_mode="specific",
parent_uuid=parent.uuid,
)
today = date(2026, 1, 5) # inside the 2025-anchored window's January tail
alloc_input = _build_input(
today=today,
horizon_end=date(2026, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(390, 90, 30)},
vacation={
(parent.uuid, 2025): VacationBudget(25, 0, 0),
(parent.uuid, 2026): VacationBudget(25, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome_2025 = next(o for o in proposal.outcomes if o.year == 2025)
# Clipped to [today, end] — the already-past Dec 20 2025 -> Jan 4
# 2026 stub is gone; only the future Jan 5 -> Jan 16 part remains.
assert outcome_2025.start_date == today
assert outcome_2025.end_date == date(2026, 1, 16)
assert outcome_2025.start_date > date(2025, 12, 20), (
"the window must be clipped to today, not left at its original (now-past) start date"
)
funded_dates = {
d.date for d in proposal.days if d.wish_id == wish.id and d.date <= date(2026, 1, 16)
}
assert funded_dates, "the future (Jan 5-16) part of the window must still be funded"
assert all(d >= today for d in funded_dates), (
"no day before `today` should ever appear — the window was clipped, not just "
"reported as clipped"
)