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:
2026-09-21 07:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
@@ -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(