"""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"