From 82569eefaadf68e28cf9e0119d63c29df5fb8699 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 8 Sep 2026 19:14:33 -0400 Subject: [PATCH] Reconcile household resources in the backend and separate service values --- .github/workflows/ci.yml | 47 +++++ .github/workflows/live-uk.yml | 32 +++ README.md | 34 +++- app/MarriageApp.jsx | 115 ++++++++--- app/components/MetricCards.jsx | 20 +- app/components/ResultsDisplay.jsx | 104 +++++----- app/components/USChildcareInputs.jsx | 5 +- app/globals.css | 86 ++++++++ backend/README.md | 41 +++- backend/accounting.py | 125 ++++++++++++ backend/modal_app.py | 2 + backend/pyproject.toml | 4 +- backend/simulation.py | 29 ++- backend/tests/test_accounting.py | 159 +++++++++++++++ backend/tests/test_uk_accounting.py | 139 +++++++++++++ backend/uk.py | 187 ++++++++++++++++++ backend/uv.lock | 4 +- backend/web.py | 38 ++++ docs/testing.md | 41 ++++ lib/api.js | 284 +++++++++------------------ lib/childcare.js | 38 ---- lib/countries.js | 8 + lib/utils.js | 69 ++++++- package.json | 8 +- scripts/test-ci.mjs | 67 +++++++ tests/accounting-contract.test.js | 76 +++++++ tests/api.test.js | 115 +---------- tests/childcare-ui.test.jsx | 10 +- tests/childcare.test.js | 26 --- tests/living-arrangement.test.js | 24 ++- tests/request-recovery.test.jsx | 127 ++++++++++++ tests/resources-ui.test.jsx | 111 +++++++++++ tests/review-fixes.test.js | 20 +- tests/setup-local-api.js | 14 ++ tests/uk-api-live.test.js | 120 +++++++++++ tests/uk-inputs.test.js | 40 +--- vitest.ci.config.js | 16 ++ 37 files changed, 1852 insertions(+), 533 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/live-uk.yml create mode 100644 backend/accounting.py create mode 100644 backend/tests/test_accounting.py create mode 100644 backend/tests/test_uk_accounting.py create mode 100644 backend/uk.py create mode 100644 docs/testing.md create mode 100644 scripts/test-ci.mjs create mode 100644 tests/accounting-contract.test.js create mode 100644 tests/request-recovery.test.jsx create mode 100644 tests/resources-ui.test.jsx create mode 100644 tests/setup-local-api.js create mode 100644 tests/uk-api-live.test.js create mode 100644 vitest.ci.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..03c1deb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: Calculator checks + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: calculator-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + PYTHONDONTWRITEBYTECODE: "1" + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.12" + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + enable-cache: true + cache-dependency-glob: backend/uv.lock + - name: Install locked dependencies + run: | + bun install --frozen-lockfile + uv sync --project backend --frozen --python 3.12 + - name: Check backend formatting and lint + run: | + uv run --project backend --no-sync ruff check backend + uv run --project backend --no-sync ruff format --check backend + - name: Backend tests + run: bun run test:backend + - name: Frontend lint + run: bun run lint + - name: Unit and local US integration tests + run: bun run test:ci + - name: Production build + run: bun run build diff --git a/.github/workflows/live-uk.yml b/.github/workflows/live-uk.yml new file mode 100644 index 0000000..369d8a7 --- /dev/null +++ b/.github/workflows/live-uk.yml @@ -0,0 +1,32 @@ +name: Live UK integration checks + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: live-uk-${{ github.ref }} + cancel-in-progress: true + +jobs: + live-uk: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.12" + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + enable-cache: true + cache-dependency-glob: backend/uv.lock + - name: Install locked dependencies + run: | + bun install --frozen-lockfile + uv sync --project backend --frozen --python 3.12 + - name: External UK integration checks + run: bun run test:live:uk diff --git a/README.md b/README.md index a7083cb..59e20ca 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ forwarded to the server component; see `app/page.jsx`. ## Development ```sh -bun install +bun install --frozen-lockfile +uv sync --project backend --frozen --python 3.12 NEXT_PUBLIC_BASE_PATH="" bun run dev # serve at http://localhost:5173/ -bun run test:ci # component and live API tests +bun run test:ci # unit and local US integration tests +bun run test:backend # accounting and HTTP tests bun run build # production bundle ``` @@ -29,9 +31,10 @@ server serves at the root — matches the Oregon Kicker convention. ## US childcare, early education and disability US calculations use the app-owned [household backend](backend/README.md), pinned -to PolicyEngine US 1.824.1. UK calculations use the public v1 API. Set -`NEXT_PUBLIC_US_API_URL=http://127.0.0.1:8012` to use the local US backend for -development or tests. Production defaults to the deployed Modal service and +to PolicyEngine US 1.824.1. UK policy calculations still use the public v1 API, +through the same backend, which applies the calculator’s rent accounting. Set +`NEXT_PUBLIC_US_API_URL=http://127.0.0.1:8012` to use a local backend for both +countries in development. Production defaults to the deployed Modal service and rejects responses whose model version differs from the committed metadata. Paid childcare costs feed into the estimates automatically. The funded-slot @@ -53,9 +56,11 @@ Initial-applicant, standard-quality, zero-assets and available-funding assumptions apply unless an input says otherwise. Head Start and Early Head Start eligibility are shown separately from their -estimated service values. Those noncash values are excluded from net income -unless explicitly selected; they use state spending per enrollee and assume -eligible participation. Actual Head Start enrollment is a separate childcare +estimated service values. Healthcare and early education values are always +separate from household financial resources and added only in the explicitly +labeled combined-resources comparison. Head Start values use state spending +per enrollee and assume eligible participation. Employer insurance affects +eligibility inputs, independently of this presentation. Actual Head Start enrollment is a separate childcare input. The SSI medical-disability checkbox sets both `meets_ssi_disability_criteria` and `is_disabled`; financial eligibility remains model-computed. @@ -68,3 +73,16 @@ provider enums and aggregate composition after reviewing a model update: uv run --project backend python -m backend.simulation --metadata /tmp/marriage-metadata.json US_METADATA_FILE=/tmp/marriage-metadata.json bun run metadata ``` + +## Accounting and release checks + +The backend returns versioned, reconciled annual series for every household +and income-grid point. The browser displays these series without adjusting +childcare benefits, service values, or rent again. Financial resources include +benefits such as SNAP; they are not a cash-only measure. The original model +outputs remain available separately in the API response. + +The [test workflow](docs/testing.md) checks the pinned runtime and frontend on +every pull request. The UK model’s external integration checks run separately. +Requests time out after two minutes and are cancelled when inputs change. +Failed charts retain the household comparison and offer a chart-only retry. diff --git a/app/MarriageApp.jsx b/app/MarriageApp.jsx index a0d0f75..de228f6 100644 --- a/app/MarriageApp.jsx +++ b/app/MarriageApp.jsx @@ -182,6 +182,7 @@ export default function MarriageApp({ initialCountry = null }) { const [loading, setLoading] = useState(false); const [heatmapLoading, setHeatmapLoading] = useState(false); const [error, setError] = useState(null); + const [heatmapError, setHeatmapError] = useState(null); const [formData, setFormData] = useState(null); const [valentine, setValentine] = useState(false); const [showConfetti, setShowConfetti] = useState(false); @@ -189,6 +190,10 @@ export default function MarriageApp({ initialCountry = null }) { const [sidebarOpen, setSidebarOpen] = useState(true); const didAutoCalc = useRef(false); const calculationId = useRef(0); + const activeRequest = useRef(null); + const retrySnapshot = useRef(null); + + useEffect(() => () => activeRequest.current?.abort(), []); // Resolve browser-only state after mount. // initialCountry already seeded countryId (for the rewrite path), so it @@ -266,11 +271,15 @@ export default function MarriageApp({ initialCountry = null }) { // A request from the previous living arrangement must not repopulate the // page after an input has changed. calculationId.current += 1; + activeRequest.current?.abort(); + activeRequest.current = null; + retrySnapshot.current = null; setResults(null); setHeatmapData(null); setLoading(false); setHeatmapLoading(false); setError(null); + setHeatmapError(null); setExternalIncomes(null); } @@ -282,16 +291,14 @@ export default function MarriageApp({ initialCountry = null }) { } } - async function handleCalculate(data) { - const requestId = ++calculationId.current; - setFormData(data); - updateHash(data); - + function requestArguments(snapshot, signal) { + const { data, countryId: requestCountry } = snapshot; const { headIncome, spouseIncome, headAge, spouseAge, children, disabilityStatus, pregnancyStatus, esiStatus, year, } = data; const extras = { + signal, livingArrangement: data.livingArrangement || "cohabiting", rent: data.rent || 0, tenureType: data.tenureType || "OWNED_OUTRIGHT", @@ -308,45 +315,85 @@ export default function MarriageApp({ initialCountry = null }) { childcareActivityEligible: (data.regionCode || data.stateCode) === "NV" && Boolean(data.childcareActivityEligible), }; const regionCode = data.regionCode || data.stateCode; - const effectiveRegion = countryId === "us" && regionCode === "NYC" ? "NY" : regionCode; - const inNYC = countryId === "us" && regionCode === "NYC"; + const effectiveRegion = requestCountry === "us" && regionCode === "NYC" ? "NY" : regionCode; + const inNYC = requestCountry === "us" && regionCode === "NYC"; + return { + scalar: [requestCountry, effectiveRegion, headIncome, spouseIncome, children, + disabilityStatus, year, pregnancyStatus, headAge, spouseAge, esiStatus, inNYC, extras], + heatmap: [requestCountry, effectiveRegion, children, disabilityStatus, year, + pregnancyStatus, headIncome, spouseIncome, headAge, spouseAge, esiStatus, inNYC, extras], + }; + } + + function startRequest() { + activeRequest.current?.abort(); + const controller = new AbortController(); + activeRequest.current = controller; + return { requestId: ++calculationId.current, controller }; + } + + async function loadHeatmap(snapshot, requestId, controller) { + setHeatmapLoading(true); + setHeatmapError(null); + try { + const heatmap = await getHeatmapData(...requestArguments(snapshot, controller.signal).heatmap); + if (requestId !== calculationId.current || controller.signal.aborted) return; + setHeatmapData(heatmap); + } catch (e) { + if (requestId !== calculationId.current || controller.signal.aborted) return; + setHeatmapError(e.message || "The heatmap could not be calculated. Please try again."); + // A failed comparison must also stop any sibling fetches still running. + controller.abort(); + } finally { + if (requestId === calculationId.current) { + setHeatmapLoading(false); + activeRequest.current = null; + } + } + } + + async function handleCalculate(data) { + // Retry the submitted values, never a partially edited form or mutable + // object retained by the caller. Country changes clear this snapshot. + const snapshot = { countryId, data: structuredClone(data) }; + retrySnapshot.current = snapshot; + const { requestId, controller } = startRequest(); + setFormData(snapshot.data); + updateHash(snapshot.data); setLoading(true); setError(null); + setHeatmapError(null); + setHeatmapLoading(false); setResults(null); setHeatmapData(null); try { - const result = await getCategorizedPrograms( - countryId, effectiveRegion, headIncome, spouseIncome, children, - disabilityStatus, year, pregnancyStatus, headAge, spouseAge, - esiStatus, inNYC, extras, - ); - if (requestId !== calculationId.current) return; + const result = await getCategorizedPrograms(...requestArguments(snapshot, controller.signal).scalar); + if (requestId !== calculationId.current || controller.signal.aborted) return; setResults(result); setLoading(false); - setHeatmapLoading(true); - try { - const heatmap = await getHeatmapData( - countryId, effectiveRegion, children, disabilityStatus, year, - pregnancyStatus, headIncome, spouseIncome, headAge, spouseAge, - esiStatus, inNYC, extras, - ); - if (requestId !== calculationId.current) return; - setHeatmapData(heatmap); - } catch (e) { - console.error("Heatmap error:", e); - } finally { - if (requestId === calculationId.current) setHeatmapLoading(false); - } + await loadHeatmap(snapshot, requestId, controller); } catch (e) { - if (requestId !== calculationId.current) return; - setError(e.message); + if (requestId !== calculationId.current || controller.signal.aborted) return; + setError(e.message || "The calculation could not be completed. Please try again."); setLoading(false); + controller.abort(); + activeRequest.current = null; } } + function retryCalculation() { + if (retrySnapshot.current) handleCalculate(retrySnapshot.current.data); + } + + function retryHeatmap() { + if (!retrySnapshot.current || !results) return; + const { requestId, controller } = startRequest(); + loadHeatmap(retrySnapshot.current, requestId, controller); + } + function handleCellClick(headIncome, spouseIncome) { setExternalIncomes({ headIncome, spouseIncome }); const data = { ...formData, headIncome, spouseIncome }; @@ -423,7 +470,11 @@ export default function MarriageApp({ initialCountry = null }) {
- {error &&
{error}
} + {error &&
+

{error}

+ +
} {loading && (
@@ -431,7 +482,7 @@ export default function MarriageApp({ initialCountry = null }) {
)} - {!results && !loading && ( + {!results && !loading && !error && (

What would marriage mean for your taxes?

@@ -456,6 +507,8 @@ export default function MarriageApp({ initialCountry = null }) { results={results} heatmapData={heatmapData} heatmapLoading={heatmapLoading} + heatmapError={heatmapError} + onRetryHeatmap={retryHeatmap} headIncome={formData?.headIncome ?? 0} spouseIncome={formData?.spouseIncome ?? 0} valentine={valentine} diff --git a/app/components/MetricCards.jsx b/app/components/MetricCards.jsx index 7580a1c..60e0b7b 100644 --- a/app/components/MetricCards.jsx +++ b/app/components/MetricCards.jsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { formatCurrency, formatPercent, unmarriedTotal } from "@/lib/utils"; +import { formatCurrency, formatPercent, unmarriedTotal, resourceValues } from "@/lib/utils"; function getShareUrl(countryId) { const hash = window.location.hash; @@ -15,9 +15,13 @@ export default function MetricCards({ results, showHealth, currencySymbol, count const { married } = results; const [copied, setCopied] = useState(false); + const financialView = countryId === "us"; const netKey = showHealth ? "householdNetIncomeWithHealth" : "householdNetIncome"; - const netMarried = married.aggregates[netKey]; - const netSeparate = unmarriedTotal(results, "aggregates", netKey); + const netMarried = financialView ? resourceValues(married).financialResources : married.aggregates[netKey]; + const netSeparate = financialView + ? (results.unmarried ? [results.unmarried] : [results.headSingle, results.spouseSingle]) + .reduce((sum, result) => sum + resourceValues(result).financialResources, 0) + : unmarriedTotal(results, "aggregates", netKey); const delta = netMarried - netSeparate; const pctChange = netSeparate !== 0 ? delta / netSeparate : 0; const afterChildcare = married.childcare?.enabled; @@ -64,17 +68,22 @@ export default function MetricCards({ results, showHealth, currencySymbol, count } return ( +
+ {financialView &&
+

Household financial resources

+

Annual resources after taxes and modeled expenses, including benefits such as SNAP.

+
}
{unmarriedLabel}
{formatCurrency(netSeparate, false, sym)}
-
{results.unmarried ? "Household net income" : "Combined net income"}{afterChildcare ? " after childcare" : ""}
+
{financialView ? "Annual financial resources" : <>{results.unmarried ? "Household net income" : "Combined net income"}{afterChildcare ? " after childcare" : ""}}
Married
{formatCurrency(netMarried, false, sym)}
-
Household net income{afterChildcare ? " after childcare" : ""}
+
{financialView ? "Annual financial resources" : <>Household net income{afterChildcare ? " after childcare" : ""}}
@@ -90,5 +99,6 @@ export default function MetricCards({ results, showHealth, currencySymbol, count
{formatPercent(pctChange, true)}
+
); } diff --git a/app/components/ResultsDisplay.jsx b/app/components/ResultsDisplay.jsx index 45a2256..33081a4 100644 --- a/app/components/ResultsDisplay.jsx +++ b/app/components/ResultsDisplay.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, Suspense, lazy } from "react"; -import { computeTableData, unmarriedTotal, PROGRAM_DESCRIPTIONS, formatCurrency } from "@/lib/utils"; +import { computeTableData, unmarriedTotal, PROGRAM_DESCRIPTIONS, formatCurrency, resourceValues } from "@/lib/utils"; import { buildCellResults, buildCellBreakdown } from "@/lib/api"; import MetricCards from "./MetricCards"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@policyengine/ui-kit/primitives"; @@ -19,7 +19,7 @@ const UK_HEATMAP_OVERRIDES = { taxes: "tax", }; -function DataTable({ rows, emptyMessage, unmarriedLabel = "Not married", countryId }) { +function DataTable({ rows, emptyMessage, unmarriedLabel = "Not married", countryId, regionLabel = "Tax and benefit comparison" }) { if (rows.length === 0) { return

{emptyMessage || "No data to display."}

; } @@ -46,7 +46,7 @@ function DataTable({ rows, emptyMessage, unmarriedLabel = "Not married", country return ( -
+
@@ -94,6 +94,8 @@ export default function ResultsDisplay({ results, heatmapData, heatmapLoading, + heatmapError, + onRetryHeatmap, headIncome, spouseIncome, valentine, @@ -103,7 +105,7 @@ export default function ResultsDisplay({ livingArrangement, hasChildren = false, }) { - const showHealth = !esiStatus?.head && !esiStatus?.spouse; + const showHealth = country.id !== "us" && !esiStatus?.head && !esiStatus?.spouse; const [activeTab, setActiveTab] = useState("summary"); const [viewMode, setViewMode] = useState("table"); const [cellSelection, setCellSelection] = useState(null); @@ -143,7 +145,7 @@ export default function ResultsDisplay({ const activeResults = cellResults || results; const currentTab = visibleTabs.find((t) => t.key === activeTab) || visibleTabs[0]; - const rows = computeTableData(activeResults, activeTab, { showHealth, currencySymbol: sym }); + const rows = computeTableData(activeResults, activeTab, { showHealth, currencySymbol: sym, countryId }); const EMPTY_MESSAGES = { summary: "No data available for this scenario.", @@ -153,39 +155,13 @@ export default function ResultsDisplay({ }; // Resolve heatmap key — use UK overrides if applicable - let heatmapKey = UK_HEATMAP_OVERRIDES[activeTab] && countryId === "uk" + const heatmapKey = UK_HEATMAP_OVERRIDES[activeTab] && countryId === "uk" ? UK_HEATMAP_OVERRIDES[activeTab] : currentTab.heatmapKey; - if (showHealth && activeTab === "summary" && countryId === "us") { - heatmapKey = "net income (with healthcare)"; - } - - // For benefits tab with healthcare, combine grids element-wise - let heatmapGrid = heatmapData?.grids?.[heatmapKey] || null; - let combinedHeadLine = heatmapData?.headLines?.[heatmapKey]; - let combinedSpouseLine = heatmapData?.spouseLines?.[heatmapKey]; - let unmarriedGrid = heatmapData?.unmarriedGrids?.[heatmapKey]; - - if (showHealth && activeTab === "benefits" && heatmapData?.grids && countryId === "us") { - const benefitsGrid = heatmapData.grids["benefits"]; - const healthGrid = heatmapData.grids["healthcare benefits"]; - if (benefitsGrid && healthGrid) { - heatmapGrid = benefitsGrid.map((row, i) => - row.map((val, j) => val + (healthGrid[i]?.[j] || 0)), - ); - } - const bHead = heatmapData.headLines?.["benefits"]; - const hHead = heatmapData.headLines?.["healthcare benefits"]; - if (bHead && hHead) combinedHeadLine = bHead.map((v, i) => v + (hHead[i] || 0)); - const bSpouse = heatmapData.spouseLines?.["benefits"]; - const hSpouse = heatmapData.spouseLines?.["healthcare benefits"]; - if (bSpouse && hSpouse) combinedSpouseLine = bSpouse.map((v, i) => v + (hSpouse[i] || 0)); - const bUnmarried = heatmapData.unmarriedGrids?.["benefits"]; - const hUnmarried = heatmapData.unmarriedGrids?.["healthcare benefits"]; - if (bUnmarried && hUnmarried) { - unmarriedGrid = bUnmarried.map((row, i) => row.map((v, j) => v + (hUnmarried[i]?.[j] || 0))); - } - } + const heatmapGrid = heatmapData?.grids?.[heatmapKey] || null; + const combinedHeadLine = heatmapData?.headLines?.[heatmapKey]; + const combinedSpouseLine = heatmapData?.spouseLines?.[heatmapKey]; + const unmarriedGrid = heatmapData?.unmarriedGrids?.[heatmapKey]; const HEATMAP_AGG = { "net income": "householdNetIncome", @@ -197,12 +173,7 @@ export default function ResultsDisplay({ }; const aggKey = HEATMAP_AGG[heatmapKey]; let markerDelta = null; - if (activeTab === "benefits" && showHealth && countryId === "us") { - const m = (activeResults.married.aggregates.householdBenefits || 0) + (activeResults.married.aggregates.healthcareBenefitValue || 0); - const unmarried = unmarriedTotal(activeResults, "aggregates", "householdBenefits") - + unmarriedTotal(activeResults, "aggregates", "healthcareBenefitValue"); - markerDelta = m - unmarried; - } else if (aggKey) { + if (aggKey) { const m = activeResults.married.aggregates[aggKey] || 0; markerDelta = m - unmarriedTotal(activeResults, "aggregates", aggKey); if (heatmapKey === "tax before refundable credits" || heatmapKey === "tax") { @@ -218,9 +189,8 @@ export default function ResultsDisplay({ } } - let heatmapLabel = heatmapKey; - if (heatmapKey === "net income (with healthcare)") heatmapLabel = "net income"; - if (showHealth && activeTab === "benefits" && countryId === "us") heatmapLabel = "benefits"; + const heatmapLabel = countryId === "us" && activeTab === "summary" + ? "household financial resources" : heatmapKey; // Check if the active heatmap key corresponds to an inverted variable (e.g. taxes) const heatmapInvertDelta = country.gridConfig?.some( @@ -275,6 +245,12 @@ export default function ResultsDisplay({ Loading heatmap...

+ ) : heatmapError ? ( +
+

{heatmapError}

+

Your calculated household results are still available in the table.

+ {onRetryHeatmap && } +
) : heatmapProps ? ( + {countryId === "us" && }
+ + {rows.map(([label, key]) => { + const unmarried = singles.reduce((sum, value) => sum + value[key], 0); + return + + + + + ; + })} +
{unmarriedLabel}MarriedChange
{label}{formatCurrency(unmarried)}{formatCurrency(married[key])}{formatCurrency(married[key] - unmarried, true)}
+
+

Combined resources add estimated healthcare and early education service values to financial resources. Service values do not represent additional spendable income.

+
setShowHealthcare(event.currentTarget.open)}> + Healthcare benefit details + {showHealthcare && } +
+ ; +} + function EarlyEducationTable({ results, unmarriedLabel }) { const married = results.married.childcare; const singles = results.unmarried ? [results.unmarried.childcare] diff --git a/app/components/USChildcareInputs.jsx b/app/components/USChildcareInputs.jsx index 93319d3..7034d10 100644 --- a/app/components/USChildcareInputs.jsx +++ b/app/components/USChildcareInputs.jsx @@ -77,7 +77,7 @@ function NumberField({ id, label, value, onChange, max, step = "any" }) { export default function USChildcareInputs({ regionCode, childEntries, updateChild, ccdfSlotAvailable, onCcdfSlotAvailableChange, childcareCounty, onCountyChange, childcareWorkHours, onWorkHoursChange, - includeHeadStart, onIncludeHeadStartChange, childcareActivityEligible, onActivityEligibleChange, error, + childcareActivityEligible, onActivityEligibleChange, error, }) { const state = stateCode(regionCode); const counties = childcareCounties(regionCode); @@ -147,8 +147,7 @@ export default function USChildcareInputs({ ))}
)} - -

Includes modeled Head Start and Early Head Start service values for eligible enrollment. These are in-kind services, not cash payments or guaranteed places.

+

Head Start and Early Head Start service values appear separately from financial resources in the results. Eligibility does not guarantee an available place.

{error &&

{error}

}
); diff --git a/app/globals.css b/app/globals.css index d75fcff..48a44f3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1028,6 +1028,92 @@ body { } /* Metric cards — 3-column summary grid */ +.resource-heading { + padding: 1.1rem 1.25rem 0; + background: var(--pe-gray-50); +} + +.resource-heading h3, +.service-comparison h3 { + font-size: 1rem; + font-weight: 600; + color: var(--color-text); +} + +.resource-heading p, +.resource-note { + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.service-comparison { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border-light); +} + +.service-comparison h3 { + margin-bottom: 0.6rem; +} + +.resource-table { + min-width: 510px; + font-variant-numeric: tabular-nums; +} + +.resource-table th[scope="row"] { + text-align: left; + font-weight: 400; + color: var(--color-text); + background: var(--color-surface); + text-transform: none; + letter-spacing: normal; + font-size: 0.88rem; + border-bottom-color: var(--color-table-cell-border); +} + +.resource-table .total-row th[scope="row"] { + font-weight: 600; +} + +.resource-note { + margin-top: 0.65rem; +} + +.service-details { + margin-top: 0.75rem; + font-size: 0.8rem; +} + +.service-details summary { + width: fit-content; + color: var(--color-link); + cursor: pointer; +} + +.service-details summary:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 3px; +} + +.service-details[open] summary { + margin-bottom: 0.65rem; +} + +.heatmap-recovery { + padding: 1rem 1.25rem; + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.heatmap-recovery p + p { + margin-top: 0.3rem; +} + +.heatmap-recovery button { + margin-top: 0.75rem; +} + .metric-cards { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/backend/README.md b/backend/README.md index 1473f27..f1c5c39 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,10 +1,11 @@ -# Marriage calculator US household API +# Marriage calculator household API This app-owned service runs `policyengine-us==1.824.1`, verified against the [PyPI release](https://pypi.org/project/policyengine-us/1.824.1/) on September 8, 2026. The model and transitive dependencies are pinned by `uv.lock`. The public v1 API's older model does not yet expose the same nationwide childcare programs -and current Head Start household treatment. UK calculations continue to use v1. +and current Head Start household treatment. UK calculations use the public v1 +model through this service's fixed upstream proxy. From the repository root: @@ -29,11 +30,45 @@ for each requested entity; the first axis varies fastest. Annual requests for monthly money variables sum the 12 months through the model; annual categorical inputs are dispatched to each month through the model's input rules. -`include_head_start_benefits` controls the model's parameter for counting the +Without versioned accounting, `include_head_start_benefits` controls the model's parameter for counting the noncash value of Head Start and Early Head Start in net income. It does not change the programs' calculated eligibility or amounts. Arbitrary reforms are not accepted. +Add `"accounting_version": 1` to receive an `accounting` object alongside the +raw model `result`. Its `series` contains annual numeric arrays aggregated to +the household: one value for a scalar request, or one per income-axis point. +Boolean outputs are numeric counts. The object also identifies its `version`, +calculation `year`, `model_version`, and `axis_order: "first_axis_fastest"`. + +The accounting separates `financial_resources`, `healthcare_service_value`, +and `early_education_service_value`. Their sum is `combined_resources`. +`household_net_income` aliases financial resources, while +`household_net_income_including_health_benefits` adds only healthcare value. +Head Start and Early Head Start remain available as `head_start_service_value` +and `early_head_start_service_value`; their financial-benefit entries are zero. +This version does not change when `include_head_start_benefits` changes. + +Financial resources equal model `household_market_income`, plus normalized +`household_benefits` and `household_refundable_tax_credits`, less +`household_tax_before_refundable_credits`, `household_health_costs`, and +`childcare_cost_deducted`. The benefits exclude healthcare/education service +values and replace state childcare payments with the reduction in family +spending. The accounting resolves state-benefit composition from the model's +parameters for the requested year. `childcare_provider_payment` preserves the +full payment, `child_care_subsidies` is the recognized spending reduction, +`childcare_out_of_pocket` is the model's actual family cost, and +`childcare_cost_deducted` is that cost plus the recognized subsidy. This deducts +childcare once, including Vermont's collectible family share. + +`POST /uk/calculate` accepts `household` and optional `accounting_version: 1`. +It sends the UK entity shape to `https://api.policyengine.org/uk/calculate` with +a 110-second upstream timeout. Its versioned series subtracts entered rent from +net income only for renters, exposes that deduction as `rent_deducted`, and +aliases the result as `financial_resources` and `combined_resources`. This +preserves the calculator's existing UK rent treatment; the proxy applies no +new policy rules. Original upstream model results remain in `result`. + Send `ccdf_participation_filter: true` when a funded childcare slot is available. The filter immediately returns for households outside Vermont. Vermont's model eligible-child set also determines participating children for payments diff --git a/backend/accounting.py b/backend/accounting.py new file mode 100644 index 0000000..19ee6e2 --- /dev/null +++ b/backend/accounting.py @@ -0,0 +1,125 @@ +"""Versioned presentation accounting over actual household model outputs. + +This module changes no eligibility or payment formula. It distinguishes the +family's financial resources from government-valued health/education services, +and recognizes childcare assistance only to the extent it reduces family costs. +""" + +import numpy as np + +ACCOUNTING_VERSION = 1 +MANDATORY_VARIABLES = ( + "household_market_income", + "household_net_income", + "household_net_income_including_health_benefits", + "household_benefits", + "household_state_benefits", + "household_health_benefits", + "household_head_start_benefits", + "household_health_costs", + "household_refundable_tax_credits", + "household_refundable_state_tax_credits", + "household_tax_before_refundable_credits", + "healthcare_benefit_value", + "child_care_subsidies", + "childcare_expenses", + "pre_subsidy_childcare_expenses", + "vt_ccfap_family_share", + "head_start", + "early_head_start", + "is_head_start_eligible", + "is_early_head_start_eligible", +) + + +def build_accounting(simulation, requested, year, model_version): + """Return one value per household/axis point, in the model's axis order.""" + system = simulation.tax_benefit_system + values = {} + + def calculate(name): + if name not in values: + result = np.asarray( + simulation.calculate(name, year, map_to="household"), dtype=float + ) + if not np.isfinite(result).all(): + raise ValueError(f"Nonfinite accounting result: {name}.") + values[name] = result + return values[name] + + for _, _, name, _ in requested: + if system.variables[name].value_type in (int, float, bool): + calculate(name) + for name in MANDATORY_VARIABLES: + calculate(name) + + # Resolve actual model composition for this request's year. The browser's + # former state/year lookup is neither the authority nor an input here. + p = system.parameters(year) + state_benefits = set(p.gov.household.household_state_benefits) + childcare_programs = set(p.gov.hhs.ccdf.child_care_subsidy_programs) + included_payments = np.zeros_like(values["child_care_subsidies"]) + for name in sorted(state_benefits & childcare_programs): + included_payments += calculate(name) + + payments = values["child_care_subsidies"].copy() + gross = calculate("pre_subsidy_childcare_expenses") + costs = calculate("childcare_expenses") + if (gross < 0).any() or (costs < 0).any() or (payments < 0).any(): + raise ValueError( + "Childcare accounting requires nonnegative costs and payments." + ) + subsidy = np.minimum(payments, np.maximum(gross - costs, 0)) + deducted_cost = costs + subsidy + embedded_services = ( + values["household_health_benefits"] + values["household_head_start_benefits"] + ) + benefits = ( + values["household_benefits"] - included_payments - embedded_services + subsidy + ) + financial = ( + values["household_market_income"] + + benefits + + values["household_refundable_tax_credits"] + - values["household_tax_before_refundable_credits"] + - values["household_health_costs"] + - deducted_cost + ) + healthcare = values["healthcare_benefit_value"].copy() + head_start = values["head_start"].copy() + early_head_start = values["early_head_start"].copy() + education = head_start + early_head_start + + # Aliases keep existing tables/grids usable, but all money adjustments now + # happen here. Original unadjusted model outputs remain in response.result. + values.update( + household_benefits=benefits, + household_state_benefits=values["household_state_benefits"] - included_payments, + household_health_benefits=np.zeros_like(benefits), + household_head_start_benefits=np.zeros_like(benefits), + household_net_income=financial, + household_net_income_including_health_benefits=financial + healthcare, + financial_resources=financial, + healthcare_service_value=healthcare, + early_education_service_value=education, + combined_resources=financial + healthcare + education, + child_care_subsidies=subsidy, + childcare_gross_cost=gross, + childcare_out_of_pocket=costs, + childcare_cost_deducted=deducted_cost, + childcare_provider_payment=payments, + childcare_family_share=np.where( + payments > 0, values["vt_ccfap_family_share"], 0 + ), + head_start_service_value=head_start, + early_head_start_service_value=early_head_start, + head_start=np.zeros_like(head_start), + early_head_start=np.zeros_like(early_head_start), + ) + return { + "version": ACCOUNTING_VERSION, + "model_version": model_version, + "year": str(year), + "axis_order": "first_axis_fastest", + "series": {name: array.tolist() for name, array in values.items()}, + } diff --git a/backend/modal_app.py b/backend/modal_app.py index 864bab5..2ba3bab 100644 --- a/backend/modal_app.py +++ b/backend/modal_app.py @@ -11,7 +11,9 @@ .uv_sync(uv_project_dir=backend_dir, frozen=True, groups=[]) .add_local_file(backend_dir / "__init__.py", "/root/backend/__init__.py") .add_local_file(backend_dir / "simulation.py", "/root/backend/simulation.py") + .add_local_file(backend_dir / "accounting.py", "/root/backend/accounting.py") .add_local_file(backend_dir / "web.py", "/root/backend/web.py") + .add_local_file(backend_dir / "uk.py", "/root/backend/uk.py") ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5c2cd3b..e65f6cb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,10 +7,11 @@ dependencies = [ "fastapi>=0.115,<1", "modal>=1,<2", "uvicorn>=0.30,<1", + "httpx>=0.27,<1", ] [dependency-groups] -dev = ["pytest>=8,<10", "httpx>=0.27,<1", "ruff>=0.12,<1"] +dev = ["pytest>=8,<10", "ruff>=0.12,<1"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -18,4 +19,3 @@ pythonpath = [".."] [tool.ruff] line-length = 88 - diff --git a/backend/simulation.py b/backend/simulation.py index 7bee22f..257e7fb 100644 --- a/backend/simulation.py +++ b/backend/simulation.py @@ -243,13 +243,24 @@ def _apply_vermont_family_share(simulation, year): def calculate_household( - household, include_head_start_benefits=False, ccdf_participation_filter=False + household, + include_head_start_benefits=False, + ccdf_participation_filter=False, + accounting_version=None, ): if type(include_head_start_benefits) is not bool: raise InvalidHousehold("include_head_start_benefits must be a boolean.") if type(ccdf_participation_filter) is not bool: raise InvalidHousehold("ccdf_participation_filter must be a boolean.") - system = get_system(include_head_start_benefits) + if accounting_version is not None and ( + type(accounting_version) is not int or accounting_version != 1 + ): + raise InvalidHousehold("Only accounting_version 1 is supported.") + # New accounting reports service values independently of presentation + # choices. Legacy requests retain the original opt-in inclusion behavior. + system = get_system( + False if accounting_version == 1 else include_head_start_benefits + ) requested = validate_household(household, system) result = deepcopy(household) try: @@ -291,7 +302,19 @@ def calculate_household( index = simulation.get_population(plural).get_index(entity_id) entity_result = values[index] result[plural][entity_id][name][period] = entity_result - return {"status": "ok", "result": result, "model_version": MODEL_VERSION} + response = {"status": "ok", "result": result, "model_version": MODEL_VERSION} + if accounting_version == 1: + from backend.accounting import build_accounting + + try: + response["accounting"] = build_accounting( + simulation, requested, requested[0][3], MODEL_VERSION + ) + except Exception as error: + raise CalculationError( + "Could not reconcile household resources." + ) from error + return response @lru_cache(maxsize=2) diff --git a/backend/tests/test_accounting.py b/backend/tests/test_accounting.py new file mode 100644 index 0000000..3ab72b8 --- /dev/null +++ b/backend/tests/test_accounting.py @@ -0,0 +1,159 @@ +from copy import deepcopy + +import numpy as np +import pytest +from backend.simulation import calculate_household +from backend.tests.test_simulation import household as household_fixture +from backend.tests.test_vermont_copay import infant_household +from backend.web import web_app +from fastapi.testclient import TestClient + +household = household_fixture + + +def assert_reconciled(series): + financial = np.asarray(series["financial_resources"]) + identity = ( + np.asarray(series["household_market_income"]) + + series["household_benefits"] + + series["household_refundable_tax_credits"] + - np.asarray(series["household_tax_before_refundable_credits"]) + - series["household_health_costs"] + - series["childcare_cost_deducted"] + ) + np.testing.assert_allclose(financial, identity, atol=0.01) + np.testing.assert_allclose(series["household_net_income"], financial) + np.testing.assert_allclose( + series["household_net_income_including_health_benefits"], + financial + series["healthcare_service_value"], + ) + np.testing.assert_allclose( + series["combined_resources"], + financial + + series["healthcare_service_value"] + + series["early_education_service_value"], + ) + assert all(type(v) is float for values in series.values() for v in values) + assert all(np.isfinite(values).all() for values in series.values()) + + +def test_accounting_separates_services_and_sums_multiple_tax_units(household): + response = calculate_household(household, accounting_version=1) + accounting = response["accounting"] + series = accounting["series"] + raw = response["result"] + assert accounting["version"] == 1 + assert accounting["year"] == "2026" + assert accounting["model_version"] == response["model_version"] + assert series["household_market_income"] == [25000] + assert series["household_net_income"][0] == pytest.approx( + raw["households"]["household"]["household_net_income"]["2026"], abs=0.05 + ) + assert series["eitc"][0] == pytest.approx( + sum(unit["eitc"]["2026"] for unit in raw["tax_units"].values()) + ) + assert series["head_start"] == [0] + assert series["head_start_service_value"][0] > 0 + assert series["healthcare_service_value"][0] > 0 + assert series["is_head_start_eligible"][0] == 1.0 + assert_reconciled(series) + # The old endpoint contract remains unmodified and still honors its flag. + assert "accounting" not in calculate_household(household) + assert ( + calculate_household( + household, include_head_start_benefits=True, accounting_version=1 + )["accounting"] + == accounting + ) + + +def test_vermont_provider_excess_is_never_financial_income(): + source = infant_household() + response = calculate_household( + source, ccdf_participation_filter=True, accounting_version=1 + ) + series = response["accounting"]["series"] + raw = response["result"] + assert series["childcare_provider_payment"][0] == pytest.approx(18564, abs=0.05) + assert series["childcare_family_share"][0] == pytest.approx(2600, abs=0.05) + assert series["childcare_out_of_pocket"][0] == pytest.approx(2600, abs=0.05) + assert series["child_care_subsidies"][0] == pytest.approx(10400, abs=0.05) + assert series["childcare_cost_deducted"][0] == pytest.approx(13000, abs=0.05) + assert raw["spm_units"]["spm"]["child_care_subsidies"]["2026"] > 18563 + assert_reconciled(series) + + +@pytest.mark.parametrize("no_funding", [False, True]) +def test_accounting_preserves_no_care_and_unfunded_costs(no_funding): + source = infant_household() + if no_funding: + source["spm_units"]["spm"]["vt_child_care_subsidies"]["2026"] = 0 + else: + source["people"]["paid"]["pre_subsidy_childcare_expenses"]["2026"] = 0 + series = calculate_household( + source, ccdf_participation_filter=True, accounting_version=1 + )["accounting"]["series"] + assert series["child_care_subsidies"] == [0] + assert series["childcare_provider_payment"] == [0] + assert series["childcare_family_share"] == [0] + assert series["childcare_cost_deducted"][0] == pytest.approx( + 13000 if no_funding else 0, abs=0.05 + ) + assert_reconciled(series) + + +def test_accounting_grid_matches_scalars_and_request_state_does_not_leak(): + source = infant_household() + source["axes"] = [ + [ + { + "name": "employment_income", + "count": 2, + "min": 25000, + "max": 45000, + "index": index, + "period": "2026", + } + ] + for index in (0, 1) + ] + grid = calculate_household( + source, ccdf_participation_filter=True, accounting_version=1 + )["accounting"] + assert grid["axis_order"] == "first_axis_fastest" + assert_reconciled(grid["series"]) + for index, (head, partner) in enumerate( + [(25000, 25000), (45000, 25000), (25000, 45000), (45000, 45000)] + ): + point = deepcopy(source) + point.pop("axes") + point["people"]["you"]["employment_income"]["2026"] = head + point["people"]["partner"]["employment_income"]["2026"] = partner + scalar = calculate_household( + point, ccdf_participation_filter=True, accounting_version=1 + )["accounting"]["series"] + for name, values in scalar.items(): + assert grid["series"][name][index] == pytest.approx(values[0], abs=0.05) + assert ( + calculate_household( + source, ccdf_participation_filter=True, accounting_version=1 + )["accounting"] + == grid + ) + + +def test_http_accounting_versions_and_numeric_boolean_outputs(household): + client = TestClient(web_app) + response = client.post( + "/us/calculate", json={"household": household, "accounting_version": 1} + ) + assert response.status_code == 200 + assert_reconciled(response.json()["accounting"]["series"]) + for value in (True, "1", 2, 0): + assert ( + client.post( + "/us/calculate", + json={"household": household, "accounting_version": value}, + ).status_code + == 422 + ) diff --git a/backend/tests/test_uk_accounting.py b/backend/tests/test_uk_accounting.py new file mode 100644 index 0000000..0f7546f --- /dev/null +++ b/backend/tests/test_uk_accounting.py @@ -0,0 +1,139 @@ +from copy import deepcopy + +import httpx +import pytest +from backend.simulation import CalculationError, InvalidHousehold +from backend.uk import calculate_uk_household, validate_uk_household + + +def household(tenure="RENT_PRIVATELY", rent=12000, axis=False): + result = { + "people": {"you": {"age": {"2026": 35}}}, + "benunits": { + "family": {"members": ["you"], "universal_credit": {"2026": None}} + }, + "households": { + "home": { + "members": ["you"], + "rent": {"2026": rent}, + "tenure_type": {"2026": tenure}, + "household_net_income": {"2026": None}, + } + }, + } + if axis: + result["axes"] = [ + [ + { + "name": "employment_income", + "count": 2, + "index": 0, + "min": 0, + "max": 10000, + "period": "2026", + } + ] + ] + return result + + +def mock_model(monkeypatch, source, net=40000, benefit=5000): + result = deepcopy(source) + result["households"]["home"]["household_net_income"]["2026"] = net + result["benunits"]["family"]["universal_credit"]["2026"] = benefit + + def post(url, **kwargs): + assert url == "https://api.policyengine.org/uk/calculate" + assert kwargs["follow_redirects"] is False + return httpx.Response( + 200, + json={"status": "ok", "result": result}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(httpx, "post", post) + + +@pytest.mark.parametrize( + "tenure,expected", + [ + ("RENT_PRIVATELY", 28000), + ("RENT_FROM_COUNCIL", 28000), + ("RENT_FROM_HA", 28000), + ("OWNED_OUTRIGHT", 40000), + ("OWNED_WITH_MORTGAGE", 40000), + ], +) +def test_cost_accounting_deducts_only_renter_costs(monkeypatch, tenure, expected): + source = household(tenure) + mock_model(monkeypatch, source) + response = calculate_uk_household(source, 1) + series = response["accounting"]["series"] + assert series["financial_resources"] == [expected] + assert series["household_net_income"] == [expected] + assert series["rent_deducted"] == [40000 - expected] + assert series["universal_credit"] == [5000] + assert series["combined_resources"] == [expected] + assert ( + response["result"]["households"]["home"]["household_net_income"]["2026"] + == 40000 + ) + assert source["households"]["home"]["household_net_income"]["2026"] is None + + +def test_grid_deducts_same_cost_at_each_point(monkeypatch): + source = household(axis=True) + mock_model(monkeypatch, source, net=[20000, 40000], benefit=[5000, 0]) + response = calculate_uk_household(source, 1) + assert response["accounting"]["axis_order"] == "first_axis_fastest" + assert response["accounting"]["series"]["financial_resources"] == [8000, 28000] + + +def test_partial_upstream_results_are_not_silently_zero(monkeypatch): + source = household(axis=True) + mock_model(monkeypatch, source, net=[40000], benefit=[5000, 0]) + with pytest.raises(CalculationError, match="incomplete"): + calculate_uk_household(source, 1) + + +def test_upstream_failure_is_retryable(monkeypatch): + def failure(*args, **kwargs): + raise httpx.ReadTimeout("timeout") + + monkeypatch.setattr(httpx, "post", failure) + with pytest.raises(CalculationError, match="unavailable"): + calculate_uk_household(household(), 1) + + +def test_rejects_unbounded_work_and_unknown_entities(): + source = household(axis=True) + source["axes"][0][0]["count"] = 100000 + with pytest.raises(InvalidHousehold): + validate_uk_household(source) + source = household() + source["tax_units"] = {} + with pytest.raises(InvalidHousehold): + validate_uk_household(source) + with pytest.raises(InvalidHousehold): + calculate_uk_household(household(), True) + + +def test_http_route_uses_the_versioned_accounting(monkeypatch): + from backend.web import web_app + from fastapi.testclient import TestClient + + source = household() + mock_model(monkeypatch, source) + with TestClient(web_app) as client: + response = client.post( + "/uk/calculate", json={"household": source, "accounting_version": 1} + ) + assert response.status_code == 200 + assert response.json()["accounting"]["series"]["financial_resources"] == [28000] + assert ( + client.post( + "/uk/calculate", json={"household": source, "accounting_version": 2} + ).status_code + == 422 + ) + assert client.post("/uk/calculate", content="x" * 262145).status_code == 413 diff --git a/backend/uk.py b/backend/uk.py new file mode 100644 index 0000000..4ed4bfd --- /dev/null +++ b/backend/uk.py @@ -0,0 +1,187 @@ +"""UK v1 model adapter with the calculator's existing rent accounting. + +The public PolicyEngine API still performs UK policy calculations. Only the +calculator's household-cost reconciliation moves here from the browser. +""" + +import json +import math +import re +from copy import deepcopy + +import httpx + +from backend.simulation import ( + MAX_AMOUNT, + MAX_AXIS_COUNT, + MAX_INPUTS, + MAX_OUTPUTS, + MAX_PAYLOAD_BYTES, + MAX_PEOPLE, + CalculationError, + InvalidHousehold, + _year, +) + +UK_API_URL = "https://api.policyengine.org/uk/calculate" +RENTED_TENURES = {"RENT_PRIVATELY", "RENT_FROM_COUNCIL", "RENT_FROM_HA"} + + +def validate_uk_household(household): + if not isinstance(household, dict): + raise InvalidHousehold("household must be an object.") + try: + size = len(json.dumps(household, allow_nan=False).encode()) + except (TypeError, ValueError) as error: + raise InvalidHousehold("Inputs must be finite JSON values.") from error + if size > MAX_PAYLOAD_BYTES: + raise InvalidHousehold("Household exceeds the request size limit.") + if set(household) - {"people", "benunits", "households", "axes"}: + raise InvalidHousehold("Unknown entity collection.") + people = household.get("people") + if not isinstance(people, dict) or not 1 <= len(people) <= MAX_PEOPLE: + raise InvalidHousehold(f"Provide between 1 and {MAX_PEOPLE} people.") + requested, years, inputs = [], set(), 0 + for plural in ("people", "benunits", "households"): + group = household.get(plural) + if not isinstance(group, dict) or not 1 <= len(group) <= len(people): + raise InvalidHousehold(f"Invalid {plural} collection.") + if plural == "households" and len(group) != 1: + raise InvalidHousehold("Provide exactly one household per request.") + for name, values in group.items(): + if not isinstance(name, str) or not 1 <= len(name) <= 120: + raise InvalidHousehold("Entity identifiers must be short strings.") + if not isinstance(values, dict): + raise InvalidHousehold("Entity inputs must be objects.") + for variable, periods in values.items(): + if variable == "members" and plural != "people": + if not isinstance(periods, list) or len(periods) > len(people): + raise InvalidHousehold("Invalid entity membership.") + if any(not isinstance(p, str) or p not in people for p in periods): + raise InvalidHousehold("Unknown person in entity membership.") + continue + if not re.fullmatch(r"[a-z][a-z0-9_]{0,119}", variable): + raise InvalidHousehold("Invalid variable name.") + if not isinstance(periods, dict) or not periods: + raise InvalidHousehold("Variables must contain annual values.") + for period, value in periods.items(): + years.add(_year(period)) + if value is None: + requested.append((plural, name, variable, str(period))) + else: + inputs += 1 + if isinstance(value, (int, float)): + if not math.isfinite(value) or abs(value) > MAX_AMOUNT: + raise InvalidHousehold("Input is outside the range.") + elif not isinstance(value, str) or len(value) > 200: + raise InvalidHousehold("Inputs must be scalar values.") + if len(years) != 1 or not requested: + raise InvalidHousehold("Request outputs for one calculation year.") + if len(requested) > MAX_OUTPUTS or inputs > MAX_INPUTS: + raise InvalidHousehold("Too many inputs or outputs.") + year = next(iter(years)) + count = 1 + axes = household.get("axes", []) + if not isinstance(axes, list) or len(axes) > 2: + raise InvalidHousehold("At most two income axes are supported.") + for group in axes: + if not isinstance(group, list) or len(group) != 1: + raise InvalidHousehold("Each axis must be a single dimension.") + axis = group[0] + if not isinstance(axis, dict) or axis.get("name") != "employment_income": + raise InvalidHousehold("Only employment_income axes are supported.") + if set(axis) - {"name", "count", "index", "min", "max", "period"}: + raise InvalidHousehold("Unknown income axis option.") + points, index = axis.get("count"), axis.get("index", 0) + if type(points) is not int or not 1 <= points <= MAX_AXIS_COUNT: + raise InvalidHousehold("Invalid number of income axis points.") + if type(index) is not int or not 0 <= index < len(people): + raise InvalidHousehold("Unknown income axis person.") + if _year(axis.get("period")) != year: + raise InvalidHousehold("Income axes must use the calculation year.") + low, high = axis.get("min"), axis.get("max") + if ( + type(low) not in (int, float) + or type(high) not in (int, float) + or not math.isfinite(low) + or not math.isfinite(high) + or not 0 <= low <= high <= MAX_AMOUNT + ): + raise InvalidHousehold("Invalid income axis bounds.") + count *= points + return requested, year, count + + +def reconcile_uk_result(household, result, requested, year, count): + """Return annual household series; reject partial or malformed responses.""" + series = {} + for plural, name, variable, period in requested: + value = result.get(plural, {}).get(name, {}).get(variable, {}).get(period) + values = value if isinstance(value, list) else [value] + if len(values) != count or any( + not isinstance(v, (int, float)) or not math.isfinite(v) for v in values + ): + raise CalculationError("The UK service returned incomplete results.") + total = series.setdefault(variable, [0.0] * count) + for i, value in enumerate(values): + total[i] += float(value) + if "household_net_income" not in series: + raise CalculationError("The UK service did not return household net income.") + hh = next(iter(household["households"].values())) + tenure = hh.get("tenure_type", {}).get(year, "OWNED_OUTRIGHT") + rent = hh.get("rent", {}).get(year, 0) if tenure in RENTED_TENURES else 0 + if not isinstance(rent, (int, float)) or rent < 0: + raise InvalidHousehold("Rent must be a nonnegative amount.") + series["rent_deducted"] = [float(rent)] * count + series["household_net_income"] = [v - rent for v in series["household_net_income"]] + series["financial_resources"] = list(series["household_net_income"]) + series["healthcare_service_value"] = [0.0] * count + series["early_education_service_value"] = [0.0] * count + series["combined_resources"] = list(series["financial_resources"]) + return { + "version": 1, + "year": year, + "axis_order": "first_axis_fastest", + "series": series, + } + + +def calculate_uk_household(household, accounting_version=None): + if accounting_version is not None and ( + type(accounting_version) is not int or accounting_version != 1 + ): + raise InvalidHousehold("Unsupported accounting version.") + requested, year, count = validate_uk_household(household) + situation = deepcopy(household) + if accounting_version == 1 and not any( + v == "household_net_income" for _, _, v, _ in requested + ): + name = next(iter(situation["households"])) + situation["households"][name]["household_net_income"] = {year: None} + requested.append(("households", name, "household_net_income", year)) + try: + response = httpx.post( + UK_API_URL, + json={"household": situation}, + timeout=110, + follow_redirects=False, + ) + response.raise_for_status() + data = response.json() + except (httpx.HTTPError, ValueError) as error: + raise CalculationError( + "The UK calculation service is unavailable. Try again." + ) from error + if not isinstance(data, dict) or data.get("status") == "error": + raise CalculationError( + "The UK calculation service could not complete the request." + ) + result = data.get("result", data) + if not isinstance(result, dict): + raise CalculationError("The UK service returned incomplete results.") + output = {"status": "ok", "result": result} + if accounting_version == 1: + output["accounting"] = reconcile_uk_result( + situation, result, requested, year, count + ) + return output diff --git a/backend/uv.lock b/backend/uv.lock index 25e852a..a1926d8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -822,6 +822,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, + { name = "httpx" }, { name = "modal" }, { name = "policyengine-us" }, { name = "uvicorn" }, @@ -829,7 +830,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "pytest" }, { name = "ruff" }, ] @@ -837,6 +837,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "httpx", specifier = ">=0.27,<1" }, { name = "modal", specifier = ">=1,<2" }, { name = "policyengine-us", specifier = "==1.824.1" }, { name = "uvicorn", specifier = ">=0.30,<1" }, @@ -844,7 +845,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.27,<1" }, { name = "pytest", specifier = ">=8,<10" }, { name = "ruff", specifier = ">=0.12,<1" }, ] diff --git a/backend/web.py b/backend/web.py index 0254a91..0a30f33 100644 --- a/backend/web.py +++ b/backend/web.py @@ -55,6 +55,7 @@ async def calculate(request: Request): "household", "include_head_start_benefits", "ccdf_participation_filter", + "accounting_version", }: raise InvalidHousehold("Unknown request fields.") return await run_in_threadpool( @@ -62,6 +63,7 @@ async def calculate(request: Request): params.get("household"), params.get("include_head_start_benefits", False), params.get("ccdf_participation_filter", False), + params.get("accounting_version"), ) except (ValueError, InvalidHousehold) as error: return JSONResponse( @@ -71,3 +73,39 @@ async def calculate(request: Request): return JSONResponse( status_code=500, content={"status": "error", "message": str(error)} ) + + +@web_app.post("/uk/calculate") +async def calculate_uk(request: Request): + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > MAX_PAYLOAD_BYTES: + return JSONResponse( + status_code=413, + content={"status": "error", "message": "Request size limit exceeded."}, + ) + try: + import json + + from backend.uk import calculate_uk_household + + params = json.loads(body) + if not isinstance(params, dict) or set(params) - { + "household", + "accounting_version", + }: + raise InvalidHousehold("Unknown request fields.") + return await run_in_threadpool( + calculate_uk_household, + params.get("household"), + params.get("accounting_version"), + ) + except ValueError as error: + return JSONResponse( + status_code=422, content={"status": "error", "message": str(error)} + ) + except CalculationError as error: + return JSONResponse( + status_code=500, content={"status": "error", "message": str(error)} + ) diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..82533bf --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,41 @@ +# Calculator checks + +Install the committed dependency versions before running checks: + +```sh +bun install --frozen-lockfile +uv sync --project backend --frozen --python 3.12 +``` + +`bun run test:ci` starts the checkout's pinned US backend on an available local +port, waits for the matching model version, runs frontend unit and US integration +tests, and stops the server. It does not depend on a deployed API. The standard +suite rejects external requests and calls to the UK proxy. One Vitest worker +runs at a time because individual tests already submit multiple model grids. + +Run the Python tests separately with `bun run test:backend`. The checked-in +GitHub Actions workflow runs both suites, backend formatting and lint, frontend +lint, and a production build for pull requests and pushes to main. + +For a focused frontend check, pass test paths to the same local runner: + +```sh +bun run test:ci tests/request-recovery.test.jsx tests/api.test.js +``` + +## Optional UK integration checks + +`bun run test:live:uk` starts the current checkout's backend and runs the UK +integration suites through its UK adapter. These tests call the external public +PolicyEngine UK model, so their availability and results depend on that service. +They run separately from standard CI. To run them in GitHub Actions, dispatch +the separate **Live UK integration checks** workflow. + +The optional suites include the UK API shape, household-input effects, and +combined-input reconciliation checks. Standard CI retains UK input construction, +mocked API handling, and UI tests without external requests. + +The historical `tests/audit-values.test.js` compares older generated numeric +fixtures and remains outside the standard suite, as before this workflow. Use +current pinned-runtime integration checks for the release gate; fixture audits +require confirming that their generator and model version match first. diff --git a/lib/api.js b/lib/api.js index 3743ac2..bbe9ce4 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,7 +1,6 @@ import { getCountry, DEFAULT_BRMA } from "./countries"; -import { addUSChildcareInputs, grossChildcareCost, normalizeUSChildcare, US_CHILDCARE_OUTPUTS, US_CHILDCARE_SERIES } from "./childcare"; +import { addUSChildcareInputs, US_CHILDCARE_OUTPUTS, US_CHILDCARE_SERIES } from "./childcare"; -const API_BASE = "https://api.policyengine.org"; const US_API_BASE = "https://policyengine--marriage-household-api-householdapi-api.modal.run"; // ---------- per-country computed data (cached) ---------- @@ -278,106 +277,65 @@ function addOutputVariables(countryId, situation, year, regionCode) { async function callApi(countryId, situation, extras = {}) { const country = getCountry(countryId); - const base = countryId === "us" ? (process.env.NEXT_PUBLIC_US_API_URL || US_API_BASE) : API_BASE; - const res = await fetch(`${base}${country.apiPath}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ household: situation, ...(countryId === "us" ? { - include_head_start_benefits: Boolean(extras.includeHeadStart), - ccdf_participation_filter: extras.ccdfSlotAvailable !== false, - } : {}) }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`API error ${res.status}: ${text}`); - } - - const data = await res.json(); - if (data.status === "error") { - throw new Error(data.message || "Calculation error"); - } - if (countryId === "us" && data.model_version !== country.metadata.modelVersion) { - throw new Error("The calculation service uses a different model version. Please refresh and try again."); - } - return data.result || data; -} - -// ---------- extraction helpers ---------- - -function extractVal(result, entity, name, variable, year) { - const val = result?.[entity]?.[name]?.[variable]?.[year]; - if (val == null) return 0; - if (Array.isArray(val)) return val[0]; - return val; -} - -function extractArray(result, entity, name, variable, year) { - const val = result?.[entity]?.[name]?.[variable]?.[year]; - if (Array.isArray(val)) return val; - return val != null ? [val] : []; -} - -function sumPersonVar(result, variable, year) { - const people = result?.people || {}; - let total = 0; - for (const person of Object.values(people)) { - const val = person?.[variable]?.[year]; - if (typeof val === "number" || typeof val === "boolean") total += Number(val); - else if (Array.isArray(val)) total += val[0] || 0; - } - return total; -} - -function sumPersonArray(result, variable, year) { - const people = result?.people || {}; - let total = null; - for (const person of Object.values(people)) { - const val = person?.[variable]?.[year]; - if (Array.isArray(val)) { - if (!total) total = new Array(val.length).fill(0); - for (let i = 0; i < val.length; i++) total[i] += val[i] || 0; - } else if (typeof val === "number" || typeof val === "boolean") { - if (!total) total = [Number(val)]; - else total[0] += Number(val); + const base = process.env.NEXT_PUBLIC_US_API_URL || US_API_BASE; + const controller = new AbortController(); + const abort = () => controller.abort(extras.signal?.reason); + if (extras.signal?.aborted) abort(); + else extras.signal?.addEventListener("abort", abort, { once: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, 120000); + try { + const res = await fetch(`${base}${country.apiPath}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ household: situation, accounting_version: 1, + ...(countryId === "us" ? { + ccdf_participation_filter: extras.ccdfSlotAvailable !== false, + } : {}), + }), + }); + if (!res.ok) { + throw new Error(`The calculation service could not complete this request (${res.status}). Please try again.`); } + const data = await res.json(); + if (data.status === "error") throw new Error(data.message || "Calculation error"); + if (countryId === "us" && data.model_version !== country.metadata.modelVersion) { + throw new Error("The calculation service uses a different model version. Please refresh and try again."); + } + const accounting = data.accounting; + const required = new Set(["financial_resources", "healthcare_service_value", "early_education_service_value", "combined_resources", + ...(countryId === "us" ? [...US_CHILDCARE_SERIES, "household_market_income", "household_health_costs"] : ["rent_deducted"]), + ]); + for (const [plural, entities] of Object.entries(situation)) { + if (plural === "axes") continue; + for (const entity of Object.values(entities)) { + for (const [variable, periods] of Object.entries(entity)) { + if (!Array.isArray(periods) && Object.values(periods || {}).includes(null)) required.add(variable); + } + } + } + const count = (situation.axes || []).reduce((n, group) => n * group[0].count, 1); + if (accounting?.version !== 1 || accounting.axis_order !== "first_axis_fastest" + || !accounting.series || [...required].some(variable => !Array.isArray(accounting.series[variable])) + || Object.values(accounting.series).some(values => !Array.isArray(values) + || values.length !== count || values.some(value => typeof value !== "number" || !Number.isFinite(value)))) { + throw new Error("The calculation service returned incomplete results. Please refresh and try again."); + } + return { ...data.result, accounting }; + } catch (error) { + if (timedOut) throw new Error("The calculation took too long. Please try again."); + throw error; + } finally { + clearTimeout(timer); + extras.signal?.removeEventListener("abort", abort); } - return total || []; -} - -// ---------- metadata-driven extraction (parameterized by country) ---------- - -function extractMetaVal(countryId, result, entry, year) { - const { country } = countryData(countryId); - const { variable, entity } = entry; - if (entity === "person") return sumPersonVar(result, variable, year); - const c = country.entityContainers[entity]; - if (!c) return 0; - return Object.keys(result?.[c.key] || {}).reduce( - (sum, name) => sum + extractVal(result, c.key, name, variable, year), 0, - ); -} - -function extractMetaDict(countryId, result, entries, year) { - const dict = {}; - for (const entry of entries) { - dict[entry.variable] = extractMetaVal(countryId, result, entry, year); - } - return dict; } -function extractMetaArray(countryId, result, entry, year) { - const { country } = countryData(countryId); - const { variable, entity } = entry; - if (entity === "person") return sumPersonArray(result, variable, year); - const c = country.entityContainers[entity]; - if (!c) return []; - const arrays = Object.keys(result?.[c.key] || {}).map( - name => extractArray(result, c.key, name, variable, year), - ); - const length = Math.max(0, ...arrays.map(arr => arr.length)); - return Array.from({ length }, (_, i) => arrays.reduce((sum, arr) => sum + (arr[i] || 0), 0)); -} +// ---------- extraction helpers ---------- // The API varies the first axis (You) fastest. Internally cells use // headIdx * count + spouseIdx, so normalize both two-income scenarios once. @@ -389,14 +347,19 @@ function normalizeIncomeGrid(array, count) { } function extractAllArrays(countryId, result, year, count = null) { - const { allVars } = countryData(countryId); - const data = {}; - for (const entry of allVars) { - data[entry.variable] = normalizeIncomeGrid(extractMetaArray(countryId, result, entry, year), count); - } - return data; + if (!result) return {}; + // Accounting is performed once by the service, for scalars and every axis + // point. The browser only changes the axis order used by the chart. + return Object.fromEntries(Object.entries(result.accounting.series).map( + ([variable, values]) => [variable, normalizeIncomeGrid(values, count)], + )); } +const ACCOUNTING_SERIES = [ + "financial_resources", "healthcare_service_value", "early_education_service_value", + "combined_resources", "rent_deducted", "household_market_income", "household_health_costs", ...US_CHILDCARE_SERIES, +]; + // ---------- separate-household extras ---------- // When the couple splits, the UK inputs have to be allocated between the two @@ -448,39 +411,6 @@ export function isRentedTenure(tenureType) { ].includes(tenureType || UK_EXTRAS_DEFAULTS.tenureType); } -export function housingCostFor(extras = {}) { - return isRentedTenure(extras.tenureType) ? extras.rent || 0 : 0; -} - -export function deductRent(countryId, aggregates, extras = {}) { - const country = getCountry(countryId); - const rent = housingCostFor(extras); - if (!country.deductRentFromNetIncome || !rent) return aggregates; - return { - ...aggregates, - householdNetIncome: aggregates.householdNetIncome - rent, - householdNetIncomeWithHealth: aggregates.householdNetIncomeWithHealth - rent, - rentDeducted: rent, - }; -} - -// ---------- aggregate extraction helper ---------- - -function extractAggregates(countryId, result, year) { - const { country, m } = countryData(countryId); - const am = country.aggregateMap; - const aggs = {}; - for (const [key, varName] of Object.entries(am)) { - if (!varName) { - aggs[key] = 0; - continue; - } - const entry = m.aggregates.find((a) => a.variable === varName); - aggs[key] = entry ? extractMetaVal(countryId, result, entry, year) : 0; - } - return aggs; -} - // ---------- public API ---------- export async function getPrograms( @@ -500,38 +430,34 @@ export async function getPrograms( const result = await callApi(countryId, situation, extras); const series = extractAllArrays(countryId, result, year); - if (countryId === "us") normalizeUSChildcare(series, regionCode, grossChildcareCost(children, extras), extras); const values = entries => Object.fromEntries(entries.map(entry => [entry.variable, series[entry.variable]?.[0] || 0])); // Per-state credits (US only) const stateCreditEntries = getStateCreditEntries(m, regionCode); const perStateDict = {}; for (const entry of stateCreditEntries) { - perStateDict[entry.label] = extractMetaVal(countryId, result, entry, year); + perStateDict[entry.label] = series[entry.variable]?.[0] || 0; } - const aggregates = deductRent(countryId, extractAggregates(countryId, result, year), extras); - if (countryId === "us") { - for (const [key, variable] of Object.entries(getCountry(countryId).aggregateMap)) { - aggregates[key] = series[variable]?.[0] || 0; - } - aggregates.childcareCostDeducted = series.childcare_cost_deducted?.[0] || 0; - } + const aggregates = Object.fromEntries(Object.entries(getCountry(countryId).aggregateMap) + .map(([key, variable]) => [key, series[variable]?.[0] || 0])); + if (countryId === "us") aggregates.childcareCostDeducted = series.childcare_cost_deducted?.[0] || 0; + else aggregates.rentDeducted = series.rent_deducted?.[0] || 0; return { aggregates, benefits: values(m.benefits), ...(countryId === "us" ? { childcare: childcareResult(variable => series[variable]?.[0] || 0, extras) } : {}), - health: extractMetaDict(countryId, result, m.healthcare || [], year), - credits: extractMetaDict(countryId, result, m.credits || [], year), + health: values(m.healthcare || []), + credits: values(m.credits || []), stateCredits: m.stateCredits?.length ? { - state_refundable_credits: extractMetaVal(countryId, result, m.stateCredits[0], year), + state_refundable_credits: series[m.stateCredits[0].variable]?.[0] || 0, ...perStateDict, } : {}, - taxes: extractMetaDict(countryId, result, m.taxes, year), - stateTaxes: extractMetaDict(countryId, result, m.stateTaxes || [], year), + taxes: values(m.taxes), + stateTaxes: values(m.stateTaxes || []), }; } @@ -653,31 +579,21 @@ export async function getHeatmapData( const headData = extractAllArrays(countryId, headResult, year); const spouseData = extractAllArrays(countryId, spouseResult, year); const unmarriedData = extractAllArrays(countryId, unmarriedResult, year, cohabiting ? count : null); - if (countryId === "us") { - const cost = grossChildcareCost(children, extras); - normalizeUSChildcare(marriedData, regionCode, cost, extras); - if (cohabiting) normalizeUSChildcare(unmarriedData, regionCode, cost, extras); - else { - normalizeUSChildcare(headData, regionCode, cost, extras); - normalizeUSChildcare(spouseData, regionCode, 0, extras); - } - } - const stateCreditEntries = getStateCreditEntries(m, regionCode); const allEntriesForProgram = [...countryData(countryId).allVars, ...stateCreditEntries, - ...(countryId === "us" ? US_CHILDCARE_SERIES.map(variable => ({ variable })) : []), + ...ACCOUNTING_SERIES.map(variable => ({ variable })), ]; const programData = {}; for (const entry of allEntriesForProgram) { const v = entry.variable; programData[v] = { - married: marriedData[v] || normalizeIncomeGrid(extractMetaArray(countryId, marriedResult, entry, year), count), + married: marriedData[v] || [], ...(cohabiting ? { - unmarried: unmarriedData[v] || normalizeIncomeGrid(extractMetaArray(countryId, unmarriedResult, entry, year), count), + unmarried: unmarriedData[v] || [], } : { - head: headData[v] || extractMetaArray(countryId, headResult, entry, year), - spouse: spouseData[v] || extractMetaArray(countryId, spouseResult, entry, year), + head: headData[v] || [], + spouse: spouseData[v] || [], }), }; } @@ -704,18 +620,11 @@ export async function getHeatmapData( const headLines = {}; const spouseLines = {}; - // Rent is not subtracted by household_net_income, so apply it here to each - // scenario's series before differencing. Doing it per-scenario (rather than - // adjusting the delta) also keeps the single-person lines after housing. - const heatmapRent = country.deductRentFromNetIncome ? housingCostFor(extras) : 0; - const afterHousing = (arr, gc) => - heatmapRent && gc.deductRent && arr ? arr.map((v) => v - heatmapRent) : arr; - for (const gc of country.gridConfig) { const { grid: transposed, headLine, spouseLine } = buildDeltaGrid( - afterHousing(marriedData[gc.variable], gc), - afterHousing(headData[gc.variable], gc), - afterHousing(spouseData[gc.variable], gc), + marriedData[gc.variable], + headData[gc.variable], + spouseData[gc.variable], unmarriedData[gc.variable], ); headLines[gc.tab] = headLine; @@ -750,9 +659,7 @@ export async function getHeatmapData( ); } - // extras travels with the payload so buildCellResults can apply the same - // housing-cost treatment as the grid. Without it, clicking a cell showed - // pre-rent figures under a post-rent heatmap. + // Retain non-accounting assumptions for the selected-cell explanation. return { grids, maxIncome, count, programData, stateCreditEntries, headLines, spouseLines, unmarriedGrids, extras }; } @@ -800,10 +707,15 @@ export function buildCellBreakdown( return rows.slice(0, limitPerGroup); } + const expenseEntries = countryId === "us" ? [ + { variable: "childcare_cost_deducted", label: "Childcare costs" }, + { variable: "household_health_costs", label: "Health costs" }, + ] : [{ variable: "rent_deducted", label: "Housing costs" }]; const groups = [ { key: "benefits", title: "Benefits", rows: rowsFor(m.benefits, 1) }, { key: "credits", title: "Credits", rows: rowsFor(m.credits, 1) }, { key: "taxes", title: "Taxes", rows: rowsFor(m.taxes, -1) }, + { key: "costs", title: "Costs", rows: rowsFor(expenseEntries, -1) }, ]; const wanted = only ? groups.filter((g) => g.key === only) : groups; @@ -849,17 +761,9 @@ export function buildCellResults( return aggs; } - // The grid and the metric cards report UK net income after housing costs, - // so the selected cell has to as well. Each separate household pays the rent - // entered, matching splitExtras in the non-heatmap path. - const extrasFor = { - married: extras, - head: splitExtras(extras, "head"), - spouse: splitExtras(extras, "spouse", { childcareCosts: 0 }), - }; - function buildResult(scenario) { - const aggregates = deductRent(countryId, buildAggs(scenario), extrasFor[scenario]); + const aggregates = buildAggs(scenario); + if (countryId === "uk") aggregates.rentDeducted = getVal("rent_deducted", scenario); if (countryId === "us") aggregates.childcareCostDeducted = getVal("childcare_cost_deducted", scenario); return { aggregates, @@ -897,6 +801,6 @@ function childcareResult(value, extras) { earlyHeadStartEligible: value("is_early_head_start_eligible"), headStartValue: value("head_start_service_value"), earlyHeadStartValue: value("early_head_start_service_value"), - includeHeadStart: Boolean(extras.includeHeadStart), + includeHeadStart: false, }; } diff --git a/lib/childcare.js b/lib/childcare.js index 2072533..483563f 100644 --- a/lib/childcare.js +++ b/lib/childcare.js @@ -82,41 +82,3 @@ export function addUSChildcareInputs(situation, children, year, extras = {}) { } return situation; } - -// Normalize every scalar/grid scenario exactly once. Some state childcare -// programs enter household benefits already; others only enter the nationwide -// childcare aggregate. Expose one CCDF row and include the missing amount once. -export function normalizeUSChildcare(data, state, grossCost, extras = {}) { - if (!data.child_care_subsidies) return data; - const included = metadata.states[state]?.includedInStateBenefits; - const payments = data.child_care_subsidies; - const costs = data.childcare_expenses || payments.map(value => Math.max(0, grossCost - value)); - // Provider reimbursement can exceed the price paid by a family. Count only - // the reduction in family spending as its benefit, retaining the full model - // payment separately. The runtime preserves Vermont's collectible copay in - // childcare_expenses, including its effects on other taxes and benefits. - const subsidies = payments.map((value, i) => Math.min(value, Math.max(0, grossCost - costs[i]))); - const deductions = costs.map((value, i) => value + subsidies[i]); - const adjustment = subsidies.map((value, i) => value - (included ? payments[i] : 0)); - const add = (name, values) => { - if (data[name]) data[name] = data[name].map((value, i) => value + values[i]); - }; - if (included) add("household_state_benefits", payments.map(v => -v)); - add("household_benefits", adjustment); - for (const name of ["household_net_income", "household_net_income_including_health_benefits"]) { - add(name, adjustment.map((v, i) => v - deductions[i])); - } - data.childcare_gross_cost = subsidies.map(() => grossCost); - data.childcare_cost_deducted = deductions; - data.child_care_subsidies = subsidies; - data.childcare_provider_payment = [...payments]; - data.childcare_family_share = payments.map((value, i) => state === "VT" && value > 0 ? (data.vt_ccfap_family_share?.[i] || 0) : 0); - data.childcare_out_of_pocket = [...costs]; - data.head_start_service_value = [...(data.head_start || subsidies.map(() => 0))]; - data.early_head_start_service_value = [...(data.early_head_start || subsidies.map(() => 0))]; - if (!extras.includeHeadStart) { - data.head_start = subsidies.map(() => 0); - data.early_head_start = subsidies.map(() => 0); - } - return data; -} diff --git a/lib/countries.js b/lib/countries.js index 0d4f467..8d8dcd9 100644 --- a/lib/countries.js +++ b/lib/countries.js @@ -291,6 +291,9 @@ export const COUNTRIES = { // housing costs for the UK to keep the comparison honest. deductRentFromNetIncome: true, aggregateMap: { + financialResources: "financial_resources", + earlyEducationServiceValue: "early_education_service_value", + combinedResources: "combined_resources", householdNetIncome: "household_net_income", householdNetIncomeWithHealth: "household_net_income", // No separate health metric in UK householdBenefits: "household_benefits", @@ -334,6 +337,11 @@ export const COUNTRIES = { hasNYC: true, // Maps standard aggregate keys to variable names in the API response aggregateMap: { + marketIncome: "household_market_income", + healthCosts: "household_health_costs", + financialResources: "financial_resources", + earlyEducationServiceValue: "early_education_service_value", + combinedResources: "combined_resources", householdNetIncome: "household_net_income", householdNetIncomeWithHealth: "household_net_income_including_health_benefits", householdBenefits: "household_benefits", diff --git a/lib/utils.js b/lib/utils.js index f960856..5f16973 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -37,6 +37,18 @@ const ACRONYMS = { export const PROGRAM_DESCRIPTIONS = { // Summary (category labels from computeTableData) + "Household financial resources": + "Resources after taxes and modeled expenses, including benefits such as SNAP. Healthcare and early education service values are shown separately.", + "Market income": + "Income before taxes and benefits, including employment income and any other modeled market income.", + "Healthcare costs": + "Modeled household healthcare expenses deducted from financial resources. The value of healthcare benefits is shown separately.", + "Healthcare service value": + "Estimated value of healthcare benefits, including Medicaid, CHIP and ACA premium subsidies. Shown separately from household financial resources.", + "Early education service value": + "Estimated Head Start and Early Head Start service values, based on spending per enrollee and eligible participation.", + "Combined resources": + "Household financial resources plus estimated healthcare and early education service values.", "Net income": "Total income after taxes, credits, and benefits. Does not include the value of healthcare coverage.", "Net income (incl. healthcare)": @@ -50,9 +62,9 @@ export const PROGRAM_DESCRIPTIONS = { "Head Start (service value)": "Estimated service value based on state spending per enrollee, assuming eligible participation. This is not a cash payment or a guaranteed place.", "Early Head Start (service value)": "Estimated infant and toddler service value based on state spending per enrollee, assuming eligible participation. This is not a cash payment or a guaranteed place.", "Healthcare benefits": - "Cash equivalent value of healthcare coverage (Medicaid, CHIP, ACA premium subsidies). Not included in net income.", + "Estimated value of healthcare benefits, including Medicaid, CHIP and ACA premium subsidies. Shown separately from household financial resources.", Benefits: - "Government assistance programs like SNAP, TANF, and Head Start. Eligibility often depends on combined household income.", + "Government assistance such as SNAP, TANF and childcare assistance. Healthcare and early education service values are shown separately.", "Refundable tax credits": "Tax credits that can result in a payment even if you owe no tax. Includes EITC and CTC.", "Taxes before refundable credits": @@ -92,7 +104,7 @@ export const PROGRAM_DESCRIPTIONS = { "Additional government benefits not individually listed, as computed by PolicyEngine.", // Healthcare "Medicaid cost": - "Cash equivalent value of Medicaid health coverage. Eligibility thresholds depend on household income; marriage can change eligibility.", + "Estimated value of Medicaid health coverage. Eligibility thresholds depend on household income; marriage can change eligibility.", CHIP: "Children's Health Insurance Program. Provides health coverage for children in families with incomes too high for Medicaid.", "ACA PTC": @@ -203,11 +215,26 @@ function sumDict(dict) { return Object.values(dict || {}).reduce((a, b) => a + b, 0); } -export function computeTableData(results, tab, { showHealth = false, currencySymbol = "$" } = {}) { +export function resourceValues(result) { + const a = result.aggregates; + const financialResources = a.financialResources ?? a.householdNetIncome ?? 0; + const healthcareBenefitValue = a.healthcareBenefitValue ?? 0; + const earlyEducationServiceValue = a.earlyEducationServiceValue + ?? ((result.childcare?.headStartValue || 0) + (result.childcare?.earlyHeadStartValue || 0)); + return { + financialResources, + healthcareBenefitValue, + earlyEducationServiceValue, + combinedResources: a.combinedResources ?? (financialResources + healthcareBenefitValue + earlyEducationServiceValue), + }; +} + +export function computeTableData(results, tab, { showHealth = false, currencySymbol = "$", countryId } = {}) { const { married } = results; const singles = unmarriedScenarios(results); const scenarios = [married, ...singles]; const sym = currencySymbol; + const separateServices = countryId === "us"; function makeRow(program, values, { isTotal = false, invertDelta = false } = {}) { const [m, ...unmarried] = values; @@ -229,6 +256,29 @@ export function computeTableData(results, tab, { showHealth = false, currencySym } if (tab === "summary") { + if (separateServices) { + const agg = key => r => r.aggregates[key] || 0; + const financial = r => resourceValues(r).financialResources; + const marketIncome = r => r.aggregates.marketIncome ?? ( + financial(r) - (r.aggregates.householdBenefits || 0) + - (r.aggregates.householdRefundableCredits || 0) + (r.aggregates.householdTaxBeforeCredits || 0) + + (r.aggregates.healthCosts || 0) + (r.aggregates.childcareCostDeducted || 0) + ); + const definitions = [ + ["Household financial resources", financial, { isTotal: true }], + ["Market income", marketIncome], + ["Benefits", agg("householdBenefits")], + ["Refundable tax credits", agg("householdRefundableCredits")], + ["Taxes before refundable credits", agg("householdTaxBeforeCredits"), { invertDelta: true }], + ["Healthcare costs", agg("healthCosts"), { invertDelta: true }], + ["Childcare costs", agg("childcareCostDeducted"), { invertDelta: true }], + ]; + return definitions.flatMap(([label, value, options]) => { + const values = scenarios.map(value); + return label === "Household financial resources" || label === "Market income" || values.some(v => v !== 0) + ? [makeRow(label, values, options)] : []; + }); + } const netKey = showHealth ? "householdNetIncomeWithHealth" : "householdNetIncome"; const agg = key => r => r.aggregates[key] || 0; // Restore costs already deducted from net income to recover earnings. @@ -260,10 +310,17 @@ export function computeTableData(results, tab, { showHealth = false, currencySym ); const config = { benefits: { - dict: r => ({ ...r.benefits, ...r.health }), - total: r => r.aggregates.householdBenefits + r.aggregates.healthcareBenefitValue, + dict: r => separateServices + ? Object.fromEntries(Object.entries(r.benefits || {}).filter(([key]) => !["head_start", "early_head_start"].includes(key))) + : ({ ...r.benefits, ...r.health }), + total: r => (r.aggregates.householdBenefits || 0) + (separateServices ? 0 : (r.aggregates.healthcareBenefitValue || 0)), totalLabel: "Total benefits", otherLabel: "Other Benefits", }, + healthcare: { + dict: r => r.health || {}, + total: r => r.aggregates.healthcareBenefitValue || 0, + totalLabel: "Healthcare service value", otherLabel: "Other healthcare", + }, credits: { dict: r => ({ ...r.credits, ...stripStateAggregate(r.stateCredits) }), total: r => r.aggregates.householdRefundableCredits, diff --git a/package.json b/package.json index a53d5d6..2b547f0 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,12 @@ "metadata": "node scripts/update-metadata.cjs", "prebuild": "node scripts/update-metadata.cjs", "test": "vitest run", - "test:ci": "vitest run tests/api.test.js tests/design.test.jsx tests/heatmap.test.js tests/page-country.test.jsx tests/metadata-integrity.test.js tests/uk-inputs.test.js tests/uk-live.test.js tests/breakdown.test.js tests/uk-combinations.test.js tests/review-fixes.test.js tests/heatmap-tooltip.test.jsx tests/living-arrangement.test.js tests/living-arrangement-live.test.js tests/living-arrangement-ui.test.jsx tests/childcare.test.js tests/childcare-ui.test.jsx", - "test:watch": "vitest" + "test:ci": "bun scripts/test-ci.mjs", + "test:watch": "vitest", + "test:frontend": "vitest run --config vitest.ci.config.js", + "test:backend": "uv run --project backend --no-sync pytest backend/tests -q", + "test:live:uk": "bun scripts/test-ci.mjs --live-uk", + "test:frontend:live:uk": "vitest run tests/uk-api-live.test.js tests/uk-live.test.js tests/uk-combinations.test.js --maxWorkers=1" }, "dependencies": { "next": "^16.2.6", diff --git a/scripts/test-ci.mjs b/scripts/test-ci.mjs new file mode 100644 index 0000000..49321a4 --- /dev/null +++ b/scripts/test-ci.mjs @@ -0,0 +1,67 @@ +/** Start the pinned local backend, run deterministic frontend tests, and stop it. */ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:net"; + +const args = process.argv.slice(2); +const liveUk = args[0] === "--live-uk"; +if (liveUk) args.shift(); +const listener = createServer(); +listener.listen(0, "127.0.0.1"); +await once(listener, "listening"); +const port = listener.address().port; +await new Promise((resolve) => listener.close(resolve)); +const url = `http://127.0.0.1:${port}`; +const metadata = JSON.parse(await readFile(new URL("../lib/metadata.json", import.meta.url), "utf8")); +const backend = spawn("uv", ["run", "--project", "backend", "--no-sync", "uvicorn", + "backend.web:web_app", "--host", "127.0.0.1", "--port", String(port)], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + stdio: ["ignore", "pipe", "pipe"], +}); +let backendLog = ""; +let backendError; +for (const stream of [backend.stdout, backend.stderr]) { + stream.on("data", chunk => { backendLog = (backendLog + chunk.toString()).slice(-32000); }); +} +backend.on("error", error => { backendError = error; }); +let tests; +const cancel = () => { tests?.kill("SIGTERM"); backend.kill("SIGTERM"); }; +process.once("SIGINT", cancel); +process.once("SIGTERM", cancel); +try { + const deadline = Date.now() + 180000; + let ready = false; + while (Date.now() < deadline) { + if (backendError) throw backendError; + if (backend.exitCode !== null || backend.signalCode !== null) throw new Error("The local backend exited before becoming ready."); + try { + const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) }); + if (response.ok) { + const health = await response.json(); + if (health.status === "ok" && health.model_version === metadata.modelVersion) { ready = true; break; } + } + } catch { /* The model may still be importing. */ } + await new Promise(resolve => setTimeout(resolve, 300)); + } + if (!ready) throw new Error("The pinned local backend did not become ready within three minutes."); + console.log(`Testing against local PolicyEngine US ${metadata.modelVersion} at ${url}`); + tests = spawn("bun", ["run", liveUk ? "test:frontend:live:uk" : "test:frontend", ...args], { + stdio: "inherit", + env: { ...process.env, NEXT_PUBLIC_US_API_URL: url }, + }); + const [code, signal] = await once(tests, "exit"); + process.exitCode = code ?? (signal ? 1 : 0); + if (process.exitCode) console.error(backendLog); +} catch (error) { + console.error(error.message); + console.error(backendLog); + process.exitCode = 1; +} finally { + backend.kill("SIGTERM"); + const forceStop = setTimeout(() => backend.kill("SIGKILL"), 5000); + forceStop.unref(); + if (backend.exitCode === null && backend.signalCode === null) await once(backend, "exit"); + clearTimeout(forceStop); +} diff --git a/tests/accounting-contract.test.js b/tests/accounting-contract.test.js new file mode 100644 index 0000000..3edbb3f --- /dev/null +++ b/tests/accounting-contract.test.js @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getPrograms } from "../lib/api"; +import metadata from "../lib/metadata.json"; + +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); + +function responseFor(body) { + const situation = JSON.parse(body).household; + const series = {}; + for (const [plural, entities] of Object.entries(situation)) { + if (plural === "axes") continue; + for (const entity of Object.values(entities)) { + for (const [variable, periods] of Object.entries(entity)) { + if (periods?.[2026] === null) series[variable] = [0]; + } + } + } + return {model_version: metadata.modelVersion, result: situation, accounting: { + version: 1, axis_order: "first_axis_fastest", series: { ...series, + household_net_income: [32123], household_net_income_including_health_benefits: [36123], + financial_resources: [32123], healthcare_benefit_value: [4000], healthcare_service_value: [4000], + early_education_service_value: [5000], combined_resources: [41123], + household_market_income: [40000], household_health_costs: [0], + childcare_family_share: [0], head_start_service_value: [5000], early_head_start_service_value: [0], + childcare_gross_cost: [10000], childcare_cost_deducted: [10000], childcare_out_of_pocket: [2000], + child_care_subsidies: [8000], childcare_provider_payment: [11000], household_benefits: [9000], + }, + }}; +} +const calculate = extras => getPrograms("us", "CA", 25000, {}, 15000, [], "2026", {}, 40, 40, {}, false, extras); + +describe("backend accounting contract", () => { + it("renders authoritative resources without recalculating them from form costs or inclusion flags", async () => { + vi.stubGlobal("fetch", vi.fn(async (_url, options) => { + expect(JSON.parse(options.body).accounting_version).toBe(1); + expect(JSON.parse(options.body)).not.toHaveProperty("include_head_start_benefits"); + return {ok: true, json: async () => responseFor(options.body)}; + })); + const result = await calculate({includeHeadStart: true}); + expect(result.aggregates.financialResources).toBe(32123); + expect(result.aggregates.householdNetIncome).toBe(32123); + expect(result.aggregates.combinedResources).toBe(41123); + expect(result.childcare.outOfPocket).toBe(2000); + expect(result.childcare.subsidy).toBe(8000); + expect(result.childcare.providerPayment).toBe(11000); + }); + + it.each(["snap", "childcare_out_of_pocket", "childcare_cost_deducted", "household_market_income"])("rejects missing %s instead of silently displaying zero", async (variable) => { + vi.stubGlobal("fetch", vi.fn(async (_url, options) => { + const response = responseFor(options.body); + delete response.accounting.series[variable]; + return {ok: true, json: async () => response}; + })); + await expect(calculate()).rejects.toThrow("incomplete results"); + }); + + it("aborts a superseded request", async () => { + const controller = new AbortController(); + vi.stubGlobal("fetch", vi.fn((_url, options) => new Promise((_, reject) => { + options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }))); + const pending = calculate({signal: controller.signal}); + controller.abort(); + await expect(pending).rejects.toMatchObject({name: "AbortError"}); + }); + + it("gives an actionable error when the service times out", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn((_url, options) => new Promise((_, reject) => { + options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }))); + const pending = expect(calculate()).rejects.toThrow("took too long"); + await vi.advanceTimersByTimeAsync(120000); + await pending; + }); +}); diff --git a/tests/api.test.js b/tests/api.test.js index 5cb93e2..7a1d501 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,15 +1,14 @@ /** - * Integration tests for api.js — calls the live PolicyEngine API + * Integration tests for api.js — calls the pinned local US runtime * and verifies that every metadata-driven variable is properly extracted, * breakdowns sum to aggregates, and the result shape is correct. * - * Run: npm test + * Run: bun run test:ci */ import { describe, it, expect, beforeAll } from "vitest"; import { getPrograms, getCategorizedPrograms, buildCellResults } from "../lib/api.js"; import metadata from "../lib/metadata.json"; -import ukMetadata from "../lib/metadata-uk.json"; // Tolerance for floating-point aggregate vs sum-of-parts comparisons. // The API returns rounded values; $2 covers any rounding. @@ -378,113 +377,3 @@ describe("Scenario 5: Low-income single CA $10k to trigger benefits", () => { ); }); }); - -// ========================================================================== -// UK TESTS -// ========================================================================== - -// ---------- UK Scenario 1: Married couple, England, £30k/£30k ---------- - -describe("UK Scenario 1: Married England £30k/£30k no children", () => { - let result; - - beforeAll(async () => { - result = await getPrograms( - "uk", - "ENGLAND", - 30000, - { head: false, spouse: false }, - 30000, - [], - "2025", - ); - }, 60000); - - it("returns all aggregate keys as numbers", () => { - const agg = result.aggregates; - expect(typeof agg.householdNetIncome).toBe("number"); - expect(typeof agg.householdBenefits).toBe("number"); - expect(typeof agg.householdTaxBeforeCredits).toBe("number"); - }); - - it("benefits dict has all UK metadata benefit keys", () => { - expectNumericDict(result.benefits, allKeys(ukMetadata.benefits), "benefits"); - }); - - it("taxes dict has all UK metadata tax keys", () => { - expectNumericDict(result.taxes, allKeys(ukMetadata.taxes), "taxes"); - }); - - it("net income is positive and plausible", () => { - expect(result.aggregates.householdNetIncome).toBeGreaterThan(0); - expect(result.aggregates.householdNetIncome).toBeLessThan(100000); - }); -}); - -// ---------- UK Scenario 2: getCategorizedPrograms England £40k/£20k ---------- - -describe("UK Scenario 2: getCategorizedPrograms England £40k/£20k", () => { - let results; - - beforeAll(async () => { - results = await getCategorizedPrograms( - "uk", - "ENGLAND", - 40000, - 20000, - [], - { head: false, spouse: false }, - "2025", - ); - }, 120000); - - it("returns married, headSingle, spouseSingle", () => { - expect(results).toHaveProperty("married"); - expect(results).toHaveProperty("headSingle"); - expect(results).toHaveProperty("spouseSingle"); - }); - - it("all three scenarios have complete shapes", () => { - for (const key of ["married", "headSingle", "spouseSingle"]) { - const r = results[key]; - expect(r).toHaveProperty("aggregates"); - expect(r).toHaveProperty("benefits"); - expect(r).toHaveProperty("taxes"); - } - }); - - it("married net income is a plausible value", () => { - expect(results.married.aggregates.householdNetIncome).toBeGreaterThan(0); - expect(results.married.aggregates.householdNetIncome).toBeLessThan(100000); - }); -}); - -// ---------- UK Scenario 3: Low income with child to trigger child benefit ---------- - -describe("UK Scenario 3: Single England £15k with 1 child (age 5)", () => { - let result; - - beforeAll(async () => { - result = await getPrograms( - "uk", - "ENGLAND", - 15000, - { head: false }, - null, - [{ age: 5, isDisabled: false }], - "2025", - ); - }, 60000); - - it("returns all UK benefit keys as numbers", () => { - expectNumericDict(result.benefits, allKeys(ukMetadata.benefits), "benefits"); - }); - - it("child_benefit is non-zero with a child", () => { - expect(result.benefits.child_benefit).toBeGreaterThan(0); - }); - - it("universal_credit is non-zero at low income with child", () => { - expect(result.benefits.universal_credit).toBeGreaterThan(0); - }); -}); diff --git a/tests/childcare-ui.test.jsx b/tests/childcare-ui.test.jsx index f55a9d4..f314e84 100644 --- a/tests/childcare-ui.test.jsx +++ b/tests/childcare-ui.test.jsx @@ -114,17 +114,17 @@ it("resets US childcare options when switching countries", () => { rerender(); openDetails(); expect(screen.getByRole("checkbox", { name: "Assume a funded childcare slot" }).checked).toBe(true); - expect(screen.getByRole("checkbox", { name: "Include Head Start service values" }).checked).toBe(false); + expect(screen.queryByRole("checkbox", { name: "Include Head Start service values" })).toBeNull(); }); -it("allows Head Start values independently of CCDF and explains the service valuation", () => { +it("explains separate Head Start service values without an accounting toggle", () => { const onCalculate = vi.fn(); render(); openDetails(); - fireEvent.click(screen.getByRole("checkbox", { name: "Include Head Start service values" })); + expect(screen.queryByRole("checkbox", { name: "Include Head Start service values" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Calculate" })); - expect(onCalculate.mock.calls[0][0]).toMatchObject({ includeHeadStart: true, ccdfSlotAvailable: true }); - expect(screen.getByText(/These are in-kind services, not cash payments/)).toBeTruthy(); + expect(onCalculate.mock.calls[0][0]).toMatchObject({ ccdfSlotAvailable: true }); + expect(screen.getByText(/service values appear separately from financial resources/)).toBeTruthy(); }); describe("childcare shares", () => { diff --git a/tests/childcare.test.js b/tests/childcare.test.js index 8b9877f..1f4a745 100644 --- a/tests/childcare.test.js +++ b/tests/childcare.test.js @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; import { createSituation, getCategorizedPrograms, getPrograms, getHeatmapData, buildCellResults } from "../lib/api"; -import { normalizeUSChildcare } from "../lib/childcare"; import metadata from "../lib/childcare-metadata.json"; import { computeTableData } from "../lib/utils"; @@ -41,32 +40,7 @@ describe("childcare inputs and accounting", () => { } }); - it.each([["CA", 8000, 2000], ["FL", 2000, 2000]])("counts %s childcare once", (state, stateBenefits, expectedOther) => { - const data = { child_care_subsidies: [6000], household_state_benefits: [stateBenefits], - household_benefits: [stateBenefits + 1000], household_net_income: [30000], - household_net_income_including_health_benefits: [35000], head_start: [20000], early_head_start: [0] }; - normalizeUSChildcare(data, state, 12000, {}); - expect(data.household_state_benefits[0]).toBe(expectedOther); - expect(data.household_benefits[0]).toBe(9000); - expect(data.household_net_income[0]).toBe(state === "CA" ? 18000 : 24000); - expect(data.head_start[0]).toBe(0); - expect(data.head_start_service_value[0]).toBe(20000); - expect(data.childcare_out_of_pocket[0]).toBe(6000); - }); - it("separates provider reimbursement from household assistance and collectible copays", () => { - const data = { child_care_subsidies: [18564, 0, 18564], childcare_expenses: [2600, 13000, 15000], - vt_ccfap_family_share: [2600, 2600, 15000], household_state_benefits: [0, 0, 0], - household_benefits: [1000, 1000, 1000], household_net_income: [50000, 50000, 50000], - household_net_income_including_health_benefits: [55000, 55000, 55000] }; - normalizeUSChildcare(data, "VT", 13000, {}); - expect(data.childcare_provider_payment).toEqual([18564, 0, 18564]); - expect(data.child_care_subsidies).toEqual([10400, 0, 0]); - expect(data.childcare_out_of_pocket).toEqual([2600, 13000, 15000]); - expect(data.childcare_family_share).toEqual([2600, 0, 15000]); - expect(data.household_net_income).toEqual([47400, 37000, 35000]); - expect(data.childcare_cost_deducted).toEqual([13000, 13000, 15000]); - }); }); describe("current runtime childcare regressions", () => { diff --git a/tests/living-arrangement.test.js b/tests/living-arrangement.test.js index 58a0734..2b9b4d8 100644 --- a/tests/living-arrangement.test.js +++ b/tests/living-arrangement.test.js @@ -38,7 +38,27 @@ function mockApi() { } }); } - return { ok: true, json: async () => ({ result: s, model_version: metadata.modelVersion }) }; + const series = {}; + for (const [container, entities] of Object.entries(s)) { + if (container === "axes") continue; + for (const entity of Object.values(entities)) { + for (const [variable, periods] of Object.entries(entity)) { + const values = periods?.["2026"]; + if (!Array.isArray(values)) continue; + const total = series[variable] ||= new Array(length).fill(0); + values.forEach((value, i) => total[i] += value); + } + } + } + for (const variable of ["childcare_gross_cost", "childcare_out_of_pocket", "childcare_cost_deducted", + "childcare_provider_payment", "childcare_family_share", "head_start_service_value", "early_head_start_service_value", + "household_market_income", "household_health_costs"]) series[variable] = new Array(length).fill(0); + series.financial_resources = [...series.household_net_income]; + series.healthcare_service_value = [...series.healthcare_benefit_value]; + series.early_education_service_value = new Array(length).fill(0); + series.combined_resources = series.financial_resources.map((v, i) => v + series.healthcare_service_value[i]); + return { ok: true, json: async () => ({ result: s, model_version: metadata.modelVersion, + accounting: {version: 1, axis_order: "first_axis_fastest", series} }) }; })); return requests; } @@ -93,7 +113,9 @@ describe("cohabiting comparison", () => { const r = await getCategorizedPrograms("us", "CA", 20000, 10000, [], {}, "2026", {}, 40, 40, {}, false, extras); expect(unmarriedTotal(r, "credits", "eitc")).toBe(300); r.married.aggregates.householdNetIncome = 89352.69; + r.married.aggregates.financialResources = 89352.69; r.unmarried.aggregates.householdNetIncome = 90554.33; + r.unmarried.aggregates.financialResources = 90554.33; expect(computeTableData(r, "summary")[0].delta).toBe("-$1,202"); for (const tab of ["summary", "benefits", "credits", "taxes"]) { const rows = computeTableData(r, tab); diff --git a/tests/request-recovery.test.jsx b/tests/request-recovery.test.jsx new file mode 100644 index 0000000..6370bd6 --- /dev/null +++ b/tests/request-recovery.test.jsx @@ -0,0 +1,127 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import MarriageApp from "../app/MarriageApp.jsx"; +import { getCategorizedPrograms, getHeatmapData } from "../lib/api.js"; + +let submitted; +vi.mock("../lib/api.js", () => ({ getCategorizedPrograms: vi.fn(), getHeatmapData: vi.fn() })); +vi.mock("../app/components/InputForm.jsx", () => ({ + default: ({ onCalculate, onInputChange, onCountryChange }) =>
+ + + +
, +})); +vi.mock("../app/components/ResultsDisplay.jsx", () => ({ + default: ({ results, heatmapData, heatmapError, onRetryHeatmap, heatmapLoading }) =>
+

Calculated amount: {results.amount}

+ {heatmapData &&

Heatmap ready

} + {heatmapError &&
{heatmapError}
} +
, +})); + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +beforeEach(() => { + vi.resetAllMocks(); + window.history.replaceState(null, "", "/"); + submitted = { regionCode: "CA", headIncome: 20000, spouseIncome: 15000, + headAge: 40, spouseAge: 40, children: [{ age: 3 }], disabilityStatus: {}, + pregnancyStatus: {}, esiStatus: {}, year: "2026", livingArrangement: "cohabiting" }; + getCategorizedPrograms.mockResolvedValue({ amount: 35000 }); + getHeatmapData.mockResolvedValue({ ready: true }); +}); +afterEach(cleanup); + +it("shows a scalar error and retries the retained input snapshot with a new request signal", async () => { + getCategorizedPrograms.mockRejectedValueOnce(new Error("Calculation service unavailable.")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + expect((await screen.findByRole("alert")).textContent).toContain("Calculation service unavailable."); + const firstSignal = getCategorizedPrograms.mock.calls[0][12].signal; + expect(firstSignal.aborted).toBe(true); + submitted.children[0].age = 12; + submitted.headIncome = 90000; + fireEvent.click(screen.getByRole("button", { name: "Retry calculation" })); + await screen.findByText("Calculated amount: 35000"); + expect(getCategorizedPrograms).toHaveBeenCalledTimes(2); + const retry = getCategorizedPrograms.mock.calls[1]; + expect(retry[2]).toBe(20000); + expect(retry[4]).toEqual([{ age: 3 }]); + expect(retry[12].signal).not.toBe(firstSignal); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("preserves scalar results after a heatmap failure and retries only the heatmap", async () => { + getHeatmapData.mockRejectedValueOnce(new Error("The heatmap took too long.")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + expect((await screen.findByRole("alert")).textContent).toContain("The heatmap took too long."); + expect(screen.getByText("Calculated amount: 35000")).toBeTruthy(); + const first = getHeatmapData.mock.calls[0]; + fireEvent.click(screen.getByRole("button", { name: "Retry heatmap" })); + await screen.findByText("Heatmap ready"); + expect(getCategorizedPrograms).toHaveBeenCalledOnce(); + expect(getHeatmapData).toHaveBeenCalledTimes(2); + expect(getHeatmapData.mock.calls[1].slice(0, 12)).toEqual(first.slice(0, 12)); + expect(getHeatmapData.mock.calls[1][12].signal).not.toBe(first[12].signal); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("aborts a scalar request on input changes and ignores its late result", async () => { + const pending = deferred(); + getCategorizedPrograms.mockReturnValueOnce(pending.promise); + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + const signal = getCategorizedPrograms.mock.calls[0][12].signal; + fireEvent.click(screen.getByRole("button", { name: "Change inputs" })); + expect(signal.aborted).toBe(true); + await act(async () => pending.resolve({ amount: 111 })); + expect(screen.queryByText("Calculated amount: 111")).toBeNull(); + expect(getHeatmapData).not.toHaveBeenCalled(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("cancels a pending heatmap when the country changes without showing an obsolete error", async () => { + const pending = deferred(); + getHeatmapData.mockReturnValueOnce(pending.promise); + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + await waitFor(() => expect(getHeatmapData).toHaveBeenCalledOnce()); + const signal = getHeatmapData.mock.calls[0][12].signal; + fireEvent.click(screen.getByRole("button", { name: "Switch country" })); + expect(signal.aborted).toBe(true); + await act(async () => pending.reject(new DOMException("Cancelled", "AbortError"))); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText("Calculated amount: 35000")).toBeNull(); +}); + +it("aborts an earlier submission and does not replace a newer result with its late failure", async () => { + const pending = deferred(); + getCategorizedPrograms.mockReturnValueOnce(pending.promise); + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + const firstSignal = getCategorizedPrograms.mock.calls[0][12].signal; + submitted = { ...submitted, headIncome: 50000 }; + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + expect(firstSignal.aborted).toBe(true); + await screen.findByText("Calculated amount: 35000"); + await act(async () => pending.reject(new Error("Old response"))); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByText("Calculated amount: 35000")).toBeTruthy(); +}); + +it("cancels the active request when the app unmounts", () => { + getCategorizedPrograms.mockReturnValueOnce(new Promise(() => {})); + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: "Submit inputs" })); + const signal = getCategorizedPrograms.mock.calls[0][12].signal; + unmount(); + expect(signal.aborted).toBe(true); +}); diff --git a/tests/resources-ui.test.jsx b/tests/resources-ui.test.jsx new file mode 100644 index 0000000..0b69f74 --- /dev/null +++ b/tests/resources-ui.test.jsx @@ -0,0 +1,111 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import ResultsDisplay from "../app/components/ResultsDisplay.jsx"; +import { COUNTRIES } from "../lib/countries.js"; +import { computeTableData } from "../lib/utils.js"; + +vi.mock("../app/components/Heatmap.jsx", () => ({ + default: props =>
{JSON.stringify({ label: props.label, grid: props.grid, markerDelta: props.markerDelta })}
, +})); + +afterEach(cleanup); + +function scenario({ financial, health, early, market, benefits, credits, taxes, healthCosts, careCosts }) { + return { + aggregates: { + financialResources: financial, + householdNetIncome: financial, + householdNetIncomeWithHealth: financial + health, + healthcareBenefitValue: health, + earlyEducationServiceValue: early, + combinedResources: financial + health + early, + marketIncome: market, + householdBenefits: benefits, + householdRefundableCredits: credits, + householdTaxBeforeCredits: taxes, + healthCosts, + childcareCostDeducted: careCosts, + }, + benefits: { snap: benefits }, health: { medicaid_cost: health }, + credits: { eitc: credits }, taxes: { income_tax_before_refundable_credits: taxes }, + stateCredits: {}, stateTaxes: {}, + childcare: { enabled: true, headStartValue: early, earlyHeadStartValue: 0, includeHeadStart: true }, + }; +} + +const results = { + married: scenario({ financial: 50000, health: 10000, early: 5000, market: 65000, benefits: 2000, credits: 1000, taxes: 8000, healthCosts: 3000, careCosts: 7000 }), + unmarried: scenario({ financial: 45000, health: 20000, early: 20000, market: 60000, benefits: 3000, credits: 500, taxes: 10000, healthCosts: 2000, careCosts: 6500 }), +}; +const props = { country: COUNTRIES.us, results, headIncome: 30000, spouseIncome: 35000, livingArrangement: "cohabiting" }; + +describe("financial resources and service values", () => { + it("keeps the primary financial gain separate from a loss in combined resources, regardless of ESI flags", () => { + const { rerender } = render(); + const check = () => { + expect(screen.getByTestId("metric-net").textContent).toContain("$45,000"); + expect(screen.getByTestId("metric-pct").textContent).toContain("$50,000"); + expect(screen.getByTestId("metric-delta").textContent).toContain("+$5,000"); + const comparison = screen.getByRole("region", { name: "Resource comparison" }); + const combined = within(comparison).getByRole("row", { name: /Combined resources/ }); + expect(combined.textContent).toContain("$85,000"); + expect(combined.textContent).toContain("$65,000"); + expect(combined.textContent).toContain("-$20,000"); + expect(within(comparison).getByRole("row", { name: /Healthcare service value/ }).textContent).toContain("$20,000"); + expect(within(comparison).getByRole("row", { name: /Early education service value/ }).textContent).toContain("$5,000"); + }; + check(); + rerender(); + check(); + }); + + it("shows the explicit market income and every modeled deduction in the financial summary", () => { + const rows = computeTableData(results, "summary", { countryId: "us", showHealth: true }); + const amount = label => rows.find(row => row.program === label)?.married; + expect(amount("Household financial resources")).toBe("$50,000"); + expect(amount("Market income")).toBe("$65,000"); + expect(amount("Healthcare costs")).toBe("$3,000"); + expect(amount("Childcare costs")).toBe("$7,000"); + expect(rows.some(row => /service value|Combined resources/.test(row.program))).toBe(false); + }); + + it("keeps healthcare and early education out of financial benefits while preserving healthcare detail", async () => { + const legacy = { ...results, married: { ...results.married, benefits: { ...results.married.benefits, head_start: 5000 } } }; + const benefits = computeTableData(legacy, "benefits", { countryId: "us" }); + expect(benefits.map(row => row.program)).toEqual(["Total benefits", "SNAP"]); + expect(benefits[0].married).toBe("$2,000"); + render(); + fireEvent.click(screen.getByText("Healthcare benefit details")); + expect(await screen.findByText("Medicaid cost")).toBeTruthy(); + }); + + it("uses financial resources for the summary grid and financial benefits for the benefits grid", async () => { + const heatmapData = { grids: { + "net income": [[5000]], "net income (with healthcare)": [[-5000]], + benefits: [[-1000]], "healthcare benefits": [[-10000]], + } }; + render(); + fireEvent.click(screen.getByRole("button", { name: "Grid", exact: true })); + expect(JSON.parse((await screen.findByTestId("resource-grid")).textContent)).toEqual({ + label: "household financial resources", grid: [[5000]], markerDelta: 5000, + }); + fireEvent.click(screen.getByRole("button", { name: "Benefits", exact: true })); + expect(JSON.parse(screen.getByTestId("resource-grid").textContent)).toEqual({ + label: "benefits", grid: [[-1000]], markerDelta: -1000, + }); + }); + + it("shows an actionable grid error without hiding scalar resources or allowing duplicate retries", () => { + const retry = vi.fn(); + const { rerender } = render(); + expect(screen.getByRole("alert").textContent).toContain("The income grid could not load."); + expect(screen.getByTestId("metric-pct").textContent).toContain("$50,000"); + fireEvent.click(screen.getByRole("button", { name: "Retry heatmap" })); + expect(retry).toHaveBeenCalledOnce(); + rerender(); + expect(screen.queryByRole("button", { name: "Retry heatmap" })).toBeNull(); + expect(screen.getByTestId("metric-pct").textContent).toContain("$50,000"); + }); +}); diff --git a/tests/review-fixes.test.js b/tests/review-fixes.test.js index 20984d4..b99d88a 100644 --- a/tests/review-fixes.test.js +++ b/tests/review-fixes.test.js @@ -7,7 +7,7 @@ import { describe, it, expect } from "vitest"; import { - buildCellResults, buildCellBreakdown, createSituation, deductRent, + buildCellResults, buildCellBreakdown, createSituation, } from "../lib/api.js"; import { computeTableData } from "../lib/utils.js"; import { COUNTRIES, DEFAULT_COUNTRY, LEGACY_HASH_COUNTRY, UK_BRMAS, DEFAULT_BRMA } from "../lib/countries.js"; @@ -25,12 +25,14 @@ function series(marriedAt11, headAt1, spouseAt1) { describe("C1: selecting a heatmap cell keeps the rent deduction", () => { const programData = { - household_net_income: series(40000, 22500, 22500), + household_net_income: series(28000, 10500, 10500), + financial_resources: series(28000, 10500, 10500), + rent_deducted: series(12000, 12000, 12000), household_benefits: series(0, 0, 0), household_tax: series(0, 0, 0), }; - it("subtracts rent from every scenario, as the grid does", () => { + it("preserves the backend rent deduction in every scenario", () => { const withRent = buildCellResults("uk", programData, 1, 1, COUNT, [], RENT); expect(withRent.married.aggregates.householdNetIncome).toBe(40000 - 12000); // Each separate household pays its own rent. @@ -52,14 +54,12 @@ describe("C1: selecting a heatmap cell keeps the rent deduction", () => { expect(apart - together).toBe(gridApart - gridTogether); }); - it("leaves owners and the US untouched", () => { - const owner = buildCellResults("uk", programData, 1, 1, COUNT, [], { - rent: 12000, tenureType: "OWNED_OUTRIGHT", - }); - expect(owner.married.aggregates.householdNetIncome).toBe(40000); - const none = buildCellResults("uk", programData, 1, 1, COUNT, [], {}); - expect(none.married.aggregates.householdNetIncome).toBe(40000); + it("does not recalculate housing accounting from display extras", () => { + const cell = buildCellResults("uk", programData, 1, 1, COUNT, [], {}); + expect(cell.married.aggregates.householdNetIncome).toBe(28000); + expect(cell.married.aggregates.rentDeducted).toBe(12000); }); + }); describe("C2: share links keep their country", () => { diff --git a/tests/setup-local-api.js b/tests/setup-local-api.js new file mode 100644 index 0000000..ce5f812 --- /dev/null +++ b/tests/setup-local-api.js @@ -0,0 +1,14 @@ +// Standard CI must never silently depend on a deployed model or public UK API. +const nativeFetch = globalThis.fetch; +const configured = process.env.NEXT_PUBLIC_US_API_URL; +if (!configured || !["127.0.0.1", "localhost", "[::1]"].includes(new URL(configured).hostname)) { + throw new Error("Standard tests require the pinned local API. Run bun run test:ci."); +} +const localOrigin = new URL(configured).origin; +globalThis.fetch = (input, init) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + if (url.origin !== localOrigin || url.pathname.startsWith("/uk/")) { + throw new Error(`External API calls are disabled in standard CI: ${url.origin}. Use bun run test:live:uk for UK integration checks.`); + } + return nativeFetch(input, init); +}; diff --git a/tests/uk-api-live.test.js b/tests/uk-api-live.test.js new file mode 100644 index 0000000..f8e7f58 --- /dev/null +++ b/tests/uk-api-live.test.js @@ -0,0 +1,120 @@ +/** Optional external UK API integration checks: bun run test:live:uk */ +import { describe, it, expect, beforeAll } from "vitest"; +import { getPrograms, getCategorizedPrograms } from "../lib/api.js"; +import ukMetadata from "../lib/metadata-uk.json"; + +function allKeys(category) { + return category.map((value) => value.variable); +} +function expectNumericDict(dict, expectedKeys, label) { + for (const key of expectedKeys) { + expect(dict, `${label}: missing key "${key}"`).toHaveProperty(key); + expect(typeof dict[key], `${label}.${key} should be a number`).toBe("number"); + } +} + +// ---------- UK Scenario 1: Married couple, England, £30k/£30k ---------- + +describe("UK Scenario 1: Married England £30k/£30k no children", () => { + let result; + + beforeAll(async () => { + result = await getPrograms( + "uk", + "ENGLAND", + 30000, + { head: false, spouse: false }, + 30000, + [], + "2025", + ); + }, 60000); + + it("returns all aggregate keys as numbers", () => { + const agg = result.aggregates; + expect(typeof agg.householdNetIncome).toBe("number"); + expect(typeof agg.householdBenefits).toBe("number"); + expect(typeof agg.householdTaxBeforeCredits).toBe("number"); + }); + + it("benefits dict has all UK metadata benefit keys", () => { + expectNumericDict(result.benefits, allKeys(ukMetadata.benefits), "benefits"); + }); + + it("taxes dict has all UK metadata tax keys", () => { + expectNumericDict(result.taxes, allKeys(ukMetadata.taxes), "taxes"); + }); + + it("net income is positive and plausible", () => { + expect(result.aggregates.householdNetIncome).toBeGreaterThan(0); + expect(result.aggregates.householdNetIncome).toBeLessThan(100000); + }); +}); + +// ---------- UK Scenario 2: getCategorizedPrograms England £40k/£20k ---------- + +describe("UK Scenario 2: getCategorizedPrograms England £40k/£20k", () => { + let results; + + beforeAll(async () => { + results = await getCategorizedPrograms( + "uk", + "ENGLAND", + 40000, + 20000, + [], + { head: false, spouse: false }, + "2025", + ); + }, 120000); + + it("returns married, headSingle, spouseSingle", () => { + expect(results).toHaveProperty("married"); + expect(results).toHaveProperty("headSingle"); + expect(results).toHaveProperty("spouseSingle"); + }); + + it("all three scenarios have complete shapes", () => { + for (const key of ["married", "headSingle", "spouseSingle"]) { + const r = results[key]; + expect(r).toHaveProperty("aggregates"); + expect(r).toHaveProperty("benefits"); + expect(r).toHaveProperty("taxes"); + } + }); + + it("married net income is a plausible value", () => { + expect(results.married.aggregates.householdNetIncome).toBeGreaterThan(0); + expect(results.married.aggregates.householdNetIncome).toBeLessThan(100000); + }); +}); + +// ---------- UK Scenario 3: Low income with child to trigger child benefit ---------- + +describe("UK Scenario 3: Single England £15k with 1 child (age 5)", () => { + let result; + + beforeAll(async () => { + result = await getPrograms( + "uk", + "ENGLAND", + 15000, + { head: false }, + null, + [{ age: 5, isDisabled: false }], + "2025", + ); + }, 60000); + + it("returns all UK benefit keys as numbers", () => { + expectNumericDict(result.benefits, allKeys(ukMetadata.benefits), "benefits"); + }); + + it("child_benefit is non-zero with a child", () => { + expect(result.benefits.child_benefit).toBeGreaterThan(0); + }); + + it("universal_credit is non-zero at low income with child", () => { + expect(result.benefits.universal_credit).toBeGreaterThan(0); + }); +}); diff --git a/tests/uk-inputs.test.js b/tests/uk-inputs.test.js index 3346f98..918604f 100644 --- a/tests/uk-inputs.test.js +++ b/tests/uk-inputs.test.js @@ -8,7 +8,7 @@ import { describe, it, expect } from "vitest"; import { - createSituation, splitExtras, deductRent, housingCostFor, isRentedTenure, + createSituation, splitExtras, isRentedTenure, UK_EXTRAS_DEFAULTS, } from "../lib/api.js"; import { COUNTRIES, DEFAULT_COUNTRY } from "../lib/countries.js"; @@ -116,25 +116,6 @@ describe("splitExtras: allocation when the couple separates", () => { }); }); -describe("deductRent: net income after housing costs", () => { - const aggs = { householdNetIncome: 40000, householdNetIncomeWithHealth: 40000 }; - - it("subtracts rent for a UK renter", () => { - const r = deductRent("uk", aggs, { rent: 12000, tenureType: "RENT_PRIVATELY" }); - expect(r.householdNetIncome).toBe(28000); - expect(r.rentDeducted).toBe(12000); - }); - - it("is a no-op at zero rent, so existing UK results do not move", () => { - expect(deductRent("uk", aggs, { rent: 0, tenureType: "RENT_PRIVATELY" })).toEqual(aggs); - }); - - it("never applies to the US, which has no rent input", () => { - expect(deductRent("us", aggs, { rent: 12000, tenureType: "RENT_PRIVATELY" })).toEqual(aggs); - expect(COUNTRIES.us.deductRentFromNetIncome).toBeUndefined(); - }); -}); - describe("UK config", () => { it("enables the Universal Credit inputs", () => { const c = COUNTRIES.uk; @@ -160,7 +141,6 @@ describe("UK config", () => { }); describe("tenure: owners have no rent to deduct", () => { - const aggs = { householdNetIncome: 40000, householdNetIncomeWithHealth: 40000 }; it("treats the three rented tenures as renting", () => { expect(isRentedTenure("RENT_PRIVATELY")).toBe(true); @@ -173,25 +153,7 @@ describe("tenure: owners have no rent to deduct", () => { expect(isRentedTenure("OWNED_WITH_MORTGAGE")).toBe(false); }); - it("charges no housing cost to an owner even if a rent figure is present", () => { - expect(housingCostFor({ rent: 12000, tenureType: "OWNED_OUTRIGHT" })).toBe(0); - expect(housingCostFor({ rent: 12000, tenureType: "RENT_PRIVATELY" })).toBe(12000); - }); - - it("does not deduct rent from an owner's net income", () => { - // uc_housing_costs_element pays nothing on an owned tenure, so deducting - // rent here would take money off with no housing support to match it. - const owner = deductRent("uk", aggs, { rent: 12000, tenureType: "OWNED_OUTRIGHT" }); - expect(owner).toEqual(aggs); - const renter = deductRent("uk", aggs, { rent: 12000, tenureType: "RENT_PRIVATELY" }); - expect(renter.householdNetIncome).toBe(28000); - }); - it("defaults to owning outright, so no rent is deducted by default", () => { - expect(UK_EXTRAS_DEFAULTS.tenureType).toBe("OWNED_OUTRIGHT"); - expect(isRentedTenure(undefined)).toBe(false); - expect(housingCostFor({ rent: 12000 })).toBe(0); - }); }); describe("UK years", () => { diff --git a/vitest.ci.config.js b/vitest.ci.config.js new file mode 100644 index 0000000..efac4cc --- /dev/null +++ b/vitest.ci.config.js @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig, mergeConfig } from "vitest/config"; +import config from "./vitest.config.js"; + +export default mergeConfig(config, defineConfig({ + test: { + // Each integration test may already request three 1,089-point model grids. + maxWorkers: 1, + setupFiles: ["./tests/setup-local-api.js"], + exclude: [...configDefaults.exclude, + "tests/uk-api-live.test.js", "tests/uk-live.test.js", "tests/uk-combinations.test.js", + // Historical model fixtures have their own manual audit command; they + // are not a substitute for the pinned-runtime integration assertions. + "tests/audit-values.test.js", + ], + }, +}));