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/); + }); +});