1a02bdcdbf
Settings only offered vacation-day rows from the current year forward, while analytics ran its balance series from the child's birth year. Every year in the gap had no vacation_days row, so the series assumed a full statutory 25 earned with no use and banked earned - floor of it forward — phantom saved days the user had no row to correct. Settings now offers editable rows back to the birth year, and a past year with no row assumes only the floor was earned, so it banks nothing. A past year the user did enter is untouched by that rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1363 lines
53 KiB
Python
1363 lines
53 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`.
|
|
|
|
Every case here passes `current_year=years[0]` so its whole series is
|
|
"current year or later" — these tests exercise the FIFO mechanics
|
|
themselves, not the past-vs-current-year `earned` rule, which
|
|
`TestPastYearEarnedRule` below covers on its own.
|
|
"""
|
|
|
|
def test_no_rows_assumes_statutory_default_and_floor(self) -> None:
|
|
years = [2025, 2026, 2027]
|
|
result = AnalyticsService._vacation_year_series(
|
|
years, {}, {}, floor=20, current_year=years[0]
|
|
)
|
|
|
|
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, current_year=years[0]
|
|
)
|
|
|
|
# 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, current_year=years[0]
|
|
)
|
|
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, current_year=years[0]
|
|
)
|
|
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, current_year=years[0]
|
|
)
|
|
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, current_year=years[0]
|
|
)
|
|
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, current_year=years[0]
|
|
)
|
|
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, current_year=years[0]
|
|
)
|
|
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 TestPastYearEarnedRule:
|
|
"""`_vacation_year_series`'s `current_year` parameter — the fix for
|
|
|
|
Settings now offering editable rows back to the child's birth year
|
|
(`computeVacationYearRange`). Before this, a year before `current_year`
|
|
with no `vacation_days` row was indistinguishable from a future year:
|
|
it assumed the full statutory 25 and banked `25 - floor` forward even
|
|
though the row was never rendered for the user to correct — phantom
|
|
"saved" vacation. See `ParentYearBalance`'s docstring.
|
|
"""
|
|
|
|
def test_phantom_past_years_with_no_row_carry_nothing_forward(self) -> None:
|
|
years = [2020, 2021, 2022]
|
|
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20, current_year=2023)
|
|
by_year = {r.year: r for r in result}
|
|
|
|
for year in years:
|
|
assert by_year[year].earned == 20 # floor, not the statutory 25
|
|
assert by_year[year].is_assumed is True
|
|
assert by_year[year].carried_in == 0
|
|
assert by_year[year].carried_out == 0
|
|
assert [r.carried_in for r in result] == [0, 0, 0]
|
|
|
|
def test_entered_past_row_still_carries_its_surplus_forward(self) -> None:
|
|
"""A past year the user genuinely entered data for is untouched by
|
|
|
|
the phantom-year rule above: its `total_days` still earns normally,
|
|
and any real surplus it banks still carries into the next (also
|
|
past) year — only a phantom (no-row) year banks nothing, never an
|
|
entered one.
|
|
"""
|
|
from app.entities.vacation_days import VacationDays
|
|
|
|
years = [2020, 2021]
|
|
rows = {
|
|
2020: VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=uuid4(),
|
|
workspace_id=uuid4(),
|
|
year=2020,
|
|
total_days=30,
|
|
saved_days_in=0,
|
|
),
|
|
}
|
|
result = AnalyticsService._vacation_year_series(
|
|
years, rows, {}, floor=20, current_year=2023
|
|
)
|
|
by_year = {r.year: r for r in result}
|
|
|
|
assert by_year[2020].earned == 30 # the entered row, not the floor
|
|
assert by_year[2020].is_assumed is False
|
|
assert by_year[2020].carried_out == 10 # 30 - floor(20), genuinely saved
|
|
|
|
# 2021 has no row and is itself a past year, but 2020's REAL surplus
|
|
# must still show up here, untouched.
|
|
assert by_year[2021].earned == 20 # floor — 2021 itself has no row
|
|
assert by_year[2021].carried_in == 10
|
|
assert by_year[2021].carried_out == 10
|
|
|
|
|
|
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
|