From 33b2706c899fdaa67fc2643d0c27d6ab988a55e1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 08:58:26 -0400 Subject: [PATCH] Correct the US overview's program count and behavioral-response claims The policy-rules card said "Over 55 federal, state, and local programs"; the coverage registry lists 103. The card now reads the registry at runtime through fetchProgramsWithSource('us') and cites the same total and complete counts the coverage tracker shows, with a link to it. It shows no number while loading or when only the bundled fallback list is available. Walkthrough step 3 said earnings shift by the elasticity of taxable income. policyengine-us 2.2.1 (the version policyengine.py pins) has no ETI: it scales earnings by a substitution elasticity on the change in marginal tax rate and an income elasticity on the change in household net income, and every elasticity defaults to zero. The step now says so. UK copy is unchanged. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 5 + package.json | 1 + src/components/microsim/ThreeIngredients.tsx | 48 ++++- src/data/microsimSteps.ts | 2 +- src/data/programStatus.ts | 17 ++ src/test/overviewClaims.test.tsx | 195 +++++++++++++++++++ 6 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 src/test/overviewClaims.test.tsx diff --git a/bun.lock b/bun.lock index 335d7de..53e0b9b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "eslint": "^9.39.1", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", + "fast-check": "^4.10.2", "globals": "^16.5.0", "jsdom": "^28.1.0", "typescript": "~5.9.3", @@ -706,6 +707,8 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -976,6 +979,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], diff --git a/package.json b/package.json index ec6b659..767dded 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "eslint": "^9.39.1", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", + "fast-check": "^4.10.2", "globals": "^16.5.0", "jsdom": "^28.1.0", "typescript": "~5.9.3", diff --git a/src/components/microsim/ThreeIngredients.tsx b/src/components/microsim/ThreeIngredients.tsx index e3ad1cd..8899878 100644 --- a/src/components/microsim/ThreeIngredients.tsx +++ b/src/components/microsim/ThreeIngredients.tsx @@ -1,12 +1,17 @@ -import { type ReactNode } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { motion } from 'framer-motion'; import { colors } from '../../designTokens'; import { IconFileText, IconUsers, IconTrendingUp } from '@tabler/icons-react'; import type { Country } from '../../hooks/useCountry'; +import { usePublicBasePrefix } from '../../hooks/usePublicBasePrefix'; +import { fetchProgramsWithSource } from '../../data/fetchPrograms'; +import { summarizeRegistry, type RegistrySummary } from '../../data/programStatus'; interface IngredientCardProps { title: string; - description: string; + description: ReactNode; icon: ReactNode; delay: number; isActive?: boolean; @@ -42,12 +47,49 @@ interface ThreeIngredientsProps { country?: Country; } +/** Cites the coverage registry's counts once they load, never a hardcoded figure. */ +function UsPolicyRulesDescription({ registry }: { registry: RegistrySummary | null }) { + const basePrefix = usePublicBasePrefix(); + const search = useSearchParams().toString(); + const trackerPath = `${basePrefix}/rules/coverage`; + const tracker = ( + + coverage tracker + + ); + return ( + <> + Tax codes, benefit formulas, and eligibility rules encoded as executable logic. The{' '} + {tracker}{' '} + {registry + ? `lists ${registry.total.toLocaleString('en-US')} federal, state, and local programs, ${registry.complete.toLocaleString('en-US')} of them marked complete.` + : 'lists federal, state, and local programs and the status of each.'} + + ); +} + export default function ThreeIngredients({ activeIngredient, country = 'us' }: ThreeIngredientsProps) { + const [registry, setRegistry] = useState(null); + + useEffect(() => { + if (country !== 'us') return; + let active = true; + fetchProgramsWithSource(country).then(result => { + if (active) setRegistry(summarizeRegistry(result)); + }); + return () => { active = false; }; + }, [country]); + const ingredients = [ { title: 'Policy rules', description: - 'Tax codes, benefit formulas, and eligibility rules encoded as executable logic. Over 55 federal, state, and local programs.', + country === 'uk' + ? 'Tax codes, benefit formulas, and eligibility rules encoded as executable logic. Over 55 federal, state, and local programs.' + : , icon: , key: 'policies' as const, }, diff --git a/src/data/microsimSteps.ts b/src/data/microsimSteps.ts index 49c9f12..6e2ae46 100644 --- a/src/data/microsimSteps.ts +++ b/src/data/microsimSteps.ts @@ -168,7 +168,7 @@ export function getMicrosimSteps(country: Country): MicrosimStep[] { title: country === 'uk' ? 'Model behavioural responses' : 'Model behavioral responses', description: country === 'uk' ? 'People change their behaviour in response to tax changes. Using elasticity of taxable income, we estimate how earnings shift.' - : 'People change their behavior in response to tax changes. Using elasticity of taxable income, we estimate how earnings shift.', + : 'People may work more or less when taxes and benefits change. Earnings stay fixed by default; once labor supply elasticities are set, workers\' earnings respond to changes in their marginal tax rates and in their households\' net income.', ingredient: 'dynamics', }, { diff --git a/src/data/programStatus.ts b/src/data/programStatus.ts index 2dd6c80..980fe83 100644 --- a/src/data/programStatus.ts +++ b/src/data/programStatus.ts @@ -1,4 +1,5 @@ import type { CoverageStatus, Program } from '../types/Program'; +import type { ProgramsWithSource } from './fetchPrograms'; export const ALL_STATES = [ 'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI','ID','IL','IN','IA', @@ -170,3 +171,19 @@ export function computeStatusCount(programList: Program[]): StatusCounts { }); return counts; } + +export interface RegistrySummary { + total: number; + complete: number; +} + +/** + * Headline counts for citing the coverage registry elsewhere on the site. + * They match the coverage tracker's "Total programs" and "Complete" cards. + * Returns null when there is no registry to cite: the bundled fallback list + * is a stale hand-kept copy, not the registry. + */ +export function summarizeRegistry({ programs, source }: ProgramsWithSource): RegistrySummary | null { + if (source.kind === 'fallback' || programs.length === 0) return null; + return { total: programs.length, complete: computeStatusCount(programs).complete }; +} diff --git a/src/test/overviewClaims.test.tsx b/src/test/overviewClaims.test.tsx new file mode 100644 index 0000000..985d609 --- /dev/null +++ b/src/test/overviewClaims.test.tsx @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import fc from 'fast-check'; +import type { CoverageStatus, Program } from '../types/Program'; +import type { ProgramsSource } from '../data/fetchPrograms'; +import { computeStatusCount, summarizeRegistry } from '../data/programStatus'; +import { getMicrosimSteps } from '../data/microsimSteps'; + +const searchParams = vi.hoisted(() => ({ current: new URLSearchParams() })); +vi.mock('next/navigation', () => ({ + usePathname: () => '/', + useSearchParams: () => searchParams.current, + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), +})); + +// A pinned copy of the registry snapshot (public/programs-us.json is generated at build time and not committed). +const usSnapshot = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/programs-us.snapshot.json'), 'utf8')); + +function response(data: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => data } as Response; +} + +const policyRulesCard = () => screen.getByRole('heading', { name: 'Policy rules' }).parentElement!; + +describe('US overview policy-rules card', () => { + beforeEach(() => { + // fetchPrograms caches per module instance; start every test from an empty cache. + vi.resetModules(); + searchParams.current = new URLSearchParams(); + window.history.replaceState({}, '', '/us/model'); + }); + + afterEach(() => { + vi.doUnmock('../data/fetchPrograms'); + vi.unstubAllGlobals(); + window.history.replaceState({}, '', '/'); + }); + + it('cites the same total and complete counts the coverage tracker shows', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(usSnapshot))); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + const { default: CoverageTrackerPage } = await import('../views/rules/CoverageTrackerPage'); + + const tracker = render(); + // A summary stat card is a label whose previous sibling holds the number. + const statValue = (label: string) => { + const card = within(tracker.container).getAllByText(label) + .find(el => /^\d+$/.test(el.previousElementSibling?.textContent ?? '')); + return Number(card!.previousElementSibling!.textContent); + }; + await within(tracker.container).findByText(/Federal programs \(\d+\)/); + const total = statValue('Total programs'); + const complete = statValue('Complete'); + tracker.unmount(); + + render(); + expect(total).toBe(usSnapshot.programs.length); + expect(await within(policyRulesCard()).findByText( + `lists ${total} federal, state, and local programs, ${complete} of them marked complete.`, + { exact: false }, + )).toBeInTheDocument(); + expect(policyRulesCard()).not.toHaveTextContent(/Over 55/); + }); + + it('links to the coverage tracker under the public mount and keeps the query string', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(usSnapshot))); + searchParams.current = new URLSearchParams('embed=true&country=us'); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + + render(); + const link = within(policyRulesCard()).getByRole('link', { name: 'coverage tracker' }); + expect(link).toHaveAttribute('href', '/us/model/rules/coverage?embed=true&country=us'); + await within(policyRulesCard()).findByText(/marked complete/); + }); + + it('shows no number while the registry is loading', async () => { + vi.doMock('../data/fetchPrograms', () => ({ fetchProgramsWithSource: () => new Promise(() => {}) })); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + + render(); + expect(policyRulesCard()).toHaveTextContent( + 'The coverage tracker lists federal, state, and local programs and the status of each.', + ); + expect(policyRulesCard()).not.toHaveTextContent(/\d/); + }); + + it('shows no number when only the bundled fallback list is available', async () => { + const fallbackPrograms: Program[] = [ + { id: 'a', name: 'A', fullName: 'A', status: 'complete', githubLinks: {} }, + ]; + const fetchProgramsWithSource = vi.fn().mockResolvedValue({ + programs: fallbackPrograms, + source: { kind: 'fallback' }, + }); + vi.doMock('../data/fetchPrograms', () => ({ fetchProgramsWithSource })); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + + render(); + await waitFor(() => expect(fetchProgramsWithSource).toHaveBeenCalledWith('us')); + await act(async () => {}); + expect(policyRulesCard()).toHaveTextContent('lists federal, state, and local programs and the status of each.'); + expect(policyRulesCard()).not.toHaveTextContent(/\d/); + }); + + it('cites the production API registry when the snapshot is unavailable', async () => { + const programs: Program[] = [ + { id: 'a', name: 'A', fullName: 'A', agency: 'IRS', status: 'complete', githubLinks: {} }, + { id: 'b', name: 'B', fullName: 'B', agency: 'State', status: 'partial', githubLinks: {} }, + ]; + vi.doMock('../data/fetchPrograms', () => ({ + fetchProgramsWithSource: vi.fn().mockResolvedValue({ + programs, + source: { kind: 'api', repo: 'PolicyEngine/policyengine-us', apiVersion: '2.2.1' }, + }), + })); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + + render(); + expect(await within(policyRulesCard()).findByText( + 'lists 2 federal, state, and local programs, 1 of them marked complete.', + { exact: false }, + )).toBeInTheDocument(); + }); + + it('does not load the US registry for the UK overview', async () => { + const fetchProgramsWithSource = vi.fn(); + vi.doMock('../data/fetchPrograms', () => ({ fetchProgramsWithSource })); + const { default: ThreeIngredients } = await import('../components/microsim/ThreeIngredients'); + + render(); + await act(async () => {}); + expect(fetchProgramsWithSource).not.toHaveBeenCalled(); + expect(within(policyRulesCard()).queryByRole('link')).not.toBeInTheDocument(); + }); +}); + +const STATUSES: CoverageStatus[] = ['complete', 'partial', 'inProgress', 'notStarted']; +const status = fc.constantFrom(...STATUSES); +const program: fc.Arbitrary = fc.record({ + id: fc.string(), + agency: fc.option(fc.constantFrom('IRS', 'HHS', 'SSA', 'State', 'Local' as const), { nil: undefined }), + coverage: fc.option(fc.constantFrom('US', 'CA', 'NY', 'Los Angeles County, CA', ''), { nil: undefined }), + status, + hasStateVariation: fc.option(fc.boolean(), { nil: undefined }), + stateImplementations: fc.option( + fc.array(fc.record({ state: fc.constantFrom('CA', 'NY', 'TX', 'WA'), status })), + { nil: undefined }, + ), +}).map(p => ({ ...p, name: p.id, fullName: p.id, githubLinks: {} })); +const source: fc.Arbitrary = fc.constantFrom( + { kind: 'fallback' }, + { kind: 'api', repo: 'PolicyEngine/policyengine-us' }, + { + kind: 'snapshot', repo: 'PolicyEngine/policyengine-us', branch: 'main', + commit: 'abcdef1', fetchedAt: '2026-10-14T00:00:00.000Z', version: '2.2.1', + }, +); + +describe('summarizeRegistry invariants', () => { + it('never cites the bundled fallback list or an empty registry', () => { + fc.assert(fc.property(fc.array(program), programs => { + expect(summarizeRegistry({ programs, source: { kind: 'fallback' } })).toBeNull(); + })); + expect(summarizeRegistry({ programs: [], source: { kind: 'api', repo: 'PolicyEngine/policyengine-us' } })).toBeNull(); + }); + + it('counts every registry program once and never claims more complete programs than the registry marks complete', () => { + fc.assert(fc.property(fc.array(program, { minLength: 1 }), source, (programs, src) => { + const summary = summarizeRegistry({ programs, source: src }); + if (src.kind === 'fallback') { + expect(summary).toBeNull(); + return; + } + const counts = computeStatusCount(programs); + expect(summary).toEqual({ total: programs.length, complete: counts.complete }); + expect(Object.values(counts).reduce((a, b) => a + b, 0)).toBe(programs.length); + expect(summary!.complete).toBeGreaterThanOrEqual(0); + expect(summary!.complete).toBeLessThanOrEqual(programs.filter(p => p.status === 'complete').length); + })); + }); +}); + +describe('US walkthrough behavioral step', () => { + const step = getMicrosimSteps('us')[3]; + + it('describes the labor supply mechanism policyengine-us implements, not the elasticity of taxable income', () => { + expect(step.description).not.toMatch(/taxable income/i); + expect(step.description).toMatch(/fixed by default/); + expect(step.description).toMatch(/labor supply elasticities/); + expect(step.description).toMatch(/marginal tax rates/); + expect(step.description).toMatch(/net income/); + }); +});