Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_analytics_service.py
T
jacobh2 4b802ba0be Give the analytics overview a per-parent, per-year balance series
Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-19 19:02:02 +02:00

1099 lines
42 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)
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)
assert first.expiring == 0
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` exactly once, in the year they lapsed.
expiring_years = [y for y, r in by_year.items() if r.expiring > 0]
assert expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring == 5
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 == 5
# 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
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