Let a day removed from a period stay removed
Taking one day out of a period the planner placed did nothing that lasted: the period still asked for a full week, so the next sync put the day straight back. The option was disabled rather than lying about it. A removed day is now recorded as its own thing — no leave for this parent, this child, this date — and the planner honours it on every later sync. It is kept apart from the working-day overrides, which answer a different question about the calendar rather than about the plan. The day belongs to the parent whose band was clicked, so removing one parent's day leaves their partner's alone; where both parents were to be off on one funding arrangement, the partner is simply funded on their own instead. A removed day shortens what the period is asking for rather than counting as a day the plan failed to find, so the advice about closing a shortfall does not start arguing for a day nobody wants. Nothing is removed without a way back: the toast undoes it, and the day itself is marked in the calendar and offers to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
@@ -27,6 +27,9 @@ from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverri
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
||||
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
||||
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
|
||||
SQLitePlannedDayExclusionRepository,
|
||||
)
|
||||
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
||||
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
|
||||
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
|
||||
@@ -44,6 +47,7 @@ from app.services.auth_service import AuthService
|
||||
from app.services.child_service import ChildService
|
||||
from app.services.day_override_service import DayOverrideService
|
||||
from app.services.leave_wish_service import LeaveWishService
|
||||
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
|
||||
from app.services.planning_service import PlanningService
|
||||
from app.services.tips_service import TipsService
|
||||
from app.services.vacation_days_service import VacationDaysService
|
||||
@@ -299,6 +303,26 @@ async def day_override_service(
|
||||
return DayOverrideService(day_override_repo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlannedDayExclusion (user-removed days — see app/entities/planned_day_exclusion.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def planned_day_exclusion_repo(
|
||||
connection: aiosqlite.Connection,
|
||||
) -> SQLitePlannedDayExclusionRepository:
|
||||
return SQLitePlannedDayExclusionRepository(connection)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def planned_day_exclusion_service(
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
) -> PlannedDayExclusionService:
|
||||
return PlannedDayExclusionService(planned_day_exclusion_repo, planned_leave_repo)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def child_service(
|
||||
child_repo: SQLiteChildRepository,
|
||||
@@ -329,6 +353,7 @@ async def allocation_input_loader(
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
day_override_repo: SQLiteDayOverrideRepository,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
) -> AllocationInputLoader:
|
||||
return AllocationInputLoader(
|
||||
workspace_repo=workspace_repo,
|
||||
@@ -339,6 +364,7 @@ async def allocation_input_loader(
|
||||
planned_leave_repo=planned_leave_repo,
|
||||
leave_wish_repo=leave_wish_repo,
|
||||
day_override_repo=day_override_repo,
|
||||
exclusion_repo=planned_day_exclusion_repo,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Tests for the PlannedDayExclusion repository, service and router.
|
||||
|
||||
The core promise, in the user's words: "removing a single day should stay
|
||||
permanent. Even if it's an auto added period day." `TestExclusionSurvivesSync`
|
||||
is that promise — a day removed from a planner-placed period must still be
|
||||
gone after the next `POST /api/plan/sync`, which is precisely what re-derives
|
||||
it today.
|
||||
|
||||
Follows the DayOverride reference pattern (arrange an entity, exercise the
|
||||
repo/service, assert on the result).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.planned_day_exclusion import PlannedDayExclusion
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
|
||||
SQLitePlannedDayExclusionRepository,
|
||||
)
|
||||
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
||||
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
|
||||
from app.services.planning_service import PlanningService
|
||||
from app.utils.errors import NotFoundError
|
||||
from tests.conftest import SeededIds
|
||||
|
||||
_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below
|
||||
|
||||
|
||||
def _make(seeded: SeededIds, **overrides) -> PlannedDayExclusion:
|
||||
base = dict(
|
||||
id=uuid4(),
|
||||
workspace_id=seeded.workspace_id,
|
||||
child_id=seeded.child_id,
|
||||
parent_personal_data_uuid=seeded.personal_data_uuid,
|
||||
date=_DAY,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
base.update(overrides)
|
||||
return PlannedDayExclusion(**base)
|
||||
|
||||
|
||||
def _make_wish(seeded: SeededIds) -> LeaveWish:
|
||||
"""A fixed, one-week February 2027 window, funded by the seeded parent."""
|
||||
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 TestSQLitePlannedDayExclusionRepository:
|
||||
async def test_create_and_get_by_id(
|
||||
self,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(seeded_ids)
|
||||
await planned_day_exclusion_repo.create(entity)
|
||||
|
||||
fetched = await planned_day_exclusion_repo.get_by_id(entity.id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == entity.id
|
||||
assert fetched.date == _DAY
|
||||
assert fetched.child_id == seeded_ids.child_id
|
||||
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
||||
|
||||
async def test_get_by_natural_key(
|
||||
self,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(seeded_ids)
|
||||
await planned_day_exclusion_repo.create(entity)
|
||||
|
||||
found = await planned_day_exclusion_repo.get_by_natural_key(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
assert found is not None
|
||||
assert found.id == entity.id
|
||||
|
||||
# The parent IS part of the key — a different parent is a different row.
|
||||
other_parent = await planned_day_exclusion_repo.get_by_natural_key(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY
|
||||
)
|
||||
assert other_parent is None
|
||||
|
||||
other_day = await planned_day_exclusion_repo.get_by_natural_key(
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
date(2027, 2, 4),
|
||||
)
|
||||
assert other_day is None
|
||||
|
||||
async def test_update_and_delete(
|
||||
self,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(seeded_ids)
|
||||
await planned_day_exclusion_repo.create(entity)
|
||||
|
||||
entity.date = date(2027, 2, 5)
|
||||
updated = await planned_day_exclusion_repo.update(entity.id, entity)
|
||||
assert updated.date == date(2027, 2, 5)
|
||||
|
||||
await planned_day_exclusion_repo.delete(entity.id)
|
||||
assert await planned_day_exclusion_repo.get_by_id(entity.id) is None
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await planned_day_exclusion_repo.delete(entity.id)
|
||||
with pytest.raises(NotFoundError):
|
||||
await planned_day_exclusion_repo.update(entity.id, entity)
|
||||
|
||||
async def test_list_by_workspace_is_date_ordered(
|
||||
self,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 5)))
|
||||
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 1)))
|
||||
|
||||
rows = await planned_day_exclusion_repo.list_by_workspace(seeded_ids.workspace_id)
|
||||
assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)]
|
||||
|
||||
assert await planned_day_exclusion_repo.list_by_workspace(uuid4()) == []
|
||||
|
||||
async def test_unsupported_filter_is_rejected(
|
||||
self,
|
||||
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
await planned_day_exclusion_repo.list(nonsense=1)
|
||||
|
||||
|
||||
class TestPlannedDayExclusionService:
|
||||
async def test_exclude_is_idempotent(
|
||||
self,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
first = await planned_day_exclusion_service.exclude(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
second = await planned_day_exclusion_service.exclude(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
assert second.id == first.id
|
||||
rows = await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id)
|
||||
assert len(rows) == 1
|
||||
|
||||
async def test_exclude_removes_the_planned_day_immediately(
|
||||
self,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=seeded_ids.child_id,
|
||||
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
date=_DAY,
|
||||
day_type="sjukpenning",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await planned_day_exclusion_service.exclude(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
|
||||
rows = await planned_leave_repo.list_by_workspace_child(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id
|
||||
)
|
||||
assert [r for r in rows if r.date == _DAY] == []
|
||||
|
||||
async def test_get_and_delete_are_workspace_scoped(
|
||||
self,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
created = await planned_day_exclusion_service.exclude(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await planned_day_exclusion_service.get(created.id, workspace_id=uuid4())
|
||||
with pytest.raises(NotFoundError):
|
||||
await planned_day_exclusion_service.delete(created.id, workspace_id=uuid4())
|
||||
|
||||
await planned_day_exclusion_service.delete(created.id, workspace_id=seeded_ids.workspace_id)
|
||||
assert await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) == []
|
||||
|
||||
async def test_excluded_keys_shape_matches_the_allocator_input(
|
||||
self,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
await planned_day_exclusion_service.exclude(
|
||||
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
||||
)
|
||||
keys = await planned_day_exclusion_service.get_excluded_keys(seeded_ids.workspace_id)
|
||||
assert keys == frozenset({(seeded_ids.personal_data_uuid, seeded_ids.child_id, _DAY)})
|
||||
|
||||
|
||||
class TestExclusionSurvivesSync:
|
||||
"""The core promise: a removed day stays removed across re-syncs."""
|
||||
|
||||
async def test_removed_day_is_not_re_derived_by_the_next_sync(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
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"
|
||||
|
||||
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ...and a third sync must not quietly bring it back either.
|
||||
third = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert third.apply_result.accepted is True
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert _DAY not in {r.date for r in rows}
|
||||
|
||||
async def test_removal_does_not_report_a_shortfall(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""A day the user removed is not a day the plan failed to fund.
|
||||
|
||||
If it were counted as a gap, `sync_plan`'s eviction preview could
|
||||
refuse the very sync that carries out the removal.
|
||||
"""
|
||||
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))
|
||||
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
||||
|
||||
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
assert result.apply_result.accepted is True
|
||||
assert result.eviction_preview is not None
|
||||
assert result.eviction_preview.would_evict is False
|
||||
assert [o.shortfall_days for o in result.proposal.outcomes] == [0]
|
||||
|
||||
async def test_un_excluding_puts_the_day_back(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_day_exclusion_service: PlannedDayExclusionService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""There must be a way back — this is what the undo toast calls."""
|
||||
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))
|
||||
created = await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
||||
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
await planned_day_exclusion_service.delete(created.id, workspace_id=ws)
|
||||
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 TestPlannedDayExclusionRouter:
|
||||
async def test_create_list_delete_round_trip(
|
||||
self, authed_client: AsyncClient, seeded_ids: SeededIds
|
||||
) -> None:
|
||||
payload = {
|
||||
"workspace_id": str(seeded_ids.workspace_id),
|
||||
"child_id": str(seeded_ids.child_id),
|
||||
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
||||
"date": _DAY.isoformat(),
|
||||
}
|
||||
created = await authed_client.post("/api/planned-day-exclusions", json=payload)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["date"] == _DAY.isoformat()
|
||||
|
||||
listed = await authed_client.get(
|
||||
f"/api/planned-day-exclusions/workspace/{seeded_ids.workspace_id}"
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert [r["id"] for r in listed.json()] == [body["id"]]
|
||||
|
||||
deleted = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
|
||||
assert deleted.status_code == 204
|
||||
|
||||
missing = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
|
||||
assert missing.status_code == 404
|
||||
|
||||
async def test_a_foreign_workspace_is_rejected(
|
||||
self, authed_client: AsyncClient, seeded_ids: SeededIds
|
||||
) -> None:
|
||||
response = await authed_client.post(
|
||||
"/api/planned-day-exclusions",
|
||||
json={
|
||||
"workspace_id": str(uuid4()),
|
||||
"child_id": str(seeded_ids.child_id),
|
||||
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
||||
"date": _DAY.isoformat(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None:
|
||||
response = await authed_client.get(f"/api/planned-day-exclusions/workspace/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,270 @@
|
||||
"""The allocator must never re-place a day the user removed by hand.
|
||||
|
||||
User's requirement, verbatim: "removing a single day should stay permanent.
|
||||
Even if it's an auto added period day." The wish still says "a full week", so
|
||||
without `AllocationInput.excluded_days` the next `POST /api/plan/sync` simply
|
||||
re-derives the removed date — which is exactly what these tests pin down.
|
||||
|
||||
Two decisions are asserted here, both explained at length on
|
||||
`_Ledger._excluded_window_dates`:
|
||||
|
||||
1. **The parent is part of the key.** A both-parents window places one day
|
||||
per parent on the same date; removing one parent's day leaves the
|
||||
partner's standing. A date only leaves the window entirely when no
|
||||
responsible parent may take it.
|
||||
2. **An excluded date shrinks the window's target; it is not a shortfall.**
|
||||
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).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.allocation_service import Allocator, DayPool, VacationBudget
|
||||
from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish
|
||||
|
||||
# Feb 2027 is the suite's clean "no red-day interference" month; Feb 1-7 2027
|
||||
# is Mon-Sun, so 5 spendable days + Sat 6 + Sun 7.
|
||||
_WINDOW_START = (2, 1)
|
||||
_WINDOW_END = (2, 7)
|
||||
_REMOVED = date(2027, 2, 3) # a Wednesday in the middle of the window
|
||||
|
||||
|
||||
def _solo_scenario(*, excluded: frozenset | None = None, reuse=None):
|
||||
"""A one-parent, one-week fixed window.
|
||||
|
||||
`reuse` takes a `(child, parent)` pair from an earlier call so a test can
|
||||
build two inputs that are identical down to the uuids — needed both for a
|
||||
before/after comparison and for the determinism check, since `_Ledger`
|
||||
mutates its copies of the pool/vacation dicts in place and every
|
||||
`propose()` run therefore needs its own fresh input.
|
||||
"""
|
||||
if reuse is not None:
|
||||
child, parent = reuse
|
||||
else:
|
||||
child, parent = _child(birth_date=date(2025, 6, 1)), _parent(name="Solo")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Sportlov",
|
||||
start=_WINDOW_START,
|
||||
end=_WINDOW_END,
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
parent_mode="specific",
|
||||
parent_uuid=parent.uuid,
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
excluded_days=excluded,
|
||||
)
|
||||
return alloc_input, parent, child
|
||||
|
||||
|
||||
def _both_scenario(*, excluded: frozenset | None = None, reuse=None):
|
||||
"""A both-parents ("co-leave") one-week fixed window; `reuse` as above."""
|
||||
if reuse is not None:
|
||||
child, parent_a, parent_b = reuse
|
||||
else:
|
||||
child = _child(birth_date=date(2025, 6, 1))
|
||||
parent_a = _parent(name="A")
|
||||
parent_b = _parent(name="B")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Tillsammans",
|
||||
start=_WINDOW_START,
|
||||
end=_WINDOW_END,
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
parent_mode="both",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent_a, parent_b],
|
||||
pools={
|
||||
(parent_a.uuid, child.id): DayPool(200, 90, 60),
|
||||
(parent_b.uuid, child.id): DayPool(200, 90, 60),
|
||||
},
|
||||
vacation={
|
||||
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
|
||||
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
|
||||
},
|
||||
wishes=[wish],
|
||||
excluded_days=excluded,
|
||||
)
|
||||
return alloc_input, parent_a, parent_b, child
|
||||
|
||||
|
||||
class TestExcludedDayIsNeverPlaced:
|
||||
def test_solo_window_skips_the_removed_date(self) -> None:
|
||||
baseline_input, parent, child = _solo_scenario()
|
||||
baseline = Allocator().propose(baseline_input)
|
||||
assert _REMOVED in {d.date for d in baseline.days if d.day_type != "free"}
|
||||
|
||||
alloc_input, parent, child = _solo_scenario(
|
||||
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
assert _REMOVED not in {d.date for d in proposal.days}
|
||||
# The rest of the window is untouched.
|
||||
assert {d.date for d in proposal.days if d.day_type != "free"} == {
|
||||
date(2027, 2, 1),
|
||||
date(2027, 2, 2),
|
||||
date(2027, 2, 4),
|
||||
date(2027, 2, 5),
|
||||
}
|
||||
|
||||
def test_removed_date_is_not_reported_as_a_shortfall(self) -> None:
|
||||
"""Decision: an excluded date reduces the window's target.
|
||||
|
||||
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.
|
||||
"""
|
||||
_seed, parent, child = _solo_scenario()
|
||||
alloc_input, _p, _c = _solo_scenario(
|
||||
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
assert outcome.remedies == []
|
||||
# `weeks_funded` still tells the truth about what was actually
|
||||
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
|
||||
assert outcome.weeks_funded == round(6 / 7, 2)
|
||||
|
||||
def test_an_unrelated_parents_exclusion_does_not_touch_this_window(self) -> None:
|
||||
_seed, parent, child = _solo_scenario()
|
||||
alloc_input, _p, _c = _solo_scenario(
|
||||
excluded=frozenset({(_parent(name="Stranger").uuid, child.id, _REMOVED)}),
|
||||
reuse=(child, parent),
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
assert _REMOVED in {d.date for d in proposal.days if d.day_type != "free"}
|
||||
assert proposal.outcomes[0].shortfall_days == 0
|
||||
|
||||
|
||||
class TestExclusionIsKeyedPerParent:
|
||||
def test_both_parents_window_keeps_the_partners_day(self) -> None:
|
||||
"""The parent-keying decision, stated as a test.
|
||||
|
||||
Removing "this day" from A's band must not silently cancel B's leave
|
||||
on the same date — B never asked for that, and on a shared plan they
|
||||
would only discover it by noticing the hole.
|
||||
"""
|
||||
alloc_input, parent_a, parent_b, child = _both_scenario()
|
||||
baseline = Allocator().propose(alloc_input)
|
||||
baseline_on_date = [d for d in baseline.days if d.date == _REMOVED]
|
||||
assert {d.parent_personal_data_uuid for d in baseline_on_date} == {
|
||||
parent_a.uuid,
|
||||
parent_b.uuid,
|
||||
}
|
||||
|
||||
alloc_input, parent_a, parent_b, child = _both_scenario(
|
||||
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
|
||||
reuse=(child, parent_a, parent_b),
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
on_date = [d for d in proposal.days if d.date == _REMOVED]
|
||||
assert [d.parent_personal_data_uuid for d in on_date] == [parent_b.uuid]
|
||||
assert on_date[0].day_type != "free"
|
||||
|
||||
def test_the_date_leaves_the_window_only_when_both_parents_are_removed(self) -> None:
|
||||
_seed, parent_a, parent_b, child = _both_scenario()
|
||||
alloc_input, _a, _b, _c = _both_scenario(
|
||||
excluded=frozenset(
|
||||
{
|
||||
(parent_a.uuid, child.id, _REMOVED),
|
||||
(parent_b.uuid, child.id, _REMOVED),
|
||||
}
|
||||
),
|
||||
reuse=(child, parent_a, parent_b),
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
assert _REMOVED not in {d.date for d in proposal.days}
|
||||
assert proposal.outcomes[0].shortfall_days == 0
|
||||
|
||||
def test_one_parents_exclusion_is_not_a_shortfall_for_the_window(self) -> None:
|
||||
_seed, parent_a, parent_b, child = _both_scenario()
|
||||
alloc_input, _a, _b, _c = _both_scenario(
|
||||
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
|
||||
reuse=(child, parent_a, parent_b),
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
assert proposal.outcomes[0].shortfall_days == 0
|
||||
|
||||
|
||||
class TestSwapWindowDoesNotRefillTheHole:
|
||||
def test_the_removed_date_is_not_handed_to_the_other_parent(self) -> None:
|
||||
"""`_fund_swap`'s leftover hand-off must not undo the removal.
|
||||
|
||||
In a swap window exactly one parent is on leave per date, so a date
|
||||
one parent is removed from would otherwise fall through as "unfunded
|
||||
leftover" and be offered to the partner — the day would visibly come
|
||||
back, just in the other parent's colour.
|
||||
"""
|
||||
child = _child(birth_date=date(2025, 6, 1))
|
||||
parent_a = _parent(name="A")
|
||||
parent_b = _parent(name="B")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Sommar",
|
||||
start=_WINDOW_START,
|
||||
end=_WINDOW_END,
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
parent_mode="either",
|
||||
)
|
||||
# `parent_mode='either'` with two parents resolves to a swap window.
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent_a, parent_b],
|
||||
pools={
|
||||
(parent_a.uuid, child.id): DayPool(200, 90, 60),
|
||||
(parent_b.uuid, child.id): DayPool(200, 90, 60),
|
||||
},
|
||||
vacation={
|
||||
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
|
||||
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
|
||||
},
|
||||
wishes=[wish],
|
||||
excluded_days=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
|
||||
)
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
assert _REMOVED not in {d.date for d in proposal.days}
|
||||
assert proposal.outcomes[0].shortfall_days == 0
|
||||
|
||||
|
||||
class TestDeterminismIsPreserved:
|
||||
def test_same_input_same_output_with_exclusions(self) -> None:
|
||||
"""§5 test #11 — the purity guarantee still holds with exclusions."""
|
||||
_seed_input, parent, child = _solo_scenario()
|
||||
excluded = frozenset({(parent.uuid, child.id, _REMOVED)})
|
||||
first_input, _p, _c = _solo_scenario(excluded=excluded, reuse=(child, parent))
|
||||
second_input, _p2, _c2 = _solo_scenario(excluded=excluded, reuse=(child, parent))
|
||||
|
||||
first = Allocator().propose(first_input)
|
||||
second = Allocator().propose(second_input)
|
||||
|
||||
assert [(d.date, d.day_type, d.reason) for d in first.days] == [
|
||||
(d.date, d.day_type, d.reason) for d in second.days
|
||||
]
|
||||
@@ -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,
|
||||
excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None,
|
||||
) -> AllocationInput:
|
||||
years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years}
|
||||
if age4_savable_cap is None:
|
||||
@@ -204,6 +205,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,
|
||||
excluded_days=excluded_days or frozenset(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user