Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
"""API-level test for `POST /api/plan/propose`.
|
||||
|
||||
This exists specifically to catch the class of bug where the pure
|
||||
`Allocator` computes a field on `PlanProposal` that the wire model
|
||||
(`app.models.plan.PlanProposalResponse`) then drops — the dataclass tests in
|
||||
`tests/unit/test_services/test_allocation_service_branches.py` cannot catch
|
||||
this, because they call `Allocator().propose()` directly and never go
|
||||
through `PlanProposalResponse.from_domain`. `surplus_advice` (§9.2) was
|
||||
exactly such a case: computed, tested against the dataclass, but absent from
|
||||
the HTTP response until this test/fix landed.
|
||||
|
||||
Uses the same in-memory sqlite `connection` fixture as the rest of the suite
|
||||
(via `tests/conftest.py`), wired into the real FastAPI app by overriding the
|
||||
`get_connection` dependency — this exercises the actual router + dependency
|
||||
graph without touching the real `foraldraledig.db` file or running Alembic.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
import pytest_asyncio
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.dependencies import get_connection
|
||||
from app.entities.child import Child
|
||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.vacation_days import VacationDays
|
||||
from app.entities.workspace import Workspace
|
||||
from app.main import app
|
||||
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.vacation_days_repository import SQLiteVacationDaysRepository
|
||||
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
|
||||
|
||||
|
||||
def _make_fetch(
|
||||
personal_data_uuid,
|
||||
workspace_id,
|
||||
child_id,
|
||||
sjukpenning_left: int = 390,
|
||||
lagsta_left: int = 90,
|
||||
) -> ParentalLeaveFetch:
|
||||
now = datetime.now()
|
||||
tier = TierData(
|
||||
sjukpenning_total=390,
|
||||
sjukpenning_used=390 - sjukpenning_left,
|
||||
sjukpenning_left=sjukpenning_left,
|
||||
lagsta_niva_total=90,
|
||||
lagsta_niva_used=90 - lagsta_left,
|
||||
lagsta_niva_left=lagsta_left,
|
||||
)
|
||||
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=480,
|
||||
days_used=480 - sjukpenning_left - lagsta_left,
|
||||
days_left=sjukpenning_left + lagsta_left,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=sjukpenning_left + lagsta_left,
|
||||
dubbeldagar_left=60,
|
||||
tier_data=tier,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(connection: aiosqlite.Connection) -> AsyncClient:
|
||||
"""An httpx client against the real app, with `get_connection` overridden
|
||||
to hand back the test's in-memory connection instead of opening the real
|
||||
on-disk database via the (never-run) lifespan.
|
||||
"""
|
||||
app.dependency_overrides[get_connection] = lambda: connection
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.pop(get_connection, None)
|
||||
|
||||
|
||||
class TestProposeSurplusAdviceOverTheWire:
|
||||
async def test_surplus_advice_appears_in_the_http_response(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
"""A workspace with a small child, a full/untouched FP pool and no
|
||||
leave wishes at all is a guaranteed surplus at the age-4 boundary
|
||||
(nothing is planned before it, so the whole 480-day pool minus the
|
||||
96-day ceiling is forecast to be forfeited) — the router must return
|
||||
that as `surplus_advice`, not just the `days_lost_at_age_4` warning.
|
||||
"""
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
|
||||
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="Alex",
|
||||
salary_type="monthly",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
# Under 2 years old "today" -> still small enough for
|
||||
# front_load_early_years too, though this test only asserts
|
||||
# on wish_more_leave (the always-present one here).
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert "surplus_advice" in body, (
|
||||
"PlanProposalResponse must expose surplus_advice — it was silently "
|
||||
"dropped at the API boundary even though the pure Allocator computed it"
|
||||
)
|
||||
assert body["surplus_advice"], "expected at least one surplus suggestion"
|
||||
|
||||
by_kind = {a["kind"]: a for a in body["surplus_advice"]}
|
||||
assert "wish_more_leave" in by_kind
|
||||
|
||||
advice = by_kind["wish_more_leave"]
|
||||
assert advice["days_involved"] > 0
|
||||
# §3.5 — i18n-ready: message_key + data, not a pre-rendered Swedish
|
||||
# string (see PlanResults.tsx / frontend/src/i18n/{sv,en}.ts).
|
||||
assert advice["message_key"] == "planSurplusAdvice.wish_more_leave"
|
||||
assert advice["data"]["parent_personal_data_uuid"] == str(parent.uuid)
|
||||
assert advice["data"]["child_id"] == str(child.id)
|
||||
assert isinstance(advice["data"]["weeks"], int) and advice["data"]["weeks"] > 0
|
||||
|
||||
# The warning it mirrors must also still be present, and — surface #3
|
||||
# — expose message_key + data rather than a pre-rendered string.
|
||||
age4_warning = next(w for w in body["warnings"] if w["kind"] == "days_lost_at_age_4")
|
||||
assert age4_warning["message_key"] == "planWarnings.days_lost_at_age_4"
|
||||
assert age4_warning["data"]["parent_personal_data_uuid"] == str(parent.uuid)
|
||||
assert "fourth_birthday" in age4_warning["data"]
|
||||
|
||||
|
||||
class TestProposeAge4ProjectionsOverTheWire:
|
||||
async def test_age4_projections_appear_in_the_http_response(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
"""§3.3 part 2 — the propose response must expose a plan-aware age-4
|
||||
projection per (parent, child), the same class of "computed on the
|
||||
pure Allocator but silently dropped at the API boundary" bug that
|
||||
`surplus_advice` and `is_assumed` hit before their mapping landed in
|
||||
`app.models.plan.PlanProposalResponse`.
|
||||
"""
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
|
||||
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="Alex",
|
||||
salary_type="monthly",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert "age4_projections" in body, (
|
||||
"PlanProposalResponse must expose age4_projections — computed by "
|
||||
"the pure Allocator (Age4Projection / build_age4_projections) but "
|
||||
"invisible to the UI until mapped onto the wire response"
|
||||
)
|
||||
assert body["age4_projections"], "expected one projection for the (parent, child) pair"
|
||||
|
||||
projection = next(
|
||||
p
|
||||
for p in body["age4_projections"]
|
||||
if p["parent_personal_data_uuid"] == str(parent.uuid) and p["child_id"] == str(child.id)
|
||||
)
|
||||
assert (
|
||||
projection["fourth_birthday"] == (child.birth_date + relativedelta(years=4)).isoformat()
|
||||
)
|
||||
assert isinstance(projection["projected_left_at_b4"], int)
|
||||
assert isinstance(projection["savable_after_b4"], int)
|
||||
assert isinstance(projection["expiring_at_b4"], int)
|
||||
# No leave planned at all before B4 in this scenario -> everything
|
||||
# above the savable ceiling is forecast to expire, matching the
|
||||
# surplus-advice test above.
|
||||
assert projection["expiring_at_b4"] > 0
|
||||
|
||||
|
||||
class TestProposeMessageKeysOverTheWire:
|
||||
"""§3.5 — outcomes/remedies must expose `message_key` + `data` (not a
|
||||
|
||||
pre-rendered Swedish string) all the way through `PlanProposalResponse`,
|
||||
the same class of "computed by the pure Allocator but dropped/wrong-
|
||||
shaped at the API boundary" bug the surplus_advice/age4_projections
|
||||
tests above guard against. The warning surface is covered by
|
||||
`TestProposeSurplusAdviceOverTheWire` above (it already sets up a
|
||||
scenario with a guaranteed `days_lost_at_age_4` warning).
|
||||
"""
|
||||
|
||||
async def test_outcome_remedy_and_warning_expose_message_key_and_data(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
vacation_repo = SQLiteVacationDaysRepository(connection)
|
||||
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
||||
|
||||
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="Alex",
|
||||
salary_type="monthly",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# Exhaust the FP pool entirely so the wish's window can't be funded
|
||||
# from it -> guaranteed shortfall.
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=0, lagsta_left=0)
|
||||
)
|
||||
|
||||
# No vacation next year (the wish's funding year) either -> the
|
||||
# window is entirely unfunded (shortfall_days > 0), which is what
|
||||
# `_build_remedies` requires to run at all.
|
||||
next_year = date.today().year + 1
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=parent.uuid,
|
||||
workspace_id=ws.id,
|
||||
year=next_year,
|
||||
total_days=0,
|
||||
saved_days_in=0,
|
||||
)
|
||||
)
|
||||
# Spare vacation *this* year (before the wish's window) -> the
|
||||
# carry_vacation_forward remedy has something to point at:
|
||||
# available (25) above the semesterlagen minimum (20) = 5 spare days.
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=parent.uuid,
|
||||
workspace_id=ws.id,
|
||||
year=date.today().year,
|
||||
total_days=25,
|
||||
saved_days_in=0,
|
||||
)
|
||||
)
|
||||
|
||||
create_wish = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": None,
|
||||
"name": "Vinter",
|
||||
"window_start_month": 12,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 12,
|
||||
"window_end_day": 7,
|
||||
"target_weeks": 1,
|
||||
"recurrence": "once",
|
||||
"first_year": next_year,
|
||||
},
|
||||
)
|
||||
assert create_wish.status_code == 201
|
||||
|
||||
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
outcomes = [o for o in body["outcomes"] if o["shortfall_days"] > 0]
|
||||
assert outcomes, "expected the wish's window to be underfunded"
|
||||
outcome = outcomes[0]
|
||||
|
||||
# Surface #1 — outcome.explanation -> message_key + data.
|
||||
assert outcome["message_key"] == "planOutcomes.windowExplanation"
|
||||
assert outcome["data"]["wish_name"] == "Vinter"
|
||||
assert outcome["data"]["shortfall_days"] == outcome["shortfall_days"]
|
||||
|
||||
# Surface #2 — remedy.description -> message_key + data.
|
||||
assert outcome["remedies"], "expected at least the carry_vacation_forward remedy"
|
||||
remedy = next(r for r in outcome["remedies"] if r["kind"] == "carry_vacation_forward")
|
||||
assert remedy["message_key"] == "planRemedies.carry_vacation_forward"
|
||||
assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5
|
||||
|
||||
|
||||
class TestApplyRejectionsOverTheWireMessageKeys:
|
||||
"""§3.5 — `PlanApplyResponse.rejections` must expose `message_key` + `data`
|
||||
|
||||
(surface #4), not a pre-rendered string, all the way through the HTTP
|
||||
response.
|
||||
"""
|
||||
|
||||
async def test_insufficient_balance_rejection_carries_message_key_and_data(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
||||
|
||||
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="Alex",
|
||||
salary_type="monthly",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=1, lagsta_left=90)
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/api/plan/apply",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-06-15",
|
||||
"parent_personal_data_uuid": str(parent.uuid),
|
||||
"child_id": str(child.id),
|
||||
"day_type": "sjukpenning",
|
||||
},
|
||||
{
|
||||
"date": "2026-06-16",
|
||||
"parent_personal_data_uuid": str(parent.uuid),
|
||||
"child_id": str(child.id),
|
||||
"day_type": "sjukpenning",
|
||||
},
|
||||
],
|
||||
"replace": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["accepted"] is False
|
||||
assert body["rejections"], "expected the balance violation to be reported"
|
||||
rejection = body["rejections"][0]
|
||||
assert rejection["message_key"] == "planApply.rejection.insufficientBalance"
|
||||
assert rejection["data"]["tier"] == "sjukpenning"
|
||||
assert rejection["data"]["needed"] == 2
|
||||
assert rejection["data"]["remaining"] == 1
|
||||
|
||||
|
||||
class TestLeaveWishWindowModeOverTheWire:
|
||||
"""PLANNING_ENGINE_SPEC.md §10 — `window_mode` must round-trip through
|
||||
|
||||
the `/api/leave-wishes` create endpoint (§10.5's model mapping), and a
|
||||
flexible wish's engine-picked placement/unplaced outcome must survive
|
||||
the `/api/plan/propose` wire mapping (`app.models.plan`), the same class
|
||||
of "computed by the pure Allocator but dropped/wrong-shaped at the API
|
||||
boundary" bug the other classes in this file guard against.
|
||||
"""
|
||||
|
||||
async def test_create_leave_wish_round_trips_window_mode(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
||||
|
||||
response = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": None,
|
||||
"name": "Vårlov",
|
||||
"window_start_month": 3,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 5,
|
||||
"window_end_day": 31,
|
||||
"target_weeks": 1,
|
||||
"recurrence": "once",
|
||||
"first_year": date.today().year + 1,
|
||||
"window_mode": "flexible",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["window_mode"] == "flexible"
|
||||
|
||||
listed = await client.get(f"/api/leave-wishes/{ws.id}")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["window_mode"] == "flexible"
|
||||
|
||||
async def test_create_leave_wish_rejects_bound_shorter_than_target_weeks(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
||||
|
||||
response = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": None,
|
||||
"name": "Vårlov",
|
||||
"window_start_month": 3,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 3,
|
||||
"window_end_day": 10, # 10-day bound
|
||||
"target_weeks": 2, # needs 14 days
|
||||
"recurrence": "once",
|
||||
"first_year": date.today().year + 1,
|
||||
"window_mode": "flexible",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_flexible_wish_placement_appears_in_propose_response(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
vacation_repo = SQLiteVacationDaysRepository(connection)
|
||||
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
||||
|
||||
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="Alex", salary_type="monthly", created_at=now
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
next_year = date.today().year + 1
|
||||
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=parent.uuid,
|
||||
workspace_id=ws.id,
|
||||
year=next_year,
|
||||
total_days=25,
|
||||
saved_days_in=0,
|
||||
)
|
||||
)
|
||||
|
||||
create_wish = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": str(child.id),
|
||||
"name": "Vårlov",
|
||||
"window_start_month": 1,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 3,
|
||||
"window_end_day": 31,
|
||||
"target_weeks": 1,
|
||||
"recurrence": "once",
|
||||
"first_year": next_year,
|
||||
"window_mode": "flexible",
|
||||
},
|
||||
)
|
||||
assert create_wish.status_code == 201
|
||||
wish_id = create_wish.json()["id"]
|
||||
|
||||
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id)
|
||||
assert outcome["strategy"] != "unplaced"
|
||||
assert outcome["shortfall_days"] == 0
|
||||
start = date.fromisoformat(outcome["start_date"])
|
||||
end = date.fromisoformat(outcome["end_date"])
|
||||
assert date(next_year, 1, 1) <= start <= date(next_year, 3, 31)
|
||||
assert date(next_year, 1, 1) <= end <= date(next_year, 3, 31)
|
||||
assert (end - start).days == 6 # a full 1-week (7-day) placement
|
||||
|
||||
async def test_flexible_wish_unplaced_when_fixed_wish_claims_the_bound(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
connection: aiosqlite.Connection,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
workspace_repo = SQLiteWorkspaceRepository(connection)
|
||||
personal_data_repo = SQLitePersonalDataRepository(connection)
|
||||
child_repo = SQLiteChildRepository(connection)
|
||||
vacation_repo = SQLiteVacationDaysRepository(connection)
|
||||
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
||||
|
||||
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="Alex", salary_type="monthly", created_at=now
|
||||
)
|
||||
)
|
||||
child = await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date.today() - relativedelta(months=6),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
next_year = date.today().year + 1
|
||||
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=parent.uuid,
|
||||
workspace_id=ws.id,
|
||||
year=next_year,
|
||||
total_days=25,
|
||||
saved_days_in=0,
|
||||
)
|
||||
)
|
||||
|
||||
# Fixed wish claims exactly the same 7-day range the flexible wish's
|
||||
# bound offers as its only full-length candidate.
|
||||
fixed = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": str(child.id),
|
||||
"name": "Fixed",
|
||||
"window_start_month": 1,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 1,
|
||||
"window_end_day": 7,
|
||||
"target_weeks": 1,
|
||||
"recurrence": "once",
|
||||
"first_year": next_year,
|
||||
},
|
||||
)
|
||||
assert fixed.status_code == 201
|
||||
|
||||
flexible = await client.post(
|
||||
"/api/leave-wishes",
|
||||
json={
|
||||
"workspace_id": str(ws.id),
|
||||
"child_id": str(child.id),
|
||||
"name": "Flex",
|
||||
"window_start_month": 1,
|
||||
"window_start_day": 1,
|
||||
"window_end_month": 1,
|
||||
"window_end_day": 7,
|
||||
"target_weeks": 1,
|
||||
"recurrence": "once",
|
||||
"first_year": next_year,
|
||||
"window_mode": "flexible",
|
||||
},
|
||||
)
|
||||
assert flexible.status_code == 201
|
||||
flexible_id = flexible.json()["id"]
|
||||
|
||||
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
outcome = next(o for o in body["outcomes"] if o["wish_id"] == flexible_id)
|
||||
assert outcome["strategy"] == "unplaced"
|
||||
assert outcome["weeks_funded"] == 0.0
|
||||
assert outcome["shortfall_days"] == 7
|
||||
Reference in New Issue
Block a user