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:
@@ -21,7 +21,11 @@ from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonal
|
||||
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 app.services.planning_service import (
|
||||
EVICTION_CHOICE_DROP_FUTURE_PL,
|
||||
EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
|
||||
PlanningService,
|
||||
)
|
||||
from tests.conftest import SeededIds
|
||||
|
||||
|
||||
@@ -419,6 +423,547 @@ class TestSyncCreditsFreedCapacityWithinSameCall:
|
||||
assert any(r.day_type == "sjukpenning" for r in winter_rows)
|
||||
|
||||
|
||||
class TestSyncEvictionPreview:
|
||||
"""`sync_plan`'s eviction preview/resolution — the fix for the data-loss
|
||||
|
||||
bug where editing one wish silently deleted another wish's rows because
|
||||
the shared pool went to the edited wish first (see this file's module
|
||||
docstring / PLANNING_ENGINE_SPEC.md). Both 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_would_evict_returns_preview_and_mutates_nothing(
|
||||
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 shortfall/eviction).
|
||||
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 first.eviction_preview.would_evict is False
|
||||
|
||||
# Shrink the shared pool to exactly what `existing` already spent (no
|
||||
# slack), then a higher-priority wish over the SAME window steals it.
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(
|
||||
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, 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,
|
||||
)
|
||||
)
|
||||
|
||||
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
# Nothing accepted, nothing changed, and the caller must be told why.
|
||||
assert second.apply_result.accepted is False
|
||||
assert second.changes == []
|
||||
assert second.apply_result.rejections
|
||||
assert (
|
||||
second.apply_result.rejections[0].message_key
|
||||
== "planSync.rejection.evictionChoiceRequired"
|
||||
)
|
||||
|
||||
preview = second.eviction_preview
|
||||
assert preview.would_evict is True
|
||||
assert preview.candidates, "expected the evicted 'Existing' days to be listed"
|
||||
assert all(c.wish_id == existing.id for c in preview.candidates)
|
||||
assert all(c.wish_name == "Existing" for c in preview.candidates)
|
||||
assert all(c.day_type == "sjukpenning" for c in preview.candidates)
|
||||
assert all(
|
||||
c.child_id == child and c.parent_personal_data_uuid == parent
|
||||
for c in preview.candidates
|
||||
)
|
||||
|
||||
assert len(preview.shortages) == 1
|
||||
shortage = preview.shortages[0]
|
||||
assert shortage.resource_kind == "parental_leave"
|
||||
assert shortage.parent_personal_data_uuid == parent
|
||||
assert shortage.tier == "sjukpenning"
|
||||
assert shortage.child_id == child
|
||||
assert shortage.year is None
|
||||
assert shortage.days_evicted == len(preview.candidates)
|
||||
|
||||
# Requirement: mutates NOTHING, not even the harmless parts.
|
||||
rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert {r.id for r in rows_after_second} == {r.id for r in rows_after_first}
|
||||
|
||||
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 eviction-preview 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.
|
||||
|
||||
NOTE — known, separately-scoped gap this test does NOT claim to fix:
|
||||
because the two wishes here land on the identical (child, parent,
|
||||
date) keys, `sync_plan`'s `stale_keys`/`_genuinely_evicted_rows`
|
||||
(which only look at keys ABSENT from the fresh batch) never fire —
|
||||
the row survives and is silently metadata-updated to the new wish's
|
||||
`wish_id` (a 'reattributed' `PlanChange`, not a deletion), bypassing
|
||||
`eviction_preview` entirely. That is a real, distinct hole in the
|
||||
eviction-preview feature (same-date collisions between two DIFFERENT
|
||||
wishes are not gated the way stale-key drops are) — flagged in this
|
||||
session's handoff report as follow-up work, not fixed here. This
|
||||
test only pins down that `shortfall_days`/`weeks_funded` — the
|
||||
signal both the UI's "window funded" display AND
|
||||
`_genuinely_evicted_rows` depend on — are no longer silently wrong.
|
||||
"""
|
||||
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
|
||||
|
||||
async def test_drop_future_pl_choice_frees_the_days_and_lets_the_edit_land(
|
||||
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
|
||||
)
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert preview.eviction_preview.would_evict is True
|
||||
|
||||
resolved = await planning_service.sync_plan(
|
||||
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
|
||||
)
|
||||
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
|
||||
|
||||
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert rows_after, "expected the new wish's days to have been written"
|
||||
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_wrong_choice_still_rejects_and_mutates_nothing(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""The vacation-eviction option can't free a parental-leave shortage
|
||||
|
||||
-- `apply_proposed_days`'s own balance validation rejects the whole
|
||||
batch exactly as it would with no choice at all, atomically.
|
||||
"""
|
||||
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_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
existing_count = len(rows_after_first)
|
||||
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(
|
||||
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, 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),
|
||||
eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
|
||||
)
|
||||
assert resolved.apply_result.accepted is False
|
||||
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert {r.id for r in rows_after} == {r.id for r in rows_after_first}
|
||||
|
||||
async def test_drop_latest_vacation_choice_frees_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:
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert preview.eviction_preview.would_evict is True
|
||||
assert all(c.day_type == "vacation" for c in preview.eviction_preview.candidates)
|
||||
assert len(preview.eviction_preview.shortages) == 1
|
||||
shortage = preview.eviction_preview.shortages[0]
|
||||
assert shortage.resource_kind == "vacation"
|
||||
assert shortage.year == 2027
|
||||
assert shortage.tier is None
|
||||
assert shortage.child_id is None
|
||||
|
||||
# The wrong option (PL) can't touch a vacation shortage.
|
||||
wrong = await planning_service.sync_plan(
|
||||
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
|
||||
)
|
||||
assert wrong.apply_result.accepted is False
|
||||
rows_still_original = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert {r.id for r in rows_still_original} == {r.id for r in rows_after_first}
|
||||
|
||||
resolved = await planning_service.sync_plan(
|
||||
ws,
|
||||
today=date(2027, 1, 1),
|
||||
eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
|
||||
)
|
||||
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
|
||||
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_evicted_under_any_choice(
|
||||
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
|
||||
)
|
||||
)
|
||||
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]
|
||||
pinned = await planning_service.update(to_pin.id, is_pinned=True)
|
||||
assert pinned.is_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,
|
||||
)
|
||||
)
|
||||
|
||||
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert preview.eviction_preview.would_evict is True
|
||||
assert all(c.date != pinned.date for c in preview.eviction_preview.candidates)
|
||||
|
||||
resolved = await planning_service.sync_plan(
|
||||
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
|
||||
)
|
||||
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
|
||||
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 evicted"
|
||||
assert survivor.is_pinned is True
|
||||
assert survivor.date == pinned.date
|
||||
assert survivor.day_type == pinned.day_type
|
||||
|
||||
|
||||
class TestSyncEvictionPreviewAbsentWhenWithinBudget:
|
||||
async def test_plain_sync_has_no_preview_friction(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""Requirement 5 — a save that fits within budget applies straight
|
||||
|
||||
through: no extra round trip, no empty-but-present friction either.
|
||||
"""
|
||||
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 result.eviction_preview.would_evict is False
|
||||
assert result.eviction_preview.candidates == []
|
||||
assert result.eviction_preview.shortages == []
|
||||
|
||||
|
||||
class TestProposeStaysReadOnly:
|
||||
async def test_propose_never_writes_a_row(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user