782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
1294 lines
55 KiB
Python
1294 lines
55 KiB
Python
"""Tests for `PlanningService.sync_plan` (POST /api/plan/sync).
|
|
|
|
Covers the Task 4 contract: propose+apply in one step, a pinned
|
|
`planned_leave_day` is never retyped/overwritten by a re-plan even when the
|
|
fresh proposal would have chosen differently for that date, the `changes`
|
|
diff surfaces each of its four kinds, sync is idempotent on an unchanged
|
|
plan, and `/propose` stays read-only (persists nothing).
|
|
"""
|
|
|
|
from datetime import date, datetime, timedelta
|
|
from uuid import uuid4
|
|
|
|
from app.entities.day_assertion import DayAssertion
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
|
from app.entities.personal_data import PersonalData
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.entities.vacation_days import VacationDays
|
|
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
|
from app.services.allocation_service import PlanningEngineService
|
|
from app.services.planning_service import PlanningService
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish:
|
|
base = dict(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
name="Test wish",
|
|
window_start_month=2,
|
|
window_start_day=1,
|
|
window_end_month=2,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
window_mode="fixed",
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
base.update(overrides)
|
|
return LeaveWish(**base)
|
|
|
|
|
|
def _make_fetch(personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left):
|
|
tier = TierData(
|
|
sjukpenning_total=390,
|
|
sjukpenning_used=390 - sjuk_left,
|
|
sjukpenning_left=sjuk_left,
|
|
lagsta_niva_total=90,
|
|
lagsta_niva_used=90 - lagsta_left,
|
|
lagsta_niva_left=lagsta_left,
|
|
)
|
|
return ParentalLeaveFetch(
|
|
id=uuid4(),
|
|
personal_data_uuid=personal_data_uuid,
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
source="manual",
|
|
fetched_at=fetched_at,
|
|
total_days=480,
|
|
days_used=480 - sjuk_left - lagsta_left,
|
|
days_left=sjuk_left + lagsta_left,
|
|
reserved_days_total=90,
|
|
reserved_days_left=90,
|
|
transferable_days_left=sjuk_left + lagsta_left,
|
|
dubbeldagar_left=60,
|
|
tier_data=tier,
|
|
created_at=fetched_at,
|
|
updated_at=fetched_at,
|
|
)
|
|
|
|
|
|
class TestSyncNeverOverwritesAPinnedDay:
|
|
async def test_pinned_day_survives_even_though_fresh_plan_would_differ(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
day_assertion_repo: SQLiteDayAssertionRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
# No vacation budget this year -> the monthly-salaried parent's
|
|
# tier-3 policy (vacation first) falls through to FP, sjukpenning
|
|
# preferred -- so a free interior day in this window would be
|
|
# funded as 'sjukpenning', never 'lagsta'.
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await leave_wish_repo.create(_make_wish(ws, child))
|
|
|
|
pinned_date = date(2027, 2, 3) # a Wednesday inside the wish's window
|
|
pinned = await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=pinned_date,
|
|
day_type="lagsta", # deliberately NOT what a fresh plan would pick
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
# Restructure step 3b: "pinned" is a `DayAssertion` fact now.
|
|
await day_assertion_repo.create(
|
|
DayAssertion(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=pinned_date,
|
|
day_type="lagsta",
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
assert result.apply_result.accepted is True
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
pinned_after = next(r for r in rows if r.date == pinned_date)
|
|
assert pinned_after.id == pinned.id
|
|
assert pinned_after.day_type == "lagsta"
|
|
assert await planning_service.is_pinned(pinned_after) is True
|
|
|
|
# Every OTHER day in the window was free to be funded normally.
|
|
other_days = [r for r in rows if r.date != pinned_date]
|
|
assert other_days, "expected the rest of the window to be funded"
|
|
assert all(r.day_type == "sjukpenning" for r in other_days)
|
|
|
|
# The pinned date never shows up as a changed slot.
|
|
assert all(c.date != pinned_date for c in result.changes)
|
|
|
|
|
|
class TestSyncPreservesPromotedLockedWish:
|
|
async def test_pinned_wish_attributed_days_untouched_and_debited_once(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
day_assertion_repo: SQLiteDayAssertionRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""§promote-to-locked-wish — when the frontend "promotes" an ad-hoc
|
|
|
|
period into a `LeaveWish` via the "locked" option, the existing
|
|
`planned_leave_day` rows are re-pointed at the new wish (`wish_id`
|
|
set) while a `DayAssertion` locks each date in place (restructure
|
|
step 3b — see `PlanningService._assert_pin`). `sync_plan` must treat
|
|
that state
|
|
exactly like any other pinned row for the write path (never moved,
|
|
retyped, reassigned, or deleted, per `TestSyncNeverOverwritesAPinnedDay`
|
|
above) -- but it must ALSO never double-spend its cost: `_Ledger.
|
|
_reserve_pinned_days` (allocation_service.py) debits a pinned day off
|
|
the top unconditionally, and `_fund_solo` separately must recognize
|
|
those same dates as already-occupied (`_existing_lookup`) and skip
|
|
funding them again, rather than spending a second unit of pool
|
|
capacity for a window whose days are already fully paid for.
|
|
|
|
Rigged so the two failure modes are each independently visible:
|
|
- a moved/retyped/reassigned/deleted pinned row fails the row-level
|
|
assertions directly;
|
|
- a double-spend of the sjukpenning pool leaves too little of it for
|
|
the second, freshly-funded wish below, which would then come back
|
|
short (fewer than 5 persisted weekdays, or a different day_type) --
|
|
exactly what a double debit of `_reserve_pinned_days` plus a
|
|
second spend during window-funding would cause.
|
|
"""
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
# No vacation budget -> tier-3 falls to FP, sjukpenning preferred
|
|
# (same reasoning as `TestSyncNeverOverwritesAPinnedDay` above).
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
# Wish A's 7-day window (Mon-Sun) is entirely pre-existing/pinned, so
|
|
# all 7 calendar days -- including the weekend -- were explicitly
|
|
# pinned to `sjukpenning` (a deliberate override of what would
|
|
# otherwise be a free/unpersisted weekend; `_reserve_pinned_days`
|
|
# debits a pinned day by `day_type` alone, never by whether the
|
|
# calendar date is otherwise free). Wish B's window is fresh: its
|
|
# own Sat/Sun are genuinely free (`is_free_day`) and never persisted,
|
|
# leaving 5 spendable weekdays to fund. Exactly enough sjukpenning
|
|
# for both (7 + 5 = 12), no spare -- a single extra debit anywhere
|
|
# is immediately visible as a shortfall on wish B.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=12, lagsta_left=0)
|
|
)
|
|
|
|
# -- wish A: the "promoted, locked" wish. All 7 of its days are
|
|
# ALREADY persisted, pinned, and wish_id-attributed to it -- exactly
|
|
# the state the frontend's "locked" promotion produces, rather than
|
|
# something sync_plan itself would ever create in one call.
|
|
wish_a = await leave_wish_repo.create(_make_wish(ws, child, name="Promoted (locked)"))
|
|
pinned_dates = [date(2027, 2, 1) + timedelta(days=i) for i in range(7)]
|
|
pinned_rows = [
|
|
await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=d,
|
|
day_type="sjukpenning",
|
|
wish_id=wish_a.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
for d in pinned_dates
|
|
]
|
|
for d in pinned_dates:
|
|
await day_assertion_repo.create(
|
|
DayAssertion(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=d,
|
|
day_type="sjukpenning",
|
|
wish_id=wish_a.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
# -- wish B: an ordinary, not-yet-funded wish competing for the SAME
|
|
# pool. Only gets fully funded if wish A's pinned days were debited
|
|
# exactly once.
|
|
wish_b = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Fresh B",
|
|
priority=2,
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=7,
|
|
)
|
|
)
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result.apply_result.accepted is True
|
|
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
rows_by_id = {r.id: r for r in rows}
|
|
|
|
# -- 1. the pinned, wish-attributed rows: untouched -----------------
|
|
assert len(pinned_rows) == 7
|
|
for original in pinned_rows:
|
|
after = rows_by_id.get(original.id)
|
|
assert after is not None, "a pinned row was deleted by sync_plan"
|
|
assert after.date == original.date, "a pinned row was moved"
|
|
assert after.day_type == "sjukpenning", "a pinned row was retyped"
|
|
assert after.parent_personal_data_uuid == parent, "a pinned row changed parent"
|
|
assert after.wish_id == wish_a.id, "a pinned row was reassigned to a different wish"
|
|
assert await planning_service.is_pinned(after) is True
|
|
|
|
# Exactly wish A's 7 original dates carry its wish_id -- no
|
|
# duplicate/extra row was created for its already-funded window.
|
|
wish_a_rows = [r for r in rows if r.wish_id == wish_a.id]
|
|
assert len(wish_a_rows) == 7
|
|
assert {r.id for r in wish_a_rows} == {r.id for r in pinned_rows}
|
|
|
|
# None of wish A's dates show up in the sync diff.
|
|
pinned_dates = {r.date for r in pinned_rows}
|
|
assert not any(c.date in pinned_dates for c in result.changes)
|
|
|
|
# -- 2. cost debited exactly once ------------------------------------
|
|
# 12 sjukpenning days existed: wish A's 7 pre-paid pinned days plus
|
|
# exactly the 5 weekday days wish B needs, nothing spare. If
|
|
# `_reserve_pinned_days` debited wish A's pinned days AND the
|
|
# allocator also funded wish A's (already-occupied) window a second
|
|
# time, the pool would run dry before wish B and it would come back
|
|
# short. Getting all 5 of its weekdays funded (its Sat/Sun stay free
|
|
# and unpersisted, like any fresh window's weekend) proves the debit
|
|
# happened exactly once.
|
|
wish_b_rows = [r for r in rows if r.wish_id == wish_b.id]
|
|
assert len(wish_b_rows) == 5, (
|
|
f"expected wish B fully funded from the 5 remaining sjukpenning days, "
|
|
f"got {len(wish_b_rows)} -- looks like wish A's pinned days were debited twice"
|
|
)
|
|
assert all(r.day_type == "sjukpenning" for r in wish_b_rows)
|
|
assert {r.date for r in wish_b_rows} == {
|
|
date(2027, 3, 1),
|
|
date(2027, 3, 2),
|
|
date(2027, 3, 3),
|
|
date(2027, 3, 4),
|
|
date(2027, 3, 5),
|
|
}
|
|
|
|
|
|
class TestSyncChangeKinds:
|
|
async def test_added_then_removed_when_the_wish_shrinks(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A wish that still exists but now claims fewer dates: the dates it
|
|
|
|
drops (still `wish_id`-attributed, just no longer produced by this
|
|
run's batch) must be reported (and written) as 'removed' -- distinct
|
|
from a deleted wish, whose rows the DB itself detaches via
|
|
`ON DELETE SET NULL` (making them ad-hoc, permanently kept) before
|
|
`sync_plan` ever sees them.
|
|
"""
|
|
ws, child, _parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
wish = await leave_wish_repo.create(_make_wish(ws, child, name="A", window_end_day=7))
|
|
|
|
# -- first sync: pure 'added' (Feb 1-7) ---------------------------
|
|
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_1.apply_result.accepted is True
|
|
assert result_1.changes, "expected at least one 'added' change"
|
|
assert {c.kind for c in result_1.changes} == {"added"}
|
|
|
|
# -- idempotent re-sync: no changes -------------------------------
|
|
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_2.apply_result.accepted is True
|
|
assert result_2.changes == []
|
|
|
|
# -- shrink the window (Feb 1-4) -> the dropped tail is 'removed' -
|
|
wish.window_end_day = 4
|
|
await leave_wish_repo.update(wish.id, wish)
|
|
result_3 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_3.apply_result.accepted is True
|
|
assert result_3.changes, "expected 'removed' changes once the window shrinks"
|
|
assert {c.kind for c in result_3.changes} == {"removed"}
|
|
assert all(c.date > date(2027, 2, 4) for c in result_3.changes)
|
|
|
|
async def test_retyped_when_the_underlying_pool_changes(
|
|
self,
|
|
planning_service: PlanningService,
|
|
personal_data_repo: SQLitePersonalDataRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child = seeded_ids.workspace_id, seeded_ids.child_id
|
|
# An hourly-salaried parent prefers FP (sjukpenning first) over
|
|
# vacation for tier-3 interior days -- UNLESS §Task 2's
|
|
# vacation-forfeiture bias overrides that for a year where vacation
|
|
# would otherwise be lost. With no `vacation_days` row entered, the
|
|
# loader defaults this parent's 2027 entitlement to the statutory
|
|
# 25 days and, since nothing has been used yet, `AnalyticsService.
|
|
# _vacation_year_series` forecasts most of it as forfeited (the
|
|
# `min_vacation_days_per_year` floor caps how much can be banked)
|
|
# -- which would make this test's own assertions about sjukpenning
|
|
# flakily depend on the forfeiture bias instead of the FP-pool
|
|
# fallback it's meant to isolate. `min_vacation_days_per_year=0`
|
|
# means the ENTIRE entitlement is bankable, so the simulation
|
|
# forecasts zero forfeiture and this parent is deterministically
|
|
# FP-first, exactly like before §Task 2.
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws,
|
|
name="Hourly Parent",
|
|
salary_type="hourly",
|
|
min_vacation_days_per_year=0,
|
|
)
|
|
)
|
|
# `parent_mode="specific"` -- with a second (seeded) parent now also
|
|
# in the workspace, an "either" wish would default to whichever
|
|
# parent sorts first, not necessarily this one.
|
|
await leave_wish_repo.create(
|
|
_make_wish(ws, child, parent_mode="specific", parent_personal_data_uuid=parent.uuid)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(
|
|
parent.uuid, ws, child, datetime(2027, 1, 1), sjuk_left=100, lagsta_left=100
|
|
)
|
|
)
|
|
|
|
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_1.apply_result.accepted is True
|
|
assert any(c.new_day_type == "sjukpenning" for c in result_1.changes)
|
|
|
|
# sjukpenning now exhausted -> the SAME dates must fall back to lagsta.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent.uuid, ws, child, datetime(2027, 1, 2), sjuk_left=0, lagsta_left=100)
|
|
)
|
|
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_2.apply_result.accepted is True
|
|
assert result_2.changes, "expected 'retyped' changes once sjukpenning ran out"
|
|
assert {c.kind for c in result_2.changes} == {"retyped"}
|
|
assert all(
|
|
c.old_day_type == "sjukpenning" and c.new_day_type == "lagsta" for c in result_2.changes
|
|
)
|
|
|
|
async def test_reattributed_when_a_day_moves_to_a_different_wish(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
# `old_wish`'s own window doesn't cover `target_date` at all, so it
|
|
# never competes for that key -- it just needs to be a real, still-
|
|
# existing wish so the persisted row's `wish_id` stays non-null
|
|
# (unlike deleting it, which `ON DELETE SET NULL` would turn into an
|
|
# ad-hoc, permanently-kept row before `sync_plan` ever sees it).
|
|
old_wish = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Old",
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=7,
|
|
)
|
|
)
|
|
new_wish = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="New",
|
|
window_start_month=2,
|
|
window_start_day=3,
|
|
window_end_month=2,
|
|
window_end_day=3,
|
|
target_days=1,
|
|
)
|
|
)
|
|
target_date = date(2027, 2, 3)
|
|
|
|
# A row identical to what `new_wish` would compute for this date
|
|
# (same date/type), but still attributed to `old_wish`.
|
|
await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=target_date,
|
|
day_type="sjukpenning",
|
|
wish_id=old_wish.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result.apply_result.accepted is True
|
|
by_date = {c.date: c for c in result.changes}
|
|
assert target_date in by_date
|
|
changed = by_date[target_date]
|
|
assert changed.kind == "reattributed"
|
|
assert changed.old_wish_id == old_wish.id
|
|
assert changed.new_wish_id == new_wish.id
|
|
assert changed.old_day_type == changed.new_day_type == "sjukpenning"
|
|
|
|
|
|
class TestSyncIdempotent:
|
|
async def test_unchanged_plan_produces_no_changes(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, _parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(ws, child))
|
|
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert second.apply_result.accepted is True
|
|
assert second.changes == []
|
|
|
|
|
|
class TestSyncCreditsFreedCapacityWithinSameCall:
|
|
"""Regression for the bug this change fixes: `sync_plan` used to delete
|
|
|
|
stale (shrunk-away) rows only AFTER `apply_proposed_days` had already
|
|
validated and written the fresh batch, so a wish shrinking never
|
|
credited the capacity it freed to a same-call growing wish -- a family
|
|
near their sjukpenning cap who shortened one window and lengthened
|
|
another would have the whole sync rejected with `insufficientBalance`
|
|
even though the freed days more than covered the new demand.
|
|
"""
|
|
|
|
async def test_shrinking_one_window_frees_capacity_for_a_new_one_in_the_same_sync(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
# No vacation budget -> forces sjukpenning/lagsta funding, so this
|
|
# test only has to reason about the sjukpenning pool.
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=90)
|
|
)
|
|
|
|
summer = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Summer",
|
|
window_start_month=6,
|
|
window_start_day=1,
|
|
window_end_month=6,
|
|
window_end_day=30,
|
|
target_weeks=4,
|
|
)
|
|
)
|
|
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
|
|
summer_rows = await planned_leave_repo.list_by_workspace_child(
|
|
ws, child, from_date=date(2027, 6, 1), to_date=date(2027, 6, 30)
|
|
)
|
|
sjuk_used = sum(1 for r in summer_rows if r.day_type == "sjukpenning")
|
|
assert sjuk_used > 10, "expected the 4-week summer window to fund several sjukpenning days"
|
|
|
|
# Pin the balance to exactly what's already spent -> zero slack left,
|
|
# simulating a family sitting right at their sjukpenning cap.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(
|
|
parent, ws, child, datetime(2027, 1, 2), sjuk_left=sjuk_used, lagsta_left=90
|
|
)
|
|
)
|
|
|
|
# Shrink summer to one week and add a smaller winter wish elsewhere.
|
|
# The capacity freed by shrinking summer comfortably covers winter's
|
|
# demand, but only if the freed days are credited within this call.
|
|
summer.window_end_day = 7
|
|
summer.target_weeks = 1
|
|
await leave_wish_repo.update(summer.id, summer)
|
|
await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Winter",
|
|
window_start_month=12,
|
|
window_start_day=1,
|
|
window_end_month=12,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
)
|
|
)
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
assert second.apply_result.accepted is True, second.apply_result.rejections
|
|
assert not any(
|
|
r.message_key == "planApply.rejection.insufficientBalance"
|
|
for r in second.apply_result.rejections
|
|
)
|
|
|
|
winter_rows = await planned_leave_repo.list_by_workspace_child(
|
|
ws, child, from_date=date(2027, 12, 1), to_date=date(2027, 12, 7)
|
|
)
|
|
assert any(r.day_type == "sjukpenning" for r in winter_rows)
|
|
|
|
|
|
class TestSyncDisplacement:
|
|
"""`sync_plan`'s displacement handling -- Step 2 of the intent-vs-
|
|
|
|
derived-output restructure made this an informational outcome, never a
|
|
refusal: `sync_plan` always writes, and a genuinely evicted row (its OWN
|
|
wish still wants that day but lost the priority race for a shared pool
|
|
to a DIFFERENT wish's edit -- see `PlanningService._genuinely_evicted_
|
|
rows`) comes back as a 'removed' `PlanChange` with `evicted=True`
|
|
instead of blocking the whole batch (see the fix for the data-loss bug
|
|
this replaces in this file's module docstring / PLANNING_ENGINE_SPEC.md).
|
|
The scenarios below use two wishes over the EXACT SAME window (Mon 11 -
|
|
Fri 15 Jan 2027 -- five plain workdays, no weekend, no Swedish red day in
|
|
range) so a higher-priority wish's fresh placement claims literally the
|
|
same dates the lower-priority wish already had persisted, guaranteeing
|
|
full contention regardless of the exact day-count the allocator
|
|
computes.
|
|
"""
|
|
|
|
async def test_over_subscribed_sync_writes_and_reports_displacement(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
# No vacation this year, no lagsta -> the only lever is sjukpenning,
|
|
# so a shortfall there can never be silently backstopped by anything
|
|
# else (it must show up as a genuine displacement).
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0)
|
|
)
|
|
|
|
existing = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Existing",
|
|
window_start_month=1,
|
|
window_start_day=11,
|
|
window_end_month=1,
|
|
window_end_day=15,
|
|
priority=5,
|
|
)
|
|
)
|
|
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
existing_count = len(rows_after_first)
|
|
assert existing_count > 0
|
|
assert all(r.wish_id == existing.id for r in rows_after_first)
|
|
assert not any(c.evicted for c in first.changes)
|
|
|
|
# Shrink the shared pool to exactly what `existing` already spent (no
|
|
# slack), then a higher-priority wish over a DIFFERENT window steals
|
|
# it -- `existing`'s own window is now entirely unfunded.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(
|
|
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
|
|
)
|
|
)
|
|
new_wish = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="New",
|
|
window_start_month=2,
|
|
window_start_day=1,
|
|
window_end_month=2,
|
|
window_end_day=5,
|
|
priority=1,
|
|
)
|
|
)
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
# The edit lands -- displacement is informational, never a refusal.
|
|
assert second.apply_result.accepted is True, second.apply_result.rejections
|
|
assert second.apply_result.rejections == []
|
|
|
|
removed = [c for c in second.changes if c.kind == "removed"]
|
|
assert removed, "expected the crowded-out 'Existing' days to be reported as removed"
|
|
assert all(c.evicted for c in removed), (
|
|
"a row removed because a DIFFERENT wish's edit won the shared pool must be "
|
|
"flagged evicted=True, not folded in as an ordinary self-caused removal"
|
|
)
|
|
assert all(c.old_wish_id == existing.id for c in removed)
|
|
assert all(c.old_day_type == "sjukpenning" for c in removed)
|
|
assert all(c.child_id == child and c.parent_personal_data_uuid == parent for c in removed)
|
|
|
|
added = [c for c in second.changes if c.kind == "added"]
|
|
assert added, "expected the new wish's days to be reported as added"
|
|
assert all(c.new_wish_id == new_wish.id for c in added)
|
|
|
|
# The write actually happened: `existing`'s rows are gone, `new`'s
|
|
# are there.
|
|
rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert rows_after_second
|
|
assert all(r.wish_id == new_wish.id for r in rows_after_second)
|
|
assert not any(r.wish_id == existing.id for r in rows_after_second)
|
|
|
|
async def test_shortfall_days_is_not_masked_when_a_higher_priority_wish_claims_the_same_dates(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Regression for a real bug this displacement-handling work uncovered
|
|
|
|
in `_Ledger._fund_solo` (`app/services/allocation_service.py`): when a
|
|
higher-priority wish's placement lands on the EXACT SAME calendar
|
|
dates a lower-priority wish already occupied (as opposed to merely
|
|
exhausting a shared pool on different dates — the scenario the test
|
|
above covers), `_fund_solo` used to find those dates already in
|
|
`_existing_lookup` (via `record_days()`, folded in by the earlier-
|
|
processed wish this same run) and credit them toward the LATER
|
|
wish's own `cost`/`shortfall_days` as if THAT wish had gotten them
|
|
too — so a wish entirely crowded out on every one of its dates could
|
|
still report `shortfall_days == 0`. Fixed by only crediting cost for
|
|
dates present in `_existing_lookup` BEFORE this run's own
|
|
`record_days()` calls started (`_pre_run_occupied`); a same-run
|
|
claim by a different wish now correctly falls through unfunded.
|
|
|
|
Follow-up fix (this session): because the two wishes here land on
|
|
the identical (child, parent, date) keys, `sync_plan`'s
|
|
`stale_keys` (which only looks at keys ABSENT from the fresh batch)
|
|
never fires for them — the row survives and is metadata-updated to
|
|
the new wish's `wish_id`, i.e. a 'reattributed' `PlanChange`, not a
|
|
'removed' one. That used to bypass the `evicted` flag entirely
|
|
(same-date collisions between two DIFFERENT wishes were not called
|
|
out as displacement the way stale-key drops are). `sync_plan` now
|
|
runs the same "does the row's own wish still have unmet shortfall
|
|
here" check `_genuinely_evicted_rows` applies to deleted stale rows
|
|
against 'reattributed' changes too — asserted below.
|
|
"""
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0)
|
|
)
|
|
existing = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Existing",
|
|
window_start_month=1,
|
|
window_start_day=11,
|
|
window_end_month=1,
|
|
window_end_day=15,
|
|
priority=5,
|
|
)
|
|
)
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child))
|
|
|
|
await parental_leave_repo.create(
|
|
_make_fetch(
|
|
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
|
|
)
|
|
)
|
|
# SAME window as `existing` -- the higher-priority wish's placement
|
|
# lands on the literal same calendar dates, not merely a shared pool.
|
|
await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="New",
|
|
window_start_month=1,
|
|
window_start_day=11,
|
|
window_end_month=1,
|
|
window_end_day=15,
|
|
priority=1,
|
|
)
|
|
)
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
existing_outcome = next(o for o in second.proposal.outcomes if o.wish_id == existing.id)
|
|
assert existing_outcome.shortfall_days == existing_count, (
|
|
"the crowded-out wish's own outcome must report the real shortfall, "
|
|
"not silently claim the other wish's days as its own funded total"
|
|
)
|
|
assert existing_outcome.weeks_funded == 0.0
|
|
|
|
reattributed = [c for c in second.changes if c.kind == "reattributed"]
|
|
assert len(reattributed) == existing_count, (
|
|
"every day the higher-priority wish claimed from `existing` "
|
|
"should surface as a reattribution, not vanish silently"
|
|
)
|
|
assert all(c.old_wish_id == existing.id for c in reattributed)
|
|
assert all(c.evicted is True for c in reattributed), (
|
|
"a same-date collision that displaces a wish which still wants "
|
|
"that day must be flagged evicted=True, exactly like a "
|
|
"different-date eviction would be — a displaced day must never "
|
|
"go unreported to the user just because it happened to land on "
|
|
"the identical calendar slot"
|
|
)
|
|
|
|
async def test_over_subscribed_vacation_sync_writes_and_displaces_vacation_days(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Displacement isn't only a parental-leave-pool phenomenon --
|
|
|
|
vacation is budgeted per (parent, year) the same way, and an
|
|
over-subscribed vacation sync must write and report displacement
|
|
exactly like the sjukpenning case above.
|
|
"""
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
# No FP at all -> the ONLY way either wish's window can be funded is
|
|
# vacation, so a shortfall here can only ever be a vacation shortage.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=0, lagsta_left=0)
|
|
)
|
|
vacation_row = await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=25
|
|
)
|
|
)
|
|
existing = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Existing",
|
|
window_start_month=1,
|
|
window_start_day=11,
|
|
window_end_month=1,
|
|
window_end_day=15,
|
|
priority=5,
|
|
)
|
|
)
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
existing_count = len(rows_after_first)
|
|
assert existing_count > 0
|
|
assert all(r.day_type == "vacation" for r in rows_after_first)
|
|
|
|
# Shrink the shared vacation budget to exactly what `existing` spent.
|
|
vacation_row.total_days = existing_count
|
|
await vacation_repo.update(vacation_row.id, vacation_row)
|
|
new_wish = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="New",
|
|
window_start_month=2,
|
|
window_start_day=1,
|
|
window_end_month=2,
|
|
window_end_day=5,
|
|
priority=1,
|
|
)
|
|
)
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
assert second.apply_result.accepted is True, second.apply_result.rejections
|
|
removed = [c for c in second.changes if c.kind == "removed"]
|
|
assert removed, "expected the crowded-out 'Existing' vacation days to be reported"
|
|
assert all(c.evicted for c in removed)
|
|
assert all(c.old_day_type == "vacation" for c in removed)
|
|
assert all(c.old_wish_id == existing.id for c in removed)
|
|
|
|
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert rows_after
|
|
assert all(r.wish_id == new_wish.id for r in rows_after)
|
|
assert not any(r.wish_id == existing.id for r in rows_after)
|
|
|
|
async def test_pinned_day_never_displaced(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
day_assertion_repo: SQLiteDayAssertionRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0)
|
|
)
|
|
await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Existing",
|
|
window_start_month=1,
|
|
window_start_day=11,
|
|
window_end_month=1,
|
|
window_end_day=15,
|
|
priority=5,
|
|
)
|
|
)
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert rows
|
|
|
|
to_pin = rows[0]
|
|
# Restructure step 3b: "pinned" is a `DayAssertion` fact, not an
|
|
# `update()` parameter — see `PlanningService.update`'s docstring.
|
|
await day_assertion_repo.create(
|
|
DayAssertion(
|
|
id=uuid4(),
|
|
workspace_id=to_pin.workspace_id,
|
|
child_id=to_pin.child_id,
|
|
parent_personal_data_uuid=to_pin.parent_personal_data_uuid,
|
|
date=to_pin.date,
|
|
day_type=to_pin.day_type,
|
|
wish_id=to_pin.wish_id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
pinned = to_pin
|
|
assert await planning_service.is_pinned(pinned) is True
|
|
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 2), sjuk_left=len(rows), lagsta_left=0)
|
|
)
|
|
await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="New",
|
|
window_start_month=2,
|
|
window_start_day=1,
|
|
window_end_month=2,
|
|
window_end_day=5,
|
|
priority=1,
|
|
)
|
|
)
|
|
|
|
resolved = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
|
|
assert all(c.date != pinned.date for c in resolved.changes if c.evicted)
|
|
|
|
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
survivor = next((r for r in rows_after if r.id == pinned.id), None)
|
|
assert survivor is not None, "the pinned day must never be displaced"
|
|
assert await planning_service.is_pinned(survivor) is True
|
|
assert survivor.date == pinned.date
|
|
assert survivor.day_type == pinned.day_type
|
|
|
|
|
|
class TestSyncNoDisplacementWithinBudget:
|
|
async def test_plain_sync_has_no_evicted_changes(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A save that fits within budget applies straight through: no
|
|
|
|
removed change is ever flagged `evicted` when nothing was displaced.
|
|
"""
|
|
ws, child, _parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(ws, child))
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
assert result.apply_result.accepted is True
|
|
assert not any(c.evicted for c in result.changes)
|
|
|
|
|
|
class TestProposeStaysReadOnly:
|
|
async def test_propose_never_writes_a_row(
|
|
self,
|
|
planning_engine_service: PlanningEngineService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, _parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(ws, child))
|
|
|
|
proposal = await planning_engine_service.propose(ws, today=date(2027, 1, 1))
|
|
assert proposal.days, "expected the proposal to compute something"
|
|
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert rows == []
|
|
|
|
|
|
class TestSyncPreservesElapsedAutoPlacedDays:
|
|
"""§the-past-is-fact — a stale key is only ever "self-caused" or
|
|
|
|
"genuinely evicted" when its own wish is still capable of re-claiming
|
|
the date at all. A date that has already elapsed can never come back
|
|
from the allocator (`_expand_all_windows`/`_expand_flexible_wish_years`
|
|
clip every window to `>= today`), so treating it as stale the moment it
|
|
elapses -- as `sync_plan` did before this fix -- silently deletes
|
|
already-taken leave on the very next load, since `PlanSyncProvider`
|
|
(frontend) syncs on every workspace load.
|
|
|
|
Reproduces the reported scenario exactly: a fixed wish for Feb 1-7
|
|
2027 synced once with `today=2027-01-01` (creating auto-placed rows),
|
|
then synced again with `today=2027-02-04` -- Feb 1-3 must survive and
|
|
must not appear in `changes`.
|
|
"""
|
|
|
|
async def test_past_autoplaced_rows_survive_later_sync(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
await leave_wish_repo.create(_make_wish(ws, child))
|
|
|
|
# Feb 1-7 2027: Feb 6-7 are a Sat/Sun and stay free/unpersisted (same
|
|
# reasoning as `TestSyncNeverOverwritesAPinnedDay`), so the wish
|
|
# auto-places exactly the 5 weekdays Feb 1-5.
|
|
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_1.apply_result.accepted is True
|
|
rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert {r.date for r in rows_1} >= {date(2027, 2, i) for i in range(1, 6)}
|
|
|
|
# "Today" advances past Feb 1-3: the allocator's own window clipping
|
|
# now excludes those three dates from the fresh proposal entirely.
|
|
result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 4))
|
|
assert result_2.apply_result.accepted is True
|
|
rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
rows_2_dates = {r.date for r in rows_2}
|
|
|
|
elapsed = {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 3)}
|
|
assert elapsed <= rows_2_dates, (
|
|
f"elapsed rows were deleted by sync: missing {elapsed - rows_2_dates}"
|
|
)
|
|
assert not any(c.date in elapsed for c in result_2.changes), (
|
|
f"elapsed rows appeared in changes: "
|
|
f"{[c for c in result_2.changes if c.date in elapsed]}"
|
|
)
|
|
|
|
|
|
class TestSyncTodayItselfIsStillReplannable:
|
|
"""The boundary choice pinned: `today` itself is NOT frozen -- only a
|
|
|
|
row dated strictly before `today` is "the past". A row dated exactly
|
|
`today` whose wish no longer wants it must still be droppable as
|
|
ordinary self-caused staleness, exactly like any other date the wish
|
|
stopped claiming.
|
|
"""
|
|
|
|
async def test_todays_date_still_evicted_when_its_own_wish_drops_it(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(ws, child, window_start_day=1, window_end_day=5)
|
|
)
|
|
|
|
# Feb 1-5 2027 are all weekdays -> all five auto-placed.
|
|
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert result_1.apply_result.accepted is True
|
|
rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert {r.date for r in rows_1} == {date(2027, 2, i) for i in range(1, 6)}
|
|
|
|
# The wish itself shrinks to drop Feb 1 -- its own edit, not a
|
|
# different wish stealing the day, so this is ordinary self-caused
|
|
# staleness, never "genuine eviction".
|
|
wish.window_start_day = 2
|
|
await leave_wish_repo.update(wish.id, wish)
|
|
|
|
# `today` is exactly Feb 1, the date being dropped. If the boundary
|
|
# were implemented as `<=` (today frozen too) this row would
|
|
# survive and never show up in `changes`, which is the wrong
|
|
# reading: today must stay fully re-plannable in the same call.
|
|
result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 1))
|
|
assert result_2.apply_result.accepted is True
|
|
rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
rows_2_dates = {r.date for r in rows_2}
|
|
|
|
assert date(2027, 2, 1) not in rows_2_dates, (
|
|
"today's own date was frozen even though its wish dropped it"
|
|
)
|
|
assert any(c.kind == "removed" and c.date == date(2027, 2, 1) for c in result_2.changes), (
|
|
"expected today's dropped date to be reported as a 'removed' change"
|
|
)
|
|
# The rest of the (still-claimed) window is untouched.
|
|
assert {date(2027, 2, i) for i in range(2, 6)} <= rows_2_dates
|
|
|
|
|
|
class TestSyncDebitsElapsedDaysFromPools:
|
|
"""§the-past-is-fact, second half — `AllocationInputLoader.load`'s
|
|
|
|
`only_pinned_existing` mode hides every unpinned wish-attributed row so
|
|
the auto-placed portion can be re-derived, but a row dated before
|
|
`today` is never re-derived by the allocator at all (see
|
|
`TestSyncPreservesElapsedAutoPlacedDays`). Left out of the pool
|
|
netting too, it would be spent capacity the plan still reports as
|
|
available -- a competing wish would wrongly get funded from days that
|
|
are already gone.
|
|
|
|
Rigged so any deviation is loud, not a silent off-by-one: the parent's
|
|
sjukpenning pool (per a stale FK snapshot that predates the already-
|
|
elapsed commitment) has exactly `elapsed + wish B's demand - 1` days
|
|
left. Correctly netting the 3 elapsed days leaves exactly 4 -- one
|
|
short of wish B's 5 weekdays -- so wish B's window must show 4
|
|
`sjukpenning` rows and exactly 1 `lagsta` fallback row. If the elapsed
|
|
days are NOT netted, the pool has enough spare capacity to fund all 5
|
|
of wish B's days as `sjukpenning`, and this test fails on the day-type
|
|
split rather than a fragile total-count check.
|
|
"""
|
|
|
|
async def test_elapsed_rows_reduce_the_pool_a_competing_wish_can_draw_from(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
|
)
|
|
)
|
|
|
|
# A wish whose window is entirely in the past by the time `sync_plan`
|
|
# runs below -- `_expand_all_windows` drops it outright, so nothing
|
|
# about its own funding matters here. What matters is the 3 rows
|
|
# already persisted against it: unpinned, wish-attributed,
|
|
# `sjukpenning`, dated before `today`.
|
|
elapsed_wish = await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Elapsed",
|
|
window_start_month=1,
|
|
window_start_day=4,
|
|
window_end_month=1,
|
|
window_end_day=6,
|
|
)
|
|
)
|
|
elapsed_dates = [date(2027, 1, 4), date(2027, 1, 5), date(2027, 1, 6)]
|
|
for d in elapsed_dates:
|
|
await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
child_id=child,
|
|
parent_personal_data_uuid=parent,
|
|
date=d,
|
|
day_type="sjukpenning",
|
|
wish_id=elapsed_wish.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
# A stale FK snapshot that predates those 3 already-taken days:
|
|
# 7 sjukpenning left, plenty of lagsta so a shortfall never rejects
|
|
# the batch outright, it just falls back to a visibly different
|
|
# day_type once sjukpenning runs out.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=7, lagsta_left=99)
|
|
)
|
|
|
|
# Wish B: 5 weekdays (Mon-Fri), competing for the SAME pool.
|
|
await leave_wish_repo.create(
|
|
_make_wish(
|
|
ws,
|
|
child,
|
|
name="Competing",
|
|
priority=2,
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=5,
|
|
)
|
|
)
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 2, 1))
|
|
assert result.apply_result.accepted is True
|
|
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
# The elapsed rows themselves must survive untouched (covered in
|
|
# detail by `TestSyncPreservesElapsedAutoPlacedDays`; asserted here
|
|
# too since a bug that deletes them would otherwise free the pool
|
|
# back up and mask the netting bug this test targets).
|
|
assert {r.date for r in rows if r.date in elapsed_dates} == set(elapsed_dates)
|
|
|
|
march_rows = {r.date: r.day_type for r in rows if r.date.year == 2027 and r.date.month == 3}
|
|
assert march_rows == {
|
|
date(2027, 3, 1): "sjukpenning",
|
|
date(2027, 3, 2): "sjukpenning",
|
|
date(2027, 3, 3): "sjukpenning",
|
|
date(2027, 3, 4): "sjukpenning",
|
|
date(2027, 3, 5): "lagsta",
|
|
}, (
|
|
f"expected the elapsed days to have debited the sjukpenning pool down to "
|
|
f"4, leaving wish B's 5th weekday to fall back to lagsta; got {march_rows} "
|
|
f"-- looks like the elapsed rows were not netted out of the pool"
|
|
)
|