Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_analytics_service.py
T
jacobh2 782415122d 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
2026-09-21 07:20:14 +02:00

1282 lines
50 KiB
Python

"""Tests for the core calculation engine (days-left-at-age-4).
These tests cover the headline feature and must cover edge cases:
leap years, unborn child (due date), no FK data yet, full planned calendar.
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from dateutil.relativedelta import relativedelta
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
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.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.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.analytics_service import AnalyticsService
from tests.conftest import SeededIds
def _make_child(workspace_id, birth_date: date) -> Child:
return Child(
id=uuid4(),
workspace_id=workspace_id,
name="TestChild",
birth_date=birth_date,
created_at=datetime.now(),
)
def _make_fetch(
personal_data_uuid, workspace_id, child_id, total=240, used=100
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=used,
sjukpenning_left=390 - used,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=total,
days_used=used,
days_left=total - used,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=50,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
updated_at=now,
)
async def _make_service(
child_repo: SQLiteChildRepository,
leave_repo: SQLiteParentalLeaveRepository,
planned_repo: SQLitePlannedLeaveRepository,
pd_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> AnalyticsService:
return AnalyticsService(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
class TestAge4Projection:
async def test_no_planned_days_projection(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
# Use a birth date far in the future so today < B4
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=100)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.days_used == 100
assert proj.planned_days_before_b4 == 0
assert proj.projected_left_at_b4 == 140 # 240 - 100
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 44 # 140 - 96
assert proj.savable_cap_at_b4 == 96 # solo workspace -> per-child ceiling
async def test_no_fetch_data_assumes_full_entitlement(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
# No FK data entered
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
# `seeded_ids`'s workspace has exactly one member -> a sole parent's
# default entitlement is the FULL pool (480), not a partnered
# parent's half-share (240) -- see `default_tier_pool_share`.
assert proj.total_days == 480 # default entitlement
assert proj.days_used == 0
assert proj.projected_left_at_b4 == 480
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 384 # 480 - 96
async def test_leap_year_birthday(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
# Child born 2024-02-29 (leap day); 4th birthday must be 2028-02-29
birth = date(2024, 2, 29)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
# dateutil.relativedelta gives 2028-02-29 (2028 is a leap year)
assert proj.fourth_birthday == date(2028, 2, 29)
async def test_all_days_used_nothing_left(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=240)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.projected_left_at_b4 == 0
assert proj.savable_after_b4 == 0
assert proj.expiring_at_b4 == 0
async def test_child_not_found_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_days_left_at_age_4(uuid4(), uuid4())
class TestValidateDubbeldagar:
async def test_child_not_found_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.validate_dubbeldagar(uuid4(), date.today())
async def test_outside_window_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.utils.errors import DubbeldagError
ws = seeded_ids.workspace_id
birth = date(2024, 1, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
# Date beyond the 15-month window
beyond_window = birth + relativedelta(months=16)
with pytest.raises(DubbeldagError):
await service.validate_dubbeldagar(child.id, beyond_window)
async def test_quota_exceeded_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.config import settings
from app.entities.planned_leave import PlannedLeaveDay
from app.utils.errors import DubbeldagError
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2024, 1, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
# Fill up the dubbeldagar quota
for i in range(settings.rules.max_dubbeldagar):
day = birth + relativedelta(days=i)
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child.id,
parent_personal_data_uuid=pd_uuid,
date=day,
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,
)
within_window = birth + relativedelta(months=1)
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(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=80)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws, child.id)
assert overview.workspace_id == ws
assert len(overview.parent_projections) == 1
assert overview.combined_days_used == 80
assert overview.combined_days_left == 160 # 240 - 80
assert overview.combined_expiring_at_b4 == 64 # 160 - 96
async def test_overview_no_parents_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_overview(uuid4(), uuid4())
class TestCoupleWorkspacePerParentCap:
"""Headline regression: a two-parent `couple` workspace must cap each
parent's own savable balance at 48 (not 96), so the combined savable
figure across both parents tops out at 96 total — never 192. This is
the bug this task exists to fix: the age-4 ceiling is per CHILD, not
per PARENT pool.
"""
async def test_two_parent_couple_caps_48_each_96_combined(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
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.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Both parents sit on a huge untouched pool -> both should be
# clamped to the per-parent cap, never the per-child one.
fetch_a = _make_fetch(parent_a.uuid, ws.id, child.id, total=240, used=0)
fetch_b = _make_fetch(parent_b.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch_a)
await parental_leave_repo.create(fetch_b)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id)
proj_b = await service.get_days_left_at_age_4(parent_b.uuid, child.id)
assert proj_a.savable_cap_at_b4 == 48
assert proj_b.savable_cap_at_b4 == 48
assert proj_a.savable_after_b4 == 48
assert proj_b.savable_after_b4 == 48
assert proj_a.expiring_at_b4 == 192 # 240 - 48
assert proj_b.expiring_at_b4 == 192
overview = await service.get_overview(ws.id, child.id)
combined_savable = sum(p.savable_after_b4 for p in overview.parent_projections)
assert combined_savable == 96 # never 192 — this is the headline regression
assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2
async def test_single_member_workspace_falls_back_to_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
"""Safety belt: a workspace with only one member so far (partner not
added yet) must still use the per-child (96) figure — there is no
second parent to divide it with.
"""
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
fetch = _make_fetch(parent.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 144 # 240 - 96
class TestSingleParentWorkspaceCap:
"""The age-4 cap is purely a function of parent count (no separate
"planning mode" flag exists): a workspace with a single member gets the
full per-child figure (96); `TestCoupleWorkspacePerParentCap` above
covers the two-parent 48/48 split.
"""
async def test_genuine_single_parent_workspace_gets_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
class TestAgreesWithPlanProposalForTheSameCommittedState:
"""Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's
propose-time projection (`allocation_service._Ledger.build_age4_projections`,
via `Allocator`/`AllocationInputLoader`) must compute the same
`expiring_at_b4` for a parent with no FK snapshot yet and no leave wishes
(so the proposal adds nothing new) — i.e. the *committed* state.
Both surfaces derive their no-snapshot default through the same shared
`app.utils.tip_predicates.default_tier_pool_share(rules, parent_count)`
helper, so they can never disagree again: a genuinely SOLE parent (this
workspace has exactly one member, "Sam") is entitled to the WHOLE
child's combined tier totals (`rules.sjukpenning_days` +
`rules.lagsta_niva_days` == 390 + 90 == 480) since there's no partner to
split the pool with; a partnered parent would instead get their fair
half-share (195 + 45 == 240). This test pins the two surfaces together.
"""
async def test_no_snapshot_no_wishes_matches_dashboard(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Sam",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Deliberately no ParentalLeaveFetch and no leave wishes for `parent`
# -> both surfaces must fall back to default entitlement.
# -- Dashboard side --
analytics = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
# -- Plan-tab side --
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.today())
proposal = Allocator().propose(alloc_input)
assert proposal.days == [] # no wishes -> nothing new proposed
plan_proj = next(
p
for p in proposal.age4_projections
if p.parent_personal_data_uuid == parent.uuid and p.child_id == child.id
)
assert dashboard_proj.projected_left_at_b4 == plan_proj.projected_left_at_b4
assert dashboard_proj.savable_after_b4 == plan_proj.savable_after_b4
assert dashboard_proj.expiring_at_b4 == plan_proj.expiring_at_b4
# Pin the actual figures too, so a future regression of either
# fallback (240 vs. 480) fails loudly rather than merely disagreeing.
assert dashboard_proj.projected_left_at_b4 == 480
assert dashboard_proj.expiring_at_b4 == 384
async def test_vacation_left_matches_plan_proposal_in_the_birth_year(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""The birth year is the FIRST year of `year_balances`' series, so its
`carried_in` is read straight off the `vacation_days` row's
`saved_days_in` (no multi-year FIFO simulation has happened yet) —
exactly what `AllocationInputLoader.load`'s `VacationBudget` also
does. The two surfaces must therefore agree for that one year.
(A LATER year's `left` is not asserted here: analytics simulates a
FIFO carry-forward bank across the whole series, while the plan
proposal reads whatever `saved_days_in` the user entered for that
specific year directly — they only need to agree where analytics
hasn't yet layered its own simulation on top, i.e. the first year.)
"""
from app.entities.personal_data import PersonalData
from app.entities.vacation_days import VacationDays
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Sam",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=birth.year,
total_days=25,
saved_days_in=4,
)
)
analytics = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
birth_year_balance = next(
yb for yb in dashboard_proj.year_balances if yb.year == birth.year
)
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.today())
proposal = Allocator().propose(alloc_input)
plan_vacation = proposal.balances_after.vacation_after[(parent.uuid, birth.year)]
assert birth_year_balance.vacation.left == plan_vacation.available
assert birth_year_balance.vacation.left == 29 # 25 + 4 saved_days_in
class TestVacationYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._vacation_year_series` — the
FIFO carry-forward bank simulation documented on `ParentYearBalance`.
"""
def test_no_rows_assumes_statutory_default_and_floor(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
first = result[0]
assert first.earned == 25 # rules.statutory_vacation_days_per_year default
assert first.is_assumed is True
assert first.carried_in == 0
assert first.left == 25
assert first.carried_out == 5 # max(25 - floor(20), 0)
# `expiring` now reports EVERY lost day, not just age-based losses
# (the bug this predicate/service pair fixes): of the 25 unused
# days, only 5 (earned - floor) can be banked — the other 20 (the
# unused floor) are forfeited outright, with no age-expiry involved
# yet in year one.
assert first.expiring == 20
assert first.expiring_age == 0
assert first.expiring_over_cap == 20
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
# Unused years keep saving 5/year (25 earned - 20 floor) and the
# prior years' saved days carry forward untouched.
assert [r.carried_in for r in result] == [0, 5, 10]
assert [r.carried_out for r in result] == [5, 10, 15]
assert [r.left for r in result] == [25, 30, 35]
def test_saved_days_expire_after_five_years(self) -> None:
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
# Exactly carry_years + 1 years: only 2020's own batch can possibly
# have aged out by the last year in this series.
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
by_year = {r.year: r for r in result}
# 2020's 5 saved days must not still be sitting in `carried_in`
# `carry_years` years later, and must have been reported as
# `expiring_age` exactly once, in the year they lapsed. (`expiring`
# itself is nonzero every year here too, since the unused floor is
# an over-cap loss every year — isolate the age-based portion.)
age_expiring_years = [y for y, r in by_year.items() if r.expiring_age > 0]
assert age_expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring_age == 5
assert by_year[2020 + carry_years].expiring_over_cap == 20
assert by_year[2020 + carry_years].expiring == 25
def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None:
"""Once the bank has been running >= carry_years, an unbroken
surplus (earned always above floor, nothing spent) ages out exactly
one batch per year from then on — steady state, not a one-off event.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 3))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
assert by_year[year].expiring_age == 5
assert by_year[year].expiring_over_cap == 20 # unused floor, every year
assert by_year[year].expiring == 25
# Carried-in plateaus at exactly carry_years batches of 5.
assert by_year[years[-1]].carried_in == 5 * carry_years
def test_saved_days_in_override_on_later_year(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026, 2027]
# No row for 2025 (the seed year); a later year's row overrides the
# computed carry-in per `ParentYearBalance`'s docstring.
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=12,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
by_year = {r.year: r for r in result}
assert by_year[2025].carried_in == 0 # nothing entered/simulated yet
assert by_year[2026].carried_in == 12 # override wins over computed (5)
assert by_year[2026].is_assumed is False
# 2027 continues the FIFO simulation from the overridden figure.
assert by_year[2027].carried_in == 17 # 12 (override) + 5 (2026's own saveable)
def test_zero_saved_days_in_on_later_year_does_not_override(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026]
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
by_year = {r.year: r for r in result}
# 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on
# 2026's row must not wipe that computed carry-in out.
assert by_year[2026].carried_in == 5
def test_expiring_breakdown_matches_dashboard_screenshot_case(self) -> None:
"""Real dashboard bug report: earned 30, floor 20, planned_use 15 →
left 15, carried_out 10 — 5 days genuinely lost, but the OLD
age-only `expiring` showed 0. `expiring` must report every lost day,
with `expiring_over_cap` isolating the over-cap portion."""
from app.entities.vacation_days import VacationDays
years = [2025]
rows = {
2025: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2025,
total_days=30,
saved_days_in=0,
),
}
use_by_year = {2025: 15}
result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20)
balance = result[0]
assert balance.left == 15
assert balance.carried_out == 10
assert balance.expiring == 5
assert balance.expiring_age == 0
assert balance.expiring_over_cap == 5
def test_age_expiry_and_over_cap_loss_coincide_without_double_counting(self) -> None:
"""A bank entry aging out at the 5-year cap and a fresh over-cap
loss can both happen in the same year (earned 25, floor 20, never
used: every year loses the unused floor AND, from year
`carry_years` on, also ages out that year's oldest 5-day batch).
`expiring` must equal their sum exactly once, never double-counted.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
last = result[-1]
assert last.expiring_age == 5
assert last.expiring_over_cap == 20
assert last.expiring == 25
assert last.expiring == last.expiring_age + last.expiring_over_cap
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""
def test_no_snapshot_deducts_from_birth_using_default_share(self) -> None:
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
rows = [
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 6, 1),
day_type="sjukpenning",
)
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=None, default_share=(390, 90)
)
by_year = {r.year: r for r in result}
assert by_year[2025].sjukpenning_left == 390 # nothing planned yet
assert by_year[2026].sjukpenning_used == 1
assert by_year[2026].sjukpenning_left == 389 # deducted from birth (no snapshot)
def test_snapshot_mid_series_only_deducts_post_snapshot_rows(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2026, 6, 1),
total_days=480,
days_used=10,
days_left=470,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=10,
sjukpenning_left=380,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
rows = [
# Before the snapshot — already reflected in `sjukpenning_left`
# above, must NOT be deducted again.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 1, 1),
day_type="sjukpenning",
),
# After the snapshot — must be deducted.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 7, 1),
day_type="sjukpenning",
),
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
assert by_year[2026].sjukpenning_used == 2 # both rows counted for display
assert by_year[2026].sjukpenning_left == 379 # only the post-snapshot row deducted
assert by_year[2027].sjukpenning_left == 379 # unchanged, carries forward
def test_age4_clamp_cuts_lagsta_first_in_b4_year(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2025, 1, 1),
total_days=480,
days_used=0,
days_left=480,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=0,
sjukpenning_left=390,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
result = AnalyticsService._pl_year_series(
years, [], birth, b4, savable_cap=48, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
# Untouched pool (390 + 90 = 480) clamped to 48 at b4, lagsta cut first.
assert by_year[2029].lagsta_left == 0
assert by_year[2029].sjukpenning_left == 48
# Years before b4 are unaffected by the clamp.
assert by_year[2028].sjukpenning_left == 390
assert by_year[2028].lagsta_left == 90
class TestYearBalancesOnProjection:
"""`Age4Projection.year_balances` end-to-end through the real repos."""
async def test_series_spans_birth_year_through_b4_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert [yb.year for yb in proj.year_balances] == [2025, 2026, 2027, 2028, 2029]
assert all(yb.personal_data_uuid == pd_uuid for yb in proj.year_balances)
assert all(yb.child_id == child.id for yb in proj.year_balances)
async def test_vacation_planned_use_counts_rows_across_all_children(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
"""Vacation is budgeted per (parent, year), not per child — see
`PlanningService._vacation_days_left`. A vacation day planned
against a DIFFERENT child of the same parent, in the same year,
must still reduce `planned_use` here.
"""
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
other_child = _make_child(ws, date(2020, 1, 1))
await child_repo.create(other_child)
from app.entities.planned_leave import PlannedLeaveDay
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=other_child.id,
parent_personal_data_uuid=pd_uuid,
date=date(2025, 6, 1),
day_type="vacation",
)
)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
year_2025 = next(yb for yb in proj.year_balances if yb.year == 2025)
assert year_2025.vacation.planned_use == 1
class TestOverviewYearTotals:
async def test_combines_both_parents_per_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
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(2025, 1, 1)
child = _make_child(ws.id, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws.id, child.id)
assert [yt.year for yt in overview.year_totals] == [2025, 2026, 2027, 2028, 2029]
birth_year_totals = overview.year_totals[0]
parent_a_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_a.uuid
)
parent_b_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_b.uuid
)
a_2025 = next(yb for yb in parent_a_proj.year_balances if yb.year == 2025)
b_2025 = next(yb for yb in parent_b_proj.year_balances if yb.year == 2025)
expected_pl_left = (
a_2025.parental_leave.sjukpenning_left
+ a_2025.parental_leave.lagsta_left
+ b_2025.parental_leave.sjukpenning_left
+ b_2025.parental_leave.lagsta_left
)
expected_vacation_left = a_2025.vacation.left + b_2025.vacation.left
assert birth_year_totals.pl_left == expected_pl_left
assert birth_year_totals.vacation_left == expected_vacation_left