Separate user intent from planner output via day assertions
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
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"""Proves a `DayAssertion` is honoured by `sync_plan` exactly as the legacy
|
||||
|
||||
signals it replaces — restructure step 3
|
||||
(`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`):
|
||||
|
||||
- a pinned day (`PlannedLeaveDay.is_pinned=True`) -> §`TestAssertionActsLikeAPin`.
|
||||
- a `PlannedDayExclusion` row -> §`TestAssertionActsLikeAnExclusion`.
|
||||
|
||||
Both tests deliberately avoid the LEGACY signal they're replacing (no
|
||||
`is_pinned=True` on the row in the pin test, no `planned_day_exclusion` row in
|
||||
the exclusion test) so the assertion is proven to work on its own, not merely
|
||||
alongside a fallback that would have produced the same result anyway. Reuses
|
||||
the exact scenarios from `test_planning_service_sync.py::
|
||||
TestSyncNeverOverwritesAPinnedDay` and `test_planned_day_exclusion.py::
|
||||
TestExclusionSurvivesSync` so the equivalence is provable rather than
|
||||
freshly invented.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.entities.day_assertion import DayAssertion
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
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.planned_leave_repository import SQLitePlannedLeaveRepository
|
||||
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
||||
from app.services.day_assertion_service import DayAssertionService
|
||||
from app.services.leave_wish_service import LeaveWishService
|
||||
from app.services.planning_service import PlanningService
|
||||
from tests.conftest import SeededIds
|
||||
|
||||
_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below
|
||||
|
||||
|
||||
def _make_wish(seeded: SeededIds) -> LeaveWish:
|
||||
"""A fixed, one-week February 2027 window — same shape as the reference
|
||||
scenarios in test_planning_service_sync.py / test_planned_day_exclusion.py.
|
||||
"""
|
||||
return LeaveWish(
|
||||
id=uuid4(),
|
||||
workspace_id=seeded.workspace_id,
|
||||
child_id=seeded.child_id,
|
||||
name="Sportlov",
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
class TestAssertionActsLikeAPin:
|
||||
async def test_a_concrete_assertion_alone_freezes_the_day_like_is_pinned_would(
|
||||
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 -> tier-3 falls through to FP,
|
||||
# sjukpenning preferred -- so a freshly-funded interior day in this
|
||||
# window would be 'sjukpenning', never 'lagsta' (see
|
||||
# TestSyncNeverOverwritesAPinnedDay, the scenario this mirrors).
|
||||
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(seeded_ids))
|
||||
|
||||
# The row is deliberately NOT `is_pinned=True` and NOT ad-hoc
|
||||
# (`wish_id` points at the real wish) -- under pre-step-3 rules this
|
||||
# row is exactly the kind `only_pinned_existing` would hide and
|
||||
# `sync_plan` would freely retype. Only the DayAssertion below should
|
||||
# be what keeps it frozen.
|
||||
row = await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="lagsta", # deliberately NOT what a fresh plan would pick
|
||||
wish_id=wish.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await day_assertion_repo.create(
|
||||
DayAssertion(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="lagsta",
|
||||
wish_id=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
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
frozen = next(r for r in rows if r.date == _DAY)
|
||||
assert frozen.id == row.id
|
||||
assert frozen.day_type == "lagsta"
|
||||
|
||||
# Every OTHER day in the window was free to be funded normally.
|
||||
other_days = [r for r in rows if r.date != _DAY]
|
||||
assert other_days, "expected the rest of the window to be funded"
|
||||
assert all(r.day_type == "sjukpenning" for r in other_days)
|
||||
|
||||
# The frozen date never shows up as a changed slot.
|
||||
assert all(c.date != _DAY for c in result.changes)
|
||||
|
||||
|
||||
class TestAssertionActsLikeAnExclusion:
|
||||
async def test_a_none_assertion_alone_keeps_the_day_out_with_no_exclusion_row(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository,
|
||||
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(seeded_ids))
|
||||
|
||||
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 _DAY in {r.date for r in rows}, "the planner should place the day to begin with"
|
||||
|
||||
# No `PlannedDayExclusion` row anywhere -- only a raw DayAssertion.
|
||||
await day_assertion_repo.create(
|
||||
DayAssertion(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="none",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert second.apply_result.accepted is True, second.apply_result.rejections
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert _DAY not in {r.date for r in rows}
|
||||
# The rest of the week is still planned — only the one day went.
|
||||
assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= {
|
||||
r.date for r in rows
|
||||
}
|
||||
# Not a reported eviction: this is a deliberate removal, not a lost
|
||||
# priority race — see `_Ledger._excluded_window_dates`'s docstring.
|
||||
assert not any(c.evicted for c in second.changes)
|
||||
|
||||
async def test_clearing_the_exclusion_puts_the_day_back(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""There must be a way back — this is what the undo toast calls.
|
||||
|
||||
Ported from the deleted `test_planned_day_exclusion.py::
|
||||
TestExclusionSurvivesSync.test_un_excluding_puts_the_day_back`, now
|
||||
exercised through `DayAssertionService.clear` instead of the deleted
|
||||
`PlannedDayExclusionService.delete`.
|
||||
"""
|
||||
ws, child, parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
await leave_wish_repo.create(_make_wish(seeded_ids))
|
||||
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
|
||||
await service.assert_day(ws, child, parent, _DAY, day_type="none")
|
||||
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert _DAY not in {r.date for r in rows}
|
||||
|
||||
await service.clear(ws, child, parent, _DAY)
|
||||
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert _DAY in {r.date for r in rows}
|
||||
|
||||
|
||||
class TestWishDeleteClearsAssertions:
|
||||
"""Restructure step 3b — deleting a wish must not strand a permanently
|
||||
|
||||
locked slot behind. `LeaveWishService.delete()` clears the `DayAssertion`
|
||||
at each deleted row's key (see that method's docstring for why this is
|
||||
unconditional for a concrete assertion): otherwise the assertion
|
||||
survives with no `planned_leave_day` row behind it (invisible to the
|
||||
frontend, which only ever lists assertions via the rows/exclusions it
|
||||
already knows about) while still silently pinning whatever a later sync
|
||||
or hand-pin tries to place at that same (workspace, child, parent, date).
|
||||
"""
|
||||
|
||||
async def test_delete_clears_the_assertion_and_the_date_is_placeable_again(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
leave_wish_service: LeaveWishService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child, parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
wish = await leave_wish_repo.create(_make_wish(seeded_ids))
|
||||
|
||||
# A locked/promoted day: a `planned_leave_day` row plus the
|
||||
# `DayAssertion` that pins it, both attributed to `wish` — exactly
|
||||
# what `PlanningService._assert_pin` produces for an 'attach'/'pin'
|
||||
# day-lock action.
|
||||
row = await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="vacation",
|
||||
wish_id=wish.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await day_assertion_repo.create(
|
||||
DayAssertion(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="vacation",
|
||||
wish_id=wish.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await leave_wish_service.delete(wish.id)
|
||||
|
||||
assert await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY) is None
|
||||
assert await planned_leave_repo.get_by_id(row.id) is None
|
||||
|
||||
# A fresh wish covering the same date must be able to place a day
|
||||
# there — nothing left invisibly pinning the slot.
|
||||
new_wish = await leave_wish_repo.create(_make_wish(seeded_ids))
|
||||
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result.apply_result.accepted is True, result.apply_result.rejections
|
||||
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
placed = next((r for r in rows if r.date == _DAY), None)
|
||||
assert placed is not None, "the freed date must be placeable again by a later sync"
|
||||
assert placed.wish_id == new_wish.id
|
||||
|
||||
async def test_delete_leaves_a_none_exclusion_untouched(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""An exclusion (`day_type='none'`) is a standing "never place leave
|
||||
|
||||
here" instruction about the DATE, not about one period — it must
|
||||
outlive the wish it happens to share a key with (matching the
|
||||
restructure notes' already-accepted "exclusions outlive the wish
|
||||
they were made against" behaviour). No current write path actually
|
||||
produces a `'none'` assertion with a `wish_id` set (a
|
||||
`planned_leave_day` row's `day_type` CHECK constraint never allows
|
||||
`'none'`, so 'attach'/'pin' can never assert one) — this proves the
|
||||
defensive exception in `LeaveWishService._clear_assertions_for`
|
||||
anyway, in case a future caller does.
|
||||
"""
|
||||
ws, child, parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
wish = await leave_wish_repo.create(_make_wish(seeded_ids))
|
||||
row = await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="vacation",
|
||||
wish_id=wish.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await day_assertion_repo.create(
|
||||
DayAssertion(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=_DAY,
|
||||
day_type="none",
|
||||
wish_id=wish.id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await leave_wish_service.delete(wish.id)
|
||||
|
||||
assert await planned_leave_repo.get_by_id(row.id) is None
|
||||
survivor = await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY)
|
||||
assert survivor is not None
|
||||
assert survivor.day_type == "none"
|
||||
@@ -16,8 +16,8 @@ Two decisions are asserted here, both explained at length on
|
||||
A day the user removed is not a day the plan failed to fund, so
|
||||
`shortfall_days` must not grow (which would make the new shortfall
|
||||
remedies advise freeing resources for a day nobody wants, and would make
|
||||
`sync_plan`'s eviction preview treat a deliberate removal as a lost
|
||||
priority race).
|
||||
`sync_plan` report a deliberate removal as a genuinely evicted/displaced
|
||||
row instead of an ordinary self-caused one).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
@@ -129,8 +129,8 @@ class TestExcludedDayIsNeverPlaced:
|
||||
|
||||
The user removed it; the plan did not fail to fund it. If this
|
||||
counted as a gap, `_build_remedies` would start proposing ways to
|
||||
free up resources for a day nobody wants, and `sync_plan`'s eviction
|
||||
preview would block the sync that carries out the removal.
|
||||
free up resources for a day nobody wants, and `sync_plan` would
|
||||
misreport the removal as a genuinely evicted/displaced row.
|
||||
"""
|
||||
_seed, parent, child = _solo_scenario()
|
||||
alloc_input, _p, _c = _solo_scenario(
|
||||
|
||||
@@ -14,6 +14,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from app.entities.child import Child
|
||||
from app.entities.day_assertion import DayAssertion
|
||||
from app.entities.day_override import DayOverride
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
||||
@@ -23,6 +24,7 @@ from app.entities.preschool_planning_days import PreschoolPlanningDays
|
||||
from app.entities.vacation_days import VacationDays
|
||||
from app.entities.workspace import Workspace
|
||||
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
||||
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
|
||||
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
||||
@@ -46,6 +48,7 @@ def _make_loader(
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository | None = None,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository | None = None,
|
||||
) -> AllocationInputLoader:
|
||||
return AllocationInputLoader(
|
||||
workspace_repo=workspace_repo,
|
||||
@@ -56,6 +59,7 @@ def _make_loader(
|
||||
planned_leave_repo=planned_leave_repo,
|
||||
leave_wish_repo=leave_wish_repo,
|
||||
preschool_planning_days_repo=preschool_planning_days_repo,
|
||||
day_assertion_repo=day_assertion_repo,
|
||||
)
|
||||
|
||||
|
||||
@@ -247,6 +251,7 @@ class TestAllocationInputLoader:
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
day_assertion_repo: SQLiteDayAssertionRepository,
|
||||
) -> None:
|
||||
"""§fix (parent-reassignment moves dates, decision B) — `sync_plan`
|
||||
|
||||
@@ -307,7 +312,19 @@ class TestAllocationInputLoader:
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=date(2027, 10, 1),
|
||||
day_type="vacation",
|
||||
is_pinned=True,
|
||||
)
|
||||
)
|
||||
# Restructure step 3b: "pinned" is a `DayAssertion` fact now.
|
||||
await day_assertion_repo.create(
|
||||
DayAssertion(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
child_id=child.id,
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=pinned.date,
|
||||
day_type="vacation",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
unpinned_wish_row = await planned_leave_repo.create(
|
||||
@@ -319,7 +336,6 @@ class TestAllocationInputLoader:
|
||||
date=date(2027, 10, 4),
|
||||
day_type="vacation",
|
||||
wish_id=wish.id,
|
||||
is_pinned=False,
|
||||
)
|
||||
)
|
||||
ad_hoc = await planned_leave_repo.create(
|
||||
@@ -331,7 +347,6 @@ class TestAllocationInputLoader:
|
||||
date=date(2027, 10, 5),
|
||||
day_type="vacation",
|
||||
wish_id=None,
|
||||
is_pinned=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -343,6 +358,7 @@ class TestAllocationInputLoader:
|
||||
vacation_repo,
|
||||
planned_leave_repo,
|
||||
leave_wish_repo,
|
||||
day_assertion_repo=day_assertion_repo,
|
||||
)
|
||||
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1), only_pinned_existing=True)
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ def _build_input(
|
||||
day_overrides: dict[date, bool] | None = None,
|
||||
age4_savable_cap: int | None = None,
|
||||
take_pl_on_red_days: bool = False,
|
||||
pinned_days: frozenset[tuple[UUID, UUID, date]] | None = None,
|
||||
excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None,
|
||||
preschool_reserve_by_year: dict[tuple[UUID, int], int] | None = None,
|
||||
vacation_forfeiting: dict[tuple[UUID, int], int] | None = None,
|
||||
@@ -207,6 +208,7 @@ def _build_input(
|
||||
day_overrides=day_overrides or {},
|
||||
take_pl_on_red_days=take_pl_on_red_days,
|
||||
all_existing_planned=all_existing_planned,
|
||||
pinned_days=pinned_days or frozenset(),
|
||||
excluded_days=excluded_days or frozenset(),
|
||||
preschool_reserve_by_year=preschool_reserve_by_year or {},
|
||||
vacation_forfeiting=vacation_forfeiting or {},
|
||||
@@ -220,7 +222,6 @@ def _planned(
|
||||
on: date,
|
||||
day_type: str = "vacation",
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool = False,
|
||||
) -> PlannedLeaveDay:
|
||||
"""A persisted `planned_leave_day` row — the "already applied, before
|
||||
|
||||
@@ -228,12 +229,13 @@ def _planned(
|
||||
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`, `is_pinned`) 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. `is_pinned=True` marks a date
|
||||
the user locked in place — the Step 3 realignment escape hatch must
|
||||
never move a window that contains one.
|
||||
`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. "Pinned" is no longer a field on
|
||||
this entity (restructure step 3b — see `AllocationInput.pinned_days`);
|
||||
a caller that needs a date to be immovable passes it via
|
||||
`_build_input(pinned_days=...)` instead.
|
||||
"""
|
||||
return PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
@@ -243,7 +245,6 @@ def _planned(
|
||||
date=on,
|
||||
day_type=day_type,
|
||||
wish_id=wish_id,
|
||||
is_pinned=is_pinned,
|
||||
)
|
||||
|
||||
|
||||
@@ -1571,6 +1572,56 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides:
|
||||
assert not any(d.wish_id == flexible.id for d in proposal.days)
|
||||
|
||||
|
||||
class TestFlexibleWishBoundEntirelyInThePast:
|
||||
"""§silent-failure gap — narrowing a flexible wish's bound to months that
|
||||
|
||||
have since elapsed used to make `_expand_flexible_wish_years` drop the
|
||||
wish-year entirely: no `WindowOutcome`, no shortfall, no 'unplaced'
|
||||
signal. Unlike the horizon-future case (which will come back into range
|
||||
on a later re-run) or a partially-past bound (already clipped and
|
||||
reported as an ordinary shortfall), a bound that has fully elapsed can
|
||||
never be revisited -- it must surface as an explicit 'unplaced' outcome
|
||||
instead of silently vanishing from every future sync.
|
||||
"""
|
||||
|
||||
def test_fully_past_bound_reported_as_unplaced_not_dropped(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
# VAR_2027 = ((3, 1), (5, 31)) -- fully elapsed by the `today` below.
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Flex",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 6, 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(50, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
# The wish must not vanish: exactly one outcome, reporting the wish
|
||||
# as unplaced with its original (unclipped) bound.
|
||||
assert len(proposal.outcomes) == 1
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.wish_id == wish.id
|
||||
assert outcome.strategy == STRATEGY_UNPLACED
|
||||
assert outcome.weeks_funded == 0.0
|
||||
assert outcome.shortfall_days == 7
|
||||
assert outcome.start_date == date(2027, 3, 1)
|
||||
assert outcome.end_date == date(2027, 5, 31)
|
||||
assert not any(d.wish_id == wish.id for d in proposal.days)
|
||||
|
||||
|
||||
class TestCase21FlexibleYearlyWishTracksMovingEaster:
|
||||
def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
@@ -2014,6 +2065,7 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
|
||||
child: ChildFacts,
|
||||
parent: ParentFacts,
|
||||
existing: list[PlannedLeaveDay],
|
||||
pinned_days: frozenset[tuple[UUID, UUID, date]] | None = None,
|
||||
) -> AllocationInput:
|
||||
return _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
@@ -2024,6 +2076,7 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
pinned_days=pinned_days,
|
||||
)
|
||||
|
||||
def test_a_misaligned_year_releases_onto_a_monday(self) -> None:
|
||||
@@ -2074,11 +2127,12 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
|
||||
child_id=child.id,
|
||||
on=date(2027, 8, d),
|
||||
wish_id=wish.id,
|
||||
is_pinned=(d == 3), # the user locked the first day in place
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing)
|
||||
# The user locked the first day in place.
|
||||
pinned_days = frozenset({(parent.uuid, child.id, date(2027, 8, 3))})
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing, pinned_days=pinned_days)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
@@ -2138,7 +2192,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=pinned_date,
|
||||
day_type="sjukpenning",
|
||||
is_pinned=True,
|
||||
)
|
||||
# A wish covering exactly this one date -- if the pinned day were
|
||||
# NOT fixed, this monthly-salaried parent's tier-3 policy (vacation
|
||||
@@ -2160,6 +2213,7 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=[pinned],
|
||||
pinned_days=frozenset({(parent.uuid, child.id, pinned_date)}),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
@@ -2192,7 +2246,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=pinned_date,
|
||||
day_type="vacation",
|
||||
is_pinned=True,
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
@@ -2203,6 +2256,7 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[],
|
||||
existing_planned=[pinned],
|
||||
pinned_days=frozenset({(parent.uuid, child.id, pinned_date)}),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
@@ -2215,7 +2269,9 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dubbeldagar count cap (settings.rules.max_dubbeldagar) reached mid-window
|
||||
# -> the allocator falls back to a parent swap for the leftover days
|
||||
# (allocation_service.py:979, `if used >= _MAX_DUBBELDAGAR`).
|
||||
# (`_fund_both`'s `used < max_dubbeldagar` check, reading
|
||||
# `settings.rules.max_dubbeldagar` live -- see allocation_service.py; there
|
||||
# is no `_MAX_DUBBELDAGAR` module constant any more).
|
||||
#
|
||||
# TestCase10DubbeldagOutsideWindow above covers the *15-month window* edge
|
||||
# (every day outside the window falls back to swap); TestDubbeldagSuccess in
|
||||
|
||||
@@ -340,6 +340,129 @@ class TestValidateDubbeldagar:
|
||||
with pytest.raises(DubbeldagError):
|
||||
await service.validate_dubbeldagar(child.id, within_window)
|
||||
|
||||
async def test_allocator_and_validator_agree_when_cap_changes_at_runtime(
|
||||
self,
|
||||
child_repo: SQLiteChildRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: `allocation_service` used to snapshot
|
||||
|
||||
`settings.rules.max_dubbeldagar` into a module-level constant at
|
||||
import time, while `AnalyticsService.validate_dubbeldagar` always
|
||||
read the setting live. If the setting changed after import (e.g. a
|
||||
rule-year config reload) the two could disagree: the allocator would
|
||||
keep proposing dubbeldagar past a newly-lowered cap that the
|
||||
validator would then reject. Both must now read the SAME live
|
||||
value.
|
||||
|
||||
Lowers the cap to 2 at runtime -- well after `allocation_service`
|
||||
was imported, so a stale snapshot would still show the higher
|
||||
default -- and proves the allocator stops proposing dubbeldagar at
|
||||
exactly the new cap, and that the validator independently agrees
|
||||
the quota is exhausted at that same count.
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.workspace import Workspace
|
||||
from app.services.allocation_service import AllocationInputLoader, Allocator
|
||||
from app.utils.errors import DubbeldagError
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "rules", settings.rules.model_copy(update={"max_dubbeldagar": 2})
|
||||
)
|
||||
|
||||
now = datetime.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)
|
||||
)
|
||||
parent_b = await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now)
|
||||
)
|
||||
birth = date(2027, 1, 1)
|
||||
child = _make_child(ws.id, birth)
|
||||
await child_repo.create(child)
|
||||
|
||||
# Huge untouched pools so the FP pool itself never becomes the
|
||||
# limiting factor -- only the (lowered) dubbeldagar quota should bind.
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(parent_a.uuid, ws.id, child.id, total=300, used=0)
|
||||
)
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(parent_b.uuid, ws.id, child.id, total=300, used=0)
|
||||
)
|
||||
|
||||
await leave_wish_repo.create(
|
||||
LeaveWish(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
child_id=child.id,
|
||||
name="Test",
|
||||
window_start_month=2,
|
||||
window_start_day=1,
|
||||
window_end_month=2,
|
||||
window_end_day=28, # clean month, 20 spendable weekdays -- well over the cap
|
||||
target_weeks=4,
|
||||
first_year=2027,
|
||||
priority=1,
|
||||
parent_mode="both",
|
||||
parent_personal_data_uuid=None,
|
||||
wants_dubbeldag=True,
|
||||
)
|
||||
)
|
||||
|
||||
loader = AllocationInputLoader(
|
||||
workspace_repo=workspace_repo,
|
||||
child_repo=child_repo,
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=parental_leave_repo,
|
||||
vacation_repo=vacation_repo,
|
||||
planned_leave_repo=planned_leave_repo,
|
||||
leave_wish_repo=leave_wish_repo,
|
||||
)
|
||||
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
dubbel_dates = {d.date for d in proposal.days if d.is_dubbeldag}
|
||||
# Allocator must honour the LOWERED live cap, not a higher
|
||||
# import-time snapshot -- this is the headline regression.
|
||||
assert len(dubbel_dates) == 2
|
||||
|
||||
# Persist exactly what the allocator proposed, then ask the
|
||||
# validator (which always reads the setting live) whether one MORE
|
||||
# dubbeldag is still allowed -- it must agree the quota is exhausted
|
||||
# at the same count the allocator stopped at.
|
||||
for d in dubbel_dates:
|
||||
await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
child_id=child.id,
|
||||
parent_personal_data_uuid=parent_a.uuid,
|
||||
date=d,
|
||||
day_type="sjukpenning",
|
||||
is_dubbeldag=True,
|
||||
)
|
||||
)
|
||||
|
||||
service = await _make_service(
|
||||
child_repo,
|
||||
parental_leave_repo,
|
||||
planned_leave_repo,
|
||||
personal_data_repo,
|
||||
workspace_repo,
|
||||
vacation_repo,
|
||||
)
|
||||
with pytest.raises(DubbeldagError):
|
||||
await service.validate_dubbeldagar(child.id, date(2027, 2, 27))
|
||||
|
||||
|
||||
class TestGetOverview:
|
||||
async def test_overview_single_parent(
|
||||
|
||||
Reference in New Issue
Block a user