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