e4719cb9ae
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and dubbeldagar left (plus optional reserved days) and derives the rest in build_snapshot, shared with the scraper. The scraper also reads the family panel and exposes a partner deduction; with two parents it is saved as a 'deduced' snapshot (migration 002). Parent count is the single source of entitlement: one member gets 480 days and 96 savable at age 4, two get 240 and 48 each. Workspaces are capped at two parents. Also lands the in-flight squash of migrations 002–011 into the initial schema and the removal of planning_mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
11 KiB
Python
268 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,
|
|
recurrence="once",
|
|
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
|