"""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 datetime import date 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 from fire_planner.db import FireExample @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 async def test_simulate_response_includes_examples_overlay( client: AsyncClient, session: AsyncSession) -> None: """When examples exist for the scenario's target country, the simulator response surfaces them as `examples_overlay` — purely informational, never affects the simulation path.""" for i, amount in enumerate([300_000, 400_000, 500_000], start=1): session.add(FireExample( reddit_id=f"th{i:02d}", source_sub="ExpatFIRE", post_url=f"https://reddit.com/r/ExpatFIRE/comments/th{i:02d}", post_date=date(2026, 1, 1), post_title=f"Thailand FIRE {i}", country="Thailand", portfolio_gbp=Decimal(str(amount)), annual_exp_gbp=Decimal("24000"), fi_status="FIRE", llm_model="qwen3-8b", )) await session.commit() resp = await client.post( "/simulate", json={ "jurisdiction": "thailand", "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 "examples_overlay" in body overlay = body["examples_overlay"] assert overlay is not None assert overlay["country"] == "Thailand" assert overlay["count"] == 3 assert Decimal(overlay["portfolio_gbp_median"]) == Decimal("400000.00") assert len(overlay["sample_links"]) == 3