Rework balances around per-parent days left and deduce the partner from FK

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>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 375bc64f2f
commit e4719cb9ae
71 changed files with 5705 additions and 1859 deletions
@@ -4,8 +4,8 @@ planning engine (see app.services.allocation_service module docstring).
Uses the same sqlite-backed fixtures as the rest of the service test suite
(tests/conftest.py) to seed a realistic workspace and verify the loader nets
out already-committed `planned_leave_day` rows, falls back to default
entitlement when no FK snapshot exists, and reads `planning_mode` off the
workspace.
entitlement when no FK snapshot exists, and resolves the age-4 savable-days
cap from parent count.
"""
from datetime import date, datetime
@@ -91,7 +91,6 @@ class TestAllocationInputLoader:
Workspace(
id=uuid4(),
name="Familj",
planning_mode="couple",
created_at=now,
)
)
@@ -200,10 +199,8 @@ class TestAllocationInputLoader:
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
assert alloc_input.planning_mode == "couple"
assert alloc_input.today == date(2027, 1, 1)
# Two parents in a 'couple' workspace -> per-parent age-4 cap (48),
# not the per-child figure (96).
# Two parents -> per-parent age-4 cap (48), not the per-child figure (96).
assert alloc_input.age4_savable_cap == 48
pool_with_fetch = alloc_input.pools[(parent_with_fetch.uuid, child.id)]
@@ -235,6 +232,123 @@ class TestAllocationInputLoader:
assert parent_facts_by_uuid[parent_with_fetch.uuid].min_vacation_days_per_year == 20
assert parent_facts_by_uuid[parent_without_fetch.uuid].min_vacation_days_per_year == 20
async def test_only_pinned_existing_hides_unpinned_wish_rows_but_all_existing_planned_keeps_them(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""§fix (parent-reassignment moves dates, decision B) — `sync_plan`
loads with `only_pinned_existing=True`, which must keep hiding
unpinned wish-attributed rows from `existing_planned` (unchanged
behaviour), but `all_existing_planned` must carry EVERY persisted
row regardless — that's what lets `_Ledger._persisted_by_wish` find
a flexible wish's own prior placement even when it's currently
hidden from `existing_planned`. See `AllocationInput
.all_existing_planned`'s docstring in allocation_service.py.
"""
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Parent",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date(2026, 1, 1),
created_at=now,
)
)
wish = await leave_wish_repo.create(
LeaveWish(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
name="Höstlov",
window_start_month=10,
window_start_day=1,
window_end_month=10,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="flexible",
)
)
pinned = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 1),
day_type="vacation",
is_pinned=True,
)
)
unpinned_wish_row = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 4),
day_type="vacation",
wish_id=wish.id,
is_pinned=False,
)
)
ad_hoc = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 5),
day_type="vacation",
wish_id=None,
is_pinned=False,
)
)
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1), only_pinned_existing=True)
visible_ids = {d.id for d in alloc_input.existing_planned}
assert visible_ids == {pinned.id, ad_hoc.id}
assert unpinned_wish_row.id not in visible_ids
assert alloc_input.all_existing_planned is not None
all_ids = {d.id for d in alloc_input.all_existing_planned}
assert all_ids == {pinned.id, unpinned_wish_row.id, ad_hoc.id}
async def test_load_reflects_a_custom_minimum_vacation_days(
self,
workspace_repo: SQLiteWorkspaceRepository,
@@ -61,7 +61,6 @@ class TestOnceRecurrenceProducesDays:
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)},
@@ -109,7 +108,6 @@ class TestOnceRecurrenceProducesDays:
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)},
@@ -153,7 +151,6 @@ class TestWrappingWindowStraddlingToday:
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)},
@@ -101,6 +101,7 @@ def _wish(
window_mode: str = "fixed",
wish_id: UUID | None = None,
name_message_key: str | None = None,
wants_dubbeldag: bool = False,
) -> LeaveWish:
return LeaveWish(
id=wish_id or uuid4(),
@@ -121,6 +122,7 @@ def _wish(
parent_mode=parent_mode,
parent_personal_data_uuid=parent_uuid,
window_mode=window_mode,
wants_dubbeldag=wants_dubbeldag,
)
@@ -128,13 +130,13 @@ def _build_input(
*,
today: date,
horizon_end: date,
planning_mode: str = "solo",
children: list[ChildFacts],
parents: list[ParentFacts],
pools: dict[tuple[UUID, UUID], DayPool],
vacation: dict[tuple[UUID, int], VacationBudget],
wishes: list[LeaveWish],
existing_planned: list | None = None,
all_existing_planned: list | None = None,
extra_holiday_years: tuple[int, ...] = (),
day_overrides: dict[date, bool] | None = None,
age4_savable_cap: int | None = None,
@@ -145,12 +147,11 @@ def _build_input(
# `app.utils.tip_predicates.resolve_age4_savable_cap`. Computed here
# by default so existing callers that don't care about the age-4 cap
# split keep getting the historically-correct figure for their
# `planning_mode`/`parents` without having to pass it explicitly.
age4_savable_cap = resolve_age4_savable_cap(planning_mode, len(parents))
# `parents` count without having to pass it explicitly.
age4_savable_cap = resolve_age4_savable_cap(len(parents))
return AllocationInput(
today=today,
horizon_end=horizon_end,
planning_mode=planning_mode,
age4_savable_cap=age4_savable_cap,
children=children,
parents=parents,
@@ -160,18 +161,28 @@ def _build_input(
existing_planned=existing_planned or [],
holidays=_holidays_for(*years),
day_overrides=day_overrides or {},
all_existing_planned=all_existing_planned,
)
def _planned(
*, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation"
*,
parent_uuid: UUID,
child_id: UUID,
on: date,
day_type: str = "vacation",
wish_id: UUID | None = None,
) -> PlannedLeaveDay:
"""A persisted `planned_leave_day` row — the "already applied, before
this `propose()` run" building block for the §fix (double-booking on
flexible re-propose) test cases below. Only the fields
`AllocationInput.existing_planned` actually reads
(`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter.
flexible re-propose; extended for §fix parent-reassignment, decision B)
test cases below. Only the fields `AllocationInput.existing_planned`/
`all_existing_planned` actually read (`parent_personal_data_uuid`,
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
placement by — pass the wish's `.id` whenever a test relies on that
reuse mechanism finding these rows.
"""
return PlannedLeaveDay(
id=uuid4(),
@@ -180,6 +191,7 @@ def _planned(
parent_personal_data_uuid=parent_uuid,
date=on,
day_type=day_type,
wish_id=wish_id,
)
@@ -209,7 +221,6 @@ class TestCase1SoloUnder12MonthsFallsBackToFP:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -247,7 +258,6 @@ class TestCase2CoupleSwap:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -291,7 +301,6 @@ class TestCase3MonthlySalaryVacationFirst:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -330,7 +339,6 @@ class TestCase4HourlySalaryFPFirst:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -365,7 +373,6 @@ class TestCase5ShortfallByPriority:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(30, 0, 60)},
@@ -401,7 +408,6 @@ class TestCase6YearWrapping:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -439,7 +445,6 @@ class TestCase7SeparatePoolsPerChild:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child_a, child_b],
parents=[parent],
pools={
@@ -474,7 +479,6 @@ class TestCase8DaysLostAtAge4Warning:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 0, 60)},
@@ -504,7 +508,6 @@ class TestCase9MovableEaster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -529,7 +532,19 @@ class TestCase9MovableEaster:
class TestCase10DubbeldagOutsideWindow:
def test_falls_back_to_swap_when_outside_15_months(self) -> None:
"""§Decision A (co-leave, 2026-09-18 correction) REVERSES this case's old
product behaviour: a `parent_mode='both'` wish outside the 15-month
dubbeldagar window (or, per the correction, simply never opting in via
`wants_dubbeldag`) no longer falls back to a sequential parent-swap. It
now co-leaves: BOTH parents off on every SAME date, funded independently
(PL to whichever parent has more FP left, vacation to the other) —
"both" means both parents home together, not "each parent gets half the
weeks". See PLANNING_ENGINE_SPEC.md-adjacent handoff notes and
`allocation_service.py`'s `_Ledger._fund_both` docstring.
"""
def test_co_leave_places_both_parents_on_the_same_dates_when_outside_15_months(self) -> None:
child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01
parent_a = _parent(name="A")
parent_b = _parent(name="B")
@@ -539,11 +554,11 @@ class TestCase10DubbeldagOutsideWindow:
end=FEB_2027[1], # well after the 15-month window
weeks=4,
parent_mode="both",
# wants_dubbeldag defaults False -- opt-in only (Decision A).
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -566,6 +581,34 @@ class TestCase10DubbeldagOutsideWindow:
parent_b.uuid,
}
outcome = proposal.outcomes[0]
assert outcome.strategy == "co_leave"
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy_degraded is False
assert outcome.shortfall_days == 0
# The headline product behaviour: every non-free day is BOTH parents
# on the SAME date (not parent_a taking the first half of the month
# and parent_b the second, which is what the old sequential-swap
# fallback would have produced), one funded from FP, the other from
# vacation.
non_free = [d for d in proposal.days if d.day_type != "free"]
by_date: dict[date, list] = {}
for d in non_free:
by_date.setdefault(d.date, []).append(d)
assert by_date, "expected at least one funded day"
for on_date, entries in by_date.items():
parents_that_day = {e.parent_personal_data_uuid for e in entries}
assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (
f"{on_date}: expected both parents, got {parents_that_day}"
)
# Co-leave's PL+vacation split: exactly one FP day + one vacation
# day per date -- ample pools here mean it never has to fall back
# further, so this holds for every date, not just "on average".
types_that_day = sorted(e.day_type for e in entries)
assert types_that_day[1] == "vacation", (on_date, types_that_day)
assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day)
# ---------------------------------------------------------------------------
# #11 — re-run propose with identical input -> byte-identical output
@@ -581,7 +624,6 @@ class TestCase11Determinism:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -615,7 +657,6 @@ class TestCase12Age4HardCap:
alloc_input = _build_input(
today=date(2027, 1, 15),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 0, 60)},
@@ -655,7 +696,6 @@ class TestCase12Age4HardCap:
alloc_input = _build_input(
today=date(2027, 1, 15),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -705,7 +745,6 @@ class TestCase13PreschoolReserveRemedy:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31), # single year -> reserve = 6*1 = 6
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(15, 0, 60)},
@@ -789,7 +828,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2030, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Healthy FP pools — must be used as fallback when vacation runs out.
@@ -847,7 +885,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -955,7 +992,6 @@ class TestRegressionNoShortfallWhileResourcesRemain:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -1002,7 +1038,6 @@ class TestRegressionNoShortfallWhileResourcesRemain:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Total pool: 30 sjukpenning. Feb needs ~20, Aug needs ~22.
@@ -1048,7 +1083,6 @@ class TestRegressionGenuineShortfallStillReported:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Empty pools.
@@ -1087,7 +1121,6 @@ class TestRegressionGenuineShortfallStillReported:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Total 25 days: barely enough for Feb (20), but not Aug (22).
@@ -1132,7 +1165,6 @@ class TestRegressionTwoParentSwapUnaffected:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1184,7 +1216,6 @@ class TestCase15FlexibleWishFindsEasterCluster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1226,7 +1257,6 @@ class TestCase16FlexibleTieBreakByEarliestStart:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1275,7 +1305,6 @@ class TestFlexibleWishSizedByTargetDays:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1317,7 +1346,6 @@ class TestCase17TwoFlexibleWishesPriorityOrdering:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1370,7 +1398,6 @@ class TestCase18FixedWishAlwaysWinsOverFlexible:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1411,7 +1438,6 @@ class TestCase19FlexibleBoundShrunkByHorizonIsPartialNotUnplaced:
# 7 days target_weeks needs.
today=date(2027, 5, 20),
horizon_end=date(2027, 5, 25),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1456,7 +1482,6 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1509,7 +1534,6 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
@@ -1554,7 +1578,6 @@ class TestCase22FlexibleDubbeldagOutsideWindowFallsBack:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1605,7 +1628,6 @@ class TestCase23FlexibleWishDeterminism:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1653,7 +1675,6 @@ class TestCase24WindowModeDefaultsToFixed:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1698,13 +1719,14 @@ class TestCase25FlexibleAlreadyFundedYearProposesNothingNew:
# The exact 3 spendable days TestCase15 funds when this wish-year is
# searched fresh -- simulating a prior propose -> apply cycle.
existing = [
_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d))
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d), wish_id=wish.id
)
for d in (23, 24, 25)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1743,11 +1765,14 @@ class TestCase26FlexiblePartialFundingTopsUpRemainder:
window_mode="flexible",
)
# Only ONE of the winning window's 3 spendable days was ever applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1787,11 +1812,14 @@ class TestCase27FlexibleDeterminismWithExistingPlannedDays:
first_year=2027,
window_mode="flexible",
)
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1820,10 +1848,6 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
# spendable days of the Easter window (03-24, 03-25) were proposed
# last time but never applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
# A brand-new fixed wish now claims 2027-03-25 -- one of the Easter
# window's still-open days -- and, per §3.9/§3.11.2, always wins.
blocker = _wish(
@@ -1844,10 +1868,20 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
first_year=2027,
window_mode="flexible",
)
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
# spendable days of the Easter window (03-24, 03-25) were proposed
# last time but never applied.
existing = [
_planned(
parent_uuid=parent.uuid,
child_id=child.id,
on=date(2027, 3, 23),
wish_id=flexible.id,
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1919,7 +1953,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
@@ -1963,7 +1996,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
@@ -2025,11 +2057,15 @@ class TestCase23DubbeldagCountCapFallsBackToSwap:
weeks=26,
first_year=2023,
parent_mode="both",
# §Decision A (2026-09-18 correction): dubbeldagar are opt-in --
# must be explicitly requested, or `_fund_both` never attempts
# them at all and this cap-fallback scenario has nothing to
# fall back FROM.
wants_dubbeldag=True,
)
alloc_input = _build_input(
today=date(2023, 1, 1),
horizon_end=date(2023, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -2069,3 +2105,318 @@ class TestCase23DubbeldagCountCapFallsBackToSwap:
# The fallback only ever kicks in for dates after the cap was reached
# (`used` is monotonically increasing within the window).
assert min(leftover_parents_by_date) > max(dubbel_dates_a)
# ---------------------------------------------------------------------------
# §fix — reassigning a wish's parent must not move its dates (decision B,
# 2026-09-18 handoff: Höstlov reassigned from Amelie to Jacob jumped
# windows). `_reuse_already_funded_window` already existed for exactly this
# purpose but matched a wish's prior placement by (parent, child, date) --
# reassigning the parent changes `_resolve_parents`'s output BEFORE that
# match runs, so it silently stopped finding the wish's own history and fell
# through to a fresh, occupancy-blind search. Fixed by matching on `wish_id`
# instead (`_Ledger._persisted_by_wish`), which finds the prior window
# regardless of which parent it was funded under.
# ---------------------------------------------------------------------------
class TestFixReassigningParentDoesNotMoveDates:
def test_reassigning_the_parent_leaves_start_and_end_unchanged(self) -> None:
"""THE headline regression: same wish, same window fields, only
`parent_personal_data_uuid` changes (Amelie -> Jacob) -- the
already-applied window (2027-04-05..04-11) must be reused, funded
now for the new parent, not re-searched from scratch. A fresh,
occupancy-blind search over this bound (2027-04-05..05-31) lands on
2027-04-30..05-06 instead (cost 4 vs. the original week's cost 5 --
strictly cheaper by calendar) -- that is exactly the bug this test
pins down (verified against a live pre-fix run: pytest failed with
`assert datetime.date(2027, 4, 30) == datetime.date(2027, 4, 5)`).
"""
child = _child(birth_date=date(2020, 1, 1))
amelie = _parent(name="Amelie", salary_type="monthly")
jacob = _parent(name="Jacob", salary_type="monthly")
wish_id = uuid4()
# Amelie's prior apply: the Mon-Fri workdays of 2027-04-05..04-11
# (the weekend itself is never persisted -- free days are implicit,
# exactly like TestCase25/26's `existing` lists).
existing = [
_planned(
parent_uuid=amelie.uuid,
child_id=child.id,
on=date(2027, 4, d),
wish_id=wish_id,
)
for d in (5, 6, 7, 8, 9)
]
reassigned_wish = _wish(
wish_id=wish_id,
child_id=child.id,
name="Höstlov",
start=(4, 5),
end=(5, 31),
weeks=1,
first_year=2027,
window_mode="flexible",
parent_mode="specific",
parent_uuid=jacob.uuid, # <- the only thing that changed
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[amelie, jacob],
pools={
(amelie.uuid, child.id): DayPool(90, 90, 60),
(jacob.uuid, child.id): DayPool(90, 90, 60),
},
vacation={
(amelie.uuid, 2027): VacationBudget(25, 0, 0),
(jacob.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[reassigned_wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 4, 5)
assert outcome.end_date == date(2027, 4, 11)
assert outcome.shortfall_days == 0
# Funded now for the NEW parent, not the old one.
assert {d.parent_personal_data_uuid for d in proposal.days} == {jacob.uuid}
def test_editing_the_actual_dates_still_moves_it(self) -> None:
"""The flip side: when the user genuinely edits start/end (not just
the parent), the wish's placement DOES move -- reuse must never
pin a wish to a window its current bound no longer contains.
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(name="Amelie", salary_type="monthly")
wish_id = uuid4()
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 4, d), wish_id=wish_id
)
for d in (5, 6, 7, 8, 9)
]
# Same wish, same parent -- but the user dragged the period to
# September instead.
edited_wish = _wish(
wish_id=wish_id,
child_id=child.id,
name="Höstlov",
start=(9, 1),
end=(9, 30),
weeks=1,
first_year=2027,
window_mode="flexible",
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(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[edited_wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date.month == 9
assert outcome.end_date.month == 9
assert (outcome.start_date, outcome.end_date) != (date(2027, 4, 5), date(2027, 4, 11))
# ---------------------------------------------------------------------------
# §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are opt-in.
# ---------------------------------------------------------------------------
class TestDubbeldagOptIn:
def _build(self, *, wants_dubbeldag: bool):
child = _child(birth_date=date(2027, 1, 1)) # 15mo cutoff: 2028-04-01
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=FEB_2027[0],
end=FEB_2027[1], # well inside the 15-month window
weeks=4,
parent_mode="both",
wants_dubbeldag=wants_dubbeldag,
)
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(100, 90, 60),
(parent_b.uuid, child.id): DayPool(100, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(20, 0, 0),
(parent_b.uuid, 2027): VacationBudget(20, 0, 0),
},
wishes=[wish],
)
return Allocator().propose(alloc_input)
def test_flag_unset_never_consumes_dubbeldagar_even_inside_the_window(self) -> None:
proposal = self._build(wants_dubbeldag=False)
assert not any(d.is_dubbeldag for d in proposal.days)
outcome = proposal.outcomes[0]
assert outcome.strategy == "co_leave"
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy_degraded is False
# Legally available (child inside the window, quota free, both
# parents hold FP) even though it wasn't requested -- the frontend
# uses this to decide whether to SHOW the opt-in checkbox.
assert outcome.dubbeldagar_legally_available is True
def test_flag_set_consumes_dubbeldagar_inside_the_window(self) -> None:
proposal = self._build(wants_dubbeldag=True)
assert any(d.is_dubbeldag for d in proposal.days)
outcome = proposal.outcomes[0]
assert outcome.strategy == "dubbeldag"
assert outcome.requested_strategy == "dubbeldag"
assert outcome.strategy_degraded is False
assert outcome.dubbeldagar_legally_available is True
# ---------------------------------------------------------------------------
# §Decision C — candidate-window selection must be budget-aware, not just
# calendar-cheapest.
# ---------------------------------------------------------------------------
class TestFlexibleCandidateSelectionIsBudgetAware:
def test_unfundable_cheapest_candidate_is_skipped_for_a_fundable_one(self) -> None:
"""A single parent, single child. The wish's flexible bound
(2027-12-01..2028-02-28) contains a strictly-cheaper-by-calendar
window over the Christmas/New Year red-day cluster, but that
window's spendable days fall in 2027, a year with ZERO vacation and
ZERO FP left -- completely unfundable. A plain (costlier) week
entirely in 2028, where the parent has ample vacation, is not
calendar-cheapest but IS fully payable. Budget-aware ranking must
prefer the payable window; the old calendar-only `_candidate_cost`
ranking would have picked the unfundable one and reported a
shortfall despite a perfectly fundable window existing right next
to it in the same bound.
The very cheapest candidate overall (2027-12-31..2028-01-06) is
blocked by an unrelated pre-existing commitment on 2028-01-04 so
this test isolates the budget-vs-calendar-cost tradeoff rather than
that one coincidentally-fundable window (its 3 spendable days all
happen to fall in 2028 -- verified against a live run before writing
this test).
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Jul",
start=(12, 1),
end=(1, 31), # wraps into 2028
weeks=1,
first_year=2027,
window_mode="flexible",
)
blocker = _planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2028, 1, 4), day_type="vacation"
)
alloc_input = _build_input(
today=date(2027, 11, 1),
horizon_end=date(2028, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
vacation={
(parent.uuid, 2027): VacationBudget(0, 0, 0),
(parent.uuid, 2028): VacationBudget(30, 0, 0),
},
wishes=[wish],
existing_planned=[blocker],
extra_holiday_years=(2028,),
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
assert outcome.start_date.year == 2028
assert outcome.end_date.year == 2028
# ---------------------------------------------------------------------------
# §Decision C — `strategy` must report what actually happened, never what
# was merely requested.
# ---------------------------------------------------------------------------
class TestStrategyReportsDegradation:
def test_both_mode_falling_back_to_swap_is_reported_not_hidden(self) -> None:
"""Parent A has FP but no partner-side vacation counterpart to pair
with; Parent B has neither FP nor vacation at all, so co-leave can
never pay Parent B's half of any date. Every date genuinely can't
be co-funded -> the whole window degrades to a sequential swap
(handed entirely to Parent A, who can afford it alone). `strategy`
must say 'swap', not silently keep claiming 'co_leave'.
"""
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A", salary_type="monthly")
parent_b = _parent(name="B", salary_type="monthly")
wish = _wish(
child_id=child.id,
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
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(100, 0, 60),
(parent_b.uuid, child.id): DayPool(0, 0, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(30, 0, 0),
(parent_b.uuid, 2027): VacationBudget(0, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy == "swap"
assert outcome.strategy_degraded is True
assert outcome.shortfall_days == 0 # parent A alone covers every spendable day
non_free_b = [
d
for d in proposal.days
if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free"
]
assert non_free_b == [], "parent B cannot fund anything -- must get nothing, not a lie"
@@ -49,7 +49,6 @@ class TestParentModeSpecific:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -75,7 +74,6 @@ class TestFamilyWideWish:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[],
parents=[parent],
pools={},
@@ -94,7 +92,6 @@ class TestFamilyWideWish:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[],
parents=[parent],
pools={},
@@ -117,7 +114,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 0, 60)},
@@ -139,7 +135,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(2, 0, 60)},
@@ -167,7 +162,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -192,7 +186,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
@@ -208,7 +201,6 @@ class TestTier3Fallbacks:
alloc_input_empty = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
@@ -226,7 +218,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 0, 60)},
@@ -248,7 +239,6 @@ class TestTier3Fallbacks:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
@@ -276,11 +266,14 @@ class TestDubbeldagSuccess:
end=(2, 5), # Mon 1 Feb 2027 -> Fri 5 Feb 2027, well within 15mo
weeks=1,
parent_mode="both",
# §Decision A (2026-09-18 correction): dubbeldagar are opt-in --
# without this flag `_fund_both` would co-leave (PL+vacation)
# instead, never touching the dubbeldagar quota at all.
wants_dubbeldag=True,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -315,7 +308,6 @@ class TestWarnings:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -334,7 +326,6 @@ class TestWarnings:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[],
parents=[parent],
pools={},
@@ -366,7 +357,6 @@ class TestRemedies:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
@@ -396,7 +386,6 @@ class TestRemedies:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
@@ -428,7 +417,6 @@ class TestRemedies:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
@@ -456,7 +444,6 @@ class TestRemedies:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
@@ -507,7 +494,6 @@ class TestRemedies:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
@@ -559,7 +545,6 @@ class TestDayOverrides:
base_kwargs = dict(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -592,7 +577,6 @@ class TestDayOverrides:
base_kwargs = dict(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -646,7 +630,6 @@ def _big_surplus_input(*, min_vacation_days_per_year: int = 10):
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2030, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(390, 90, 60)}, # 480 total, untouched
@@ -713,7 +696,6 @@ class TestSurplusAdvice:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Pool already at/under the age-4 ceiling -> nothing "expiring".
@@ -87,7 +87,6 @@ def _jullov_wrap_scenario(*, parent_a_starved: bool):
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -129,11 +129,14 @@ class TestAge4Projection:
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.total_days == 240 # default entitlement
# `seeded_ids`'s workspace has exactly one member -> a sole parent's
# default entitlement is the FULL pool (480), not a partnered
# parent's half-share (240) -- see `default_tier_pool_share`.
assert proj.total_days == 480 # default entitlement
assert proj.days_used == 0
assert proj.projected_left_at_b4 == 240
assert proj.projected_left_at_b4 == 480
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 144 # 240 - 96
assert proj.expiring_at_b4 == 384 # 480 - 96
async def test_leap_year_birthday(
self,
@@ -355,9 +358,7 @@ class TestCoupleWorkspacePerParentCap:
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(
Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now)
)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent_a = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
@@ -394,7 +395,7 @@ class TestCoupleWorkspacePerParentCap:
assert combined_savable == 96 # never 192 — this is the headline regression
assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2
async def test_couple_mode_with_single_parent_falls_back_to_96(
async def test_single_member_workspace_falls_back_to_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
@@ -402,18 +403,16 @@ class TestCoupleWorkspacePerParentCap:
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
"""Safety belt: `planning_mode == 'couple'` with only one member so
"""Safety belt: a workspace with only one member so far (partner not
far (partner not added yet) must still use the per-child (96)
figure — there is no second parent to divide it with.
added yet) must still use the per-child (96) figure — there is no
second parent to divide it with.
"""
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(
Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now)
)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
@@ -432,6 +431,39 @@ class TestCoupleWorkspacePerParentCap:
assert proj.expiring_at_b4 == 144 # 240 - 96
class TestSingleParentWorkspaceCap:
"""The age-4 cap is purely a function of parent count (no separate
"planning mode" flag exists): a workspace with a single member gets the
full per-child figure (96); `TestCoupleWorkspacePerParentCap` above
covers the two-parent 48/48 split.
"""
async def test_genuine_single_parent_workspace_gets_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
class TestAgreesWithPlanProposalForTheSameCommittedState:
"""Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's
propose-time projection (`allocation_service._Ledger.build_age4_projections`,
@@ -439,13 +471,14 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
`expiring_at_b4` for a parent with no FK snapshot yet and no leave wishes
(so the proposal adds nothing new) — i.e. the *committed* state.
Before the fix, `AllocationInputLoader`'s no-snapshot fallback assumed a
single parent was entitled to the WHOLE child's combined tier totals
(`rules.sjukpenning_days` + `rules.lagsta_niva_days` == 390 + 90 == 480)
instead of that parent's fair half-share (195 + 45 == 240, matching
`AnalyticsService`'s own fallback, `rules.days_per_parent`). That made the
Plan tab report roughly double what the Dashboard showed for the exact
same (empty) committed plan — this test pins the two together.
Both surfaces derive their no-snapshot default through the same shared
`app.utils.tip_predicates.default_tier_pool_share(rules, parent_count)`
helper, so they can never disagree again: a genuinely SOLE parent (this
workspace has exactly one member, "Sam") is entitled to the WHOLE
child's combined tier totals (`rules.sjukpenning_days` +
`rules.lagsta_niva_days` == 390 + 90 == 480) since there's no partner to
split the pool with; a partnered parent would instead get their fair
half-share (195 + 45 == 240). This test pins the two surfaces together.
"""
async def test_no_snapshot_no_wishes_matches_dashboard(
@@ -463,9 +496,7 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(
Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now)
)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
@@ -512,5 +543,5 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
assert dashboard_proj.expiring_at_b4 == plan_proj.expiring_at_b4
# Pin the actual figures too, so a future regression of either
# fallback (240 vs. 480) fails loudly rather than merely disagreeing.
assert dashboard_proj.projected_left_at_b4 == 240
assert dashboard_proj.expiring_at_b4 == 144
assert dashboard_proj.projected_left_at_b4 == 480
assert dashboard_proj.expiring_at_b4 == 384
@@ -1,41 +1,41 @@
"""Tests for `ManualFetchService` (backend/app/services/fetch_data_service.py).
This is the manual-entry path a parent's Försäkringskassan balances enter the
app through — now the primary onboarding action. `git grep` found zero
existing coverage for this service or its router before this file.
app through — now the primary onboarding action. The service is a thin
wrapper over the shared `app.services.leave_snapshot.build_snapshot`
derivation (also used by the FK scraper), plus the "default `total_days` off
the workspace's parent count" behaviour that is unique to manual entry (a
scraped snapshot always carries FK's own total).
`test_fk_scraper.py::test_reserved_days_derived` already proves the
`reserved_days_left <= reserved_days_total` domain rule holds for the
scraper-side derivation; this file proves the equivalent manual-entry
boundary — the `ManualFetchService.save_manual_entry` validation itself —
which was previously untested.
`test_leave_snapshot.py` covers `build_snapshot`'s own derivation/validation
exhaustively; this file only proves the wrapper forwards correctly and gets
the parent-count default right, plus basic persistence round-trips.
"""
from datetime import datetime
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.entities.personal_data import PersonalData
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.services.fetch_data_service import ManualFetchService
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction
from app.utils.errors import ValidationError
from tests.conftest import SeededIds
def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
base = dict(
base: dict[str, object] = dict(
personal_data_uuid=seeded_ids.personal_data_uuid,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
total_days=480,
days_used=100,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=150,
sjukpenning_left=310,
lagsta_niva_left=70,
dubbeldagar_left=60,
sjukpenning_total=390,
sjukpenning_used=80,
lagsta_niva_total=90,
lagsta_niva_used=20,
reserved_days_left=90,
)
base.update(overrides)
return base
@@ -44,39 +44,50 @@ def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
@pytest.fixture
def manual_fetch_service(
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
) -> ManualFetchService:
return ManualFetchService(parental_leave_repo)
return ManualFetchService(parental_leave_repo, personal_data_repo)
class TestSaveManualEntryValidation:
async def test_days_used_exceeding_total_days_is_rejected(
async def test_sjukpenning_left_exceeding_total_is_rejected(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=101)
with pytest.raises(ValidationError, match="days_used cannot exceed total_days"):
kwargs = _valid_kwargs(
seeded_ids,
total_days=240,
sjukpenning_left=196,
lagsta_niva_left=0,
reserved_days_left=0,
)
with pytest.raises(ValidationError, match="sjukpenning_left"):
await manual_fetch_service.save_manual_entry(**kwargs)
async def test_days_used_equal_to_total_days_is_accepted(
async def test_sjukpenning_left_equal_to_total_is_accepted(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
"""The boundary itself (0 days left) is valid, not an off-by-one reject."""
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=100)
"""The boundary itself (that tier fully unused) is valid, not an off-by-one reject."""
kwargs = _valid_kwargs(
seeded_ids,
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
reserved_days_left=90,
)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.days_left == 0
assert fetch.days_left == 240
async def test_reserved_days_left_exceeding_total_is_rejected(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=91)
with pytest.raises(
ValidationError, match="reserved_days_left cannot exceed reserved_days_total"
):
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=91)
with pytest.raises(ValidationError, match="reserved_days_left"):
await manual_fetch_service.save_manual_entry(**kwargs)
async def test_reserved_days_left_equal_to_total_is_accepted(
@@ -84,7 +95,7 @@ class TestSaveManualEntryValidation:
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=90)
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=90)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.reserved_days_left == 90
@@ -96,11 +107,8 @@ class TestSaveManualEntryValidation:
kwargs = _valid_kwargs(
seeded_ids,
total_days=480,
days_used=100,
sjukpenning_total=390,
sjukpenning_used=80,
lagsta_niva_total=90,
lagsta_niva_used=20,
sjukpenning_left=310,
lagsta_niva_left=70,
)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
@@ -112,6 +120,68 @@ class TestSaveManualEntryValidation:
assert fetch.created_at is not None
assert fetch.updated_at is not None
async def test_deduced_source_round_trips(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, source="deduced")
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.source == "deduced"
class TestSaveManualEntryTotalDaysDefault:
"""`total_days` defaults to `rules.total_days_per_child // parent_count`
when omitted — the whole point of Task 5/§"default pool follows parent
count" for manual entry.
"""
async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
"""`seeded_ids`'s workspace has exactly one member."""
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90)
del kwargs["total_days"]
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.total_days == 480
async def test_omitted_total_days_defaults_to_half_pool_with_a_partner(
self,
manual_fetch_service: ManualFetchService,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45)
del kwargs["total_days"]
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.total_days == 240
async def test_omitted_total_days_without_a_personal_data_repo_raises(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
seeded_ids: SeededIds,
) -> None:
"""A `ManualFetchService` built without a `personal_data_repo` (e.g. a
direct-construction call site that always passes `total_days`
explicitly) can't resolve the default and must fail loudly rather
than silently guessing.
"""
service = ManualFetchService(parental_leave_repo)
kwargs = _valid_kwargs(seeded_ids)
del kwargs["total_days"]
with pytest.raises(ValueError, match="total_days"):
await service.save_manual_entry(**kwargs)
class TestSaveManualEntryPersistence:
async def test_snapshot_persists_and_is_readable_via_get_latest(
@@ -143,16 +213,22 @@ class TestSaveManualEntryPersistence:
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=50))
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=60))
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=70))
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=50)
)
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=40)
)
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=30)
)
history = await manual_fetch_service.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert len(history) == 3
assert [h.days_used for h in history] == [70, 60, 50]
assert [h.dubbeldagar_left for h in history] == [30, 40, 50]
async def test_list_history_with_no_snapshots_returns_empty_list(
self,
@@ -161,3 +237,133 @@ class TestSaveManualEntryPersistence:
) -> None:
history = await manual_fetch_service.list_history(uuid4(), uuid4())
assert history == []
class TestPersistPartnerDeduction:
"""`persist_partner_deduction` — called by the FK scraper router's `_persist`
after `_deduce_partner` produces a `PartnerDeduction` for the family panel.
"""
async def test_creates_a_deduced_snapshot_for_the_sole_other_member(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
partner = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
snapshot = await parental_leave_repo.get_latest_by_parent(partner.uuid, seeded_ids.child_id)
assert snapshot is not None
assert snapshot.source == "deduced"
assert snapshot.days_left == 150
assert snapshot.tier_data.sjukpenning_left == 130
assert snapshot.tier_data.lagsta_niva_left == 20
async def test_sole_member_workspace_persists_nothing(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""`seeded_ids`'s workspace has exactly one member (the caller) — there
is no "other member" to attribute a deduced snapshot to."""
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
history = await parental_leave_repo.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert history == []
async def test_more_than_one_other_member_persists_nothing(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""Ambiguous "which member is the partner" — never guess."""
for name in ("Partner A", "Partner B"):
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name=name,
created_at=datetime.now(),
)
)
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
history = await parental_leave_repo.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert history == []
async def test_repo_failure_is_swallowed_not_raised(
self,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""The caller's own snapshot is already saved by the time this runs —
a DB error persisting the partner's snapshot must never fail the
scraper session."""
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
failing_repo = AsyncMock()
failing_repo.create.side_effect = RuntimeError("DB unavailable")
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=failing_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
) # must not raise
@@ -0,0 +1,285 @@
"""Tests for `app.services.leave_snapshot.build_snapshot`.
This is the ONE shared derivation both the FK scraper
(`app.scrapers.fk_scraper._extract_parental_leave`) and manual entry
(`app.services.fetch_data_service.ManualFetchService.save_manual_entry`) go
through — see its module docstring. `test_fk_scraper.py` proves the scraper
still gets identical results after the move; this file is the exhaustive,
direct unit-level coverage of the derivation and validation itself.
"""
from uuid import uuid4
import pytest
from app.services.leave_snapshot import build_snapshot
from app.utils.errors import ValidationError
def _ids() -> dict:
return dict(personal_data_uuid=uuid4(), workspace_id=uuid4(), child_id=uuid4())
class TestTotalsByParentCount:
def test_240_total_splits_into_195_and_45(self) -> None:
"""Two-parent share: 195 sjukpenning + 45 lägstanivå = 240."""
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.tier_data.sjukpenning_total == 195
assert fetch.tier_data.lagsta_niva_total == 45
assert fetch.total_days == 240
assert fetch.days_left == 240
assert fetch.days_used == 0
def test_480_total_splits_into_390_and_90(self) -> None:
"""Sole-parent share: the full 390 + 90 = 480."""
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=480,
sjukpenning_left=390,
lagsta_niva_left=90,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.tier_data.sjukpenning_total == 390
assert fetch.tier_data.lagsta_niva_total == 90
assert fetch.total_days == 480
assert fetch.days_left == 480
def test_days_used_and_tier_used_are_derived(self) -> None:
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=480,
sjukpenning_left=310,
lagsta_niva_left=70,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.days_left == 380
assert fetch.days_used == 100
assert fetch.tier_data.sjukpenning_used == 80
assert fetch.tier_data.lagsta_niva_used == 20
class TestReservedTransferableSplit:
def test_explicit_reserved_days_left_is_used_as_given(self) -> None:
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=150,
lagsta_niva_left=20,
dubbeldagar_left=60,
reserved_days_left=60,
)
assert fetch.reserved_days_total == 90
assert fetch.reserved_days_left == 60
# days_left = 170; transferable_days_left = days_left - reserved_days_left
assert fetch.transferable_days_left == 170 - 60
def test_reserved_days_left_derived_transferable_first(self) -> None:
"""240 total, 100 days_left, none given explicitly -> the classic
"transferable spent first" derivation: reserved untouched until the
150 transferable days are gone.
"""
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=60,
lagsta_niva_left=40,
dubbeldagar_left=60,
)
assert fetch.days_left == 100
assert fetch.reserved_days_total == 90
assert fetch.reserved_days_left == 90
assert fetch.transferable_days_left == 10
def test_reserved_days_left_derived_once_transferable_is_exhausted(self) -> None:
"""240 total, only 30 days_left -> transferable (150) is long gone,
so all 30 remaining days must be reserved ones."""
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=30,
lagsta_niva_left=0,
dubbeldagar_left=60,
)
assert fetch.days_left == 30
assert fetch.reserved_days_left == 30
assert fetch.transferable_days_left == 0
class TestNegativeAndOverTotalGuards:
def test_negative_sjukpenning_left_raises(self) -> None:
with pytest.raises(ValidationError, match="negative"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=-1,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
def test_negative_lagsta_niva_left_raises(self) -> None:
with pytest.raises(ValidationError, match="negative"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=-1,
dubbeldagar_left=60,
reserved_days_left=90,
)
def test_sjukpenning_left_exceeding_total_raises(self) -> None:
with pytest.raises(ValidationError, match="sjukpenning_left"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=196,
lagsta_niva_left=0,
dubbeldagar_left=60,
reserved_days_left=0,
)
def test_lagsta_niva_left_exceeding_total_raises(self) -> None:
with pytest.raises(ValidationError, match="lagsta_niva_left"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=0,
lagsta_niva_left=46,
dubbeldagar_left=60,
reserved_days_left=0,
)
def test_negative_explicit_reserved_days_left_raises(self) -> None:
with pytest.raises(ValidationError, match="negative"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=-1,
)
def test_reserved_days_left_exceeding_total_raises(self) -> None:
with pytest.raises(ValidationError, match="reserved_days_left"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=91,
)
def test_reserved_days_left_equal_to_total_is_accepted(self) -> None:
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.reserved_days_left == 90
def test_explicit_reserved_days_left_exceeding_days_left_raises(self) -> None:
"""reserved_days_left (90) > days_left (10) -> transferable_days_left
would go negative; that must be rejected, not silently persisted."""
with pytest.raises(ValidationError, match="negative"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=10,
lagsta_niva_left=0,
dubbeldagar_left=60,
reserved_days_left=90,
)
class TestDubbeldagarBound:
def test_negative_dubbeldagar_left_raises(self) -> None:
with pytest.raises(ValidationError, match="negative"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=-1,
reserved_days_left=90,
)
def test_dubbeldagar_left_exceeding_max_raises(self) -> None:
with pytest.raises(ValidationError, match="dubbeldagar_left"):
build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=61,
reserved_days_left=90,
)
def test_dubbeldagar_left_at_max_is_accepted(self) -> None:
fetch = build_snapshot(
**_ids(),
source="manual",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.dubbeldagar_left == 60
class TestSource:
@pytest.mark.parametrize("source", ["scraped", "manual", "deduced"])
def test_valid_sources_are_accepted(self, source: str) -> None:
fetch = build_snapshot(
**_ids(),
source=source,
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
assert fetch.source == source
def test_invalid_source_raises(self) -> None:
with pytest.raises(ValidationError, match="source"):
build_snapshot(
**_ids(),
source="guessed",
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
dubbeldagar_left=60,
reserved_days_left=90,
)
@@ -160,7 +160,6 @@ def _make_ctx(
vacation_by_parent: dict | None = None,
leave_wishes: list | None = None,
today: date | None = None,
planning_mode: str = "solo",
) -> TipsContext:
ws = workspace_id or uuid4()
return TipsContext(
@@ -172,7 +171,6 @@ def _make_ctx(
vacation_by_parent=vacation_by_parent or {},
leave_wishes=leave_wishes or [],
today=today or date.today(),
workspace_planning_mode=planning_mode,
)
@@ -322,13 +320,13 @@ class TestPreserveForAge4:
assert tip is not None
assert tip.data["expiring_days"] == 74
async def test_couple_workspace_with_two_parents_uses_per_parent_cap(self):
"""A two-parent `couple` workspace caps each parent at 48, not 96 —
async def test_two_parent_workspace_uses_per_parent_cap(self):
"""A two-parent workspace caps each parent at 48, not 96 — the
the headline bug this task fixes. Same inputs as
headline bug this task fixes. Same inputs as
`test_fires_when_days_will_expire` (total=240, used=20 -> projected
220) but with a second parent and `planning_mode='couple'`:
expiring should be 220-48=172, not 220-96=124.
220) but with a second parent: expiring should be 220-48=172, not
220-96=124.
"""
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
@@ -340,7 +338,6 @@ class TestPreserveForAge4:
children=[child],
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1},
planning_mode="couple",
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is not None
@@ -374,7 +371,6 @@ class TestAge4PoolingLoss:
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
@@ -405,7 +401,6 @@ class TestAge4PoolingLoss:
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
@@ -426,7 +421,6 @@ class TestAge4PoolingLoss:
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: None},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is not None
@@ -448,7 +442,6 @@ class TestAge4PoolingLoss:
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None
@@ -465,24 +458,6 @@ class TestAge4PoolingLoss:
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="couple",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None
async def test_no_fire_in_solo_mode(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3))
a = _make_parent(workspace_id=ws, name="A")
b = _make_parent(workspace_id=ws, name="B")
leave_a = _make_leave(a, child.id, total=240, used=180) # left = 60
leave_b = _make_leave(b, child.id, total=240, used=230) # left = 10
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[a, b],
leave_by_parent={a.uuid: leave_a, b.uuid: leave_b},
planning_mode="solo",
)
tip = await _rule_age4_pooling_loss(ctx)
assert tip is None