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
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""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"
|