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