"""Smoke-tests for /simulate and /compare. Uses very small n_paths (100) to keep tests fast — accuracy isn't the point, the point is the endpoint produces a valid response shape. """ from __future__ import annotations from collections.abc import AsyncIterator from decimal import Decimal import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from fire_planner.api.dependencies import get_session from fire_planner.app import app @pytest_asyncio.fixture async def client(engine: AsyncEngine, session: AsyncSession) -> AsyncIterator[AsyncClient]: factory = async_sessionmaker(engine, expire_on_commit=False) async def _override() -> AsyncIterator[AsyncSession]: async with factory() as s: yield s app.dependency_overrides[get_session] = _override transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test", timeout=30) as ac: yield ac app.dependency_overrides.clear() async def test_simulate_runs_and_returns_yearly_fan(client: AsyncClient) -> None: resp = await client.post( "/simulate", json={ "jurisdiction": "uk", "strategy": "trinity", "leave_uk_year": 0, "glide_path": "static_60_40", "spending_gbp": "60000", "nw_seed_gbp": "1500000", "horizon_years": 30, "n_paths": 100, "seed": 42, }, ) assert resp.status_code == 200, resp.text body = resp.json() assert "success_rate" in body assert len(body["yearly"]) == 30 yp = body["yearly"][0] # Quantiles must be monotone non-decreasing p10, p25, p50, p75, p90 = ( Decimal(yp[k]) for k in ("p10_portfolio_gbp", "p25_portfolio_gbp", "p50_portfolio_gbp", "p75_portfolio_gbp", "p90_portfolio_gbp")) assert p10 <= p25 <= p50 <= p75 <= p90 async def test_simulate_validates_unknown_jurisdiction(client: AsyncClient) -> None: resp = await client.post( "/simulate", json={ "jurisdiction": "atlantis", "strategy": "trinity", "leave_uk_year": 0, "glide_path": "static_60_40", "spending_gbp": "60000", "nw_seed_gbp": "1000000", "horizon_years": 10, "n_paths": 100, }, ) assert resp.status_code == 400 async def test_compare_runs_two_scenarios(client: AsyncClient) -> None: resp = await client.post( "/compare", json={ "scenarios": [ { "jurisdiction": "uk", "strategy": "trinity", "leave_uk_year": 0, "glide_path": "static_60_40", "spending_gbp": "60000", "nw_seed_gbp": "1500000", "horizon_years": 20, "n_paths": 100, "seed": 42, }, { "jurisdiction": "cyprus", "strategy": "guyton_klinger", "leave_uk_year": 2, "glide_path": "rising", "spending_gbp": "60000", "nw_seed_gbp": "1500000", "horizon_years": 20, "n_paths": 100, "seed": 42, }, ] }, ) assert resp.status_code == 200, resp.text results = resp.json()["results"] assert len(results) == 2 assert all(len(r["yearly"]) == 20 for r in results) async def test_simulate_with_life_events_changes_outcome(client: AsyncClient) -> None: """Same params with vs without a £500k inheritance at year 5 — the inheritance run must end with strictly more median NW.""" base_req = { "jurisdiction": "uk", "strategy": "trinity", "leave_uk_year": 0, "glide_path": "static_60_40", "spending_gbp": "60000", "nw_seed_gbp": "1500000", "horizon_years": 30, "n_paths": 200, "seed": 42, } base = await client.post("/simulate", json=base_req) assert base.status_code == 200, base.text enhanced = await client.post( "/simulate", json={ **base_req, "life_events": [ { "year_start": 5, "one_time_amount_gbp": "500000", } ], }, ) assert enhanced.status_code == 200, enhanced.text base_p50 = float(base.json()["p50_ending_gbp"]) enhanced_p50 = float(enhanced.json()["p50_ending_gbp"]) assert enhanced_p50 > base_p50 async def test_compare_rejects_single_scenario(client: AsyncClient) -> None: resp = await client.post( "/compare", json={ "scenarios": [{ "jurisdiction": "uk", "strategy": "trinity", "leave_uk_year": 0, "glide_path": "static_60_40", "spending_gbp": "60000", "nw_seed_gbp": "1500000", "n_paths": 100, }] }, ) assert resp.status_code == 422 # pydantic validation