"""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) # savable_cap_at_b4 (the resolved 96-per-child vs. 48-per-parent # ceiling) is exactly the kind of field this test class exists to # catch going missing at the API boundary. assert projection["savable_cap_at_b4"] == 96 # solo workspace, one parent # 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 TestProposeWishNameMessageKeyOverTheWire: """A preset-created wish (`LeaveWish.name_message_key` set, see `app/entities/leave_wish.py` and `frontend/src/utils/wishName.ts`) must carry that key through `POST /api/plan/propose`, not just `POST /api/leave-wishes` -- `WindowOutcomeResponse.wish_name_message_key` is a field the hand-written `PlanProposalResponse.from_domain` mapping in `app/models/plan.py` could silently drop, the same class of bug `TestProposeSurplusAdviceOverTheWire`/`TestProposeAge4ProjectionsOverTheWire` above guard against for other fields. """ async def test_wish_name_message_key_reaches_the_proposal_response( 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) vacation_repo = SQLiteVacationDaysRepository(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)) 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=25, saved_days_in=0, ) ) # Preset-created: name is the (server-agnostic) translated literal, # name_message_key records which preset produced it -- exactly what # `WishEditor.tsx`'s `addPreset` sends. create_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child.id), "name": "Höstlov", "name_message_key": "wishEditor.presetHostlov", "window_start_month": 10, "window_start_day": 25, "window_end_month": 11, "window_end_day": 1, "target_weeks": 1, "recurrence": "once", "first_year": next_year, }, ) 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["wish_name"] == "Höstlov" assert outcome["wish_name_message_key"] == "wishEditor.presetHostlov" # Surface #1 (outcome.explanation) mirrors the key in `data` too, so # `buildWindowExplanation` in PlanResults.tsx can re-translate it. assert outcome["data"]["wish_name_message_key"] == "wishEditor.presetHostlov" async def test_wish_name_message_key_is_null_for_a_free_typed_wish( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: """A row predating this field, or a free-typed/user-renamed wish, has no key -- the response must expose `null`, never omit the field or leak a stale/empty-string placeholder. """ now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) vacation_repo = SQLiteVacationDaysRepository(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)) 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=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": "Resa till mormor", "window_start_month": 6, "window_start_day": 1, "window_end_month": 6, "window_end_day": 7, "target_weeks": 1, "recurrence": "once", "first_year": next_year, }, ) 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["wish_name"] == "Resa till mormor" assert outcome["wish_name_message_key"] is None 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