From cc2cda85898297c1370f7ed49417e0ca4e9ddf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:20:52 +0000 Subject: [PATCH 1/4] Show UK programmes from policyengine-uk's registry on the coverage tracker The UK coverage tracker showed a fixed paragraph. It now reads the programs.yaml snapshot that the build already writes to programs-uk.json: status counts, search, a verified-year filter, and the programmes grouped into UK government, devolved and local authority programmes, then by the body that runs them. A card opens in place to show the registry's notes, verified years and variable. It links the parameters only where the parameter tree built from the same commit has that folder or file. - Move the status dot, stat card and year helpers that both trackers use into coverageParts.tsx, unchanged. - Keep parameter_prefix and the registry's own notes on each program. - Return no programmes, rather than the bundled US registry, when the UK snapshot and the API both fail. - Let the proxy rewrite param-tree-*.json under /us and /uk, as it does programs-*.json, so standalone previews find the tree. Co-Authored-By: Claude Opus 5.5 --- proxy.ts | 5 +- src/components/rules/RulesOverview.tsx | 122 +----- src/components/rules/UkCoverageTracker.tsx | 453 ++++++++++++++++++++ src/components/rules/coverageParts.tsx | 100 +++++ src/data/fetchPrograms.ts | 5 +- src/test/coverageProvenance.test.tsx | 25 +- src/test/fetchPrograms.test.ts | 11 + src/test/fixtures/programs-uk.snapshot.json | 1 + src/test/proxy.test.ts | 4 + src/test/ukCoverageTracker.test.tsx | 232 ++++++++++ src/types/Program.ts | 2 + 11 files changed, 842 insertions(+), 118 deletions(-) create mode 100644 src/components/rules/UkCoverageTracker.tsx create mode 100644 src/components/rules/coverageParts.tsx create mode 100644 src/test/fixtures/programs-uk.snapshot.json create mode 100644 src/test/ukCoverageTracker.test.tsx diff --git a/proxy.ts b/proxy.ts index 32afc70..157de46 100644 --- a/proxy.ts +++ b/proxy.ts @@ -95,10 +95,13 @@ export function proxy(req: NextRequest) { } export const config = { - // Registry snapshots need country-prefix rewrites even though they are assets. + // Registry snapshots and their parameter trees need country-prefix rewrites + // even though they are assets. matcher: [ '/((?!_next|.*\\..*).*)', '/:country(us|uk)/programs-:registry(us|uk).json', '/:country(us|uk)/model/programs-:registry(us|uk).json', + '/:country(us|uk)/param-tree-:registry(us|uk).json', + '/:country(us|uk)/model/param-tree-:registry(us|uk).json', ], }; diff --git a/src/components/rules/RulesOverview.tsx b/src/components/rules/RulesOverview.tsx index 17c483e..29b7000 100644 --- a/src/components/rules/RulesOverview.tsx +++ b/src/components/rules/RulesOverview.tsx @@ -16,108 +16,19 @@ import { } from '../../data/programStatus'; import type { CoverageStatus, Program } from '../../types/Program'; import { IconX, IconCalendar } from '@tabler/icons-react'; +import { + StatCard, + StatusDot, + collectAllYears, + programIncludesYear, + statusLabels, +} from './coverageParts'; +import UkCoverageTracker from './UkCoverageTracker'; import type { Country } from '../../hooks/useCountry'; -const MAX_FORWARD_YEAR = new Date().getFullYear() + 5; - -/** Parse a verifiedYears string like "2022-2026", "2022+", or "2024" into a Set of year numbers. */ -function parseYearRange(verifiedYears?: string): Set { - if (!verifiedYears) return new Set(); - const trimmed = verifiedYears.trim(); - // "2022+" means from 2022 onward (no end year — uprating covers future) - const openMatch = trimmed.match(/^(\d{4})\+$/); - if (openMatch) { - const start = parseInt(openMatch[1], 10); - const years = new Set(); - for (let y = start; y <= MAX_FORWARD_YEAR; y++) years.add(y); - return years; - } - // "2022-2026" range - const rangeMatch = trimmed.match(/^(\d{4})\s*-\s*(\d{4})$/); - if (rangeMatch) { - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); - const years = new Set(); - for (let y = start; y <= end; y++) years.add(y); - return years; - } - // Single year "2024" - const singleMatch = trimmed.match(/^(\d{4})$/); - if (singleMatch) return new Set([parseInt(singleMatch[1], 10)]); - return new Set(); -} - -/** Collect all unique years across all programs' verifiedYears fields. */ -function collectAllYears(programs: Program[]): number[] { - const allYears = new Set(); - for (const p of programs) { - for (const y of parseYearRange(p.verifiedYears)) allYears.add(y); - } - return Array.from(allYears).sort((a, b) => a - b); -} - -/** Check if a program's verifiedYears includes a given year. */ -function programIncludesYear(program: Program, year: number): boolean { - return parseYearRange(program.verifiedYears).has(year); -} - -const statusLabels: Record = { - complete: 'Complete', - partial: 'Partial', - inProgress: 'In progress', - notStarted: 'Not started', -}; - -const statusIcons: Record = { - complete: '\u2713', - partial: '\u25D1', - inProgress: '\u21BB', - notStarted: '\u25CB', -}; - -function StatusDot({ status, size = 10 }: { status: CoverageStatus; size?: number }) { - return ( - - {size >= 14 ? statusIcons[status] : ''} - - ); -} - -function StatCard({ label, count, color, delay }: { label: string; count: number; color: string; delay: number }) { - return ( - -
{count}
-
{label}
-
- ); -} - type ViewMode = 'programs' | 'states'; function ProgramDetailPanel({ program, onClose, allPrograms }: { program: Program; onClose: () => void; allPrograms: Program[] }) { @@ -357,7 +268,7 @@ export default function RulesOverview({ country = 'us' }: { country?: Country }) const { programs, source } = loadedPrograms; useEffect(() => { - // The UK view currently displays a static introduction, not registry data. + // The UK tracker loads its own registry. if (country === 'uk') return; let active = true; fetchProgramsWithSource(country).then(result => { @@ -425,20 +336,7 @@ export default function RulesOverview({ country = 'us' }: { country?: Country }) }).sort((a, b) => b.pct - a.pct); }, [yearFilteredPrograms]); - if (country === 'uk') { - return ( -
-

- PolicyEngine UK models over 50 tax and benefit programs across HMRC, DWP, DfE, Treasury, and devolved administrations including income tax, National Insurance, VAT, Universal Credit, pension credit, child benefit, and many more. -

-

- - View the full UK model on GitHub - -

-
- ); - } + if (country === 'uk') return ; return (
diff --git a/src/components/rules/UkCoverageTracker.tsx b/src/components/rules/UkCoverageTracker.tsx new file mode 100644 index 0000000..f7f6751 --- /dev/null +++ b/src/components/rules/UkCoverageTracker.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'; +import { IconCalendar, IconExternalLink, IconMapPin } from '@tabler/icons-react'; +import { colors, typography, spacing, statusColors } from '../../designTokens'; +import { fetchProgramsWithSource, type ProgramsWithSource } from '../../data/fetchPrograms'; +import { publicBasePrefixFromPath } from '../../hooks/usePublicBasePrefix'; +import type { CoverageStatus, Program } from '../../types/Program'; +import CoverageProvenance from './CoverageProvenance'; +import { + StatCard, + StatusDot, + collectAllYears, + programIncludesYear, + statusLabels, +} from './coverageParts'; + +const REPO = 'PolicyEngine/policyengine-uk'; +const REGISTRY_URL = `https://github.com/${REPO}/blob/main/policyengine_uk/programs.yaml`; + +export type UkLevel = 'uk' | 'devolved' | 'local' | 'other'; + +/** + * The body behind each `agency` in policyengine-uk's programs.yaml, and its + * level of government. An agency missing here shows under "Other programmes" + * until it is added. + */ +const AGENCIES: Record; name: string }> = { + HMRC: { level: 'uk', name: 'HM Revenue and Customs' }, + DWP: { level: 'uk', name: 'Department for Work and Pensions' }, + DfE: { level: 'uk', name: 'Department for Education' }, + DfT: { level: 'uk', name: 'Department for Transport' }, + DCMS: { level: 'uk', name: 'Department for Culture, Media and Sport' }, + Treasury: { level: 'uk', name: 'HM Treasury' }, + Ofgem: { level: 'uk', name: 'Ofgem' }, + 'Revenue Scotland': { level: 'devolved', name: 'Revenue Scotland' }, + 'Social Security Scotland': { level: 'devolved', name: 'Social Security Scotland' }, + 'Welsh Government': { level: 'devolved', name: 'Welsh Government' }, + Local: { level: 'local', name: 'Local authorities' }, +}; + +const AGENCY_ORDER = Object.keys(AGENCIES); + +const LEVELS: { level: UkLevel; heading: string }[] = [ + { level: 'uk', heading: 'UK government programmes' }, + { level: 'devolved', heading: 'Devolved programmes' }, + { level: 'local', heading: 'Local authority programmes' }, + { level: 'other', heading: 'Other programmes' }, +]; + +const STATUS_ORDER: CoverageStatus[] = ['complete', 'partial', 'inProgress', 'notStarted']; + +export function ukAgency(program: Program): { level: UkLevel; name: string } { + const agency = program.agency ?? ''; + return AGENCIES[agency] ?? { level: 'other', name: agency || 'Other' }; +} + +export interface AgencyGroup { + name: string; + programs: Program[]; +} + +export interface LevelSection { + level: UkLevel; + heading: string; + count: number; + groups: AgencyGroup[]; + /** False when the whole registry has one known body at this level, so the heading says enough. */ + showAgencies: boolean; +} + +/** + * The visible programmes by level of government, then by the body that runs + * them. Whether a level names its bodies depends on the whole registry, so + * headings don't come and go while searching. + */ +export function groupByGovernment(all: Program[], visible: Program[]): LevelSection[] { + const rank = (p: Program) => { + const index = AGENCY_ORDER.indexOf(p.agency ?? ''); + return index === -1 ? AGENCY_ORDER.length : index; + }; + return LEVELS.flatMap(({ level, heading }) => { + const bodies = new Set(all.filter((p) => ukAgency(p).level === level).map((p) => ukAgency(p).name)); + const programs = visible + .filter((p) => ukAgency(p).level === level) + .sort((a, b) => rank(a) - rank(b) + || ukAgency(a).name.localeCompare(ukAgency(b).name, 'en-GB') + || a.name.localeCompare(b.name, 'en-GB')); + if (programs.length === 0) return []; + const groups: AgencyGroup[] = []; + for (const program of programs) { + const name = ukAgency(program).name; + const last = groups[groups.length - 1]; + if (last && last.name === name) last.programs.push(program); + else groups.push({ name, programs: [program] }); + } + return [{ level, heading, count: programs.length, groups, showAgencies: level === 'other' || bodies.size > 1 }]; + }); +} + +/** + * A GitHub link for a registry parameter_prefix, given only when the + * parameter tree snapshotted with the registry has that folder or file. + */ +export function parameterUrl( + prefix: string | undefined, + tree: ReadonlySet | null, + commit: string, +): string | undefined { + if (!prefix || !tree) return undefined; + const path = prefix.replace(/\./g, '/'); + const root = `https://github.com/${REPO}`; + for (const file of tree) { + if (file.startsWith(`${path}/`)) return `${root}/tree/${commit}/policyengine_uk/parameters/${path}`; + } + // A parameter node can also be a whole YAML file, or a key inside one. + const parts = path.split('/'); + for (let i = parts.length; i > 0; i--) { + const file = `${parts.slice(0, i).join('/')}.yaml`; + if (tree.has(file)) return `${root}/blob/${commit}/policyengine_uk/parameters/${file}`; + } + return undefined; +} + +/** "2019+" reads "2019 onwards" and "2023-2025" reads "2023 to 2025". */ +export function describeVerifiedYears(verifiedYears?: string): string | undefined { + if (!verifiedYears) return undefined; + const openEnded = verifiedYears.match(/^(\d{4})\+$/); + if (openEnded) return `${openEnded[1]} onwards`; + const range = verifiedYears.match(/^(\d{4})\s*-\s*(\d{4})$/); + if (range) return range[1] === range[2] ? range[1] : `${range[1]} to ${range[2]}`; + return verifiedYears; +} + +function matchesSearch(program: Program, query: string): boolean { + return [ + program.name, + program.fullName, + program.registryNotes, + program.coverage, + program.agency, + ukAgency(program).name, + program.variable, + ].some((field) => field?.toLowerCase().includes(query)); +} + +/** The parameter file paths the build snapshotted from the registry's commit. */ +async function loadParameterTree(): Promise | null> { + try { + const prefix = publicBasePrefixFromPath(window.location.pathname); + const response = await fetch(`${prefix}/param-tree-uk.json`); + if (!response.ok) return null; + const paths: unknown = await response.json(); + if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) return null; + return new Set(paths); + } catch { + return null; + } +} + +function ProgrammeCard({ + program, + expanded, + onToggle, + parameterHref, +}: { + program: Program; + expanded: boolean; + onToggle: () => void; + parameterHref?: string; +}) { + const detailsId = useId(); + const showFullName = program.fullName.toLowerCase() !== program.name.toLowerCase(); + const nation = program.coverage && program.coverage !== 'UK' ? program.coverage : null; + const years = describeVerifiedYears(program.verifiedYears); + const detail = (label: string, value: ReactNode) => ( +
+
{label}:
+
{value}
+
+ ); + + return ( +
+ + +
+ ); +} + +/** + * Coverage from policyengine-uk's own programme registry (`programs.yaml`), + * read from the snapshot taken at build time. Status, coverage, verified + * years and notes are the registry's. + */ +export default function UkCoverageTracker() { + const [loaded, setLoaded] = useState(null); + const [parameterTree, setParameterTree] = useState | null>(null); + const [search, setSearch] = useState(''); + const [yearFilter, setYearFilter] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + + useEffect(() => { + let active = true; + fetchProgramsWithSource('uk').then((result) => { + if (!active) return; + setLoaded(result); + // Parameter links are checked against the tree built with this snapshot. + if (result.source.kind !== 'snapshot') return; + loadParameterTree().then((tree) => { + if (active) setParameterTree(tree); + }); + }); + return () => { active = false; }; + }, []); + + const programs = useMemo(() => loaded?.programs ?? [], [loaded]); + const commit = loaded?.source.kind === 'snapshot' ? loaded.source.commit : null; + const availableYears = useMemo(() => collectAllYears(programs), [programs]); + + const parameterLinks = useMemo( + () => new Map(programs.map((p) => [ + p.id, + commit ? parameterUrl(p.parameterPrefix, parameterTree, commit) : undefined, + ])), + [programs, parameterTree, commit], + ); + + const yearFiltered = useMemo( + () => (yearFilter === null ? programs : programs.filter((p) => programIncludesYear(p, yearFilter))), + [programs, yearFilter], + ); + + const counts = useMemo(() => { + const result: Record = { complete: 0, partial: 0, inProgress: 0, notStarted: 0 }; + for (const p of yearFiltered) result[p.status]++; + return result; + }, [yearFiltered]); + + // The registry documents complete and partial; other statuses show only once it uses them. + const statuses = useMemo( + () => STATUS_ORDER.filter((s) => s === 'complete' || s === 'partial' || programs.some((p) => p.status === s)), + [programs], + ); + + const sections = useMemo(() => { + const query = search.trim().toLowerCase(); + const visible = query ? yearFiltered.filter((p) => matchesSearch(p, query)) : yearFiltered; + return groupByGovernment(programs, visible); + }, [programs, yearFiltered, search]); + + if (!loaded) { + return

Loading programmes...

; + } + + if (programs.length === 0) { + return ( +

+ The UK programme registry could not be loaded. It is{' '} + + programs.yaml in policyengine-uk + + . +

+ ); + } + + const toggle = (id: string) => setExpanded((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( +
+ + {/* Summary stats */} +
+ + {statuses.map((status, i) => ( + + ))} +
+ + {/* Search, year filter and legend */} +
+ setSearch(e.target.value)} + className="tw:flex-1" + style={{ + padding: `${spacing.sm} ${spacing.lg}`, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + outline: 'none', + minWidth: '200px', + maxWidth: '400px', + }} + /> + {availableYears.length > 0 && ( +
+
+ )} +
+ {statuses.map((status) => ( +
+ + {statusLabels[status]} +
+ ))} +
+
+ + {sections.length === 0 && ( +

No programmes match.

+ )} + + {sections.map((section) => ( +
+

+ {section.heading} ({section.count}) +

+ {section.groups.map((group) => ( +
+ {section.showAgencies && ( +

+ {group.name} ({group.programs.length}) +

+ )} +
+ {group.programs.map((program) => ( + toggle(program.id)} + parameterHref={parameterLinks.get(program.id)} + /> + ))} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/src/components/rules/coverageParts.tsx b/src/components/rules/coverageParts.tsx new file mode 100644 index 0000000..9164188 --- /dev/null +++ b/src/components/rules/coverageParts.tsx @@ -0,0 +1,100 @@ +import { motion } from 'framer-motion'; +import { colors, typography, spacing, statusColors } from '../../designTokens'; +import type { CoverageStatus, Program } from '../../types/Program'; + +const MAX_FORWARD_YEAR = new Date().getFullYear() + 5; + +/** Parse a verifiedYears string like "2022-2026", "2022+", or "2024" into a Set of year numbers. */ +export function parseYearRange(verifiedYears?: string): Set { + if (!verifiedYears) return new Set(); + const trimmed = verifiedYears.trim(); + // "2022+" means from 2022 onward (no end year — uprating covers future) + const openMatch = trimmed.match(/^(\d{4})\+$/); + if (openMatch) { + const start = parseInt(openMatch[1], 10); + const years = new Set(); + for (let y = start; y <= MAX_FORWARD_YEAR; y++) years.add(y); + return years; + } + // "2022-2026" range + const rangeMatch = trimmed.match(/^(\d{4})\s*-\s*(\d{4})$/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + const years = new Set(); + for (let y = start; y <= end; y++) years.add(y); + return years; + } + // Single year "2024" + const singleMatch = trimmed.match(/^(\d{4})$/); + if (singleMatch) return new Set([parseInt(singleMatch[1], 10)]); + return new Set(); +} + +/** Collect all unique years across all programs' verifiedYears fields. */ +export function collectAllYears(programs: Program[]): number[] { + const allYears = new Set(); + for (const p of programs) { + for (const y of parseYearRange(p.verifiedYears)) allYears.add(y); + } + return Array.from(allYears).sort((a, b) => a - b); +} + +/** Check if a program's verifiedYears includes a given year. */ +export function programIncludesYear(program: Program, year: number): boolean { + return parseYearRange(program.verifiedYears).has(year); +} + +export const statusLabels: Record = { + complete: 'Complete', + partial: 'Partial', + inProgress: 'In progress', + notStarted: 'Not started', +}; + +const statusIcons: Record = { + complete: '\u2713', + partial: '\u25D1', + inProgress: '\u21BB', + notStarted: '\u25CB', +}; + +export function StatusDot({ status, size = 10 }: { status: CoverageStatus; size?: number }) { + return ( + + {size >= 14 ? statusIcons[status] : ''} + + ); +} + +export function StatCard({ label, count, color, delay }: { label: string; count: number; color: string; delay: number }) { + return ( + +
{count}
+
{label}
+
+ ); +} diff --git a/src/data/fetchPrograms.ts b/src/data/fetchPrograms.ts index 54b3c4e..49aff1d 100644 --- a/src/data/fetchPrograms.ts +++ b/src/data/fetchPrograms.ts @@ -109,9 +109,11 @@ function transformProgram(p: ApiProgram, githubBase: string): Program { coverage: p.coverage, hasStateVariation: p.has_state_variation, variable: p.variable, + parameterPrefix: p.parameter_prefix, notes: p.notes ? (verifiedYears ? `${p.notes}. Years: ${verifiedYears}` : p.notes) : (verifiedYears ? `Years: ${verifiedYears}` : undefined), + registryNotes: p.notes, verifiedYears, stateImplementations, githubLinks: buildGithubLinks(githubBase, p.parameter_prefix), @@ -188,7 +190,8 @@ export async function fetchProgramsWithSource(country: Country = 'us'): Promise< return result; } catch (err) { console.warn('Failed to fetch programs from API, using fallback:', err); - // Fall back to hardcoded data + // The bundled fallback registry is the US one; never show it for another country. + if (country !== 'us') return { programs: [], source: { kind: 'fallback' } }; const { programs } = await import('./programs'); return { programs, source: { kind: 'fallback' } }; } diff --git a/src/test/coverageProvenance.test.tsx b/src/test/coverageProvenance.test.tsx index 988961d..bfa3e88 100644 --- a/src/test/coverageProvenance.test.tsx +++ b/src/test/coverageProvenance.test.tsx @@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/react'; import CoverageProvenance from '../components/rules/CoverageProvenance'; import CoverageTrackerPage from '../views/rules/CoverageTrackerPage'; import { fetchProgramsWithSource, type ProgramsSource } from '../data/fetchPrograms'; +import type { Program } from '../types/Program'; vi.mock('../data/fetchPrograms', () => ({ fetchProgramsWithSource: vi.fn(), @@ -90,10 +91,26 @@ describe('Coverage provenance', () => { expect(within(partialCard).getByText('0')).toBeInTheDocument(); }); - it('preserves the static UK introduction without assigning it registry provenance', () => { + it('renders the UK registry with its own provenance instead of a static introduction', async () => { + vi.mocked(fetchProgramsWithSource).mockResolvedValue({ + source: { ...snapshotSource, repo: 'PolicyEngine/policyengine-uk', version: '2.162.12', apiVersion: '2.132.6' }, + programs: [{ + id: 'universal_credit', + name: 'Universal Credit', + fullName: 'Universal Credit', + status: 'complete', + agency: 'DWP' as Program['agency'], + coverage: 'UK', + githubLinks: {}, + }], + }); + render(); - expect(screen.getByText(/PolicyEngine UK models over 50/)).toBeInTheDocument(); - expect(screen.queryByText(/Coverage reflects/)).not.toBeInTheDocument(); - expect(fetchProgramsWithSource).not.toHaveBeenCalled(); + + expect(await screen.findByText('Coverage reflects policyengine-uk 2.162.12 (main @ abcdef1, fetched 2026-09-08). The production API served 2.132.6 when the snapshot was fetched.')).toBeInTheDocument(); + expect(fetchProgramsWithSource).toHaveBeenCalledWith('uk'); + expect(fetchProgramsWithSource).not.toHaveBeenCalledWith('us'); + expect(screen.getByRole('heading', { name: 'UK government programmes (1)' })).toBeInTheDocument(); + expect(screen.queryByText(/PolicyEngine UK models over 50/)).not.toBeInTheDocument(); }); }); diff --git a/src/test/fetchPrograms.test.ts b/src/test/fetchPrograms.test.ts index 6fe8644..457b798 100644 --- a/src/test/fetchPrograms.test.ts +++ b/src/test/fetchPrograms.test.ts @@ -67,6 +67,7 @@ describe('program source loading', () => { agency: 'HHS', category: 'Benefits', status: 'inProgress', coverage: 'US', hasStateVariation: true, variable: 'tanf', verifiedYears: '2021-2026', notes: 'Registry notes. Years: 2021-2026', + registryNotes: 'Registry notes', parameterPrefix: 'gov.hhs.tanf', stateImplementations: [ { state: 'CA', status: 'complete', name: 'CalWORKs', fullName: 'California Work Opportunity', variable: 'ca_tanf', notes: 'State notes' }, { state: 'NY', status: 'inProgress' }, @@ -203,6 +204,16 @@ describe('program source loading', () => { expect(result.source).toEqual({ kind: 'fallback' }); }); + it('never falls back to the hardcoded US programs for the UK', async () => { + window.history.replaceState({}, '', '/uk/model/rules/coverage'); + vi.mocked(fetch).mockRejectedValue(new Error('Offline')); + const { fetchProgramsWithSource } = await import('../data/fetchPrograms'); + const result = await fetchProgramsWithSource('uk'); + expect(fetch).toHaveBeenNthCalledWith(1, '/uk/model/programs-uk.json'); + expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.policyengine.org/uk/metadata'); + expect(result).toEqual({ programs: [], source: { kind: 'fallback' } }); + }); + it('uses hardcoded programs when API metadata has no registry', async () => { vi.mocked(fetch).mockResolvedValueOnce(response(null, 404)).mockResolvedValueOnce(response({ version: '1.764.6' })); const { fetchProgramsWithSource } = await import('../data/fetchPrograms'); diff --git a/src/test/fixtures/programs-uk.snapshot.json b/src/test/fixtures/programs-uk.snapshot.json new file mode 100644 index 0000000..7351fc3 --- /dev/null +++ b/src/test/fixtures/programs-uk.snapshot.json @@ -0,0 +1 @@ +{"source":{"repo":"PolicyEngine/policyengine-uk","branch":"main","commit":"b9f55f84b5cffc33979eb84c36e354561ca568c2","fetchedAt":"2026-10-27T13:59:32.361Z"},"version":"2.162.12","programs":[{"id":"lifetime_isa","name":"Lifetime ISA","full_name":"Lifetime Individual Savings Account bonus and withdrawal charge","category":"Savings","agency":"HMRC","status":"partial","coverage":"UK","variable":"lifetime_isa_bonus","parameter_prefix":"gov.hmrc.isa.lifetime","verified_start_year":2017,"notes":"Annual qualifying-payment bonus and gross-withdrawal charge, including individual first-home conditions. Assumes valid account opening and transaction qualification; does not update balances. Late-March 2020 withdrawals are outside the annual timing resolution."},{"id":"isa_cash_interest_charge","name":"Non-cash ISA cash-interest charge","full_name":"Final charge on cash interest in stocks-and-shares and innovative finance ISAs","category":"Taxes","agency":"HMRC","status":"partial","coverage":"UK","variable":"isa_cash_interest_flat_rate_charge","parameter_prefix":"gov.hmrc.isa.cash_interest_charge","verified_start_year":2027,"notes":"Separate final manager charge at the savings basic rate from 6 April 2027. Requires the cash-interest component as an input; subscription-limit compliance is not modelled."},{"id":"income_tax","name":"Income tax","full_name":"Income Tax","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"income_tax","parameter_prefix":"gov.hmrc.income_tax","verified_start_year":2019,"notes":"Includes Scottish rates via devolved tax bands, the high income child benefit charge and, from 2025-26, the winter fuel payment charge"},{"id":"national_insurance","name":"National Insurance","full_name":"National Insurance contributions","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"national_insurance","parameter_prefix":"gov.hmrc.national_insurance","verified_start_year":2019,"notes":"Classes 1, 2, 3, and 4"},{"id":"capital_gains_tax","name":"Capital gains tax","full_name":"Capital Gains Tax","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"capital_gains_tax","parameter_prefix":"gov.hmrc.cgt","verified_start_year":2022},{"id":"child_benefit","name":"Child Benefit","full_name":"Child Benefit","category":"Benefits","agency":"HMRC","status":"complete","coverage":"UK","variable":"child_benefit","parameter_prefix":"gov.hmrc.child_benefit","verified_start_year":2019,"notes":"Includes High Income Child Benefit Charge"},{"id":"stamp_duty_land_tax","name":"Stamp Duty Land Tax","full_name":"Stamp Duty Land Tax","category":"Taxes","agency":"HMRC","status":"complete","coverage":"England and Northern Ireland","variable":"stamp_duty_land_tax","parameter_prefix":"gov.hmrc.stamp_duty","verified_start_year":2022},{"id":"vat","name":"VAT","full_name":"Value Added Tax","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"vat","parameter_prefix":"gov.hmrc.vat","verified_start_year":2022},{"id":"fuel_duty","name":"Fuel duty","full_name":"Fuel Duty","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"fuel_duty","parameter_prefix":"gov.hmrc.fuel_duty","verified_start_year":2022,"notes":"Petrol and diesel, plus supplied kilograms of LPG and natural road fuel gas. Gas consumption is not imputed."},{"id":"alcohol_duty","name":"Alcohol duty","full_name":"Alcohol Duty","category":"Taxes","agency":"HMRC","status":"partial","coverage":"UK","variable":"alcohol_duty","parameter_prefix":"gov.hmrc.alcohol_duty","verified_start_year":2024,"verified_end_year":2026,"notes":"Household quantities and strengths, including draught relief. Excludes small producer relief and population imputation."},{"id":"tobacco_duty","name":"Tobacco duty","full_name":"Tobacco Products Duty","category":"Taxes","agency":"HMRC","status":"partial","coverage":"UK","variable":"tobacco_duty","parameter_prefix":"gov.hmrc.tobacco_duty","verified_start_year":2024,"verified_end_year":2026,"notes":"Household quantities and retail cigarette prices. Population quantities are not imputed."},{"id":"vehicle_excise_duty","name":"Vehicle excise duty","full_name":"Vehicle Excise Duty","category":"Taxes","agency":"DfT","status":"partial","coverage":"UK","variable":"vehicle_excise_duty","parameter_prefix":"gov.dft.vehicle_excise_duty","verified_start_year":2024,"verified_end_year":2026,"notes":"One car per person with supplied vehicle characteristics. Excludes vans, motorcycles, eVED and population imputation."},{"id":"gambling_duty","name":"Gambling duties","full_name":"Betting and gaming duties","category":"Taxes","agency":"HMRC","status":"partial","coverage":"UK","variable":"gambling_duty","parameter_prefix":"gov.hmrc.gambling_duty","verified_start_year":2024,"verified_end_year":2027,"notes":"Seven duty families on supplied attributed bases. Casino incidence uses a fixed FY2024/25 UK receipts/GB game yield proxy under household.consumption.gambling; review issue 2587. Excludes population imputation, verified machine-class-by-premises allocation and VAT-base repair."},{"id":"student_loans","name":"Student loan repayments","full_name":"Student Loan Repayments","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","variable":"student_loan_repayment","parameter_prefix":"gov.hmrc.student_loans","verified_start_year":2022},{"id":"tax_free_childcare","name":"Tax-Free Childcare","full_name":"Tax-Free Childcare","category":"Benefits","agency":"HMRC","status":"complete","coverage":"UK","variable":"tax_free_childcare","parameter_prefix":"gov.hmrc.tax_free_childcare","verified_start_year":2022},{"id":"pensions_allowance","name":"Pensions allowance","full_name":"Pensions Annual and Lifetime Allowance","category":"Taxes","agency":"HMRC","status":"complete","coverage":"UK","parameter_prefix":"gov.hmrc.pensions","verified_start_year":2022},{"id":"business_rates","name":"Business rates","full_name":"Business Rates","category":"Taxes","agency":"HMRC","status":"partial","coverage":"UK","variable":"business_rates","parameter_prefix":"gov.hmrc.business_rates","verified_start_year":2020,"verified_end_year":2030,"notes":"Receipts allocated to households by shareholding. Local and central rating lists; outturn from 2020-21 (Northern Ireland's first year) to 2025-26 (Wales 2024-25), then OBR March 2026 receipts forecasts to 2030-31."},{"id":"minimum_wage","name":"National Minimum Wage","full_name":"National Minimum Wage / National Living Wage","category":"Other","agency":"HMRC","status":"complete","coverage":"UK","variable":"minimum_wage","parameter_prefix":"gov.hmrc.minimum_wage","verified_start_year":2017,"notes":"Rates from 2015 are the 30 April values; the October 2015 and October 2016 changes fall mid fiscal year"},{"id":"universal_credit","name":"Universal Credit","full_name":"Universal Credit","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"universal_credit","parameter_prefix":"gov.dwp.universal_credit","verified_start_year":2019,"notes":"Includes standard allowance, child element, housing costs, work allowances, and taper"},{"id":"pension_credit","name":"Pension Credit","full_name":"Pension Credit","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"pension_credit","parameter_prefix":"gov.dwp.pension_credit","verified_start_year":2022,"notes":"Guarantee Credit and Savings Credit"},{"id":"state_pension","name":"State Pension","full_name":"State Pension","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"state_pension","parameter_prefix":"gov.dwp.state_pension","verified_start_year":2022,"notes":"New State Pension and basic State Pension"},{"id":"housing_benefit","name":"Housing Benefit","full_name":"Housing Benefit","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"housing_benefit","parameter_prefix":"gov.dwp.housing_benefit","verified_start_year":2022,"notes":"Legacy benefit being replaced by Universal Credit"},{"id":"income_support","name":"Income Support","full_name":"Income Support","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"income_support","parameter_prefix":"gov.dwp.income_support","verified_start_year":2022},{"id":"jsa","name":"JSA","full_name":"Jobseeker's Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"jsa","parameter_prefix":"gov.dwp.JSA","verified_start_year":2022,"notes":"Income-based and contribution-based"},{"id":"esa","name":"ESA","full_name":"Employment and Support Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"esa","parameter_prefix":"gov.dwp.ESA","verified_start_year":2022,"notes":"Income-related and contribution-based"},{"id":"attendance_allowance","name":"Attendance Allowance","full_name":"Attendance Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"attendance_allowance","parameter_prefix":"gov.dwp.attendance_allowance","verified_start_year":2022},{"id":"carers_allowance","name":"Carer's Allowance","full_name":"Carer's Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"carers_allowance","parameter_prefix":"gov.dwp.carers_allowance","verified_start_year":2022,"notes":"Reduced by overlapping benefits such as State Pension and contributory ESA"},{"id":"dla","name":"DLA","full_name":"Disability Living Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"dla","parameter_prefix":"gov.dwp.dla","verified_start_year":2022},{"id":"pip","name":"PIP","full_name":"Personal Independence Payment","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"pip","parameter_prefix":"gov.dwp.pip","verified_start_year":2022},{"id":"benefit_cap","name":"Benefit cap","full_name":"Benefit Cap","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"benefit_cap_reduction","parameter_prefix":"gov.dwp.benefit_cap","verified_start_year":2022,"notes":"Universal Credit (uc_benefit_cap) and Housing Benefit (housing_benefit_benefit_cap) each apply their own cap; benefit_cap_reduction is the sum of their reductions"},{"id":"tax_credits","name":"Tax credits","full_name":"Tax Credits (Working Tax Credit and Child Tax Credit)","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"tax_credits","parameter_prefix":"gov.dwp.tax_credits","verified_start_year":2022,"notes":"Legacy benefit being replaced by Universal Credit"},{"id":"sda","name":"SDA","full_name":"Severe Disablement Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"sda","parameter_prefix":"gov.dwp.sda","verified_start_year":2022},{"id":"winter_fuel_payment","name":"Winter Fuel Payment","full_name":"Winter Fuel Payment","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"winter_fuel_payment","parameter_prefix":"gov.dwp.winter_fuel_payment","verified_start_year":2022,"notes":"Great Britain to winter 2023-24, then England and Wales; Northern Ireland's mirror scheme uses the same parameters. From winter 2025-26 paid to everyone of pensionable age and recovered by the winter fuel payment charge (income tax)"},{"id":"ssmg","name":"Sure Start Maternity Grant","full_name":"Sure Start Maternity Grant","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"ssmg","parameter_prefix":"gov.dwp.ssmg","verified_start_year":2022},{"id":"maternity_allowance","name":"Maternity Allowance","full_name":"Maternity Allowance","category":"Benefits","agency":"DWP","status":"complete","coverage":"UK","variable":"maternity_allowance","verified_start_year":2022},{"id":"universal_childcare_entitlement","name":"Universal childcare entitlement (15h)","full_name":"Universal Free Childcare Entitlement (3- and 4-year-olds, England)","category":"Education","agency":"DfE","status":"complete","coverage":"England","variable":"universal_childcare_entitlement","parameter_prefix":"gov.dfe.universal_childcare_entitlement","verified_start_year":2023,"verified_end_year":2025,"notes":"570 hours/year (15h/week over 38 weeks term-time) for all 3- and 4-year-olds; parameters may need expansion for later years."},{"id":"extended_childcare_entitlement","name":"Extended childcare entitlement (30h)","full_name":"Extended Free Childcare Entitlement (working parents, England)","category":"Education","agency":"DfE","status":"complete","coverage":"England","variable":"extended_childcare_entitlement","parameter_prefix":"gov.dfe.extended_childcare_entitlement","verified_start_year":2023,"verified_end_year":2025,"notes":"Working parent hours above the child's legally applicable universal (3-4) or targeted (2) hours, which count towards the 30 a week regardless of standalone take-up (Childcare Act 2016 s.1(6)); up to 570 extended hours/year for children eligible for either base programme, and up to 1,140 for younger children without a base entitlement from September 2025."},{"id":"targeted_childcare_entitlement","name":"Targeted childcare entitlement","full_name":"Targeted Free Childcare Entitlement (disadvantaged 2-year-olds and working families, England)","category":"Education","agency":"DfE","status":"complete","coverage":"England","variable":"targeted_childcare_entitlement","parameter_prefix":"gov.dfe.targeted_childcare_entitlement","verified_start_year":2023,"verified_end_year":2025,"notes":"570 free hours/year for 2-year-olds in families on qualifying benefits or low incomes; kept when the family is also eligible for the extended entitlement, which adds working parent hours on top."},{"id":"wales_childcare_offer","name":"Childcare Offer for Wales","full_name":"Childcare Offer for Wales (working parents of 3- and 4-year-olds)","category":"Education","agency":"Welsh Government","status":"partial","coverage":"Wales","variable":"wales_childcare_offer","parameter_prefix":"gov.welsh_government.childcare_offer","verified_start_year":2020,"verified_end_year":2026,"notes":"The childcare element only, valued at the Welsh Government's hourly rate to providers. Up to 30 hours a week less the local authority's nursery education hours (10, the minimum) for 39 term weeks, and 30 hours for 9 holiday weeks. Nursery education, funded by local authorities, is not valued."},{"id":"childcare_grant","name":"Childcare Grant","full_name":"Student Finance England Childcare Grant","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"childcare_grant","parameter_prefix":"gov.dfe.childcare_grant","verified_start_year":2025,"notes":"First-pass full-time undergraduate England model using explicit full-time, SEN, and childcare-support inputs plus maintenance-loan proxies; eligible children's childcare_expenses are out-of-pocket spend net of free hours, aggregated and capped once per benefit unit. The model allocates the family award to the eldest eligible student parent; actual student partners can share it."},{"id":"parents_learning_allowance","name":"Parents' Learning Allowance","full_name":"Student Finance England Parents' Learning Allowance","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"parents_learning_allowance","parameter_prefix":"gov.dfe.parents_learning_allowance","verified_start_year":2025,"notes":"First-pass England model using the published maximum amount and household-income cutoff, with higher-education evidence as the default course proxy and an explicit override for edge cases such as 120-credit or teacher-training routes"},{"id":"adult_dependants_grant","name":"Adult Dependants' Grant","full_name":"Student Finance England Adult Dependants' Grant","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"adult_dependants_grant","parameter_prefix":"gov.dfe.adult_dependants_grant","verified_start_year":2025,"notes":"First-pass England model using the published maximum amount and household-income tests, with automatic 25-plus couple detection and explicit inputs for under-25 spouse/civil-partner and non-partner adult dependant cases"},{"id":"travel_grant","name":"Travel Grant","full_name":"Student Finance England Travel Grant","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"travel_grant","parameter_prefix":"gov.dfe.travel_grant","verified_start_year":2025,"notes":"First-pass England model using the published initial contribution and household-income reduction formula, with explicit placement and eligible-expense inputs because the survey does not observe reimbursable travel costs"},{"id":"disabled_students_allowance","name":"Disabled Students' Allowance","full_name":"Student Finance England Disabled Students' Allowance","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"disabled_students_allowance","parameter_prefix":"gov.dfe.disabled_students_allowance","verified_start_year":2025,"notes":"First-pass England model using the published annual cap, higher-education evidence as the default course proxy, a narrow qualifying-condition proxy with explicit override, and explicit disability-related study support costs"},{"id":"bursary_fund_16_to_19","name":"16 to 19 Bursary Fund","full_name":"16 to 19 Bursary Fund","category":"Benefits","agency":"DfE","status":"partial","coverage":"England","variable":"bursary_fund_16_to_19","parameter_prefix":"gov.dfe.bursary_fund_16_to_19","verified_start_year":2025,"notes":"First-pass model of the defined vulnerable-group bursary only, using explicit care, self-supporting, and participation-cost inputs plus person-level reported-benefit proxies"},{"id":"tv_licence","name":"TV Licence","full_name":"TV Licence","category":"Taxes","agency":"DCMS","status":"complete","coverage":"UK","variable":"tv_licence","parameter_prefix":"gov.dcms.bbc","verified_start_year":2022},{"id":"council_tax","name":"Council Tax","full_name":"Council Tax","category":"Taxes","agency":"Local","status":"complete","coverage":"UK","variable":"council_tax","verified_start_year":2022},{"id":"council_tax_reduction","name":"Council Tax Reduction","full_name":"Council Tax Reduction","category":"Benefits","agency":"Local","status":"partial","coverage":"UK","variable":"council_tax_reduction","parameter_prefix":"gov.local_authorities","verified_start_year":2013,"notes":"First-pass national support for Scotland, Wales, and England pensioner schemes (including the Pension Credit guarantee credit disregard and savings-credit-only rules), with reported CTB/CTR retained for unsupported English working-age local schemes."},{"id":"cost_of_living_support","name":"Cost of Living Support","full_name":"Cost of Living Support Payments","category":"Benefits","agency":"Treasury","status":"complete","coverage":"UK","parameter_prefix":"gov.treasury.cost_of_living_support","verified_start_year":2022,"verified_end_year":2023,"notes":"Temporary program, ended 2023"},{"id":"energy_price_guarantee","name":"Energy Price Guarantee","full_name":"Energy Price Guarantee","category":"Benefits","agency":"Ofgem","status":"partial","coverage":"UK","parameter_prefix":"gov.ofgem","verified_start_year":2022,"verified_end_year":2024,"notes":"Great Britain support ended 31 March 2024. Northern Ireland general unit discounts ran 1 November 2022 to 30 June 2023; calculations require supplied monthly fuel use, a full-rate applicability assertion and a compatible pre-discount bill. Current microdata lack these inputs."},{"id":"warm_home_discount","name":"Warm Home Discount","full_name":"Warm Home Discount (England and Wales)","category":"Benefits","agency":"Ofgem","status":"partial","coverage":"England and Wales","variable":"warm_home_discount","parameter_prefix":"gov.ofgem.warm_home_discount","verified_start_year":2025,"notes":"Supplier-funded bill rebate, outside household_net_income and gov_spending. Benefit-receipt rule from winter 2025-26; Scottish scheme not modelled"},{"id":"scottish_income_tax","name":"Scottish income tax","full_name":"Scottish Income Tax Rates","category":"Taxes","agency":"Revenue Scotland","status":"complete","coverage":"Scotland","verified_start_year":2019,"notes":"Devolved tax bands applied via income tax system"},{"id":"lbtt","name":"LBTT","full_name":"Land and Buildings Transaction Tax","category":"Taxes","agency":"Revenue Scotland","status":"complete","coverage":"Scotland","variable":"land_and_buildings_transaction_tax","parameter_prefix":"gov.revenue_scotland.lbtt","verified_start_year":2022},{"id":"scottish_child_payment","name":"Scottish Child Payment","full_name":"Scottish Child Payment","category":"Benefits","agency":"Social Security Scotland","status":"complete","coverage":"Scotland","variable":"scottish_child_payment","parameter_prefix":"gov.social_security_scotland.scottish_child_payment","verified_start_year":2022},{"id":"pawhp","name":"Pension Age Winter Heating Payment","full_name":"Pension Age Winter Heating Payment","category":"Benefits","agency":"Social Security Scotland","status":"complete","coverage":"Scotland","variable":"pension_age_winter_heating_payment","parameter_prefix":"gov.social_security_scotland.pawhp","verified_start_year":2024,"notes":"Replaced the Winter Fuel Payment in Scotland from winter 2024-25. From winter 2025-26 recovered by the winter fuel payment charge (income tax) above £35,000 of total income"}],"apiVersion":"2.132.6"} \ No newline at end of file diff --git a/src/test/proxy.test.ts b/src/test/proxy.test.ts index e1082d0..77d68a1 100644 --- a/src/test/proxy.test.ts +++ b/src/test/proxy.test.ts @@ -25,6 +25,9 @@ describe('country proxy', () => { ['/us/model/programs-uk.json', '/programs-uk.json'], ['/uk/model/programs-us.json', '/programs-us.json'], ['/uk/model/programs-uk.json', '/programs-uk.json'], + ['/uk/param-tree-uk.json', '/param-tree-uk.json'], + ['/uk/model/param-tree-uk.json', '/param-tree-uk.json'], + ['/us/model/param-tree-us.json', '/param-tree-us.json'], ])('matches and rewrites the registry snapshot at %s', (path, expected) => { expect(unstable_doesMiddlewareMatch({ config, url: `${ORIGIN}${path}` })).toBe(true); @@ -37,6 +40,7 @@ describe('country proxy', () => { '/programs-us.json', '/us/model/logo.svg', '/us/model/programs-ca.json', + '/uk/model/param-tree-ca.json', '/_next/static/chunks/main.js', ])('keeps unrelated static paths outside the proxy: %s', (path) => { expect(unstable_doesMiddlewareMatch({ config, url: `${ORIGIN}${path}` })).toBe(false); diff --git a/src/test/ukCoverageTracker.test.tsx b/src/test/ukCoverageTracker.test.tsx new file mode 100644 index 0000000..36a3817 --- /dev/null +++ b/src/test/ukCoverageTracker.test.tsx @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describeVerifiedYears, groupByGovernment, parameterUrl } from '../components/rules/UkCoverageTracker'; +import type { Program } from '../types/Program'; + +// A pinned copy of the UK registry snapshot (public/programs-uk.json is generated at build time and not committed). +// Refresh with: cp public/programs-uk.json src/test/fixtures/programs-uk.snapshot.json +const ukSnapshot = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/programs-uk.snapshot.json'), 'utf8')); + +interface RegistryEntry { + id: string; + status: string; + notes?: string; + verified_start_year: number; + verified_end_year?: number; +} +const registry: RegistryEntry[] = ukSnapshot.programs; +const commit: string = ukSnapshot.source.commit; +const github = 'https://github.com/PolicyEngine/policyengine-uk'; + +// Parameter files under the prefixes the tests open: a folder (Universal Credit) and a single file (benefit cap). +const parameterTree = [ + 'gov/dwp/universal_credit/elements/child/amount.yaml', + 'gov/dwp/benefit_cap.yaml', +]; + +function response(data: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => data } as Response; +} + +function stubFetch({ snapshot = true, tree = true } = {}) { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url === '/uk/model/programs-uk.json') return snapshot ? response(ukSnapshot) : response(null, 404); + if (url === '/uk/model/param-tree-uk.json') return tree ? response(parameterTree) : response(null, 404); + throw new Error(`Unexpected fetch: ${url}`); + })); +} + +async function renderUkTracker() { + const { default: CoverageTrackerPage } = await import('../views/rules/CoverageTrackerPage'); + render(); + await screen.findByText('Total programmes'); +} + +function statCount(label: string) { + return Number(within(screen.getAllByText(label)[0].parentElement!).getByText(/^\d+$/).textContent); +} + +function sectionCounts() { + return screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent); +} + +// Role queries with names are slow over every card, so find a card by its programme name. +function details(programme: string) { + const button = screen.getByText(programme, { selector: 'button span' }).closest('button')!; + return { button, panel: document.getElementById(button.getAttribute('aria-controls')!)! }; +} + +describe('UK coverage tracker over the pinned UK registry', () => { + beforeEach(() => { + vi.resetModules(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + window.history.replaceState({}, '', '/uk/model/rules/coverage'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + window.history.replaceState({}, '', '/'); + }); + + it('places every programme under exactly one known body and level of government', async () => { + stubFetch(); + const { fetchProgramsWithSource } = await import('../data/fetchPrograms'); + const { groupByGovernment, ukAgency } = await import('../components/rules/UkCoverageTracker'); + const { programs } = await fetchProgramsWithSource('uk'); + expect(programs).toHaveLength(registry.length); + expect(programs.filter((p) => ukAgency(p).level === 'other').map((p) => `${p.id} (${p.agency})`)).toEqual([]); + + const sections = groupByGovernment(programs, programs); + expect(sections.map((s) => s.level)).toEqual(['uk', 'devolved', 'local']); + const ids = sections.flatMap((s) => s.groups.flatMap((g) => g.programs.map((p) => p.id))); + expect(ids).toHaveLength(programs.length); + expect(new Set(ids).size).toBe(programs.length); + expect(sections.find((s) => s.level === 'devolved')!.groups.map((g) => g.name)) + .toEqual(['Revenue Scotland', 'Social Security Scotland', 'Welsh Government']); + expect(sections.find((s) => s.level === 'local')!.showAgencies).toBe(false); + }); + + it('shows the registry provenance, status counts and UK sections, with no US states', async () => { + stubFetch(); + await renderUkTracker(); + + expect(screen.getByText(/^Coverage reflects policyengine-uk \S+ \(main @ [0-9a-f]{7}, fetched \d{4}-\d{2}-\d{2}\)\./)).toBeInTheDocument(); + expect(statCount('Total programmes')).toBe(registry.length); + expect(statCount('Complete')).toBe(registry.filter((p) => p.status === 'complete').length); + expect(statCount('Partial')).toBe(registry.filter((p) => p.status === 'partial').length); + // The UK registry uses only complete and partial. + expect(screen.queryByText('In progress')).not.toBeInTheDocument(); + + const headings = sectionCounts(); + expect(headings.map((h) => h!.replace(/ \(\d+\)$/, ''))).toEqual([ + 'UK government programmes', 'Devolved programmes', 'Local authority programmes', + ]); + const total = headings.reduce((sum, h) => sum + Number(h!.match(/\((\d+)\)$/)![1]), 0); + expect(total).toBe(registry.length); + expect(screen.getByRole('heading', { level: 3, name: /^HM Revenue and Customs \(\d+\)$/ })).toBeInTheDocument(); + + expect(document.body.textContent).not.toMatch(/By state|Federal programs|State programs|All states/); + }); + + it('narrows the list by name, body or variable, and says when nothing matches', async () => { + stubFetch(); + await renderUkTracker(); + const search = screen.getByRole('searchbox', { name: 'Search programmes' }); + const dwp = registry.filter((p) => ukSnapshot.programs.find((q: { id: string; agency: string }) => q.id === p.id).agency === 'DWP'); + + fireEvent.change(search, { target: { value: 'work and pensions' } }); + expect(sectionCounts()).toEqual([`UK government programmes (${dwp.length})`]); + expect(screen.getByRole('heading', { level: 3, name: `Department for Work and Pensions (${dwp.length})` })).toBeInTheDocument(); + + fireEvent.change(search, { target: { value: 'pension_age_winter_heating_payment' } }); + expect(sectionCounts()).toEqual(['Devolved programmes (1)']); + expect(details('Pension Age Winter Heating Payment').button).toBeInTheDocument(); + + fireEvent.change(search, { target: { value: 'no such programme' } }); + expect(screen.getByText('No programmes match.')).toBeInTheDocument(); + // The summary counts describe the registry, not the search. + expect(statCount('Total programmes')).toBe(registry.length); + }); + + it('counts only the programmes verified for a chosen year', async () => { + stubFetch(); + await renderUkTracker(); + fireEvent.change(screen.getByRole('combobox', { name: 'Filter by verified year' }), { target: { value: '2023' } }); + const verified2023 = registry.filter((p) => p.verified_start_year <= 2023 && (p.verified_end_year ?? Infinity) >= 2023); + expect(statCount('Total programmes')).toBe(verified2023.length); + expect(verified2023.length).toBeLessThan(registry.length); + }); + + it('opens a programme in place with its registry details and a parameters link the tree confirms', async () => { + stubFetch(); + await renderUkTracker(); + + const benefitCap = details('Benefit cap'); + expect(benefitCap.button).toHaveAttribute('aria-expanded', 'false'); + expect(benefitCap.panel).not.toBeVisible(); + fireEvent.click(benefitCap.button); + expect(benefitCap.button).toHaveAttribute('aria-expanded', 'true'); + expect(benefitCap.panel).toBeVisible(); + const notes = registry.find((p) => p.id === 'benefit_cap')!.notes!; + expect(within(benefitCap.panel).getByText(notes)).toBeInTheDocument(); + expect(within(benefitCap.panel).getByText('2022 onwards')).toBeInTheDocument(); + expect(within(benefitCap.panel).getByText('benefit_cap_reduction')).toBeInTheDocument(); + expect(within(benefitCap.panel).queryByText(/Years:/)).not.toBeInTheDocument(); + expect(await within(benefitCap.panel).findByRole('link', { name: 'Parameters on GitHub' })) + .toHaveAttribute('href', `${github}/blob/${commit}/policyengine_uk/parameters/gov/dwp/benefit_cap.yaml`); + + const universalCredit = details('Universal Credit'); + fireEvent.click(universalCredit.button); + expect(within(universalCredit.panel).getByRole('link', { name: 'Parameters on GitHub' })) + .toHaveAttribute('href', `${github}/tree/${commit}/policyengine_uk/parameters/gov/dwp/universal_credit`); + // Both stay open. + expect(benefitCap.panel).toBeVisible(); + + // Maternity Allowance has no parameter_prefix; Child Benefit's is missing from this tree. + for (const name of ['Maternity Allowance', 'Child Benefit']) { + const programme = details(name); + fireEvent.click(programme.button); + expect(within(programme.panel).queryByRole('link')).not.toBeInTheDocument(); + } + }); + + it('omits parameter links when the parameter tree is unavailable', async () => { + stubFetch({ tree: false }); + await renderUkTracker(); + await waitFor(() => expect(fetch).toHaveBeenCalledWith('/uk/model/param-tree-uk.json')); + const universalCredit = details('Universal Credit'); + fireEvent.click(universalCredit.button); + expect(within(universalCredit.panel).queryByRole('link')).not.toBeInTheDocument(); + }); + + it('points to the registry, never the US fallback programmes, when the UK registry cannot be loaded', async () => { + stubFetch({ snapshot: false }); + const { default: CoverageTrackerPage } = await import('../views/rules/CoverageTrackerPage'); + render(); + expect(await screen.findByText(/The UK programme registry could not be loaded/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'programs.yaml in policyengine-uk' })) + .toHaveAttribute('href', `${github}/blob/main/policyengine_uk/programs.yaml`); + expect(screen.queryByText('SNAP')).not.toBeInTheDocument(); + expect(screen.queryByText(/Coverage reflects/)).not.toBeInTheDocument(); + }); +}); + +describe('UK coverage helpers', () => { + const tree = new Set(['gov/hmrc/income_tax/rates/uk.yaml', 'gov/dwp/benefit_cap.yaml', 'gov/hmrc/vat.yaml']); + + it('links a parameter folder, a whole file, or the file that holds a key', () => { + expect(parameterUrl('gov.hmrc.income_tax', tree, 'abc')).toBe(`${github}/tree/abc/policyengine_uk/parameters/gov/hmrc/income_tax`); + expect(parameterUrl('gov.dwp.benefit_cap', tree, 'abc')).toBe(`${github}/blob/abc/policyengine_uk/parameters/gov/dwp/benefit_cap.yaml`); + expect(parameterUrl('gov.hmrc.vat.standard_rate', tree, 'abc')).toBe(`${github}/blob/abc/policyengine_uk/parameters/gov/hmrc/vat.yaml`); + }); + + it('gives no link it cannot confirm', () => { + expect(parameterUrl('gov.dwp.pip', tree, 'abc')).toBeUndefined(); + expect(parameterUrl(undefined, tree, 'abc')).toBeUndefined(); + expect(parameterUrl('gov.hmrc.income_tax', null, 'abc')).toBeUndefined(); + }); + + it('names an agency it does not know under other programmes', () => { + const programme = (id: string, agency: string): Program => + ({ id, name: id, fullName: id, status: 'complete', agency: agency as Program['agency'], githubLinks: {} }); + const programs = [programme('universal_credit', 'DWP'), programme('ni_rate_relief', 'Department for Communities')]; + expect(groupByGovernment(programs, programs).map((s) => [s.heading, s.showAgencies, s.groups.map((g) => g.name)])) + .toEqual([ + ['UK government programmes', false, ['Department for Work and Pensions']], + ['Other programmes', true, ['Department for Communities']], + ]); + }); + + it.each([ + ['2019+', '2019 onwards'], + ['2023-2025', '2023 to 2025'], + ['2024-2024', '2024'], + ['2024', '2024'], + [undefined, undefined], + ])('describes verified years %s as %s', (years, expected) => { + expect(describeVerifiedYears(years)).toBe(expected); + }); +}); diff --git a/src/types/Program.ts b/src/types/Program.ts index 2113a0d..891939e 100644 --- a/src/types/Program.ts +++ b/src/types/Program.ts @@ -28,12 +28,14 @@ export interface Program { stateImplementations?: StateImplementation[]; hasStateVariation?: boolean; // Indicates program has full state-by-state variation variable?: string; // Variable name for flowchart visualization + parameterPrefix?: string; // Registry parameter_prefix, e.g. gov.hmrc.income_tax githubLinks: { parameters?: string; variables?: string; tests?: string; }; notes?: string; + registryNotes?: string; // Registry notes as written, without the years `notes` appends verifiedYears?: string; lastUpdated?: string; } \ No newline at end of file From 4c0e1c9d24b119c5c9b41b491289e8cb751f5d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:20:52 +0000 Subject: [PATCH 2/4] Describe the Rules hub in UK terms on the UK site The Rules hub said "federal, state, and local" programs and variables "at the person, family, tax unit, and household level" on both sites. On the UK site it now names UK government, devolved and local programmes and the person, benefit unit and household entities, in British spelling. The US copy is unchanged. Co-Authored-By: Claude Opus 5.5 --- app/rules/page.tsx | 33 ++++++---------- src/data/rulesSection.ts | 59 +++++++++++++++++++++++++++++ src/test/rulesOverviewCopy.test.tsx | 50 ++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 src/data/rulesSection.ts create mode 100644 src/test/rulesOverviewCopy.test.tsx diff --git a/app/rules/page.tsx b/app/rules/page.tsx index e1134e0..57a8f2f 100644 --- a/app/rules/page.tsx +++ b/app/rules/page.tsx @@ -1,31 +1,20 @@ import SectionOverview from '../../src/components/layout/SectionOverview'; +import { detectCountry } from '../../src/components/comparison/detectCountry'; +import { rulesSection } from '../../src/data/rulesSection'; -export default function RulesOverviewPage() { +export default async function RulesOverviewPage({ + searchParams, +}: { + searchParams: Promise<{ country?: string | string[] }>; +}) { + const country = await detectCountry((await searchParams).country); + const { description, children } = rulesSection[country]; return ( ); } diff --git a/src/data/rulesSection.ts b/src/data/rulesSection.ts new file mode 100644 index 0000000..6635c29 --- /dev/null +++ b/src/data/rulesSection.ts @@ -0,0 +1,59 @@ +import type { Country } from '../hooks/useCountry'; + +interface SectionCopy { + description: string; + children: { label: string; description: string; path: string }[]; +} + +const PARAMETERS_DESCRIPTION = + 'Browse every legislative parameter that drives the model — thresholds, rates, brackets, eligibility cut-offs.'; + +/** The Rules hub copy for each country, in its own terms and spelling. */ +export const rulesSection: Record = { + us: { + description: + 'How PolicyEngine encodes tax-and-benefit rules — which programs are covered, the legislative parameters that drive calculations, and the input and output variables exposed to households and microsimulations.', + children: [ + { + label: 'Coverage tracker', + description: + 'Which federal, state, and local tax-and-benefit programs PolicyEngine and peer models implement.', + path: '/rules/coverage', + }, + { + label: 'Parameters', + description: PARAMETERS_DESCRIPTION, + path: '/rules/parameters', + }, + { + label: 'Variables', + description: + 'Inspect the input and output variables computed at the person, family, tax unit, and household level.', + path: '/rules/variables', + }, + ], + }, + uk: { + description: + 'How PolicyEngine encodes tax-and-benefit rules — which programmes are covered, the legislative parameters that drive calculations, and the input and output variables exposed to households and microsimulations.', + children: [ + { + label: 'Coverage tracker', + description: + 'Which UK government, devolved and local tax-and-benefit programmes PolicyEngine and peer models implement.', + path: '/rules/coverage', + }, + { + label: 'Parameters', + description: PARAMETERS_DESCRIPTION, + path: '/rules/parameters', + }, + { + label: 'Variables', + description: + 'Inspect the input and output variables computed at the person, benefit unit and household level.', + path: '/rules/variables', + }, + ], + }, +}; diff --git a/src/test/rulesOverviewCopy.test.tsx b/src/test/rulesOverviewCopy.test.tsx new file mode 100644 index 0000000..159f29c --- /dev/null +++ b/src/test/rulesOverviewCopy.test.tsx @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +const requestHeaders = vi.hoisted(() => ({ country: null as string | null })); + +vi.mock('next/headers', () => ({ + headers: async () => new Headers(requestHeaders.country ? { 'x-pe-country': requestHeaders.country } : {}), +})); + +import RulesOverviewPage from '../../app/rules/page'; +import { rulesSection } from '../data/rulesSection'; + +async function renderRulesOverview({ param, header }: { param?: string; header?: string } = {}) { + requestHeaders.country = header ?? null; + render(await RulesOverviewPage({ searchParams: Promise.resolve(param ? { country: param } : {}) })); +} + +describe('Rules overview copy', () => { + beforeEach(() => { + requestHeaders.country = null; + }); + + it('keeps the US copy unchanged', async () => { + await renderRulesOverview(); + expect(screen.getByText('How PolicyEngine encodes tax-and-benefit rules — which programs are covered, the legislative parameters that drive calculations, and the input and output variables exposed to households and microsimulations.')).toBeInTheDocument(); + expect(screen.getByText('Which federal, state, and local tax-and-benefit programs PolicyEngine and peer models implement.')).toBeInTheDocument(); + expect(screen.getByText('Browse every legislative parameter that drives the model — thresholds, rates, brackets, eligibility cut-offs.')).toBeInTheDocument(); + expect(screen.getByText('Inspect the input and output variables computed at the person, family, tax unit, and household level.')).toBeInTheDocument(); + }); + + it.each([ + ['the country parameter', { param: 'uk' }], + ['the country header', { header: 'uk' }], + ])('describes UK programmes and entities when the UK comes from %s', async (_source, request) => { + await renderRulesOverview(request); + expect(screen.getByText('Which UK government, devolved and local tax-and-benefit programmes PolicyEngine and peer models implement.')).toBeInTheDocument(); + expect(screen.getByText('Inspect the input and output variables computed at the person, benefit unit and household level.')).toBeInTheDocument(); + expect(screen.queryByText(/federal|tax unit/)).not.toBeInTheDocument(); + }); + + it('uses no US-only terms or spelling in the UK copy', () => { + const text = [rulesSection.uk.description, ...rulesSection.uk.children.map((child) => child.description)].join(' '); + expect(text).not.toMatch(/federal|\bstates?\b|tax unit|family|\bprograms?\b/i); + }); + + it('links the same pages for both countries', () => { + expect(rulesSection.uk.children.map((child) => [child.label, child.path])) + .toEqual(rulesSection.us.children.map((child) => [child.label, child.path])); + }); +}); From e481e5011fb82fa10a8105abbf494cee56224762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:34 +0000 Subject: [PATCH 3/4] Say on the UK tracker that the registry can be ahead of the live calculator The UK provenance line now ends: "The registry is read from the latest model code, so a programme marked complete here may not be in the live calculator yet." It shows only when the snapshot's version differs from the one the production API served, or that version is unknown. The US line is unchanged. The tracker also maps DESNZ to the UK government. That is the agency proposed for the Energy Price Guarantee in policyengine-uk's registry. Co-Authored-By: Claude Opus 5.5 --- src/components/rules/CoverageProvenance.tsx | 10 +++++++++- src/components/rules/UkCoverageTracker.tsx | 5 ++++- src/test/coverageProvenance.test.tsx | 12 +++++++++++- src/test/ukCoverageTracker.test.tsx | 7 ++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/rules/CoverageProvenance.tsx b/src/components/rules/CoverageProvenance.tsx index 5b966c0..6892f6a 100644 --- a/src/components/rules/CoverageProvenance.tsx +++ b/src/components/rules/CoverageProvenance.tsx @@ -1,7 +1,14 @@ import type { ProgramsSource } from '../../data/fetchPrograms'; import { colors, spacing, typography } from '../../designTokens'; -export default function CoverageProvenance({ source }: { source: ProgramsSource }) { +export default function CoverageProvenance({ + source, + aheadNote, +}: { + source: ProgramsSource; + /** Added when the snapshot's version is not the one the production API served. */ + aheadNote?: string; +}) { let text: string; if (source.kind === 'snapshot') { const model = source.repo.split('/').pop(); @@ -10,6 +17,7 @@ export default function CoverageProvenance({ source }: { source: ProgramsSource if (source.apiVersion) { text += ` The production API served ${source.apiVersion} when the snapshot was fetched.`; } + if (aheadNote && source.apiVersion !== source.version) text += ` ${aheadNote}`; } else if (source.kind === 'api') { const model = source.repo.split('/').pop(); text = `Coverage reflects the production API, ${model} ${source.apiVersion || '(version unavailable)'}.`; diff --git a/src/components/rules/UkCoverageTracker.tsx b/src/components/rules/UkCoverageTracker.tsx index f7f6751..9d2fd38 100644 --- a/src/components/rules/UkCoverageTracker.tsx +++ b/src/components/rules/UkCoverageTracker.tsx @@ -17,6 +17,8 @@ import { const REPO = 'PolicyEngine/policyengine-uk'; const REGISTRY_URL = `https://github.com/${REPO}/blob/main/policyengine_uk/programs.yaml`; +const AHEAD_NOTE = + 'The registry is read from the latest model code, so a programme marked complete here may not be in the live calculator yet.'; export type UkLevel = 'uk' | 'devolved' | 'local' | 'other'; @@ -31,6 +33,7 @@ const AGENCIES: Record; name: string DfE: { level: 'uk', name: 'Department for Education' }, DfT: { level: 'uk', name: 'Department for Transport' }, DCMS: { level: 'uk', name: 'Department for Culture, Media and Sport' }, + DESNZ: { level: 'uk', name: 'Department for Energy Security and Net Zero' }, Treasury: { level: 'uk', name: 'HM Treasury' }, Ofgem: { level: 'uk', name: 'Ofgem' }, 'Revenue Scotland': { level: 'devolved', name: 'Revenue Scotland' }, @@ -351,7 +354,7 @@ export default function UkCoverageTracker() { return (
- + {/* Summary stats */}
diff --git a/src/test/coverageProvenance.test.tsx b/src/test/coverageProvenance.test.tsx index bfa3e88..bdd9bcb 100644 --- a/src/test/coverageProvenance.test.tsx +++ b/src/test/coverageProvenance.test.tsx @@ -62,6 +62,16 @@ describe('Coverage provenance', () => { expect(screen.getByText('Coverage reflects the bundled fallback registry.')).toBeInTheDocument(); }); + it.each([ + ['is ahead of the production API', snapshotSource, true], + ['has no production API version to compare', { ...snapshotSource, apiVersion: undefined }, true], + ['matches the production API', { ...snapshotSource, apiVersion: '1.823.0' }, false], + ['came from the production API itself', { kind: 'api', repo: 'PolicyEngine/policyengine-us', apiVersion: '1.764.6' } as ProgramsSource, false], + ])('adds the note when a snapshot %s: %#', (_case, source, shown) => { + render(); + expect(screen.queryByText(/Ahead of the live calculator\.$/) !== null).toBe(shown); + }); + it('renders the loaded source and derives TANF summary counts from the registry', async () => { vi.mocked(fetchProgramsWithSource).mockResolvedValue({ source: snapshotSource, @@ -107,7 +117,7 @@ describe('Coverage provenance', () => { render(); - expect(await screen.findByText('Coverage reflects policyengine-uk 2.162.12 (main @ abcdef1, fetched 2026-09-08). The production API served 2.132.6 when the snapshot was fetched.')).toBeInTheDocument(); + expect(await screen.findByText('Coverage reflects policyengine-uk 2.162.12 (main @ abcdef1, fetched 2026-09-08). The production API served 2.132.6 when the snapshot was fetched. The registry is read from the latest model code, so a programme marked complete here may not be in the live calculator yet.')).toBeInTheDocument(); expect(fetchProgramsWithSource).toHaveBeenCalledWith('uk'); expect(fetchProgramsWithSource).not.toHaveBeenCalledWith('us'); expect(screen.getByRole('heading', { name: 'UK government programmes (1)' })).toBeInTheDocument(); diff --git a/src/test/ukCoverageTracker.test.tsx b/src/test/ukCoverageTracker.test.tsx index 36a3817..8e57b00 100644 --- a/src/test/ukCoverageTracker.test.tsx +++ b/src/test/ukCoverageTracker.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describeVerifiedYears, groupByGovernment, parameterUrl } from '../components/rules/UkCoverageTracker'; +import { describeVerifiedYears, groupByGovernment, parameterUrl, ukAgency } from '../components/rules/UkCoverageTracker'; import type { Program } from '../types/Program'; // A pinned copy of the UK registry snapshot (public/programs-uk.json is generated at build time and not committed). @@ -94,6 +94,9 @@ describe('UK coverage tracker over the pinned UK registry', () => { await renderUkTracker(); expect(screen.getByText(/^Coverage reflects policyengine-uk \S+ \(main @ [0-9a-f]{7}, fetched \d{4}-\d{2}-\d{2}\)\./)).toBeInTheDocument(); + // The pinned snapshot is ahead of the production API, so the page says the calculator may lag. + expect(ukSnapshot.apiVersion).not.toBe(ukSnapshot.version); + expect(screen.getByText(/may not be in the live calculator yet\.$/)).toBeInTheDocument(); expect(statCount('Total programmes')).toBe(registry.length); expect(statCount('Complete')).toBe(registry.filter((p) => p.status === 'complete').length); expect(statCount('Partial')).toBe(registry.filter((p) => p.status === 'partial').length); @@ -212,6 +215,8 @@ describe('UK coverage helpers', () => { it('names an agency it does not know under other programmes', () => { const programme = (id: string, agency: string): Program => ({ id, name: id, fullName: id, status: 'complete', agency: agency as Program['agency'], githubLinks: {} }); + // DESNZ is the agency proposed for the Energy Price Guarantee in policyengine-uk's registry. + expect(ukAgency(programme('energy_price_guarantee', 'DESNZ')).level).toBe('uk'); const programs = [programme('universal_credit', 'DWP'), programme('ni_rate_relief', 'Department for Communities')]; expect(groupByGovernment(programs, programs).map((s) => [s.heading, s.showAgencies, s.groups.map((g) => g.name)])) .toEqual([ From fa00059034116ff356070208abdcb5ab095b3e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:34 +0000 Subject: [PATCH 4/4] Delete a stale parameter tree with the stale registry snapshot before a refresh fetch-metadata.js already deleted programs-*.json before refreshing it, but kept param-tree-*.json. If the tree fetch then failed, UK parameter links were checked against a tree from an earlier build while pointing at the new commit. Both files are now deleted first, so whatever is on disk after a run comes from one commit. Co-Authored-By: Claude Opus 5.5 --- scripts/fetch-metadata.js | 6 ++++-- src/test/fetchMetadataScript.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/fetch-metadata.js b/scripts/fetch-metadata.js index 8a57424..38b23a3 100644 --- a/scripts/fetch-metadata.js +++ b/scripts/fetch-metadata.js @@ -149,10 +149,13 @@ export async function fetchMetadataFiles({ for (const country of countries) { const snapshotPath = join(outputDir, `programs-${country}.json`); + const treePath = join(outputDir, `param-tree-${country}.json`); let source; try { - // A failed refresh must not leave a snapshot from an earlier build behind. + // A failed refresh must not leave a snapshot or parameter tree from an + // earlier build behind, so whatever is written comes from one commit. rmSync(snapshotPath, { force: true }); + rmSync(treePath, { force: true }); source = await resolveRepository(country, fetchImpl); } catch (error) { logger.warn(` ${country}: could not resolve registry source — ${error.message}`); @@ -172,7 +175,6 @@ export async function fetchMetadataFiles({ (async () => { try { const paths = await fetchParameterYamlPaths(country, source, fetchImpl); - const treePath = join(outputDir, `param-tree-${country}.json`); writeFileSync(treePath, JSON.stringify(paths)); logger.log(` ${country}: wrote ${treePath} (${paths.length} files)`); } catch (error) { diff --git a/src/test/fetchMetadataScript.test.ts b/src/test/fetchMetadataScript.test.ts index 390669b..9c3064e 100644 --- a/src/test/fetchMetadataScript.test.ts +++ b/src/test/fetchMetadataScript.test.ts @@ -127,6 +127,9 @@ describe('registry snapshot fetch script', () => { it('writes the snapshot even when fetching the independent parameter tree fails', async () => { const outputDir = outputDirectory(); + // A tree from an earlier build must not stand in for the new snapshot's commit. + const treePath = join(outputDir, 'param-tree-us.json'); + writeFileSync(treePath, '["stale.yaml"]'); const rawRoot = `https://raw.githubusercontent.com/${source.repo}/${commit}`; const fetchImpl = mockFetch({ 'https://api.github.com/repos/PolicyEngine/policyengine-us': { default_branch: 'main' }, @@ -140,19 +143,23 @@ describe('registry snapshot fetch script', () => { expect(JSON.parse(readFileSync(join(outputDir, 'programs-us.json'), 'utf8'))).toMatchObject({ source, version: '1.823.4', apiVersion: '1.764.6', }); + expect(existsSync(treePath)).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Tree unavailable')); }); - it('removes an old snapshot and skips a country when its default branch cannot be fetched', async () => { + it('removes an old snapshot and tree and skips a country when its default branch cannot be fetched', async () => { const outputDir = outputDirectory(); const snapshotPath = join(outputDir, 'programs-us.json'); + const treePath = join(outputDir, 'param-tree-us.json'); writeFileSync(snapshotPath, '{"stale":true}'); + writeFileSync(treePath, '["stale.yaml"]'); const fetchImpl = mockFetch({ 'https://api.github.com/repos/PolicyEngine/policyengine-us': new Error('Network unavailable'), }); await expect(fetchMetadataFiles({ countries: ['us'], outputDir, fetchImpl, logger })) .resolves.toBeUndefined(); expect(existsSync(snapshotPath)).toBe(false); + expect(existsSync(treePath)).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Network unavailable')); });