6d92b87bf3
A repeating period was a single row that the planner expanded into one window per year at read time, so no year had anywhere to differ from the others. Editing one year rewrote them all, and removing a year in the middle had to split the wish in two. Neither survives contact with the thing this app is for: Easter moves, and the cheapest week is not the same week every year. Each year is now its own row, linked by a series id. Editing one year touches one row; editing the series re-projects the rest from it. Removing a year is one delete. The weekday snap is worked out once, when the rows are made, and stored, so a row now says what it means rather than being re-derived on every read. Recurrence stops being a stored field and becomes what it always was in practice — an instruction at create and update time; whether a period repeats is whether it belongs to a series. Migration 004 rebuilds the table and expands the existing rows, keeping each original row as its first year so the days already planned against it still point somewhere. A Feb 29 anchor recurring into a common year used to raise out of the allocator; it clamps now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Regression tests for the `_fund_swap` funding-fairness bug.
|
|
|
|
Investigation trigger: a wrapping Jullov-style wish (window 12-20 -> 01-16)
|
|
reported `shortfall_days=6` with its entire December half unfunded, because
|
|
`_fund_swap` split the window at the **calendar midpoint** -- first half to
|
|
parent A, second half to parent B -- with no regard for whether either
|
|
parent could actually afford their half. In the reported case, parent A's
|
|
vacation budget for the December year was exhausted while parent B had
|
|
ample budget sitting completely unused; the family lost days that were
|
|
fundable.
|
|
|
|
The fix (see `_split_dates_by_spendable_cost` and `_fund_swap` in
|
|
`app.services.allocation_service`):
|
|
1. Splits by *spendable* (non-free) day count rather than raw calendar-day
|
|
count, so weekends/red days (free for everyone) don't skew the halves.
|
|
2. When a parent's own half leaves spendable days unfunded, offers that
|
|
leftover to the OTHER parent before giving up on it as a shortfall.
|
|
|
|
These tests build the exact class of scenario from the investigation (a
|
|
wrapping window, one parent starved, the other flush) and prove:
|
|
- the regression case is now funded that previously wasn't (before/after
|
|
numbers below, captured against `git stash`-out current behaviour
|
|
before this fix landed);
|
|
- a window where both parents have ample budget still gets a clean,
|
|
contiguous per-parent split (the fairness fix must not turn every swap
|
|
into an alternating patchwork);
|
|
- determinism holds for the asymmetric/absorption path, not just the
|
|
happy path;
|
|
- free days are still free for everyone, absorption or not.
|
|
"""
|
|
|
|
from datetime import date
|
|
|
|
from app.services.allocation_service import (
|
|
REASON_SWAP_FIRST_HALF,
|
|
REASON_SWAP_SECOND_HALF,
|
|
Allocator,
|
|
DayPool,
|
|
VacationBudget,
|
|
)
|
|
from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish
|
|
|
|
# Jullov 2027->2028: Dec 20 2027 -> Jan 16 2028, 28 calendar days, 17
|
|
# spendable, 11 free (verified against `holidays.Sweden(years=(2027, 2028),
|
|
# language="sv")`): Dec 24/25/26/31, Jan 1/2/6/8/9/15/16.
|
|
_FREE_DATES = {
|
|
date(2027, 12, 24),
|
|
date(2027, 12, 25),
|
|
date(2027, 12, 26),
|
|
date(2027, 12, 31),
|
|
date(2028, 1, 1),
|
|
date(2028, 1, 2),
|
|
date(2028, 1, 6),
|
|
date(2028, 1, 8),
|
|
date(2028, 1, 9),
|
|
date(2028, 1, 15),
|
|
date(2028, 1, 16),
|
|
}
|
|
_SPENDABLE_TOTAL = 17 # 28 calendar days - 11 free
|
|
|
|
|
|
def _jullov_wrap_scenario(*, parent_a_starved: bool):
|
|
"""One parent (A) starved of both vacation and FP; parent B flush --
|
|
|
|
or, with `parent_a_starved=False`, both parents flush. Isolated as a
|
|
builder so the regression test and the determinism test can each get an
|
|
independent `AllocationInput` (the `_Ledger` mutates its copies of the
|
|
pool/vacation dicts in place, so a fresh input is required per `propose()`
|
|
run that means to observe the same starting state).
|
|
"""
|
|
child = _child(birth_date=date(2025, 6, 1)) # 4th birthday well after the window
|
|
parent_a = _parent(name="A")
|
|
parent_b = _parent(name="B")
|
|
wish = _wish(
|
|
child_id=child.id,
|
|
name="Jullov",
|
|
start=(12, 20),
|
|
end=(1, 16), # wraps into January
|
|
weeks=4,
|
|
first_year=2027,
|
|
parent_mode="either",
|
|
)
|
|
pool_a = DayPool(0, 0, 0) if parent_a_starved else DayPool(200, 90, 60)
|
|
vacation_a_2027 = VacationBudget(20, 0, 20) if parent_a_starved else VacationBudget(25, 0, 0)
|
|
vacation_a_2028 = VacationBudget(20, 0, 20) if parent_a_starved else VacationBudget(25, 0, 0)
|
|
alloc_input = _build_input(
|
|
today=date(2027, 1, 1),
|
|
horizon_end=date(2028, 12, 31),
|
|
children=[child],
|
|
parents=[parent_a, parent_b],
|
|
pools={
|
|
(parent_a.uuid, child.id): pool_a,
|
|
(parent_b.uuid, child.id): DayPool(200, 90, 60),
|
|
},
|
|
vacation={
|
|
(parent_a.uuid, 2027): vacation_a_2027,
|
|
(parent_a.uuid, 2028): vacation_a_2028,
|
|
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
|
|
(parent_b.uuid, 2028): VacationBudget(25, 0, 0),
|
|
},
|
|
wishes=[wish],
|
|
)
|
|
return alloc_input, parent_a, parent_b
|
|
|
|
|
|
class TestFundingFairnessRegression:
|
|
"""The exact bug: parent A's vacation (and FP) exhausted, parent B flush.
|
|
|
|
Before this fix: `_fund_swap`'s calendar-midpoint split handed parent A
|
|
the December-heavy first half; parent A could fund none of its 9
|
|
spendable days (0 vacation, 0 FP), parent B's second half (8 spendable
|
|
days) was funded fully, and the window reported `shortfall_days == 8`
|
|
with parent B's ample budget for the SAME December year sitting unused.
|
|
|
|
Confirmed against current (pre-fix) `_fund_swap` before writing this
|
|
assertion: `shortfall_days == 8`, `cost_breakdown == {"free": 11,
|
|
"vacation": 9, "sjukpenning": 0, "lagsta": 0}`, and parent A's only
|
|
funded days were the 6 free days inside its half (no spendable day at
|
|
all). After the fix: `shortfall_days == 0`, every spendable day funded
|
|
(11 free + 17 paid == 28), because parent B's own resources for that
|
|
same December (and January) are used to absorb what parent A could not
|
|
afford.
|
|
"""
|
|
|
|
def test_starved_parent_a_leftover_is_absorbed_by_flush_parent_b(self) -> None:
|
|
alloc_input, parent_a, parent_b = _jullov_wrap_scenario(parent_a_starved=True)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
assert len(proposal.outcomes) == 1
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.strategy == "parent_swap"
|
|
|
|
# AFTER: fully funded -- the December stretch parent A couldn't
|
|
# afford is now covered by parent B instead of reported as lost.
|
|
assert outcome.shortfall_days == 0, (
|
|
"expected the fairness fix to fully fund the window by letting "
|
|
"parent B absorb what parent A could not afford"
|
|
)
|
|
assert outcome.cost_breakdown["free"] == len(_FREE_DATES)
|
|
paid = (
|
|
outcome.cost_breakdown["vacation"]
|
|
+ outcome.cost_breakdown["sjukpenning"]
|
|
+ outcome.cost_breakdown["lagsta"]
|
|
)
|
|
assert paid == _SPENDABLE_TOTAL
|
|
|
|
# Parent A, having zero usable resources, funds nothing but the free
|
|
# days that happened to fall in its half -- never a spendable day.
|
|
days_a = [d for d in proposal.days if d.parent_personal_data_uuid == parent_a.uuid]
|
|
assert all(d.day_type == "free" for d in days_a)
|
|
|
|
# Parent B picks up every spendable day in the window, including
|
|
# ones chronologically inside what was "parent A's" half.
|
|
days_b_spendable_dates = {
|
|
d.date
|
|
for d in proposal.days
|
|
if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free"
|
|
}
|
|
assert date(2027, 12, 20) in days_b_spendable_dates, (
|
|
"a December date from parent A's own half must be funded by "
|
|
"parent B once parent A can't afford it"
|
|
)
|
|
assert len(days_b_spendable_dates) == _SPENDABLE_TOTAL
|
|
|
|
# No day is ever double-booked to both parents.
|
|
by_date: dict[date, set] = {}
|
|
for d in proposal.days:
|
|
by_date.setdefault(d.date, set()).add(d.parent_personal_data_uuid)
|
|
assert all(len(parents) == 1 for parents in by_date.values())
|
|
|
|
|
|
class TestFreeDaysStayFreeUnderAbsorption:
|
|
def test_free_days_are_never_charged_to_either_parent(self) -> None:
|
|
alloc_input, _parent_a, _parent_b = _jullov_wrap_scenario(parent_a_starved=True)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
free_days_seen = {d.date for d in proposal.days if d.day_type == "free"}
|
|
assert free_days_seen == _FREE_DATES
|
|
# Every date this window ever considers is either a known free date
|
|
# or was funded with a real resource -- never left as an implicit
|
|
# "free" default for a date that should have cost something.
|
|
paid_dates = {d.date for d in proposal.days if d.day_type != "free"}
|
|
assert paid_dates.isdisjoint(_FREE_DATES)
|
|
|
|
def test_free_day_reasons_still_carry_the_swap_half_label(self) -> None:
|
|
"""§watch-for-this: `REASON_SWAP_FIRST_HALF`/`REASON_SWAP_SECOND_HALF`
|
|
|
|
are only ever stamped on the free-day branch inside `_fund_solo`'s
|
|
fallback (the `free_reason_override` parameter) -- never on a
|
|
resource-funded (vacation/FP) day. A free day that lands in what was
|
|
originally "parent A's half" must still carry
|
|
`REASON_SWAP_FIRST_HALF` even though the fairness fix reassigns some
|
|
of that half's *spendable* days elsewhere.
|
|
"""
|
|
alloc_input, parent_a, _parent_b = _jullov_wrap_scenario(parent_a_starved=True)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
first_half_free_day = next(
|
|
d
|
|
for d in proposal.days
|
|
if d.date == date(2027, 12, 24) and d.parent_personal_data_uuid == parent_a.uuid
|
|
)
|
|
assert first_half_free_day.reason == REASON_SWAP_FIRST_HALF
|
|
|
|
second_half_free_day = next(
|
|
d for d in proposal.days if d.date == date(2028, 1, 6) and d.day_type == "free"
|
|
)
|
|
assert second_half_free_day.reason == REASON_SWAP_SECOND_HALF
|
|
|
|
|
|
class TestAmpleBudgetSwapStaysContiguous:
|
|
"""When neither parent is starved, the fairness fix must not turn a
|
|
|
|
clean two-block swap into an alternating patchwork: no leftover is ever
|
|
generated to absorb, so the split stays exactly two contiguous stretches.
|
|
"""
|
|
|
|
def test_both_parents_get_one_contiguous_stretch_each(self) -> None:
|
|
alloc_input, parent_a, parent_b = _jullov_wrap_scenario(parent_a_starved=False)
|
|
|
|
proposal = Allocator().propose(alloc_input)
|
|
|
|
outcome = proposal.outcomes[0]
|
|
assert outcome.shortfall_days == 0
|
|
|
|
dates_a = sorted(
|
|
d.date for d in proposal.days if d.parent_personal_data_uuid == parent_a.uuid
|
|
)
|
|
dates_b = sorted(
|
|
d.date for d in proposal.days if d.parent_personal_data_uuid == parent_b.uuid
|
|
)
|
|
assert dates_a and dates_b
|
|
# Contiguous calendar ranges with no gaps...
|
|
assert (dates_a[-1] - dates_a[0]).days == len(dates_a) - 1
|
|
assert (dates_b[-1] - dates_b[0]).days == len(dates_b) - 1
|
|
# ...and A's stretch ends strictly before B's begins (a single
|
|
# boundary, not an interleaving).
|
|
assert dates_a[-1] < dates_b[0]
|
|
|
|
|
|
class TestFundingFairnessDeterminism:
|
|
"""Determinism must hold on the absorption path too, not just the
|
|
|
|
plain-split happy path already covered by `TestCase11Determinism`.
|
|
"""
|
|
|
|
def test_repeated_runs_on_the_asymmetric_scenario_are_identical(self) -> None:
|
|
alloc_input_1, _a1, _b1 = _jullov_wrap_scenario(parent_a_starved=True)
|
|
alloc_input_2, _a2, _b2 = _jullov_wrap_scenario(parent_a_starved=True)
|
|
|
|
# Two independently-built inputs (fresh UUIDs) can't compare equal
|
|
# directly; instead run the SAME input twice, as the existing
|
|
# determinism tests do.
|
|
allocator = Allocator()
|
|
proposal_1 = allocator.propose(alloc_input_1)
|
|
proposal_2 = allocator.propose(alloc_input_1)
|
|
|
|
assert proposal_1 == proposal_2
|
|
|
|
# Sanity: the second, independently-built-but-structurally-identical
|
|
# input produces the same shortfall/outcome shape.
|
|
proposal_3 = Allocator().propose(alloc_input_2)
|
|
assert proposal_3.outcomes[0].shortfall_days == proposal_1.outcomes[0].shortfall_days
|