diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 4b02d2ced..1c5b33244 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -1,5 +1,6 @@ import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; +import { trackerBillUrl, trackerPlace, withTrackerTitle } from '@/libs/flagship/billTracker'; import type { ValueInterval } from '@/types/subIngredients/valueInterval'; /** @@ -104,6 +105,10 @@ export interface TrackedBill { /** Two-letter state code for state bills; absent for federal ones. */ state?: string; title: string; + /** + * The bill's stage in the tracker's words ("In Committee"), or + * "Analyzed" when the tracker follows no stage for it. + */ status: string; summary: string; provisions: TrackedBillProvision[]; @@ -114,6 +119,8 @@ export interface TrackedBill { legiscanUrl?: string; /** The bill's source page (congress.gov / state legislature), when known. */ sourceUrl?: string; + /** The bill's page in the bill tracker. */ + trackerUrl?: string; /** Analysis date (ISO), for newest-first ordering. */ date?: string; /** Model and data versions behind the stored analysis. */ @@ -396,7 +403,8 @@ export function changesFromTrackerProvisions(raw: unknown): BillChange[] { /** Headline numbers from a reform_impacts row, when present and numeric. */ function extractImpacts(impact: any): TrackedBill['impacts'] { - const revenue = impact?.budgetary_impact?.stateRevenueImpact; + // As the tracker reads it: net cost stands in where no revenue change is stored. + const revenue = impact?.budgetary_impact?.stateRevenueImpact ?? impact?.budgetary_impact?.netCost; const poverty = impact?.poverty_impact?.percentChange; const result: NonNullable = {}; if (typeof revenue === 'number' && Number.isFinite(revenue) && revenue !== 0) { @@ -559,7 +567,8 @@ function processedBillFor( */ export async function fetchTrackerBills(): Promise { const [research, impacts, processed, validations, baselines] = await Promise.all([ - trackerSelect('research', '*'), + // Titled as the tracker shows them: an entry with no title reads as its bill number. + trackerSelect('research', '*').then((rows) => rows?.map(withTrackerTitle) ?? null), trackerSelect( 'reform_impacts', 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at' @@ -600,7 +609,7 @@ export async function fetchTrackerBills(): Promise { return { id: record.id, countryId: 'us', - jurisdiction: STATE_NAMES[record.state] ?? record.state ?? 'Federal', + jurisdiction: trackerPlace(record.state, STATE_NAMES), // The tracker files federal bills under "US", which is not a state. state: /^[a-z]{2}$/i.test(record.state ?? '') && record.state.toUpperCase() !== 'US' @@ -614,6 +623,7 @@ export async function fetchTrackerBills(): Promise { keyFindings: Array.isArray(record.key_findings) ? record.key_findings : undefined, legiscanUrl: processedBill?.legiscan_url ?? undefined, sourceUrl: record.url ?? undefined, + trackerUrl: trackerBillUrl(record), date: record.date ?? undefined, provenance: impact ? { @@ -643,7 +653,9 @@ function detectValidationDrift(row: any, impact: any): BillValidation['drift'] { } const reasons: string[] = []; const snapEstimate = snapshot.pe_estimate; - const currentEstimate = impact.budgetary_impact?.stateRevenueImpact; + // The estimate the feed headlines (see extractImpacts). + const currentEstimate = + impact.budgetary_impact?.stateRevenueImpact ?? impact.budgetary_impact?.netCost; if ( typeof snapEstimate === 'number' && typeof currentEstimate === 'number' && diff --git a/app/src/components/flagship/BillTrackerLink.tsx b/app/src/components/flagship/BillTrackerLink.tsx new file mode 100644 index 000000000..a842059e0 --- /dev/null +++ b/app/src/components/flagship/BillTrackerLink.tsx @@ -0,0 +1,22 @@ +import { IconExternalLink } from '@tabler/icons-react'; +import { colors, typography } from '@/designTokens'; + +/** A bill's page in the bill tracker, beside the report's other source links. */ +export default function BillTrackerLink({ href }: { href: string }) { + return ( + + Bill tracker + + + ); +} diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index ccbd96cbc..acb1ffe43 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -1,30 +1,39 @@ import { useEffect, useState } from 'react'; -import { Spinner, Stack, Text } from '@/components/ui'; +import { Text } from '@/components/ui'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { colors, spacing, typography } from '@/designTokens'; import { CalibrationMatches, calibrationMatchesForPaths, CalibrationRing, + CalibrationTargetRow, dashboardTargetsUrl, geographyForRegion, } from '@/libs/flagship/calibrationMatching'; import type { ReportValidationSnapshot } from '@/libs/flagship/reportValidation'; import { - cardStyle, + CellNote, detailStyle, - EvidenceHeader, + EvidenceTable, + labelCellStyle, linkStyle, - panelStyle, + numberCellStyle, + percentText, + rowButtonStyle, + rowStyle, summaryStyle, + ValidationCard, + ValidationLoading, + ValidationNote, } from './ValidationDisplay'; /** * The data-side validation surface for a report: which of the variables * this reform moves are calibration targets of the microdata, and how - * well the current populace release fits them. Nearest variables first; - * the interacted quantity within the mechanism is the one that bounds - * the data-side bias of the estimate. + * well the current populace release fits them — the model's baseline + * totals against the official ones. Nearest variables first; the + * interacted quantity within the mechanism is the one that bounds the + * data-side bias of the estimate. */ export const RING_LABELS: Record = { @@ -44,10 +53,7 @@ export function describeDepth(depth: number): string { /** Above this the fit is worth calling out; the diagnostics skill's escalation bar. */ export const ATTENTION_ERROR = 0.25; -function percent(fraction: number): string { - const value = fraction * 100; - return `${Math.abs(value) >= 100 ? Math.round(value) : value.toFixed(1)}%`; -} +const CALIBRATION_COLUMNS = ['Target', 'Period', 'Official', 'PolicyEngine', 'Gap']; function humanize(variable: string): string { const labels: Record = { @@ -60,32 +66,18 @@ function humanize(variable: string): string { return label.charAt(0).toUpperCase() + label.slice(1); } -function SectionCard({ children }: { children: React.ReactNode }) { - return {children}; +function compactNumber(value: number | null): string { + return value === null + ? '—' + : value.toLocaleString('en-US', { notation: 'compact', maximumFractionDigits: 1 }); } -function DashboardLink({ - children, - source, - level, - title, -}: { - children: React.ReactNode; - source?: string | null; - level?: 'national' | 'state' | null; - title?: string; -}) { - return ( - - {children} - - ); +function exactNumber(value: number | null): string { + return value?.toLocaleString('en-US', { maximumFractionDigits: 2 }) ?? '—'; +} + +function gapOf(target: CalibrationTargetRow): string { + return target.relativeError === null ? '—' : percentText(target.relativeError); } /** @@ -137,11 +129,66 @@ function PinNote({ pin }: { pin: CalibrationPin }) { {pin.drift.length === 0 ? `Pinned ${when}; the release and model it was matched against are still current.` - : `Pinned ${when}; since then ${pin.drift.join(' and ')}. The cards show the live comparison; the pinned one is historical.`} + : `Pinned ${when}; since then ${pin.drift.join(' and ')}. The table shows the live comparison; the pinned one is historical.`} ); } +/** A target's name, opening its exact values. */ +function TargetDetails({ target }: { target: CalibrationTargetRow }) { + return ( + + + + + + Calibration target + {target.name} +
+ {[ + ['Source', target.sourceLabel ?? target.source], + ['Geography', target.geography], + ['Period', target.period ?? '—'], + ['Measure', target.measure ?? '—'], + ['Official', exactNumber(target.target)], + ['PolicyEngine', exactNumber(target.estimate)], + ['Gap', gapOf(target)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ {target.sourceUrl && ( + + Read source + + )} + + View target group in dashboard + +
+
+ ); +} + export function CalibrationMatchSection({ matches, pin, @@ -149,219 +196,93 @@ export function CalibrationMatchSection({ matches: CalibrationMatches | null | undefined; pin?: CalibrationPin; }) { + const level = matches ? (matches.geography === 'US' ? 'national' : 'state') : null; + const card = (content: React.ReactNode) => ( + + {content} + + ); if (matches === null) { - return ( - - - Calibration checks are temporarily unavailable — see the{' '} - calibration dashboard directly. - - - ); + return card(Calibration checks are temporarily unavailable.); } if (matches === undefined) { - return ( - - - - - Checking the calibration of the variables this reform moves… - - - - ); + return card(Checking calibration targets…); } - const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`; - const level = matches.geography === 'US' ? 'national' : 'state'; if (matches.matches.length === 0) { - if (matches.reachedCount === 0) { - return null; - } + const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`; // Silence would read as "fine". State credits are the usual case: the // dashboard's state income tax targets carry no model-variable mapping. - return ( - - - None of the {matches.reachedCount} variables this reform moves is a calibration target{' '} - {where}, so the data behind this estimate is unvalidated on that side. See the{' '} - calibration dashboard for what is covered. - - + return card( + + {matches.reachedCount > 0 + ? `None of the ${matches.reachedCount} variables this reform moves is a calibration target ${where}.` + : 'No calibration target covers this reform.'} + ); } - return ( - - -
- {matches.matches.map((match) => ( - - -
- - - - {['Target', 'Period', 'Target value', 'Model value', 'Gap'].map((label) => ( - - ))} - - - - {match.targets.map((target) => ( - - - - - - - - ))} - -
- {label} -
- - - - - - Calibration target - - {target.name} - -
- {[ - ['Source', target.sourceLabel ?? target.source], - ['Geography', target.geography], - ['Period', target.period ?? '—'], - ['Measure', target.measure ?? '—'], - [ - 'Target value', - target.target?.toLocaleString('en-US', { - maximumFractionDigits: 2, - }) ?? '—', - ], - [ - 'Model value', - target.estimate?.toLocaleString('en-US', { - maximumFractionDigits: 2, - }) ?? '—', - ], - [ - 'Gap', - target.relativeError === null - ? '—' - : percent(target.relativeError), - ], - ].map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
- {target.sourceUrl && ( - - Read source - - )} - - View target group in dashboard - -
-
- - {target.sourceLabel ?? target.source} · {target.geography} - {target.measure ? ` · ${target.measure}` : ''} - -
- {target.period ?? '—'} - - {target.target === null - ? '—' - : target.target.toLocaleString('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - })} - - {target.estimate === null - ? '—' - : target.estimate.toLocaleString('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - })} - - {target.relativeError === null ? '—' : percent(target.relativeError)} -
-
-
- ))} -
+ return card( + <> + + {matches.matches.map((match) => { + const sources = [ + ...new Set(match.targets.map((target) => target.sourceLabel ?? target.source)), + ].join(', '); + return ( + + + + + {humanize(match.variable)} + + + + {sources} · {matches.geography} + {' '} + ·{' '} + + View targets + + + + + {match.targets.map((target) => ( + + + + + {target.period ?? '—'} + {compactNumber(target.target)} + {compactNumber(target.estimate)} + {gapOf(target)} + + ))} + + ); + })} +
Data release - {matches.releaseId ? `Data release ${matches.releaseId}` : 'Release not recorded'} - {matches.modelVersion ? ` · Matched at policyengine-us ${matches.modelVersion}` : ''}. + {matches.releaseId ?? 'Not recorded'} + {matches.modelVersion ? ` · policyengine-us ${matches.modelVersion}` : ''}
{pin && } -
+ ); } diff --git a/app/src/components/flagship/FlagshipShell.tsx b/app/src/components/flagship/FlagshipShell.tsx index 0cfc0a455..bafeb57df 100644 --- a/app/src/components/flagship/FlagshipShell.tsx +++ b/app/src/components/flagship/FlagshipShell.tsx @@ -1,3 +1,5 @@ +import { useAppPathname } from '@/contexts/LocationContext'; +import { isFlagshipPagePath } from '@/libs/flagship/shellRoutes'; import FlagshipTopBar from './FlagshipTopBar'; import { SIDE_PANEL_SLOT_ID } from './SidePanel'; @@ -5,26 +7,64 @@ interface FlagshipShellProps { children: React.ReactNode; } +/** + * On a phone, the shared controls — buttons (also those with a tooltip), + * inputs, selects, tabs and the buttons that open a menu or a picker — + * are at least 40px tall, the size of a fingertip, and an icon button as + * wide. A segmented control's options are as tall. Wider screens keep + * each control's own size. + */ +const PHONE_TOUCH_TARGETS = [ + 'tw:max-sm:[&_:is([data-slot=button],button[data-slot=tooltip-trigger],[data-slot=input],[data-slot=select-trigger],[data-slot=tabs-trigger],button[aria-haspopup])]:min-h-10', + 'tw:max-sm:[&_:is([data-slot=button],button[data-slot=tooltip-trigger])]:min-w-10', + 'tw:max-sm:[&_[data-slot=segmented-control]]:h-12', +].join(' '); + +/** + * The layout of a v3 page: the touch targets and a closer page edge on a + * phone, where the right plane moves under the content. + */ +const FLAGSHIP_LAYOUT = { + root: `tw:h-dvh tw:overflow-hidden tw:flex tw:flex-col ${PHONE_TOUCH_TARGETS}`, + row: 'tw:flex tw:flex-1 tw:min-h-0 tw:max-sm:flex-col', + main: 'tw:flex-1 tw:min-w-0 tw:min-h-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-lg tw:sm:p-2xl tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:max-sm:h-auto tw:bg-gray-50', +}; + +/** + * The legacy v2 pages the shell also hosts while the flag is on keep the + * layout they have always had, on every screen. + */ +const LEGACY_LAYOUT = { + root: 'tw:h-screen tw:overflow-hidden tw:flex tw:flex-col', + row: 'tw:flex tw:flex-1 tw:min-h-0', + main: 'tw:flex-1 tw:min-w-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-[24px] tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:bg-gray-50', +}; + /** * The flagship shell: a slim top bar (brand, sections, website links) * over the content area and the right plane for side panels. The bar - * is the same on every viewport, so content always has the full width. + * stays one slim row on every viewport, so content always has the full + * width. On a phone the plane moves under the content, where a side + * panel keeps a slim bar across the bottom (see SidePanel). */ export default function FlagshipShell({ children }: FlagshipShellProps) { + const layout = isFlagshipPagePath(useAppPathname()) ? FLAGSHIP_LAYOUT : LEGACY_LAYOUT; + + // data-flagship-shell: where a phone's drawer finds the page it covers (see SidePanel). return ( -
+
-
-
- {children} -
+
+
{children}
{/* The right plane: SidePanel portals its content here, so the panel is a real column of the shell — outside the scrolling content, full height by construction — rather than a floating box inside it. Empty (zero width) on pages without a companion. */} -
+
); diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index 903789865..c804d6a55 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -1,9 +1,16 @@ import { useEffect, useState } from 'react'; -import { IconAdjustments, IconDotsVertical, IconFilePencil, IconGavel } from '@tabler/icons-react'; +import { + IconAdjustments, + IconDotsVertical, + IconFilePencil, + IconGavel, + IconMenu2, +} from '@tabler/icons-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui'; import { WEBSITE_URL } from '@/constants'; @@ -25,8 +32,8 @@ const FLAGSHIP_TOP_BAR_HEIGHT = 56; /** * The flagship shell's navigation: brand, the two sections, and the * website links, in one slim bar across the top. Two destinations do - * not earn a sidebar's width, and they fit a phone's bar as they are, - * so there is no drawer to open on any viewport. + * not earn a sidebar's width. On a phone the sections and the links + * fold into one menu, so the bar keeps room for the brand and the draft. */ export default function FlagshipTopBar() { const nav = useAppNavigate(); @@ -45,6 +52,12 @@ export default function FlagshipTopBar() { useEffect(() => { setPendingSlug(null); }, [location.pathname]); + const isActive = (slug: string) => + pendingSlug ? pendingSlug === slug : location.pathname.includes(`/${slug}`); + const goTo = (slug: string) => { + setPendingSlug(slug); + nav.push(`/${countryId}/${slug}`); + }; // Away from Build, an unfinished draft stays one click away. const draft = useDraftReform(); @@ -52,6 +65,7 @@ export default function FlagshipTopBar() { draft && draft.countryId === countryId && !location.pathname.includes('/build') ? draft.provisions.length : 0; + const parameterWord = draftCount === 1 ? 'parameter' : 'parameters'; const moreLinks = [ { label: 'Research', href: `${WEBSITE_URL}/${countryId}/research` }, @@ -64,29 +78,24 @@ export default function FlagshipTopBar() { return (
- + -
)} diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index bc3ac2a03..cccdfb0e2 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -259,6 +259,8 @@ export default function ReportAdjustPanel({ type="button" onClick={() => setRemoved((current) => new Set([...current, provision.path]))} aria-label={`Remove ${name}`} + // A fingertip wide on a phone, without moving the name. + className="tw:max-sm:-my-3 tw:max-sm:-mr-3 tw:max-sm:inline-flex tw:max-sm:size-10 tw:max-sm:items-center tw:max-sm:justify-center" style={{ border: 'none', background: 'none', @@ -325,6 +327,7 @@ export default function ReportAdjustPanel({ - + - + {/* 16px on a phone, so the browser does not zoom in to type. */} + No matching state {scopes.map((option) => renderItem(option, false))} diff --git a/app/src/components/flagship/ScorecardComparisons.tsx b/app/src/components/flagship/ScorecardComparisons.tsx index 4a26c8e91..8be7bdfb7 100644 --- a/app/src/components/flagship/ScorecardComparisons.tsx +++ b/app/src/components/flagship/ScorecardComparisons.tsx @@ -1,9 +1,8 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; import { Stack, Text } from '@/components/ui'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { colors, spacing, typography } from '@/designTokens'; -import { SCORECARD_URL } from '@/libs/flagship/modelValidation'; import { loadParameterDependencies, variablesReachedByPaths, @@ -16,12 +15,20 @@ import { } from '@/libs/flagship/scorecardComparisons'; import { cardStyle, + CellNote, detailStyle, EvidenceHeader, + EvidenceTable, EvidenceValues, + gapText, + labelCellStyle, linkStyle, - panelStyle, + numberCellStyle, + rowButtonStyle, + rowStyle, summaryStyle, + ValidationNote, + ValidationSubheading, } from './ValidationDisplay'; const matchLabels = { @@ -301,132 +308,47 @@ const baselineDescriptions: Record = { ], }; -export function ScorecardClaimCard({ - row, - presentation, - showDetails = false, -}: { - showDetails?: boolean; +/** How a claim reads in its row and dialog, when the caller words it better than the claim. */ +export interface ClaimPresentation { + title: string; + sourceName?: string; + externalPeriod?: string; + modelPeriod?: string; + externalValue?: string; + peValue?: string; +} + +/** A scorecard claim, and how to present it. */ +export interface ScorecardClaimEntry { row: RelatedScorecardClaim; - presentation?: { - title: string; - sourceName?: string; - externalPeriod?: string; - modelPeriod?: string; - externalValue?: string; - peValue?: string; - }; -}) { - const latest = row.latest; - const status = latest?.status_effective ?? latest?.status ?? 'not_computed'; + presentation?: ClaimPresentation; +} + +function claimTitle(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string { + return ( + presentation?.title ?? + (isJctCtc(row) ? 'CTC: $1,000 → $2,200, plus other rule changes' : displayTitle(row.name)) + ); +} + +/** The source, and the geography when the claim records one: "Joint Committee on Taxation · US". */ +function claimWhere(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string { const sourceName = presentation?.sourceName ?? sourceNames[row.source] ?? human(row.source).toUpperCase(); + return [sourceName, row.geography && human(row.geography)].filter(Boolean).join(' · '); +} + +/** Everything the scorecard records about one claim, for its detail dialog. */ +export function ScorecardClaimDetails({ row, presentation }: ScorecardClaimEntry) { + const latest = row.latest; + const status = latest?.status_effective ?? latest?.status ?? 'not_computed'; const sourceUrl = safeUrl(row.url); - if (!showDetails) { - const title = - presentation?.title ?? - (isJctCtc(row) ? 'CTC: $1,000 → $2,200, plus other rule changes' : displayTitle(row.name)); - const published = - presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept); - const modeled = presentation?.peValue ?? claimValue(latest?.value, row.unit_concept); - const gap = - typeof latest?.value === 'number' && - typeof row.external_value === 'number' && - row.external_value !== 0 - ? `${(((latest.value - row.external_value) / Math.abs(row.external_value)) * 100).toFixed(1)}%` - : '—'; - return ( -
- - - - {['Comparison', 'Published estimate', 'PolicyEngine estimate', 'Gap'].map((label) => ( - - ))} - - - - - - - - - - -
- {label} -
- - - - - - - {row.reform_framework === 'baseline' - ? 'Baseline comparison' - : 'Related reform comparison'} - - - - - - {sourceName} · {human(row.geography)} - - {sourceUrl && ( - - Read source - - )} - - {published} - - {presentation?.externalPeriod ?? claimPeriod(row)} - - - {modeled} - - {presentation?.modelPeriod ?? modelPeriod(row)} - - - {gap} -
-
- ); - } return (
@@ -552,172 +474,187 @@ export function ScorecardClaimCard({ ); } -export function ScorecardComparisonResults({ - result, - baselineContent, - reportContext, - feedStatus, +/** One claim as a table row: what is compared, both values, and the gap. */ +function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) { + const sourceUrl = safeUrl(row.url); + return ( + + + + + + + + + {row.reform_framework === 'baseline' + ? 'Baseline comparison' + : 'Related reform comparison'} + + + + + {claimWhere(row, presentation)} + {sourceUrl && ( + + Read source + + )} + + + {presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept)} + {presentation?.externalPeriod ?? claimPeriod(row)} + + + {presentation?.peValue ?? claimValue(row.latest?.value, row.unit_concept)} + {presentation?.modelPeriod ?? modelPeriod(row)} + + {gapText(row.latest?.value, row.external_value)} + + ); +} + +/** Scorecard claims in one table, so their numbers line up. Each title opens its details. */ +export function ScorecardClaimTable({ + label, + entries, }: { - result: ScorecardComparisonsResult; - baselineContent?: ReactNode; - reportContext?: ReactNode; - feedStatus?: string; + label: string; + entries: ScorecardClaimEntry[]; }) { - const baseline = result.rows.filter((row) => row.reform_framework === 'baseline'); - const reforms = result.rows.filter((row) => row.reform_framework !== 'baseline'); return ( - - {[ - { - title: 'Baseline comparisons', - description: 'How the model compares with published measures of existing programs.', - rows: baseline, - }, - { - title: 'Related reform comparisons', - description: - 'How PolicyEngine scored other published policy changes. These are not a validation of this report’s exact reform or cost.', - rows: reforms, - }, - ].map(({ title, description, rows }) => ( - -
- - {title} - - - View scorecard -
- {title !== 'Baseline comparisons' && ( - {description} - )} - {title === 'Related reform comparisons' && reportContext} - {title === 'Baseline comparisons' && baselineContent} - {feedStatus && {feedStatus}} - {rows.length ? ( -
- {rows.map((row) => ( - - ))} -
- ) : feedStatus || (title === 'Baseline comparisons' && baselineContent) ? null : ( - - No linked {title === 'Related reform comparisons' ? 'reform' : 'baseline'} comparisons - were found in the published scorecard feed. - - )} -
+ + {entries.map((entry) => ( + ))} -
- Coverage and last update - - Coverage is limited to published claims with a recorded variable, program, parameter path - or shared source line item.{' '} - {result.unmappedCount > 0 - ? `${result.unmappedCount} claims in this country have no usable mapping and were not matched. ` - : ''} - Absence of a match does not establish that no external estimate exists. - {result.built ? ` Scorecard export: ${result.built}.` : ''} - -
+
+ ); +} + +/** Published scorecard estimates of other policies that change the same rules. */ +export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[] }) { + return ( + + Related reforms + + Published scores of policies that change the same rules. They do not score this exact + reform. + + ({ row }))} /> ); } -export default function ScorecardComparisons({ - countryId, - paths, - baselineContent, - reportContext, -}: { - countryId: string; - paths: string[]; - baselineContent?: ReactNode; - reportContext?: ReactNode; -}) { +/** What the scorecard match covers, collapsed. */ +export function ScorecardCoverage({ result }: { result: ScorecardComparisonsResult }) { + return ( +
+ Coverage + + Matches cover scorecard claims with a recorded variable, program, parameter path or source + line item. + {result.unmappedCount > 0 + ? ` ${result.unmappedCount} claims in this country have no usable mapping.` + : ''} + {result.built ? ` Scorecard export: ${result.built}.` : ''} + +
+ ); +} + +/** + * The published scorecard claims linked to a reform: `undefined` while + * loading, `null` when the feed is unavailable. + */ +export type ScorecardComparisonsState = ScorecardComparisonsResult | null | undefined; + +const NO_COMPARISONS: ScorecardComparisonsResult = { built: null, rows: [], unmappedCount: 0 }; + +/** The claims that score a policy change, apart from those that measure today's programs. */ +export function splitScorecardRows(result: ScorecardComparisonsState): { + reforms: RelatedScorecardClaim[]; + baseline: RelatedScorecardClaim[]; +} { + const rows = result?.rows ?? []; + return { + reforms: rows.filter((row) => row.reform_framework !== 'baseline'), + baseline: rows.filter((row) => row.reform_framework === 'baseline'), + }; +} + +async function fetchScorecardComparisons( + countryId: string, + paths: string[], + signal: AbortSignal +): Promise { + let variables: string[] = []; + // The committed dependency map is US-only. Never use it for another country. + if (countryId === 'us') { + try { + variables = variablesReachedByPaths(paths, await loadParameterDependencies()).map( + (entry) => entry.variable + ); + } catch { + /* Recorded parameter paths still allow conservative matching. */ + } + } + const response = await fetch('/api/scorecard-comparisons', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ country: countryId, paths, variables }), + }); + if (!response.ok) { + throw new Error('Scorecard unavailable'); + } + return (await response.json()) as ScorecardComparisonsResult; +} + +/** Fetches the published scorecard claims linked to a reform's parameter paths. */ +export function useScorecardComparisons( + countryId: string, + paths: string[] +): ScorecardComparisonsState { const requestKey = JSON.stringify({ countryId, paths }); const [state, setState] = useState<{ key: string; result: ScorecardComparisonsResult | null }>(); useEffect(() => { - let cancelled = false; - const controller = new AbortController(); const request = JSON.parse(requestKey) as { countryId: string; paths: string[] }; if (!request.paths.length) { - return () => controller.abort(); + return; } - (async () => { - let variables: string[] = []; - // The committed dependency map is US-only. Never use it for another country. - if (request.countryId === 'us') { - try { - variables = variablesReachedByPaths(request.paths, await loadParameterDependencies()).map( - (entry) => entry.variable - ); - } catch { - /* Recorded parameter paths still allow conservative matching. */ + let cancelled = false; + const controller = new AbortController(); + fetchScorecardComparisons(request.countryId, request.paths, controller.signal) + .then((result) => { + if (!cancelled) { + setState({ key: requestKey, result }); + } + }) + .catch(() => { + if (!cancelled) { + setState({ key: requestKey, result: null }); } - } - const response = await fetch('/api/scorecard-comparisons', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ country: request.countryId, paths: request.paths, variables }), }); - if (!response.ok) { - throw new Error('Scorecard unavailable'); - } - const result = (await response.json()) as ScorecardComparisonsResult; - if (!cancelled) { - setState({ key: requestKey, result }); - } - })().catch(() => { - if (!cancelled) { - setState({ key: requestKey, result: null }); - } - }); return () => { cancelled = true; controller.abort(); }; }, [requestKey]); if (!paths.length) { - return No parameter changes are available to match to scorecard comparisons.; + return NO_COMPARISONS; } - const pending = state?.key !== requestKey; - return ( - - ); + return state?.key === requestKey ? state.result : undefined; } diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx index 745c21564..df4c17be6 100644 --- a/app/src/components/flagship/SidePanel.tsx +++ b/app/src/components/flagship/SidePanel.tsx @@ -1,9 +1,10 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { IconChevronDown } from '@tabler/icons-react'; +import { IconChevronDown, IconX } from '@tabler/icons-react'; import { createPortal } from 'react-dom'; import { Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { PHONE_QUERY } from '@/libs/flagship/viewport'; /** * The flagship shell's right plane. @@ -18,6 +19,8 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; * the panel renders in place. */ export const SIDE_PANEL_SLOT_ID = 'flagship-side-panel-slot'; +/** The shell's root (see FlagshipShell): what a phone's open drawer covers. */ +const SHELL_ROOT_SELECTOR = '[data-flagship-shell]'; const PANEL_WIDTH = 340; const SPINE_WIDTH = 40; @@ -30,6 +33,13 @@ const FADE_MS = 160; * The spine keeps its place in the column as the way back. */ const OVERLAY_BELOW = '(max-width: 1023px)'; +/** + * The folded bar's height on a phone: a fingertip, and a line of text. + * There a spine would take a ninth of the screen, so the shell puts the + * plane under the content (see FlagshipShell): folded, the panel is a + * bar across the bottom; open, it fills the screen, as a drawer. + */ +const BAR_HEIGHT = spacing['4xl']; /** Effects that must run before paint, without warning during SSR. */ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -115,6 +125,7 @@ export default function SidePanel({ const focusAfterToggle = useRef(false); const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const overlay = useMediaQuery(OVERLAY_BELOW); + const phone = useMediaQuery(PHONE_QUERY); useIsomorphicLayoutEffect(() => { setSlot(document.getElementById(SIDE_PANEL_SLOT_ID)); @@ -146,6 +157,46 @@ export default function SidePanel({ writeStoredOpen(storageKey, next); }; + // A drawer over the whole screen closes with Escape, as a dialog does — + // unless the Escape already closed a picker open inside it. + useEffect(() => { + if (!phone || !open) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return; + } + focusAfterToggle.current = true; + setOpenState(false); + writeStoredOpen(storageKey, false); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [phone, open, storageKey]); + + // The drawer covers the shell: while it is open, the page and the top + // bar under it are out of reach of the keyboard and screen readers. + useEffect(() => { + const shell = slot?.closest(SHELL_ROOT_SELECTOR); + if (!phone || !open || !slot || !shell) { + return; + } + const covered: Element[] = []; + let node: Element = slot; + while (node !== shell && node.parentElement) { + const parent: Element = node.parentElement; + for (const sibling of Array.from(parent.children)) { + if (sibling !== node && !sibling.hasAttribute('inert')) { + sibling.setAttribute('inert', ''); + covered.push(sibling); + } + } + node = parent; + } + return () => covered.forEach((element) => element.removeAttribute('inert')); + }, [phone, open, slot]); + // The plane mirrors the left sidebar: the same flat surface and quiet // edge, with teal reserved for text — no colored bands. const titleColor = accent ? colors.primary[700] : colors.text.primary; @@ -170,37 +221,70 @@ export default function SidePanel({ background: colors.gray[50], }; + /** Where the open panel sits: the column, over the content, or (a phone) the whole screen. */ + const openPlacement: React.CSSProperties = phone + ? { + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 30, + width: '100%', + background: colors.gray[50], + } + : overlay + ? { + position: 'fixed', + top: 0, + right: 0, + height: '100vh', + zIndex: 30, + boxShadow: `-${spacing.sm} 0 ${spacing.xl} ${colors.shadow.medium}`, + ...surface, + // Floating on narrow shells, a wide panel never outgrows the window. + width: `min(${width}px, 100vw)`, + } + : { position: 'absolute', top: 0, left: 0, height: '100%', width }; + const content = (
- {meta && ( - - {meta} - + {(meta || phone) && ( + + {meta && ( + + {meta} + + )} + {/* A drawer shows how to close it. */} + {phone && } + )} {/* The body scrolls, not the page: the plane keeps its own height. */} @@ -293,26 +383,34 @@ export default function SidePanel({ position: 'absolute', top: 0, left: 0, - width: SPINE_WIDTH, + width: phone ? '100%' : SPINE_WIDTH, height: '100%', display: 'flex', - flexDirection: 'column', + flexDirection: phone ? 'row' : 'column', alignItems: 'center', gap: spacing.sm, - padding: `${spacing.md} 0`, + padding: phone ? `0 ${spacing.lg}` : `${spacing.md} 0`, ...face(!open), }} > + + Bill tracker + + + + ); +} diff --git a/app/src/components/flagship/ValidationDisplay.tsx b/app/src/components/flagship/ValidationDisplay.tsx index 9e0598a92..e05549c32 100644 --- a/app/src/components/flagship/ValidationDisplay.tsx +++ b/app/src/components/flagship/ValidationDisplay.tsx @@ -1,15 +1,14 @@ -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { IconExternalLink } from '@tabler/icons-react'; -import { Text } from '@/components/ui'; +import { Badge, Spinner, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -export const panelStyle = { - padding: spacing.xl, - border: `1px solid ${colors.border.light}`, - borderRadius: spacing.radius.feature, - background: colors.background.primary, - gap: spacing.lg, -}; +/** + * The validation tab's building blocks: one card frame for every check, + * the same as the overview's sections, and one table style so numbers + * line up in tabular figures across every comparison. + */ + export const cardStyle = { gap: spacing.sm, padding: spacing.md, @@ -39,6 +38,249 @@ export const summaryStyle = { paddingBlock: spacing.sm, }; +/** A link-styled button that opens a row's detail dialog. */ +export const rowButtonStyle: CSSProperties = { + ...linkStyle, + display: 'inline', + background: 'none', + border: 0, + padding: 0, + font: 'inherit', + fontWeight: typography.fontWeight.medium, + textAlign: 'left', + overflowWrap: 'anywhere', + cursor: 'pointer', +}; + +/** One validation check, framed like the overview's section cards. */ +export function ValidationCard({ + id, + title, + href, + linkLabel, + children, +}: { + /** The heading's id, which names the card for assistive technology. */ + id: string; + title: string; + href?: string; + linkLabel?: string; + children: ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {href && linkLabel && ( + + {linkLabel} + + )} +
+ {children} +
+ ); +} + +/** A heading for a group of rows inside a card. */ +export function ValidationSubheading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** A plain line of text in a card: an empty state, a caveat, a provenance note. */ +export function ValidationNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** A check that does not apply to this report, marked as such, with the reason. */ +export function NotComparable({ children }: { children: ReactNode }) { + return ( + + + Not comparable + + {children} + + ); +} + +/** A card's check is still loading; the line, not the spinner, is what gets announced. */ +export function ValidationLoading({ children }: { children: ReactNode }) { + return ( + + + ); +} + +const headerCellStyle: CSSProperties = { + padding: `0 0 ${spacing.sm} ${spacing.lg}`, + textAlign: 'right', + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.text.secondary, + whiteSpace: 'nowrap', +}; + +export const rowStyle: CSSProperties = { borderTop: `1px solid ${colors.border.light}` }; + +/** The first cell of a row: what is compared. */ +export const labelCellStyle: CSSProperties = { + padding: `${spacing.md} ${spacing.lg} ${spacing.md} 0`, + textAlign: 'left', + verticalAlign: 'top', + fontWeight: typography.fontWeight.normal, + minWidth: 160, +}; + +/** A number, right-aligned in tabular figures so columns line up. */ +export const numberCellStyle: CSSProperties = { + padding: `${spacing.md} 0 ${spacing.md} ${spacing.lg}`, + textAlign: 'right', + verticalAlign: 'top', + whiteSpace: 'nowrap', + fontVariantNumeric: 'tabular-nums', +}; + +/** A secondary line under a cell's main text, e.g. a source or a period. */ +export function CellNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * A comparison table: a label column, then right-aligned number columns. + * Children are rows; with `grouped`, they are one `` per group. + */ +export function EvidenceTable({ + label, + columns, + grouped = false, + children, +}: { + label: string; + columns: string[]; + grouped?: boolean; + children: ReactNode; +}) { + return ( +
+ + + + {columns.map((column, index) => ( + + ))} + + + {grouped ? children : {children}} +
+ {column} +
+
+ ); +} + +/** + * How far PolicyEngine's number sits from another, as a share of the + * other: (model − other) ÷ |other|. A dash when either is missing. + */ +export function gapText(model: number | null | undefined, other: number | null | undefined) { + if (typeof model !== 'number' || typeof other !== 'number' || other === 0) { + return '—'; + } + return percentText((model - other) / Math.abs(other)); +} + +/** A fraction as a percent: one decimal, whole numbers from 100%. */ +export function percentText(fraction: number): string { + const value = fraction * 100; + return `${Math.abs(value) >= 100 ? Math.round(value) : value.toFixed(1)}%`; +} + export function EvidenceHeader({ title, subtitle, @@ -110,6 +352,7 @@ export function EvidenceValues({ style={{ fontSize: typography.fontSize.lg, fontWeight: typography.fontWeight.semibold, + fontVariantNumeric: 'tabular-nums', margin: 0, }} > diff --git a/app/src/components/flagship/ValidationPanel.tsx b/app/src/components/flagship/ValidationPanel.tsx index ea0d61788..8db215dce 100644 --- a/app/src/components/flagship/ValidationPanel.tsx +++ b/app/src/components/flagship/ValidationPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { BillValidation } from '@/api/billFeed'; import { describeDepth, RING_LABELS } from '@/components/flagship/CalibrationMatches'; -import { Spinner, Stack, Text } from '@/components/ui'; +import { Stack } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { claimsFromBillValidation, @@ -9,18 +9,32 @@ import { ModelValidationRow, PROGRAM_LABELS, SCORECARD_COMPARISON_URL, - SCORECARD_URL, ScorecardMatches, scorecardMatchesForPaths, } from '@/libs/flagship/modelValidation'; -import { ScorecardClaimCard } from './ScorecardComparisons'; +import type { RelatedScorecardClaim } from '@/libs/flagship/scorecardComparisons'; +import { ScorecardClaimTable, type ScorecardClaimEntry } from './ScorecardComparisons'; +import { + CellNote, + EvidenceTable, + gapText, + labelCellStyle, + linkStyle, + NotComparable, + numberCellStyle, + rowStyle, + ValidationCard, + ValidationLoading, + ValidationNote, +} from './ValidationDisplay'; /** - * The two validation surfaces for a bill report: - * - BillValidationSection: the tracker pipeline's external checks for - * this bill (official fiscal note, third-party analyses, verdict). - * - ModelTrackRecordSection: the PolicyEngine scorecard's comparisons - * against external analyses for the programs the bill touches. + * The scorecard side of a report's validation: + * - BillExternalEstimates: the tracker pipeline's external checks for a + * bill (official fiscal note, third-party analyses, verdict). + * - BaselineComparisons: the PolicyEngine scorecard's comparisons of the + * model's baseline with published measures, for the programs the + * reform touches. */ function compactMoney(value: number): string { @@ -57,53 +71,7 @@ function metricValue(row: ModelValidationRow, value: number): string { return Math.round(value).toLocaleString(); } -const cellStyle: React.CSSProperties = { - fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, - color: colors.text.primary, - padding: `${spacing.xs} ${spacing.md} ${spacing.xs} 0`, - textAlign: 'left', - verticalAlign: 'top', -}; - -const headCellStyle: React.CSSProperties = { - ...cellStyle, - fontSize: typography.fontSize.xs, - color: colors.text.secondary, - fontWeight: typography.fontWeight.medium, -}; - -function SectionCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** Teal/gray verdict pill, also reused in the report header. */ +/** Teal/gray verdict pill, shown with the bill's external estimates only. */ export function ValidationChip({ validation }: { validation: BillValidation }) { if (validation.withinRange === undefined) { return null; @@ -146,7 +114,18 @@ export function ValidationChip({ validation }: { validation: BillValidation }) { ); } -export function BillValidationSection({ +const VERIFICATION_COPY: Record['overall'], string> = { + confirmed: 'Sources and figures independently re-verified', + partially_confirmed: 'Sources and figures partially re-verified', + refuted: 'Re-verification disputed these figures — treat with caution', + unverifiable: 'Sources could not be independently re-verified', +}; + +/** + * A bill's external estimates beside PolicyEngine's, in one table, with + * the range verdict and what the pipeline noted about the comparison. + */ +export function BillExternalEstimates({ billId, validation, }: { @@ -156,96 +135,78 @@ export function BillValidationSection({ // External numbers render from scorecard-shaped claims, so tracker // rows today and scorecard shard rows tomorrow are interchangeable. const claims = claimsFromBillValidation(billId, validation); + const peEstimate = validation.peEstimate; const range = typeof validation.targetRangeLow === 'number' && typeof validation.targetRangeHigh === 'number' ? [validation.targetRangeLow, validation.targetRangeHigh].sort((a, b) => a - b) : null; return ( - - External checks for this bill - - - {typeof validation.peEstimate === 'number' && ( - - - - - )} - {range && ( - - - - - )} - {claims.map((claim) => ( - - - - - - ))} - -
PolicyEngine estimate{compactMoney(validation.peEstimate)} -
Accepted range - {compactMoney(range[0])} to {compactMoney(range[1])} - -
{claim.source} - {compactMoney(claim.externalValue)} - {claim.sourceUrl && ( - <> - {' '} - - source - - - )} - - {typeof claim.ratio === 'number' ? `${claim.ratio.toFixed(2)}× PE` : ''} -
+ + + {typeof peEstimate === 'number' && ( + + + PolicyEngine + + + {compactMoney(peEstimate)} + + — + + )} + {claims.map((claim) => ( + + + {claim.source} + {claim.sourceUrl && ( + + + Read source + + + )} + + {compactMoney(claim.externalValue)} + {gapText(peEstimate, claim.externalValue)} + + ))} + {range && ( + + + Accepted range + + + {compactMoney(range[0])} to {compactMoney(range[1])} + + + + )} + {validation.drift && ( - + This check predates the current analysis: {validation.drift.reasons.join('; ')}. The - comparison below describes the earlier estimate until validation is re-run. - + comparison describes the earlier estimate until validation is re-run. + )} {validation.verification && ( - - { - { - confirmed: 'Sources and figures independently re-verified', - partially_confirmed: 'Sources and figures partially re-verified', - refuted: 'Re-verification disputed these figures — treat with caution', - unverifiable: 'Sources could not be independently re-verified', - }[validation.verification.overall] - } + + {VERIFICATION_COPY[validation.verification.overall]} {validation.verification.verifiedAt ? ` (${validation.verification.verifiedAt.slice(0, 10)})` : ''} . - + )} {validation.discrepancyExplanation && ( - - {validation.discrepancyExplanation} - + {validation.discrepancyExplanation} )} -
+ ); } @@ -260,11 +221,6 @@ export function trackRecordPrograms(trackRecord: ModelTrackRecord): string[] | n return trackRecord ? trackRecord.programs.map((match) => match.program) : trackRecord; } -/** Whether ModelTrackRecordSection renders anything for this track record. */ -export function hasTrackRecordContent(trackRecord: ModelTrackRecord): boolean { - return !trackRecord || trackRecord.programs.length > 0 || trackRecord.reachedCount > 0; -} - /** * Fetches the track record as soon as the paths are known — call this * at page level, not inside a tab panel, so the request runs in @@ -297,140 +253,112 @@ export function useModelTrackRecord(paths: string[]): ModelTrackRecord { return trackRecord; } -export function ModelTrackRecordSection({ +/** The track record's comparison rows, in the scorecard claim shape the tables render. */ +export function trackRecordClaims(trackRecord: ScorecardMatches): ScorecardClaimEntry[] { + return trackRecord.rows.map((row) => { + const program = trackRecord.programs.find((match) => match.program === row.program)!; + const claimId = `${row.source}-${row.program}-${row.metric}-${row.period ?? ''}`; + const claim: RelatedScorecardClaim = { + claim_id: claimId, + country: 'US', + geography: 'US', + name: metricLabel(row), + source: row.source, + url: row.sourceUrl ?? undefined, + metric: row.metric, + unit_concept: row.unitConcept ?? '', + period: row.period ?? '', + reform_framework: 'baseline', + reform_key: 'baseline', + external_value: row.externalValue, + calibration_relationship: row.heldOut ? 'held_out' : 'consumed_as_target', + matchBasis: 'variable', + diagnosis: row.diagnosis ? { title: row.diagnosis } : null, + latest: { + value: row.peValue, + status: row.status, + construction: row.construction ?? undefined, + engine_version: row.engineVersion ?? undefined, + data_bundle: row.dataBundle ?? undefined, + annotations: [ + `${RING_LABELS[program.ring]} · ${describeDepth(program.depth)}`, + `Matched at policyengine-us ${trackRecord.modelVersion}.`, + ...(row.notes ?? []).filter((note) => !note.startsWith('The 2026 column')), + ...(row.calibrationBasis ? [row.calibrationBasis] : []), + ], + ratio: row.ratio, + }, + }; + return { + row: claim, + presentation: { + title: `${PROGRAM_LABELS[row.program] ?? row.program} — ${metricLabel(row)}`, + sourceName: row.sourceName ?? row.source, + externalPeriod: row.period ?? 'Period not recorded', + modelPeriod: row.pePeriod ?? 'Period not recorded', + externalValue: metricValue(row, row.externalValue), + peValue: metricValue(row, row.peValue), + }, + }; + }); +} + +/** + * How the model's baseline compares with published measures of the + * programs the reform touches: the scorecard's track record for those + * programs, then its other baseline claims linked to the reform. + */ +export function BaselineComparisons({ trackRecord, - embedded = false, + scorecardRows, + baselineLabel, }: { trackRecord: ModelTrackRecord; - embedded?: boolean; + /** The published scorecard's baseline claims for the reform; undefined while loading. */ + scorecardRows: RelatedScorecardClaim[] | null | undefined; + /** + * What the report compares with, when not current law. The scorecard + * measures current law, so its comparisons do not apply. + */ + baselineLabel?: string | null; }) { - if (trackRecord === null) { - return ( - - - External comparisons are temporarily unavailable — see the{' '} - - PolicyEngine scorecard - {' '} - directly. - - + const entries = [ + ...(trackRecord ? trackRecordClaims(trackRecord) : []), + ...(scorecardRows ?? []).map((row) => ({ row })), + ]; + let content; + if (baselineLabel) { + content = ( + + The scorecard measures current law. This report compares with {baselineLabel}. + ); - } - if (trackRecord === undefined) { - return ( - - - - - Loading external comparisons… - - - - ); - } - if (!hasTrackRecordContent(trackRecord)) { - return null; - } - const { programs, rows } = trackRecord; - if (programs.length === 0) { + } else if (entries.length > 0) { + content = ; + } else if (trackRecord === undefined || scorecardRows === undefined) { + content = Loading baseline comparisons…; + } else if (trackRecord === null) { + content = Baseline comparisons are temporarily unavailable.; + } else if (trackRecord.reachedCount > 0) { // Silence would read as "fine". State credits are the usual case: the // scorecard measures federal programs and poverty, not state credits. - return ( - - - None of the {trackRecord.reachedCount} variables this reform moves is measured by the{' '} - - PolicyEngine scorecard - - , so there is no external comparison for the programs behind this estimate. - - + content = ( + + None of the {trackRecord.reachedCount} variables this reform moves is measured by the + scorecard. + ); + } else { + content = No baseline comparison covers this reform.; } - - const cards = ( - - {rows.map((row) => { - const program = programs.find((match) => match.program === row.program)!; - return ( - !note.startsWith('The 2026 column')), - ...(row.calibrationBasis ? [row.calibrationBasis] : []), - ], - ratio: row.ratio, - }, - }} - /> - ); - })} - - ); - return embedded ? ( - cards - ) : ( - - Baseline comparisons - {cards} - + {content} + ); } diff --git a/app/src/components/flagship/ValueInput.tsx b/app/src/components/flagship/ValueInput.tsx index 260318eeb..f856c7e1e 100644 --- a/app/src/components/flagship/ValueInput.tsx +++ b/app/src/components/flagship/ValueInput.tsx @@ -39,6 +39,8 @@ export default function ValueInput({ value, onChange, ariaLabel }: ValueInputPro value={value ?? ''} aria-label={ariaLabel} onChange={(event) => onChange(event.target.value === '' ? '' : Number(event.target.value))} + // On a phone: a fingertip tall, and 16px so the browser does not zoom in to type. + className="tw:max-sm:min-h-10 tw:max-sm:text-base!" style={{ width: 110, padding: `${spacing.xs} ${spacing.sm}`, diff --git a/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx index 7bf05097c..ce7fcc703 100644 --- a/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx +++ b/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx @@ -93,7 +93,7 @@ export default function PolicyBrowser({
@@ -180,7 +180,7 @@ function Crumb({ label, onOpen }: { label: string; onOpen: () => void }) { - + + Clear filters + + } + /> )} )} - {tab === 'yours' && !isPending && visibleReforms.length === 0 && ( - - - - No saved reforms yet — build one, or ask a policy question. - - - )} + {/* Saved reforms a search hides are a different empty from none at all. */} + {tab === 'yours' && + !isPending && + !isError && + visibleReforms.length === 0 && + ((reforms ?? []).length === 0 ? ( + } + title="No saved reforms yet." + action={buildAction} + /> + ) : ( + setQuery('')}> + Clear search + + } + /> + ))} -
+
{tab === 'bills' && pagedBills.map((bill) => ( (); const userReportId = propId ?? params.userReportId ?? ''; - const { report, simulations, policies, isLoading } = useFlagshipReport(userReportId); + const { + report, + simulations, + policies, + isLoading, + error: loadError, + notFound, + retry, + } = useFlagshipReport(userReportId); // A local association id from another browser resolves to nothing. const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); const parameters = useSelector((state: RootState) => state.metadata.parameters); @@ -58,6 +69,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const calcStatus = useCalculationStatus(report?.id || '', 'report'); // Stored provenance that can't be read: readReportMeta answers none for it. const unreadableMeta = useUnreadableReportMeta(userReportId); + // The reform's provisions are rebuilt from metadata; without it they cannot show. + const metadata = useFlagshipMetadataStatus(); // The run handed its draft over to this report; Build starts fresh. useEffect(() => { @@ -84,6 +97,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep countryId: report.countryId, year: report.year, simulations: { simulation1, simulation2: simulations[1] || null }, + // The bar follows a typical run for this region, not the country. + estimateByRegion: true, populations: { household1: null, household2: null, @@ -165,6 +180,17 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } + // A report that failed to load would otherwise wait here for good. + if (loadError) { + return layout( + + + {title} + + + + ); + } if (calcStatus.isError) { return layout( @@ -190,10 +216,9 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } + // The run card and its placeholder are each a status of their own. return layout( ) : ( - <> - - Loading report… - + )} ); @@ -246,6 +268,19 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // A window asked for in Build opens running. budgetWindow={meta?.budgetWindow} provisions={meta?.provisions ?? []} + overviewLead={ + !meta && metadata.status === 'error' ? ( +
+ +
+ ) : undefined + } customBaselineLabel={customBaselineLabel} baseline={runBaseline} year={report?.year ?? ''} diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts new file mode 100644 index 000000000..1c4dc74bb --- /dev/null +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -0,0 +1,47 @@ +import { vi } from 'vitest'; + +/** Tracker Supabase rows by table name, as a feed test serves them. */ +export type TrackerTableRows = Record; + +/** A fetch that answers each tracker table request with its rows (none for other tables). */ +export const mockTrackerFetch = (rows: TrackerTableRows) => + vi.fn(async (url: string) => { + const table = String(url).match(/rest\/v1\/(\w+)\?/)?.[1] ?? ''; + return { ok: true, json: async () => rows[table] ?? [] }; + }); + +/** Bills that read by the tracker's place names, titles and pages. */ +export const TRACKER_NAMING_ROWS: TrackerTableRows = { + research: [ + { id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }, + // An older federal row, filed under "all". + { id: 'us-hr7024', state: 'all', type: 'bill', title: 'HR 7024' }, + { id: 'dc-b26', state: 'dc', type: 'bill', title: 'DC B26' }, + // No title: the tracker names it by the bill number its id carries. + { id: 'ut-hb-106', state: 'UT', type: 'bill' }, + ], +}; + +const NET_COST_ONLY_BILL = { id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }; + +/** A stored run with a net cost and no state revenue change. */ +export const NET_COST_ONLY_ROWS: TrackerTableRows = { + research: [NET_COST_ONLY_BILL], + reform_impacts: [ + { id: NET_COST_ONLY_BILL.id, computed: true, budgetary_impact: { netCost: -74_200_000 } }, + ], +}; + +/** The same run, validated when its net cost was another number. */ +export const NET_COST_DRIFT_ROWS: TrackerTableRows = { + ...NET_COST_ONLY_ROWS, + validation_metadata: [ + { + id: NET_COST_ONLY_BILL.id, + pe_estimate: -70_000_000, + fiscal_note_estimate: -71_000_000, + within_range: true, + validated_against: { pe_estimate: -70_000_000 }, + }, + ], +}; diff --git a/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts b/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts new file mode 100644 index 000000000..c02411e76 --- /dev/null +++ b/app/src/tests/fixtures/components/flagship/report/storedImpactChartsMocks.ts @@ -0,0 +1,6 @@ +import type { TrackedBill } from '@/api/billFeed'; + +/** A stored run with a net cost and no state revenue change. */ +export const NET_COST_ONLY_IMPACT: TrackedBill['impactData'] = { + budgetary: { netCost: -74_200_000 }, +}; diff --git a/app/src/tests/fixtures/hooks/calculationHookFixtures.ts b/app/src/tests/fixtures/hooks/calculationHookFixtures.ts index 89e55cfd3..7aa607de3 100644 --- a/app/src/tests/fixtures/hooks/calculationHookFixtures.ts +++ b/app/src/tests/fixtures/hooks/calculationHookFixtures.ts @@ -12,6 +12,14 @@ export const HOOK_TEST_CONSTANTS = { TEST_USER_REPORT_ID: 'user-report-789', } as const; +/** + * Region estimates a pending society-wide status can carry (v3 runs opt in) + */ +export const TEST_ESTIMATED_DURATIONS_MS = { + US_STATE: 190_000, + US_DISTRICT: 200_000, +} as const; + /** * Create a real QueryClient for hook tests * This is needed because hooks require a real QueryClient instance diff --git a/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts new file mode 100644 index 000000000..100ecab97 --- /dev/null +++ b/app/src/tests/fixtures/hooks/useFlagshipMetadataStatusMocks.ts @@ -0,0 +1,30 @@ +import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { store } from '@/store'; + +export const METADATA_ERROR_MESSAGE = 'Failed to fetch metadata for us: 503 Service Unavailable'; + +const REQUEST_ID = 'flagship-states-test'; + +/** Puts the store where a metadata fetch that is still in flight leaves it. */ +export function seedMetadataLoading(countryId = 'us') { + store.dispatch({ + type: fetchMetadataThunk.pending.type, + meta: { arg: countryId, requestId: REQUEST_ID, requestStatus: 'pending' }, + }); +} + +/** Puts the store where a failed metadata fetch leaves it. */ +export function seedMetadataError(message = METADATA_ERROR_MESSAGE, countryId = 'us') { + seedMetadataLoading(countryId); + store.dispatch({ + type: fetchMetadataThunk.rejected.type, + meta: { + arg: countryId, + requestId: REQUEST_ID, + requestStatus: 'rejected', + rejectedWithValue: true, + }, + payload: message, + error: { message: 'Rejected' }, + }); +} diff --git a/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts new file mode 100644 index 000000000..b7bdd21e0 --- /dev/null +++ b/app/src/tests/fixtures/hooks/useFlagshipReportMocks.ts @@ -0,0 +1,79 @@ +import type { Report } from '@/types/ingredients/Report'; +import type { Simulation } from '@/types/ingredients/Simulation'; +import type { ReportMetadata } from '@/types/metadata/reportMetadata'; +import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; + +/** A report link that resolves: the API report id of a nationwide run. */ +export const EXISTING_REPORT_ID = '9001'; +/** A report link that names no report. */ +export const MISSING_REPORT_ID = '404404'; + +export const BASELINE_SIMULATION_ID = '501'; +export const REFORM_SIMULATION_ID = '502'; + +/** As fetchReportById throws on a 404. */ +export const REPORT_NOT_FOUND_ERROR = new Error( + `Failed to fetch report ${MISSING_REPORT_ID}: 404 Not Found` +); +/** As fetchReportById throws when the API is down. */ +export const REPORT_UNAVAILABLE_ERROR = new Error( + `Failed to fetch report ${EXISTING_REPORT_ID}: 503 Service Unavailable` +); +/** As fetchSimulationById throws when the API is down. */ +export const SIMULATION_UNAVAILABLE_ERROR = new Error( + `Failed to fetch simulation ${REFORM_SIMULATION_ID}: 503 Service Unavailable` +); + +/** As fetchPolicyById throws when the API is down. */ +export const POLICY_UNAVAILABLE_ERROR = new Error('Failed to fetch policy 98557'); + +export const EXISTING_REPORT_METADATA: ReportMetadata = { + id: Number(EXISTING_REPORT_ID), + country_id: 'us', + year: '2026', + api_version: 'v1', + simulation_1_id: BASELINE_SIMULATION_ID, + simulation_2_id: REFORM_SIMULATION_ID, + status: 'pending', + output: null, +}; + +export const nationwideSimulationMetadata = (id: string, policyId: string): SimulationMetadata => ({ + id: Number(id), + country_id: 'us', + api_version: 'v1', + population_id: 'us', + population_type: 'geography', + policy_id: policyId, + status: 'pending', + output: null, +}); + +/** The report the hook hands on once it is in. */ +export const EXISTING_REPORT: Report = { + id: EXISTING_REPORT_ID, + label: 'Bigger CTC', + countryId: 'us', + year: '2026', + apiVersion: 'v1', + simulationIds: [BASELINE_SIMULATION_ID, REFORM_SIMULATION_ID], + status: 'pending', +}; + +const nationwideSimulation = (id: string, policyId: string): Simulation => ({ + id, + countryId: 'us', + apiVersion: 'v1', + policyId, + populationId: 'us', + populationType: 'geography', + label: null, + isCreated: true, + status: 'pending', +}); + +/** Its baseline and reform simulations, once they are in. */ +export const EXISTING_SIMULATIONS: Simulation[] = [ + nationwideSimulation(BASELINE_SIMULATION_ID, '2'), + nationwideSimulation(REFORM_SIMULATION_ID, '98557'), +]; diff --git a/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts b/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts new file mode 100644 index 000000000..699eb963d --- /dev/null +++ b/app/src/tests/fixtures/hooks/useMediaQueryMocks.ts @@ -0,0 +1,51 @@ +import { vi } from 'vitest'; + +/** Screen widths the v3 layouts change at. */ +export const SCREEN_WIDTHS = { + PHONE: 375, + TABLET: 768, + DESKTOP: 1280, +} as const; + +/** CSS pixels in one unit of a width query; em and rem use the 16px default font size. */ +const PX_PER_UNIT: Record = { px: 1, em: 16, rem: 16 }; + +/** + * Whether a screen this wide matches a query's (min-width / max-width: N + * px | em | rem) parts. A width query in any other form throws, so a test + * never gets the desktop layout without a warning. + */ +function matchesWidth(query: string, width: number): boolean { + const parts = [...query.matchAll(/\((min|max)-width:\s*(\d+(?:\.\d+)?)(px|em|rem)\)/g)]; + if (parts.length === 0) { + if (query.includes('width')) { + throw new Error(`mockScreenWidth cannot read the width query "${query}"`); + } + return false; + } + return parts.every(([, bound, size, unit]) => { + const px = Number(size) * PX_PER_UNIT[unit]; + return bound === 'min' ? width >= px : width <= px; + }); +} + +/** + * Answers `window.matchMedia` as a screen `width` pixels wide would: width + * queries match by the width, every other query (reduced motion) does + * not. Restore with `vi.restoreAllMocks()` or the returned spy. + */ +export function mockScreenWidth(width: number) { + return vi.spyOn(window, 'matchMedia').mockImplementation( + (query: string) => + ({ + matches: matchesWidth(query, width), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as MediaQueryList + ); +} diff --git a/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts b/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts new file mode 100644 index 000000000..d3e5e227b --- /dev/null +++ b/app/src/tests/fixtures/hooks/useTrackedBillsMocks.ts @@ -0,0 +1,2 @@ +/** As fetchTrackerBills throws when the tracker is down. */ +export const TRACKER_UNAVAILABLE_ERROR = new Error('Tracker feed bills failed: 503'); diff --git a/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts b/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts new file mode 100644 index 000000000..246a20937 --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/billTrackerMocks.ts @@ -0,0 +1,27 @@ +import type { TrackerResearchRow } from '@/libs/flagship/billTracker'; + +/** policyengine.org's mount of the tracker, as tests read it (no VITE_WEBSITE_URL). */ +export const TRACKER_HOME = 'https://policyengine.org/us/bill-tracker'; + +/** Tracker research rows, one per routing case. */ +export const TRACKER_ROWS = { + STATE_BILL: { id: 'ks-hb2620', state: 'KS', type: 'bill', title: 'KS HB 2620' }, + FEDERAL_BILL: { id: 'us-hr1425', state: 'US', type: 'bill', title: 'HR 1425' }, + /** An older federal row filed under "all": the tracker links it under /us by its id. */ + LEGACY_FEDERAL_BILL: { id: 'us-hr7024', state: 'all', type: 'bill', title: 'HR 7024' }, + /** An older federal row with no bill id, which the tracker gives no page. */ + LEGACY_FEDERAL: { id: 'ctc-2026', state: 'all', type: 'bill', title: 'CTC expansion' }, + /** The tracker lists only rows typed "bill" as bills. */ + UNTYPED_STATE_ROW: { id: 'ut-sb60', state: 'UT', title: 'UT SB60' }, + STATE_BLOG: { id: 'la-flat-tax', state: 'LA', type: 'blog', title: 'Louisiana flat tax' }, + IN_REVIEW: { id: 'ga-hb111', state: 'GA', type: 'bill', status: 'in_review', title: 'GA HB 111' }, +} satisfies Record; + +export const STATE_NAMES_SAMPLE: Record = { + DC: 'District of Columbia', + KS: 'Kansas', + UT: 'Utah', +}; + +/** Tracked bills as the feed lists them, for id lookups. */ +export const FEED_IDS = [{ id: 'ks-hb2620' }, { id: 'us-hr1425' }]; diff --git a/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts b/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts index 7c585617d..42fa070ef 100644 --- a/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/trackedBillMocks.ts @@ -1,4 +1,5 @@ import type { TrackedBill } from '@/api/billFeed'; +import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; const DECILE_SHARES = { gainMore5Pct: 0.2, @@ -18,6 +19,7 @@ export const TRACKED_BILL: TrackedBill = { provisions: [{ path: 'gov.irs.credits.ctc.amount.base[0].amount', value: 5000 }], keyFindings: ['External check (cost): within CRFB band.'], sourceUrl: 'https://www.congress.gov/bill/119th-congress/house-bill/1425', + trackerUrl: `${TRACKER_HOME}/us/us-hr1425`, date: '2026-07-06', provenance: { modelVersion: '1.729.3', @@ -57,3 +59,6 @@ export const TRACKED_BILL: TrackedBill = { }, }, }; + +/** A tracker bill that the feed does not list, and its tracker page. */ +export const UNLISTED_BILL = { id: 'ks-hb2620', trackerUrl: `${TRACKER_HOME}/ks/ks-hb2620` }; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 48974f00a..6a4948af9 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -8,6 +8,13 @@ import { UNTRACKED_BILL_STATUS, } from '@/api/billFeed'; import { CURRENT_YEAR } from '@/constants'; +import { + mockTrackerFetch, + NET_COST_DRIFT_ROWS, + NET_COST_ONLY_ROWS, + TRACKER_NAMING_ROWS, +} from '@/tests/fixtures/api/billFeedMocks'; +import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; describe('provisionsFromReformParams', () => { test('given dated value maps then each path keeps its dates and its value', () => { @@ -589,4 +596,42 @@ describe('fetchTrackerBills', () => { expect(bills![0].validation?.withinRange).toBe(true); expect(bills![0].validation?.drift).toBeUndefined(); }); + + test('given tracker rows then each bill carries the tracker place, name and page', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockTrackerFetch(TRACKER_NAMING_ROWS)); + + const bills = await fetchTrackerBills(); + + expect(bills!.map((bill) => [bill.jurisdiction, bill.title, bill.trackerUrl])).toEqual([ + ['Federal', 'HR 1425', `${TRACKER_HOME}/us/us-hr1425`], + ['Federal', 'HR 7024', `${TRACKER_HOME}/us/us-hr7024`], + ['District of Columbia', 'DC B26', `${TRACKER_HOME}/dc/dc-b26`], + ['Utah', 'HB-106', `${TRACKER_HOME}/ut/ut-hb-106`], + ]); + }); + + test('given only a net cost then it reads as the revenue change, as in the tracker', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockTrackerFetch(NET_COST_ONLY_ROWS)); + + const bills = await fetchTrackerBills(); + + expect(bills![0].impacts?.revenue).toBe(-74_200_000); + }); + + test('given only a net cost that moved since validation then drift is flagged', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockTrackerFetch(NET_COST_DRIFT_ROWS)); + + const bills = await fetchTrackerBills(); + + expect(bills![0].validation?.drift).toEqual({ + stale: true, + reasons: ['the model estimate has changed since validation'], + }); + }); }); diff --git a/app/src/tests/unit/components/flagship/BillTrackerLink.test.tsx b/app/src/tests/unit/components/flagship/BillTrackerLink.test.tsx new file mode 100644 index 000000000..b5321d240 --- /dev/null +++ b/app/src/tests/unit/components/flagship/BillTrackerLink.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import BillTrackerLink from '@/components/flagship/BillTrackerLink'; +import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; + +describe('BillTrackerLink', () => { + test('given a tracker page then the link opens it in a new tab', () => { + render(); + + const link = screen.getByRole('link', { name: 'Bill tracker' }); + expect(link).toHaveAttribute('href', `${TRACKER_HOME}/ks/ks-hb2620`); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx index 271a83656..8dcca7f11 100644 --- a/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx +++ b/app/src/tests/unit/components/flagship/CalibrationMatches.test.tsx @@ -1,7 +1,10 @@ -import { fireEvent, render, screen } from '@test-utils'; +import { fireEvent, render, screen, within } from '@test-utils'; import { describe, expect, test } from 'vitest'; import { CalibrationMatchSection } from '@/components/flagship/CalibrationMatches'; -import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { + mockCalibrationMatches, + POPULACE_RELEASE_ID, +} from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { mockReportValidationSnapshot, NEWER_RELEASE_ID, @@ -12,16 +15,16 @@ describe('CalibrationMatchSection', () => { render(); expect(screen.getByText(/temporarily unavailable/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /calibration dashboard/i })).toHaveAttribute( + expect(screen.getByRole('link', { name: /view dashboard/i })).toHaveAttribute( 'href', expect.stringContaining('calibration-diagnostics') ); }); - test('given matches are loading then the spinner state renders', () => { + test('given matches are loading then the loading state renders', () => { render(); - expect(screen.getByText(/checking the calibration/i)).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Checking calibration targets…'); }); test('given reached variables but none calibrated then the gap is stated, not hidden', () => { @@ -34,19 +37,29 @@ describe('CalibrationMatchSection', () => { expect(screen.getByText(/none of the 33 variables .* in CA/i)).toBeInTheDocument(); }); - test('given no provisions at all then nothing renders', () => { - const { container } = render( + test('given no provisions at all then a plain line says no target covers the reform', () => { + render( ); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByRole('heading', { name: 'Data calibration' })).toBeInTheDocument(); + expect(screen.getByText('No calibration target covers this reform.')).toBeInTheDocument(); }); test('shows each target with its period, values, gap and source link', () => { render(); + // One table, so every variable's numbers line up in the same columns. + const table = screen.getByRole('table', { name: 'Calibration targets' }); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect( + within(table) + .getAllByRole('columnheader') + .map((cell) => cell.textContent) + ).toEqual(['Target', 'Period', 'Official', 'PolicyEngine', 'Gap']); expect(screen.getByText('Refundable Child Tax Credit')).toBeInTheDocument(); + expect(screen.getAllByText('IRS Statistics of Income · US')).toHaveLength(2); expect(screen.getByText('940K')).toBeInTheDocument(); expect(screen.getAllByText('1M')[0]).toBeInTheDocument(); expect(screen.getByText('-6.0%')).toBeInTheDocument(); @@ -104,4 +117,11 @@ describe('CalibrationMatchSection', () => { ).toBeInTheDocument(); expect(screen.getByText(/the pinned one is historical/i)).toBeInTheDocument(); }); + + test('given matches then the data release id is collapsed under one label', () => { + render(); + + expect(screen.getByText('Data release')).toBeInTheDocument(); + expect(screen.getByText(`${POPULACE_RELEASE_ID} · policyengine-us 1.808.0`)).not.toBeVisible(); + }); }); diff --git a/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx b/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx new file mode 100644 index 000000000..0e05ab1ed --- /dev/null +++ b/app/src/tests/unit/components/flagship/FlagshipShell.test.tsx @@ -0,0 +1,117 @@ +import { renderWithCountry, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import FlagshipShell from '@/components/flagship/FlagshipShell'; +import SidePanel from '@/components/flagship/SidePanel'; +import { clearDraftReform } from '@/libs/draftReform'; +import { mockScreenWidth, SCREEN_WIDTHS } from '@/tests/fixtures/hooks/useMediaQueryMocks'; + +/** The v3 pages, which get the phone layout. */ +const FLAGSHIP_PATHS = ['/us/build', '/us/reforms', '/us/report/abc123', '/uk/report/bill/hr-1']; + +/** The legacy v2 pages the shell also hosts while the flag is on. */ +const LEGACY_PATHS = [ + '/us/report-output/abc123', + '/us/report-output/abc123/overview', + '/us/reports', + '/us/reports/create', + '/us/simulations', + '/us/policies', + '/us/households', +]; + +/** The shell's classes before the phone layout, which legacy pages keep. */ +const LEGACY_CLASSES = { + root: 'tw:h-screen tw:overflow-hidden tw:flex tw:flex-col', + main: 'tw:flex-1 tw:min-w-0 tw:overflow-y-auto tw:overflow-x-hidden tw:p-[24px] tw:bg-gray-50', + slot: 'tw:shrink-0 tw:flex tw:h-full tw:bg-gray-50', +}; + +function renderShell(path: string, children: React.ReactNode = 'Page content') { + const countryId = path.split('/')[1]; + const view = renderWithCountry({children}, countryId, path); + const root = view.container.querySelector('[data-flagship-shell]') as HTMLElement; + const slot = view.container.querySelector('#flagship-side-panel-slot') as HTMLElement; + return { ...view, root, slot }; +} + +describe('FlagshipShell', () => { + beforeEach(() => { + clearDraftReform(); + }); + + test.each(FLAGSHIP_PATHS)('given the v3 page %s then it gets the phone layout', (path) => { + // When + const { root, slot } = renderShell(path); + + // Then + expect(root.className).toContain('tw:max-sm:[&_[data-slot=segmented-control]]:h-12'); + expect(root).toHaveClass('tw:h-dvh'); + expect(screen.getByRole('main')).toHaveClass('tw:p-lg', 'tw:sm:p-2xl'); + expect(slot).toHaveClass('tw:max-sm:h-auto'); + }); + + test.each(LEGACY_PATHS)('given the legacy page %s then its layout is unchanged', (path) => { + // When + const { root, slot } = renderShell(path); + + // Then — no phone touch targets, padding or plane under the content + expect(root.className).toBe(LEGACY_CLASSES.root); + expect(root.className).not.toContain('max-sm'); + expect(screen.getByRole('main').className).toBe(LEGACY_CLASSES.main); + expect(slot.className).toBe(LEGACY_CLASSES.slot); + }); +}); + +describe('FlagshipShell on a phone', () => { + beforeEach(() => { + clearDraftReform(); + sessionStorage.clear(); + mockScreenWidth(SCREEN_WIDTHS.PHONE); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('given the side panel opens as a drawer then the page under it is out of reach', async () => { + // Given + const user = userEvent.setup(); + renderShell( + '/us/report/abc123', + +

panel body

+
+ ); + + // When + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // Then — a modal drawer; the page and the top bar are inert + expect(screen.getByRole('dialog', { name: 'Adjust parameters' })).toHaveAttribute( + 'aria-modal', + 'true' + ); + expect(screen.getByRole('main', { hidden: true })).toHaveAttribute('inert'); + expect(screen.getByRole('banner', { hidden: true })).toHaveAttribute('inert'); + }); + + test('given the drawer closes then the page is in reach again', async () => { + // Given + const user = userEvent.setup(); + renderShell( + '/us/report/abc123', + +

panel body

+
+ ); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // When + await user.keyboard('{Escape}'); + + // Then + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('main')).not.toHaveAttribute('inert'); + expect(screen.getByRole('banner')).not.toHaveAttribute('inert'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx index 2d6231ad8..961e7ef15 100644 --- a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx +++ b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx @@ -93,4 +93,37 @@ describe('FlagshipTopBar', () => { // The mocked location is /us/build, where the draft already is. expect(screen.queryByRole('button', { name: /draft ·/i })).not.toBeInTheDocument(); }); + + test("given a phone's menu is opened then it offers both sections and the website links", async () => { + // Given — the menu button a phone shows in place of the sections + const user = userEvent.setup(); + renderTopBar(); + + // When + await user.click(screen.getByRole('button', { name: 'Menu' })); + + // Then + expect(await screen.findByRole('menuitem', { name: 'Build' })).toHaveAttribute( + 'aria-current', + 'page' + ); + expect(screen.getByRole('menuitem', { name: 'Reforms' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Research' })).toHaveAttribute( + 'href', + expect.stringContaining('/us/research') + ); + }); + + test("given a section is chosen from a phone's menu then it navigates there", async () => { + // Given + const user = userEvent.setup(); + renderTopBar(); + await user.click(screen.getByRole('button', { name: 'Menu' })); + + // When + await user.click(await screen.findByRole('menuitem', { name: 'Reforms' })); + + // Then + expect(mockNavigate).toHaveBeenCalledWith('/us/reforms'); + }); }); diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index 320b8492e..c009e42e2 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -390,6 +390,22 @@ describe('ParameterSearchBox', () => { expect(screen.getByText(/no parameters match/i)).toHaveTextContent('zzzz quantum flux'); }); + test('given a query with no match then clear search empties the box and returns to it', async () => { + // Given + const user = userEvent.setup(); + render(); + const search = screen.getByRole('combobox', { name: /search parameters/i }); + await user.type(search, 'zzzz quantum flux'); + + // When + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + // Then + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + expect(screen.queryByText(/no parameters match/i)).not.toBeInTheDocument(); + }); + test('given a scope with no match then other jurisdictions wait until asked for', async () => { // Given const user = userEvent.setup(); diff --git a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx index 0c1dab184..1c257b74f 100644 --- a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx +++ b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx @@ -1,7 +1,13 @@ -import { fireEvent, render, screen, userEvent } from '@test-utils'; -import { describe, expect, test } from 'vitest'; -import { ScorecardComparisonResults } from '@/components/flagship/ScorecardComparisons'; +import { fireEvent, render, renderHook, screen, userEvent, waitFor, within } from '@test-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { + RelatedReformEstimates, + ScorecardClaimTable, + splitScorecardRows, + useScorecardComparisons, +} from '@/components/flagship/ScorecardComparisons'; +import { + CTC_PATH, jctCtcClaim, scorecardClaim, stackedJctCtcClaim, @@ -9,29 +15,10 @@ import { } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; describe('scorecard evidence presentation', () => { - test('given no baseline rows and no baseline content then says none were found', () => { - render( - - ); - expect( - screen.getByText(/No linked baseline comparisons were found in the published scorecard feed/) - ).toBeInTheDocument(); - }); - test('given a related reform then shows source-policy counterparts, periods, baseline caveats and no search button', () => { - render( - - ); - expect(screen.getByText(/not a validation of this report/)).toBeInTheDocument(); - expect(screen.getByText('PolicyEngine estimate')).toBeInTheDocument(); + test('given a related reform then shows source-policy counterparts, periods and baseline caveats', () => { + render(); + expect(screen.getByText(/do not score this exact reform/)).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'PolicyEngine' })).toBeInTheDocument(); expect(screen.getByText(/2026 · fiscal year/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button')); expect(screen.getByText('Different stacking baseline')).toBeInTheDocument(); @@ -39,14 +26,34 @@ describe('scorecard evidence presentation', () => { screen.queryByRole('button', { name: /Find external estimates/ }) ).not.toBeInTheDocument(); }); + + test('given several claims then one table holds them so their numbers line up', () => { + render( + + ); + const table = screen.getByRole('table', { name: 'Related reforms' }); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect( + within(table) + .getAllByRole('columnheader') + .map((cell) => cell.textContent) + ).toEqual(['Comparison', 'Published', 'PolicyEngine', 'Gap']); + // A header row and one row per claim. + expect(within(table).getAllByRole('row')).toHaveLength(3); + expect(within(table).getAllByText('-86.9%')).toHaveLength(2); + }); + test('given an expiry-reversal run then explains its policy and different starting baseline', () => { render( - ); fireEvent.click(screen.getByRole('button')); @@ -54,28 +61,24 @@ describe('scorecard evidence presentation', () => { expect(screen.getByRole('cell', { name: '$1,000', hidden: true })).toBeInTheDocument(); expect(screen.getByText(/broad CTC expansion from an expiry baseline/)).toBeInTheDocument(); }); + test('given a stacked run then describes that run without applying the expiry-reversal explanation', () => { render( - ); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/This run adds the CTC changes after/)).toBeInTheDocument(); expect(screen.queryByText(/This run keeps other enacted tax changes/)).not.toBeInTheDocument(); }); + test('given a Utah baseline comparison then distinguishes claimed and usable credit', () => { render( - ); expect(screen.getByRole('table')).toBeInTheDocument(); @@ -84,40 +87,79 @@ describe('scorecard evidence presentation', () => { expect(screen.getByText(/Utah credits claimed before limiting/)).toBeInTheDocument(); expect(screen.getByText(/Modeled Utah credit actually usable/)).toBeInTheDocument(); }); + test('given an undocumented reform then explicitly identifies missing before-and-after rules', () => { render( - ); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/does not supply a complete before-and-after/)).toBeInTheDocument(); }); - test('given a reform card then policy details are collapsed until opened', async () => { + + test('given a reform row then policy details are collapsed until opened', async () => { render( - ); expect(screen.getByRole('table')).toBeVisible(); await userEvent.click(screen.getByRole('button')); expect(screen.getByRole('dialog')).toBeInTheDocument(); await userEvent.click(screen.getByText('Compared policy and baseline')); - expect(screen.getByRole('table')).toBeVisible(); + expect(screen.getByRole('table', { name: 'Related reforms', hidden: true })).toBeVisible(); }); - test('given an empty feed then absence of matching evidence is explicit', () => { - render(); - expect(screen.getByText(/No linked reform comparisons/)).toBeInTheDocument(); - expect( - screen.getByText(/does not establish that no external estimate exists/) - ).toBeInTheDocument(); +}); + +describe('splitScorecardRows', () => { + test('given mixed claims then reform scores and baseline measures come apart', () => { + const reform = { ...scorecardClaim(), matchBasis: 'parameter' as const }; + const baseline = { ...utahBaselineClaim, matchBasis: 'program' as const }; + + expect(splitScorecardRows({ built: null, unmappedCount: 0, rows: [reform, baseline] })).toEqual( + { reforms: [reform], baseline: [baseline] } + ); + }); + + test('given the feed is loading or unavailable then both lists are empty', () => { + expect(splitScorecardRows(undefined)).toEqual({ reforms: [], baseline: [] }); + expect(splitScorecardRows(null)).toEqual({ reforms: [], baseline: [] }); + }); +}); + +describe('useScorecardComparisons', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given no parameter paths then it resolves empty without a request', () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useScorecardComparisons('uk', [])); + + expect(result.current).toEqual({ built: null, rows: [], unmappedCount: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given paths then it is loading until the feed answers', async () => { + const feed = { built: '2026-08-29', rows: [scorecardClaim()], unmappedCount: 2 }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(feed))); + + const { result } = renderHook(() => useScorecardComparisons('uk', [CTC_PATH])); + + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toEqual(feed)); + }); + + test('given the feed fails then it resolves to null', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 }))); + + const { result } = renderHook(() => useScorecardComparisons('uk', [CTC_PATH])); + + await waitFor(() => expect(result.current).toBeNull()); }); }); diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx index 4a0298eaf..219f7d9d2 100644 --- a/app/src/tests/unit/components/flagship/SidePanel.test.tsx +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -1,6 +1,8 @@ import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import SidePanel from '@/components/flagship/SidePanel'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui'; +import { mockScreenWidth, SCREEN_WIDTHS } from '@/tests/fixtures/hooks/useMediaQueryMocks'; describe('SidePanel', () => { beforeEach(() => { @@ -159,3 +161,126 @@ describe('SidePanel', () => { ); }); }); + +describe('SidePanel on a phone', () => { + beforeEach(() => { + sessionStorage.clear(); + mockScreenWidth(SCREEN_WIDTHS.PHONE); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('given a phone then the folded panel is a bar across the screen', () => { + // Given / When + render( + +

panel body

+
+ ); + + // Then — a full-width bar that still names the panel and its count + const bar = screen.getByRole('button', { name: /open adjust parameters/i }); + expect(bar).toHaveStyle({ width: '100%' }); + expect(bar).toHaveTextContent('Adjust parameters · 2 provisions'); + }); + + test('given a phone and the bar is tapped then the panel fills the screen', async () => { + // Given + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + // When + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + + // Then — a drawer over the whole screen, not a 340px column + expect(screen.getByText('panel body')).toBeVisible(); + expect(screen.getByLabelText('Adjust parameters')).toHaveStyle({ + position: 'fixed', + top: '0px', + right: '0px', + bottom: '0px', + left: '0px', + width: '100%', + }); + }); + + test('given a phone and an open panel then Escape folds it back to the bar', async () => { + // Given + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + // When + await user.keyboard('{Escape}'); + + // Then — folded, with focus on the bar that reopens it + expect(screen.getByText('panel body')).not.toBeVisible(); + expect(screen.getByRole('button', { name: /open draft reform/i })).toHaveFocus(); + }); + + test('given a phone and a picker open in the drawer then Escape closes only the picker', async () => { + // Given — a picker inside the open drawer, as the adjust panel's population picker + const user = userEvent.setup(); + render( + + + Pick a state + State list + + + ); + await user.click(screen.getByRole('button', { name: 'Pick a state' })); + expect(screen.getByText('State list')).toBeInTheDocument(); + + // When + await user.keyboard('{Escape}'); + + // Then — the picker closed; the drawer stays open + expect(screen.queryByText('State list')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Adjust parameters' })).toBeVisible(); + expect(screen.getByRole('button', { name: /collapse adjust parameters/i })).toBeVisible(); + }); + + test('given a phone and an open panel then it is a modal dialog', () => { + // Given / When + render( + +

panel body

+
+ ); + + // Then + expect(screen.getByRole('dialog', { name: 'Draft reform' })).toHaveAttribute( + 'aria-modal', + 'true' + ); + }); + + test('given a wide screen then Escape leaves the panel open beside the content', async () => { + // Given — the default: a column beside the content + mockScreenWidth(SCREEN_WIDTHS.DESKTOP); + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + // When + await user.keyboard('{Escape}'); + + // Then — a column, not a dialog + expect(screen.getByText('panel body')).toBeVisible(); + expect(screen.getByLabelText('Draft reform')).toHaveStyle({ position: 'absolute' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/TrackerHomeButton.test.tsx b/app/src/tests/unit/components/flagship/TrackerHomeButton.test.tsx new file mode 100644 index 000000000..5d61dcd6e --- /dev/null +++ b/app/src/tests/unit/components/flagship/TrackerHomeButton.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import TrackerHomeButton from '@/components/flagship/TrackerHomeButton'; +import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; + +describe('TrackerHomeButton', () => { + test('given the home button then it links to the tracker home in a new tab', () => { + render(); + + const link = screen.getByRole('link', { name: 'Bill tracker' }); + expect(link).toHaveAttribute('href', TRACKER_HOME); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx index 39f0cb0a4..d593c1618 100644 --- a/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ValidationPanel.test.tsx @@ -1,15 +1,88 @@ -import { render, screen } from '@test-utils'; +import { render, screen, within } from '@test-utils'; import { describe, expect, test } from 'vitest'; import { - BillValidationSection, - hasTrackRecordContent, - ModelTrackRecordSection, + BaselineComparisons, + BillExternalEstimates, + trackRecordClaims, } from '@/components/flagship/ValidationPanel'; +import type { ScorecardMatches } from '@/libs/flagship/modelValidation'; +import { utahBaselineClaim } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const SNAP_AND_CTC_TRACK_RECORD: ScorecardMatches = { + modelVersion: '1.808.0', + reachedCount: 12, + programs: [ + { program: 'snap', variable: 'snap', depth: 1, ring: 'primary' }, + { program: 'ctc_refund', variable: 'refundable_ctc', depth: 2, ring: 'mechanism' }, + ], + rows: [ + { + source: 'urban-sotsn', + sourceName: 'Urban Institute — State of the Safety Net 2025', + sourceUrl: 'https://apps.urban.org/features/state-safety-net/', + program: 'snap', + metric: 'eligible_count', + period: '2023 average month', + status: 'comparable', + unitConcept: 'persons', + externalValue: 69128000, + peValue: 66363627, + ratio: 0.96, + heldOut: true, + policyengineVariables: ['snap', 'is_snap_eligible'], + }, + { + source: 'urban-sotsn', + sourceName: 'Urban Institute — State of the Safety Net 2025', + sourceUrl: 'https://apps.urban.org/features/state-safety-net/', + program: 'ctc_refund', + metric: 'eligibility_rate', + period: '2023 average month', + status: 'constructed', + unitConcept: 'tax units', + externalValue: 0.12, + peValue: 0.114, + ratio: 0.95, + heldOut: false, + policyengineVariables: ['refundable_ctc'], + }, + ], +}; + +describe('BillExternalEstimates', () => { + test('given a bill with a fiscal note then every estimate shares one aligned table', () => { + render( + + ); + + const table = screen.getByRole('table', { name: 'Estimates for this bill' }); + const cells = (name: string) => + within(within(table).getByRole('row', { name: new RegExp(`^${name}`) })) + .getAllByRole('cell') + .map((cell) => cell.textContent); + expect(cells('PolicyEngine')).toEqual(['-$225.5B', '—']); + expect(cells('Official fiscal note')).toEqual(['-$230.0B', '2.0%']); + expect(cells('CRFB')).toEqual(['-$220.0B', '-2.5%']); + expect(cells('Accepted range')).toEqual(['-$250.0B to -$210.0B', '']); + expect(screen.getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect(screen.getByText(/later effective date/)).toBeInTheDocument(); + }); + + test('given a bill outside the range then the chip says so', () => { + render( + + ); + + expect(screen.getByText('Outside fiscal-note range · Δ 31%')).toBeInTheDocument(); + }); -describe('BillValidationSection', () => { test('given drifted validation then the chip demands a re-check and the note explains', () => { render( - { }); }); -describe('ModelTrackRecordSection', () => { - const ctcMatch = { - program: 'ctc_refund', - variable: 'refundable_ctc', - depth: 2, - ring: 'mechanism' as const, - }; - - test('given the scorecard is unreachable then an honest note renders, not a blank tab', () => { - render(); +describe('BaselineComparisons', () => { + test('given the scorecard is unreachable then an honest note renders, not a blank card', () => { + render(); expect(screen.getByText(/temporarily unavailable/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /policyengine scorecard/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view scorecard/i })).toBeInTheDocument(); }); - test('given matches are loading then the spinner state renders', () => { - render(); + test('given matches are loading then the loading state renders', () => { + render(); - expect(screen.getByText(/loading external comparisons/i)).toBeInTheDocument(); + expect(screen.getByText(/loading baseline comparisons/i)).toBeInTheDocument(); }); - test('given the reform reaches nothing then nothing renders', () => { - const { container } = render( - { + render( + ); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByText('No baseline comparison covers this reform.')).toBeInTheDocument(); }); test('given reached variables the scorecard does not measure then the note says so', () => { render( - ); @@ -69,53 +137,21 @@ describe('ModelTrackRecordSection', () => { ).toBeInTheDocument(); }); - test('given rows then each program heads its comparisons with its distance from the reform', () => { + test('given track record and scorecard rows then they share one table', () => { render( - ); - expect(screen.getByText('Baseline comparisons')).toBeInTheDocument(); - expect(screen.getByText('SNAP — Eligible people')).toBeInTheDocument(); - expect(screen.getByText('Refundable CTC — Eligibility rate')).toBeInTheDocument(); - expect(screen.getAllByRole('table')).toHaveLength(2); + expect(screen.getByRole('heading', { name: 'Baseline comparisons' })).toBeInTheDocument(); + expect(screen.getAllByRole('table')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'SNAP — Eligible people' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Refundable CTC — Eligibility rate' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Utah Child Tax Credit' })).toBeInTheDocument(); expect(screen.queryByText(/formula steps/)).not.toBeInTheDocument(); expect(screen.getByText('69.1M')).toBeInTheDocument(); expect(screen.getAllByRole('link', { name: 'Read source' })[0]).toHaveAttribute( @@ -125,18 +161,13 @@ describe('ModelTrackRecordSection', () => { }); }); -describe('hasTrackRecordContent', () => { - test('given loading, unavailable, or reached variables then the section has content', () => { - expect(hasTrackRecordContent(undefined)).toBe(true); - expect(hasTrackRecordContent(null)).toBe(true); - expect( - hasTrackRecordContent({ modelVersion: '1.808.0', reachedCount: 30, programs: [], rows: [] }) - ).toBe(true); - }); +describe('trackRecordClaims', () => { + test('given track record rows then each becomes a baseline claim with its program in the title', () => { + const [snap] = trackRecordClaims(SNAP_AND_CTC_TRACK_RECORD); - test('given the reform reaches nothing then the section has no content', () => { - expect( - hasTrackRecordContent({ modelVersion: '1.808.0', reachedCount: 0, programs: [], rows: [] }) - ).toBe(false); + expect(snap.row.reform_framework).toBe('baseline'); + expect(snap.row.calibration_relationship).toBe('held_out'); + expect(snap.presentation?.title).toBe('SNAP — Eligible people'); + expect(snap.presentation?.externalValue).toBe('69.1M'); }); }); diff --git a/app/src/tests/unit/components/flagship/reform/AddParameterBar.test.tsx b/app/src/tests/unit/components/flagship/reform/AddParameterBar.test.tsx new file mode 100644 index 000000000..7ec63c376 --- /dev/null +++ b/app/src/tests/unit/components/flagship/reform/AddParameterBar.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import AddParameterBar from '@/components/flagship/reform/AddParameterBar'; +import { clearDraftReform } from '@/libs/draftReform'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { mockScreenWidth, SCREEN_WIDTHS } from '@/tests/fixtures/hooks/useMediaQueryMocks'; + +const search = () => screen.getByRole('combobox', { name: 'Search parameters' }); + +describe('AddParameterBar placeholder', () => { + beforeEach(() => { + clearDraftReform(); + seedDeductionMetadata(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + store.dispatch(clearMetadata()); + }); + + test('given a wide screen then the bar asks with an example', () => { + // Given + mockScreenWidth(SCREEN_WIDTHS.DESKTOP); + + // When + render(); + + // Then — the default, as before + expect(search()).toHaveAttribute( + 'placeholder', + 'Add a parameter — search by name, e.g. child tax credit' + ); + }); + + test('given a phone then the bar only asks, without the example', () => { + // Given + mockScreenWidth(SCREEN_WIDTHS.PHONE); + + // When + render(); + + // Then + expect(search()).toHaveAttribute('placeholder', 'Add a parameter'); + }); + + test('given a phone and the bar at the foot of the reform then it asks for another', () => { + // Given + mockScreenWidth(SCREEN_WIDTHS.PHONE); + + // When + render(); + + // Then + expect(search()).toHaveAttribute('placeholder', 'Add another parameter'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx new file mode 100644 index 000000000..d6d4fb782 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/BillReportStatus.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import BillReportStatus from '@/components/flagship/report/BillReportStatus'; +import { UNLISTED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +const FEED = { isLoading: false, isError: false, isConfigured: true }; + +describe('BillReportStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given the feed is loading then the report placeholder holds its place', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the feed failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the bill feed'); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a bill the feed does not carry then all bills is the way back', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'All bills' })); + + expect(screen.getByText('This bill is not in the feed.')).toBeInTheDocument(); + expect(mockPush).toHaveBeenCalledWith('/us/reforms'); + expect(screen.queryByRole('link', { name: /bill tracker/i })).not.toBeInTheDocument(); + }); + + test('given a bill the feed does not carry and its tracker page then it links there', () => { + render(); + + expect(screen.getByRole('link', { name: /bill tracker/i })).toHaveAttribute( + 'href', + UNLISTED_BILL.trackerUrl + ); + }); + + test('given no feed configured then it says so and offers Build', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Build a reform' })); + + expect(screen.getByText('The bill feed is not configured here.')).toBeInTheDocument(); + expect(mockPush).toHaveBeenCalledWith('/us/build'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx b/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx index f451683a5..75234373c 100644 --- a/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx +++ b/app/src/tests/unit/components/flagship/report/BudgetWindowSummary.test.tsx @@ -77,13 +77,30 @@ describe('BudgetWindowSummary', () => { expect(onOpen).toHaveBeenCalledTimes(1); }); - test('given the window failed then the overview says so', async () => { + test('given the window failed then the overview says so, without a progress link', async () => { mockFetchBudgetWindow.mockResolvedValue({ status: 'error', result: null, error: 'Failed' }); renderSummary(); - expect( - await screen.findByText('The budget window could not be calculated.') - ).toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'The budget window could not be calculated' + ); + expect(screen.queryByRole('button', { name: /see its progress/i })).not.toBeInTheDocument(); + }); + + test('given the window failed then try again asks for it again', async () => { + // Given + mockFetchBudgetWindow + .mockResolvedValueOnce({ status: 'error', result: null, error: 'Failed' }) + .mockResolvedValue({ status: 'computing', result: null, progress: 40 }); + const user = userEvent.setup(); + renderSummary(); + + // When + await user.click(await screen.findByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText('Calculating · 40%')).toBeInTheDocument(); + expect(mockFetchBudgetWindow).toHaveBeenCalledTimes(2); }); }); diff --git a/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx new file mode 100644 index 000000000..1b8e61c1b --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportLoadError.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportLoadError from '@/components/flagship/report/ReportLoadError'; + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +describe('ReportLoadError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given a link that names no report then it says not found, with no retry', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + test('given a report not found then your reforms is one click away', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Your reforms' })); + + expect(mockPush).toHaveBeenCalledWith('/us/reforms?filter=yours'); + }); + + test('given a failed load then try again asks for the report again', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a failed load then back to Build leaves for Build', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Back to Build' })); + + expect(mockPush).toHaveBeenCalledWith('/us/build'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx new file mode 100644 index 000000000..c45db6346 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportSkeleton.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import ReportSkeleton from '@/components/flagship/report/ReportSkeleton'; +import { RunLoading } from '@/components/flagship/report/RunProgress'; + +describe('ReportSkeleton', () => { + test('given a report on its way then one busy status names it, with no headings yet', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toHaveAttribute( + 'aria-busy', + 'true' + ); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); +}); + +describe('RunLoading', () => { + test('given a run not yet started then a spinner stands in, named for what loads', () => { + render(); + + expect(screen.getAllByRole('status')).toEqual([ + screen.getByRole('status', { name: 'Loading report' }), + ]); + expect(screen.getByText('Loading report…')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx new file mode 100644 index 000000000..0269d3603 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportValidation.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportValidation from '@/components/flagship/report/ReportValidation'; +import type { ScorecardComparisonsState } from '@/components/flagship/ScorecardComparisons'; +import { BillExternalEstimates } from '@/components/flagship/ValidationPanel'; +import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { + CTC_PATH, + scorecardClaim, + utahBaselineClaim, +} from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockUseScorecardComparisons = + vi.fn<(countryId: string, paths: string[]) => ScorecardComparisonsState>(); + +vi.mock('@/components/flagship/ScorecardComparisons', async (importOriginal) => ({ + ...(await importOriginal()), + useScorecardComparisons: (countryId: string, paths: string[]) => + mockUseScorecardComparisons(countryId, paths), +})); + +const NO_SCORECARD_ROWS = { built: '2026-08-29', rows: [], unmappedCount: 4 }; +const RELATED_REFORM = { ...scorecardClaim(), matchBasis: 'parameter' as const }; +const UTAH_BASELINE = { ...utahBaselineClaim, matchBasis: 'program' as const }; +const EMPTY_TRACK_RECORD = { modelVersion: '1.808.0', reachedCount: 12, programs: [], rows: [] }; +const SAVED_REFORM_BASELINE = 'Smaller CTC'; + +function renderValidation(props: Partial[0]> = {}) { + return render( + + ); +} + +const card = (name: string) => + screen.getByRole('heading', { level: 3, name }).closest('section') as HTMLElement; + +describe('ReportValidation', () => { + beforeEach(() => { + mockUseScorecardComparisons.mockReset(); + mockUseScorecardComparisons.mockReturnValue(NO_SCORECARD_ROWS); + }); + + test('given a custom report then the external estimates, calibration and baseline cards render in order', () => { + renderValidation(); + + expect( + screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent) + ).toEqual(['External estimates', 'Data calibration', 'Baseline comparisons']); + }); + + test('given a custom report without external estimates then one plain line says so', () => { + renderValidation(); + + expect( + within(card('External estimates')).getByText( + 'No external estimate is available for this reform.' + ) + ).toBeInTheDocument(); + expect(within(card('External estimates')).queryByRole('table')).not.toBeInTheDocument(); + // The calibration comparison is there all the same. + expect( + within(card('Data calibration')).getByRole('table', { name: 'Calibration targets' }) + ).toBeInTheDocument(); + }); + + test('given a custom report with scorer estimates then they show instead of the plain line', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [RELATED_REFORM], + }); + renderValidation(); + + const estimates = card('External estimates'); + expect(within(estimates).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument(); + expect(within(estimates).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); + expect(within(estimates).getByText('Joint Committee on Taxation')).toBeInTheDocument(); + expect( + within(estimates).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given a bill report then its fiscal note leads the external estimates', () => { + renderValidation({ + externalEstimates: ( + + ), + externalEstimatesNote: 'Tracker estimate: policyengine-us 1.729.3', + }); + + const estimates = card('External estimates'); + expect( + within(estimates).getByRole('table', { name: 'Estimates for this bill' }) + ).toBeInTheDocument(); + expect(within(estimates).getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect( + within(estimates).getByText('Tracker estimate: policyengine-us 1.729.3') + ).toBeInTheDocument(); + expect( + within(estimates).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given scorecard baseline claims then they join the baseline comparisons, not the estimates', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [UTAH_BASELINE], + }); + renderValidation(); + + expect( + within(card('Baseline comparisons')).getByRole('button', { name: 'Utah Child Tax Credit' }) + ).toBeInTheDocument(); + expect( + within(card('External estimates')).getByText( + 'No external estimate is available for this reform.' + ) + ).toBeInTheDocument(); + }); + + test('given the scorecard is still loading then the estimates card says it is loading', () => { + mockUseScorecardComparisons.mockReturnValue(undefined); + renderValidation(); + + expect(within(card('External estimates')).getByRole('status')).toHaveTextContent( + 'Loading external estimates…' + ); + }); + + test('given the scorecard is unavailable then the estimates card says so, not that none exists', () => { + mockUseScorecardComparisons.mockReturnValue(null); + renderValidation(); + + expect( + within(card('External estimates')).getByText( + 'External estimates are temporarily unavailable.' + ) + ).toBeInTheDocument(); + expect( + screen.queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given the coverage of the scorecard match then it is collapsed under the estimates', () => { + renderValidation(); + + expect(within(card('External estimates')).getByText('Coverage')).toBeInTheDocument(); + expect( + within(card('External estimates')).getByText(/4 claims in this country have no usable/) + ).not.toBeVisible(); + }); + + test('given a custom baseline then both scorecard cards are marked not comparable', () => { + mockUseScorecardComparisons.mockReturnValue({ + ...NO_SCORECARD_ROWS, + rows: [RELATED_REFORM, UTAH_BASELINE], + }); + renderValidation({ baselineLabel: SAVED_REFORM_BASELINE }); + + for (const name of ['External estimates', 'Baseline comparisons']) { + expect(within(card(name)).getByText('Not comparable')).toBeInTheDocument(); + expect(within(card(name)).queryByRole('table')).not.toBeInTheDocument(); + } + expect( + within(card('External estimates')).getByText( + 'External estimates assume current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + expect( + within(card('Baseline comparisons')).getByText( + 'The scorecard measures current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + }); + + test('given a custom baseline then the scorecard is not asked and calibration still shows', () => { + renderValidation({ baselineLabel: SAVED_REFORM_BASELINE }); + + expect(mockUseScorecardComparisons).toHaveBeenCalledWith('us', []); + expect( + within(card('Data calibration')).getByRole('table', { name: 'Calibration targets' }) + ).toBeInTheDocument(); + }); + + test('given current law then the scorecard is asked for the reform paths and nothing is marked', () => { + renderValidation(); + + expect(mockUseScorecardComparisons).toHaveBeenCalledWith('us', [CTC_PATH]); + expect(screen.queryByText('Not comparable')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx index 5c9784041..6f1002231 100644 --- a/app/src/tests/unit/components/flagship/report/ReportView.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportView.test.tsx @@ -1,7 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, within } from '@test-utils'; -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import ReportView from '@/components/flagship/report/ReportView'; +import { BillExternalEstimates } from '@/components/flagship/ValidationPanel'; +import type { RunReportProvision } from '@/libs/flagship/runReport'; +import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; vi.mock('@/utils/isSocietyWideReportNoOp', () => ({ @@ -119,32 +123,6 @@ describe('ReportView compared with a saved reform', () => { expect(valueHeadings()).toEqual(['Change', 'Before', 'After', 'Start', 'End']); }); - - test('then Validation says published scores compare with current law', async () => { - const user = userEvent.setup(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); - renderWith('Plan B'); - - await user.click(screen.getByRole('tab', { name: 'Validation' })); - - expect( - screen.getByText( - 'Published scores compare with current law. This report compares with Plan B.' - ) - ).toBeInTheDocument(); - fetchSpy.mockRestore(); - }); - - test('given current law then Validation has no such note', async () => { - const user = userEvent.setup(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); - renderWith(); - - await user.click(screen.getByRole('tab', { name: 'Validation' })); - - expect(screen.queryByText(/Published scores compare/)).not.toBeInTheDocument(); - fetchSpy.mockRestore(); - }); }); describe('ReportView budget window', () => { @@ -205,3 +183,92 @@ describe('ReportView cover', () => { expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); }); }); + +describe('ReportView validation', () => { + const CTC_PROVISION: RunReportProvision = { + path: 'gov.irs.credits.ctc.refundable.individual_max', + breadcrumb: 'IRS → Credits → CTC → Refundable maximum', + unit: 'currency-USD', + baselineValue: 1700, + value: 499, + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderValidationTab = async (extra: Partial[0]> = {}) => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + return screen.getByRole('tabpanel'); + }; + + test('given a custom report then validation says no external estimate is available', async () => { + const panel = await renderValidationTab(); + + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect(within(panel).getByRole('table', { name: 'Calibration targets' })).toBeInTheDocument(); + expect( + within(panel).getByRole('heading', { level: 3, name: 'Baseline comparisons' }) + ).toBeInTheDocument(); + // The reform's changes live in the overview; validation does not list them again. + expect(within(panel).queryByText(/Your reform/)).not.toBeInTheDocument(); + expect(within(panel).queryByText('IRS → Credits → CTC → Refundable maximum')).toBeNull(); + }); + + test('given a bill report then validation leads with its fiscal note and the range chip', async () => { + const panel = await renderValidationTab({ + externalEstimates: ( + + ), + }); + + expect( + within(panel).getByRole('table', { name: 'Estimates for this bill' }) + ).toBeInTheDocument(); + expect(within(panel).getByText('Within fiscal-note range · Δ 2%')).toBeInTheDocument(); + expect( + within(panel).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given a custom baseline then validation marks the scorecard not comparable', async () => { + const panel = await renderValidationTab({ customBaselineLabel: 'Smaller CTC' }); + + expect(within(panel).getAllByText('Not comparable')).toHaveLength(2); + expect(within(panel).getByRole('table', { name: 'Calibration targets' })).toBeInTheDocument(); + // External scores assume current law: the scorecard is not asked. + const urls = vi.mocked(fetch).mock.calls.map(([url]) => String(url)); + expect(urls).not.toContain('/api/scorecard-comparisons'); + }); + + test('given current law then validation asks the scorecard and marks nothing', async () => { + const panel = await renderValidationTab(); + + await within(panel).findByText('No external estimate is available for this reform.'); + const urls = vi.mocked(fetch).mock.calls.map(([url]) => String(url)); + expect(urls).toContain('/api/scorecard-comparisons'); + expect(within(panel).queryByText('Not comparable')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx index 7c0297967..9765e9971 100644 --- a/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@test-utils'; import { describe, expect, test } from 'vitest'; import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts'; +import { NET_COST_ONLY_IMPACT } from '@/tests/fixtures/components/flagship/report/storedImpactChartsMocks'; describe('StoredImpactCharts', () => { test('given a stored state revenue change then the budget leads, as a gain or a loss', () => { @@ -23,6 +24,14 @@ describe('StoredImpactCharts', () => { expect(budget).toHaveTextContent('Annual state revenue loss'); }); + test('given only a stored net cost then it stands in for the revenue change', () => { + render(); + + const budget = screen.getByRole('region', { name: 'Budgetary impact' }); + expect(budget).toHaveTextContent('−$74.2m'); + expect(budget).toHaveTextContent('Annual state revenue loss'); + }); + test('given no stored results then nothing shows', () => { const { container } = render(); diff --git a/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx b/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx new file mode 100644 index 000000000..425d8fcb4 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/EmptyState.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { EmptyState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; + +describe('EmptyState', () => { + test('given a title and an action then both show, the action usable', async () => { + // Given + const user = userEvent.setup(); + const onBuild = vi.fn(); + render( + Build a reform} + /> + ); + + // When + await user.click(screen.getByRole('button', { name: 'Build a reform' })); + + // Then + expect(screen.getByText('No saved reforms yet.')).toBeInTheDocument(); + expect(onBuild).toHaveBeenCalledTimes(1); + }); + + test('given a description then it reads under the title', () => { + render(); + + expect(screen.getByText('Try another place.')).toBeInTheDocument(); + }); + + test('given no action then it is a message alone, not an alert', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx b/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx new file mode 100644 index 000000000..a1f9954c4 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/ErrorState.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; + +describe('ErrorState', () => { + test('given a failure then it is announced as an alert, with its title as a heading', () => { + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the report'); + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + }); + + test('given no retry then no try again button shows', () => { + render(Back to Build} />); + + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Back to Build' })).toBeInTheDocument(); + }); + + test('given try again is clicked then the retry runs', async () => { + // Given + const user = userEvent.setup(); + const onRetry = vi.fn(); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + test('given a retry still in flight then the button waits for it, then comes back', async () => { + // Given + const user = userEvent.setup(); + let settle: () => void = () => {}; + const onRetry = vi.fn( + () => + new Promise((resolve) => { + settle = resolve; + }) + ); + render(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('button', { name: 'Trying again…' })).toBeDisabled(); + + // And when the retry settles, the button is back + settle(); + expect(await screen.findByRole('button', { name: 'Try again' })).toBeEnabled(); + }); + + test('given a failed retry then the button comes back for another try', async () => { + const user = userEvent.setup(); + const onRetry = vi.fn().mockRejectedValue(new Error('still down')); + render(); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + + expect(await screen.findByRole('button', { name: 'Try again' })).toBeEnabled(); + }); + + test('given the compact form then the message and retry show without a heading', () => { + render( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'The budget window could not be calculated' + ); + expect(screen.getByText('The 2031 simulation failed.')).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx b/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx new file mode 100644 index 000000000..41c4c22b1 --- /dev/null +++ b/app/src/tests/unit/components/flagship/states/Skeletons.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import { CardGridSkeleton, SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; + +describe('SkeletonRegion', () => { + test('given a label then the region is a busy status named for what is loading', () => { + render( + + + + ); + + const region = screen.getByRole('status', { name: 'Loading the bill' }); + expect(region).toHaveAttribute('aria-busy', 'true'); + }); +}); + +describe('CardGridSkeleton', () => { + test('given a count then that many placeholder cards fill the grid', () => { + render(); + + const grid = screen.getByRole('status', { name: 'Loading bills' }); + expect(grid.children).toHaveLength(4); + }); + + test('given no count then a page of six cards stands in', () => { + render(); + + expect(screen.getByRole('status', { name: 'Loading your reforms' }).children).toHaveLength(6); + }); +}); diff --git a/app/src/tests/unit/constants/calculationDurations.test.ts b/app/src/tests/unit/constants/calculationDurations.test.ts new file mode 100644 index 000000000..eff679ef7 --- /dev/null +++ b/app/src/tests/unit/constants/calculationDurations.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + getDurationForCalcType, + getDurationForCountry, + getDurationForRegion, + HOUSEHOLD_DURATION_MS, + UK_REGION_SOCIETY_WIDE_DURATION_MS, + US_DISTRICT_SOCIETY_WIDE_DURATION_MS, + US_SOCIETY_WIDE_DURATION_MS, + US_STATE_SOCIETY_WIDE_DURATION_MS, +} from '@/constants/calculationDurations'; + +// The country estimates that legacy (v2) progress bars use +const THREE_MINUTES_MS = 3 * 60 * 1000; +const SIX_MINUTES_MS = 6 * 60 * 1000; + +describe('calculationDurations', () => { + describe('getDurationForCountry', () => { + it.each([ + ['us', SIX_MINUTES_MS], + ['uk', THREE_MINUTES_MS], + ['ca', SIX_MINUTES_MS], + ])('given %s then keeps the %i ms country estimate', (countryId, expected) => { + expect(getDurationForCountry(countryId)).toBe(expected); + }); + }); + + describe('getDurationForRegion', () => { + it.each([ + ['us', undefined, US_SOCIETY_WIDE_DURATION_MS], + ['us', 'us', US_SOCIETY_WIDE_DURATION_MS], + ['us', 'state/ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'place/CA-57000', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'congressional_district/CA-46', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ['uk', undefined, UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'uk', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'constituency/Sheffield Central', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'country/england', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ])('given %s and region %s then returns %i ms', (countryId, region, expected) => { + expect(getDurationForRegion(countryId, region)).toBe(expected); + }); + + it.each([ + ['ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us-ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['CA-12', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ['us-ca-12', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ])('given the legacy US region code %s then returns %i ms', (region, expected) => { + expect(getDurationForRegion('us', region)).toBe(expected); + }); + + it('given a US state then the estimate is shorter than a nationwide run', () => { + expect(getDurationForRegion('us', 'state/ny')).toBeLessThan(getDurationForRegion('us')); + }); + + it('given an unknown country then falls back to the country default', () => { + expect(getDurationForRegion('ca', 'state/ca')).toBe(getDurationForCountry('ca')); + }); + }); + + describe('getDurationForCalcType', () => { + it('given household then returns the household duration', () => { + expect(getDurationForCalcType('household', 'us')).toBe(HOUSEHOLD_DURATION_MS); + }); + + it('given society-wide then keeps the country estimate', () => { + expect(getDurationForCalcType('societyWide', 'uk')).toBe(THREE_MINUTES_MS); + }); + }); +}); diff --git a/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx b/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx index 89e1700f5..dfde07604 100644 --- a/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx +++ b/app/src/tests/unit/hooks/useAggregatedCalculationStatus.test.tsx @@ -3,10 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAggregatedCalculationStatus } from '@/hooks/useAggregatedCalculationStatus'; +import { useSyntheticProgress } from '@/hooks/useSyntheticProgress'; import { calculationKeys } from '@/libs/queryKeys'; -import { createTestMetadata, TEST_SIM_IDS } from '@/tests/fixtures/hooks/aggregatedStatusMocks'; +import { + createTestMetadata, + TEST_REPORT_IDS, + TEST_SIM_IDS, +} from '@/tests/fixtures/hooks/aggregatedStatusMocks'; +import { TEST_ESTIMATED_DURATIONS_MS } from '@/tests/fixtures/hooks/calculationHookFixtures'; import { mockCalcStatusComplete, + mockCalcStatusComputing, mockCalcStatusError, mockCalcStatusIdle, mockHouseholdResult, @@ -278,3 +285,86 @@ describe.skip('useAggregatedCalculationStatus', () => { }); }); }); + +// Outside the skipped suite above: the calculation IDs are one stable array +// and cached statuses never go stale, so the hook subscribes once and no +// observer fetches. +describe('useAggregatedCalculationStatus duration estimate', () => { + const CALC_IDS = [TEST_REPORT_IDS.REPORT_1, TEST_REPORT_IDS.REPORT_2]; + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const cachePendingRun = (calcId: string, estimatedDurationMs?: number) => { + queryClient.setQueryData( + calculationKeys.byReportId(calcId), + mockCalcStatusComputing({ + metadata: createTestMetadata(calcId, 'societyWide', 'report'), + ...(estimatedDurationMs !== undefined && { estimatedDurationMs }), + }) + ); + }; + + it('given two pending runs with different estimates then the bar follows the longer', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1, TEST_ESTIMATED_DURATIONS_MS.US_STATE); + cachePendingRun(TEST_REPORT_IDS.REPORT_2, TEST_ESTIMATED_DURATIONS_MS.US_DISTRICT); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_DISTRICT }) + ); + }); + + it('given only one pending run has an estimate then the bar follows it', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1); + cachePendingRun(TEST_REPORT_IDS.REPORT_2, TEST_ESTIMATED_DURATIONS_MS.US_STATE); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE }) + ); + }); + + it('given no pending run has an estimate then the default applies', () => { + // Given + cachePendingRun(TEST_REPORT_IDS.REPORT_1); + cachePendingRun(TEST_REPORT_IDS.REPORT_2); + + // When + renderHook(() => useAggregatedCalculationStatus(CALC_IDS, 'report'), { wrapper }); + + // Then + expect(useSyntheticProgress).toHaveBeenLastCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: undefined }) + ); + }); +}); diff --git a/app/src/tests/unit/hooks/useBillEconomy.test.tsx b/app/src/tests/unit/hooks/useBillEconomy.test.tsx index 98e9fc7cc..ffdf38ba6 100644 --- a/app/src/tests/unit/hooks/useBillEconomy.test.tsx +++ b/app/src/tests/unit/hooks/useBillEconomy.test.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; import { billRegion, useBillEconomy } from '@/hooks/useBillEconomy'; +import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const { mockCreatePolicy, mockFetchSocietyWide } = vi.hoisted(() => ({ @@ -106,6 +107,20 @@ describe('useBillEconomy', () => { expect(result.current.output).toBeNull(); }); + test('given a state bill then the run asks for the region estimate', async () => { + mockFetchSocietyWide.mockResolvedValue({ status: 'computing', result: null }); + const execute = vi.spyOn(SocietyWideCalcStrategy.prototype, 'execute'); + + renderHook(() => useBillEconomy(BILL), { wrapper }); + + await waitFor(() => expect(execute).toHaveBeenCalled()); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ region: 'state/ut', estimateByRegion: true }), + expect.anything() + ); + execute.mockRestore(); + }); + test('given the run fails then the error is reported', async () => { mockFetchSocietyWide.mockResolvedValue({ status: 'error', result: null, error: 'Worker lost' }); diff --git a/app/src/tests/unit/hooks/useCalculationStatus.test.tsx b/app/src/tests/unit/hooks/useCalculationStatus.test.tsx index 9e1ec97ee..9a9fd4d4d 100644 --- a/app/src/tests/unit/hooks/useCalculationStatus.test.tsx +++ b/app/src/tests/unit/hooks/useCalculationStatus.test.tsx @@ -4,10 +4,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useReportProgressDisplay } from '@/hooks/useReportProgressDisplay'; +import { useSyntheticProgress } from '@/hooks/useSyntheticProgress'; import { calculationKeys } from '@/libs/queryKeys'; import { createTestQueryClient, HOOK_TEST_CONSTANTS, + TEST_ESTIMATED_DURATIONS_MS, } from '@/tests/fixtures/hooks/calculationHookFixtures'; import { mockCalcStatusComplete, @@ -361,6 +363,69 @@ describe('useCalculationStatus', () => { }); }); + test('given a pending status with a region estimate then the bar follows it', async () => { + // Given + vi.mocked(useSyntheticProgress).mockClear(); + queryClient.setQueryData( + calculationKeys.byReportId(HOOK_TEST_CONSTANTS.TEST_REPORT_ID), + mockCalcStatusComputing({ + metadata: { + calcId: HOOK_TEST_CONSTANTS.TEST_REPORT_ID, + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }, + estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE, + }) + ); + + // When + renderHook(() => useCalculationStatus(HOOK_TEST_CONSTANTS.TEST_REPORT_ID, 'report'), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(useSyntheticProgress).toHaveBeenCalledWith( + true, + 'societyWide', + expect.objectContaining({ estimatedDurationMs: TEST_ESTIMATED_DURATIONS_MS.US_STATE }) + ); + }); + }); + + test('given a pending status without an estimate then the default applies', async () => { + // Given + vi.mocked(useSyntheticProgress).mockClear(); + queryClient.setQueryData( + calculationKeys.byReportId(HOOK_TEST_CONSTANTS.TEST_REPORT_ID), + mockCalcStatusComputing({ + metadata: { + calcId: HOOK_TEST_CONSTANTS.TEST_REPORT_ID, + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }, + }) + ); + + // When + const { result } = renderHook( + () => useCalculationStatus(HOOK_TEST_CONSTANTS.TEST_REPORT_ID, 'report'), + { wrapper } + ); + + // Then - every active call leaves the estimate unset + await waitFor(() => expect(result.current.isPending).toBe(true)); + const activeCalls = vi + .mocked(useSyntheticProgress) + .mock.calls.filter(([isActive]) => isActive); + expect(activeCalls.length).toBeGreaterThan(0); + activeCalls.forEach(([, , serverProgress]) => { + expect(serverProgress?.estimatedDurationMs).toBeUndefined(); + }); + }); + test('given household pending status then uses synthetic progress', async () => { // Given const calcStatus = mockCalcStatusComputing({ diff --git a/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx b/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx new file mode 100644 index 000000000..652148ee5 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipMetadataStatus.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + METADATA_ERROR_MESSAGE, + seedMetadataError, + seedMetadataLoading, +} from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useFlagshipMetadataStatus', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + mockFetchMetadata.mockReset(); + // A retried fetch stays in flight, so the status it leaves can be read. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); + }); + + test('given a fetch in flight then the status is loading', () => { + seedMetadataLoading(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('loading'); + expect(result.current.message).toBeNull(); + }); + + test('given a failed fetch then the status is error, with its message', () => { + seedMetadataError(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('error'); + expect(result.current.message).toBe(METADATA_ERROR_MESSAGE); + }); + + test('given the metadata arrived then the status is ready', () => { + seedDeductionMetadata(); + + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + expect(result.current.status).toBe('ready'); + }); + + test('given a failed fetch then retry asks for the country again', () => { + // Given + seedMetadataError(); + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // When + act(() => { + result.current.retry(); + }); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(result.current.status).toBe('loading'); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx new file mode 100644 index 000000000..34a051c79 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -0,0 +1,197 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { reportKeys } from '@/libs/queryKeys'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + BASELINE_SIMULATION_ID, + EXISTING_REPORT_ID, + EXISTING_REPORT_METADATA, + MISSING_REPORT_ID, + nationwideSimulationMetadata, + POLICY_UNAVAILABLE_ERROR, + REFORM_SIMULATION_ID, + REPORT_NOT_FOUND_ERROR, + REPORT_UNAVAILABLE_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; + +const mockFetchReportById = vi.fn(); +const mockFetchSimulationById = vi.fn(); +const mockFetchPolicyById = vi.fn(); + +vi.mock('@/api/report', async (importOriginal) => ({ + ...(await importOriginal()), + fetchReportById: (...args: unknown[]) => mockFetchReportById(...args), +})); + +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSimulationById: (...args: unknown[]) => mockFetchSimulationById(...args), +})); + +vi.mock('@/api/policy', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), +})); + +// Old association links are not under test here. +vi.mock('@/hooks/useUserReports', () => ({ + useUserReportById: () => ({ report: undefined, simulations: [], policies: [], isLoading: false }), +})); + +function renderReportHook(id: string) { + // No retry delay: a failed request that retries settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return { queryClient, ...renderHook(() => useFlagshipReport(id), { wrapper }) }; +} + +describe('useFlagshipReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + store.dispatch(clearMetadata()); + mockFetchReportById.mockResolvedValue(EXISTING_REPORT_METADATA); + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => + nationwideSimulationMetadata(id, id === BASELINE_SIMULATION_ID ? '2' : '98557') + ); + }); + + test('given a link to a report then it loads with no error', async () => { + const { result } = renderReportHook(EXISTING_REPORT_ID); + + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + expect(result.current.error).toBeNull(); + expect(result.current.notFound).toBe(false); + }); + + test('given a link that names no report then it is not found, asked for once', async () => { + // Given + mockFetchReportById.mockRejectedValue(REPORT_NOT_FOUND_ERROR); + + // When + const { result } = renderReportHook(MISSING_REPORT_ID); + + // Then + await waitFor(() => expect(result.current.notFound).toBe(true)); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(REPORT_NOT_FOUND_ERROR); + expect(mockFetchReportById).toHaveBeenCalledTimes(1); + }); + + test('given the report is in then a refetch that fails is not a failed load', async () => { + // Given + const { result, rerender, queryClient } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + mockFetchReportById.mockRejectedValue(REPORT_UNAVAILABLE_ERROR); + + // When — e.g. the reader comes back to the tab while the run goes on + await act(async () => { + await queryClient.refetchQueries({ queryKey: reportKeys.byId(EXISTING_REPORT_ID) }); + }); + // A render after the refetch reads the query as it now stands. + rerender(); + + // Then + expect(queryClient.getQueryState(reportKeys.byId(EXISTING_REPORT_ID))?.error).toBe( + REPORT_UNAVAILABLE_ERROR + ); + expect(result.current.error).toBeNull(); + expect(result.current.report).toBeDefined(); + }); + + test('given the report is in then a refetch that finds nothing does not make it not found', async () => { + // Given + const { result, rerender, queryClient } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + mockFetchReportById.mockRejectedValue(REPORT_NOT_FOUND_ERROR); + + // When + await act(async () => { + await queryClient.refetchQueries({ queryKey: reportKeys.byId(EXISTING_REPORT_ID) }); + }); + rerender(); + + // Then + expect(queryClient.getQueryState(reportKeys.byId(EXISTING_REPORT_ID))?.error).toBe( + REPORT_NOT_FOUND_ERROR + ); + expect(result.current.notFound).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test('given a simulation fails to load then the error says so, and the report is found', async () => { + // Given + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { + if (id === REFORM_SIMULATION_ID) { + throw SIMULATION_UNAVAILABLE_ERROR; + } + return nationwideSimulationMetadata(id, '2'); + }); + + // When + const { result } = renderReportHook(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(result.current.error).toBe(SIMULATION_UNAVAILABLE_ERROR)); + expect(result.current.notFound).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + + test('given a policy fails to load then the run is not held up by it', async () => { + // Given: policies load once the model's metadata is in. + seedDeductionMetadata(); + mockFetchPolicyById.mockRejectedValue(POLICY_UNAVAILABLE_ERROR); + + // When + const { result } = renderReportHook(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(mockFetchPolicyById).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.simulations).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); + + test('given a failed simulation then retry asks for it again and the error clears', async () => { + // Given + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => { + if (id === REFORM_SIMULATION_ID) { + throw SIMULATION_UNAVAILABLE_ERROR; + } + return nationwideSimulationMetadata(id, '2'); + }); + const { result } = renderReportHook(EXISTING_REPORT_ID); + await waitFor(() => expect(result.current.error).not.toBeNull()); + mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => + nationwideSimulationMetadata(id, id === BASELINE_SIMULATION_ID ? '2' : '98557') + ); + + // When + await act(async () => { + await result.current.retry(); + }); + + // Then + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.simulations).toHaveLength(2); + // Only the failed simulation is asked for again. + expect( + mockFetchSimulationById.mock.calls.filter(([, id]) => id === BASELINE_SIMULATION_ID) + ).toHaveLength(1); + }); +}); diff --git a/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx b/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx index aa9b9e23e..818e2ef1a 100644 --- a/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx +++ b/app/src/tests/unit/hooks/useSyntheticProgress.test.tsx @@ -217,6 +217,44 @@ describe('useSyntheticProgress', () => { ); }); + it('given a region duration estimate then progresses against it', () => { + // Given + const { result } = renderHook(() => + useSyntheticProgress(true, 'societyWide', { estimatedDurationMs: 110000 }) + ); + + // When - advance 55 seconds (half of 110 seconds) + act(() => { + vi.advanceTimersByTime(55000); + }); + + // Then + expect(result.current.progress).toBeGreaterThanOrEqual(49); + expect(result.current.progress).toBeLessThanOrEqual(51); + }); + + it('given the estimate arrives after start then the bar does not restart', () => { + // Given - no estimate yet, so the 6 minute default applies + const { result, rerender } = renderHook( + ({ estimate }: { estimate?: number }) => + useSyntheticProgress(true, 'societyWide', { estimatedDurationMs: estimate }), + { initialProps: { estimate: undefined as number | undefined } } + ); + act(() => { + vi.advanceTimersByTime(60000); + }); + + // When - the first status brings a 120 second estimate + rerender({ estimate: 120000 }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + // Then - 61 of 120 seconds have passed since the start, not 1 + expect(result.current.progress).toBeGreaterThanOrEqual(50); + expect(result.current.progress).toBeLessThanOrEqual(52); + }); + it('given full duration then caps at max progress', () => { // Given const { result } = renderHook(() => useSyntheticProgress(true, 'societyWide')); diff --git a/app/src/tests/unit/hooks/useTrackedBills.test.tsx b/app/src/tests/unit/hooks/useTrackedBills.test.tsx new file mode 100644 index 000000000..a9a6a94f3 --- /dev/null +++ b/app/src/tests/unit/hooks/useTrackedBills.test.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useTrackedBills } from '@/hooks/useTrackedBills'; +import { TRACKER_UNAVAILABLE_ERROR } from '@/tests/fixtures/hooks/useTrackedBillsMocks'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockFetchTrackerBills = vi.fn(); + +vi.mock('@/api/billFeed', async (importOriginal) => ({ + ...(await importOriginal()), + fetchTrackerBills: () => mockFetchTrackerBills(), +})); + +const FEED_KEY = ['tracker-bills']; + +function renderBillsHook() { + // No retry delay, so the feed's own retry settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, ...renderHook(() => useTrackedBills('us'), { wrapper }) }; +} + +describe('useTrackedBills', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given the feed fails before any bills are in then it is an error', async () => { + // Given + mockFetchTrackerBills.mockRejectedValue(TRACKER_UNAVAILABLE_ERROR); + + // When + const { result } = renderBillsHook(); + + // Then + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.bills).toEqual([]); + }); + + test('given bills are in then a refetch that fails keeps them, with no error', async () => { + // Given + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + const { result, rerender, queryClient } = renderBillsHook(); + await waitFor(() => expect(result.current.bills).toHaveLength(1)); + mockFetchTrackerBills.mockRejectedValue(TRACKER_UNAVAILABLE_ERROR); + + // When — e.g. the reader comes back to the tab after the feed went stale + await act(async () => { + await queryClient.refetchQueries({ queryKey: FEED_KEY }); + }); + // A render after the refetch reads the query as it now stands. + rerender(); + + // Then + expect(queryClient.getQueryState(FEED_KEY)?.error).toBe(TRACKER_UNAVAILABLE_ERROR); + expect(result.current.isError).toBe(false); + expect(result.current.bills).toEqual([TRACKED_BILL]); + }); +}); diff --git a/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts b/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts index c9ae6466b..2463caa0f 100644 --- a/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts +++ b/app/src/tests/unit/libs/calculations/CalcOrchestrator.test.ts @@ -278,6 +278,35 @@ describe('CalcOrchestrator', () => { }); }); + describe('duration estimate', () => { + const startAndReadParams = async (config: ReturnType) => { + const mockQueryFn = vi.fn().mockResolvedValue(mockPendingSocietyWideStatus()); + (calculationQueries.forReport as any).mockImplementation((calcId: string) => + mockSocietyWideQueryOptions(calcId, mockQueryFn) + ); + await orchestrator.startCalculation(config); + return (calculationQueries.forReport as any).mock.calls[0][2]; + }; + + it('given the default then the params leave the region estimate out', async () => { + // When + const params = await startAndReadParams(mockSocietyWideCalcConfig()); + + // Then - the params stay exactly as before region estimates + expect(params).not.toHaveProperty('estimateByRegion'); + }); + + it('given estimateByRegion then the params pass it to the strategy', async () => { + // When + const params = await startAndReadParams( + mockSocietyWideCalcConfig({ estimateByRegion: true }) + ); + + // Then + expect(params.estimateByRegion).toBe(true); + }); + }); + describe('cleanup', () => { it('given no active polling then cleanup does nothing', () => { // When diff --git a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts index 4ffcdf1e7..e12c5cd1b 100644 --- a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts @@ -1,4 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + UK_REGION_SOCIETY_WIDE_DURATION_MS, + US_DISTRICT_SOCIETY_WIDE_DURATION_MS, + US_SOCIETY_WIDE_DURATION_MS, + US_STATE_SOCIETY_WIDE_DURATION_MS, +} from '@/constants/calculationDurations'; import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { mockSocietyWideCompleteResponse, @@ -7,6 +13,19 @@ import { STRATEGY_TEST_CONSTANTS, } from '@/tests/fixtures/libs/calculations/strategyFixtures'; import { mockSocietyWideCalcParams } from '@/tests/fixtures/types/calculationFixtures'; +import type { CalcParams } from '@/types/calculation'; + +// The country estimates that legacy (v2) progress bars use +const THREE_MINUTES_MS = 3 * 60 * 1000; +const SIX_MINUTES_MS = 6 * 60 * 1000; + +/** Metadata for a society-wide report run that started `elapsedMs` ago. */ +const startedAgo = (elapsedMs: number) => ({ + calcId: 'test-report', + calcType: 'societyWide' as const, + targetType: 'report' as const, + startedAt: Date.now() - elapsedMs, +}); // Mock the societyWide API vi.mock('@/api/societyWideCalculation', () => ({ @@ -223,6 +242,85 @@ describe('SocietyWideCalcStrategy', () => { }); }); + describe('duration estimate', () => { + it.each<[CalcParams['countryId'], string, number]>([ + ['uk', 'uk', THREE_MINUTES_MS], + ['uk', 'country/england', THREE_MINUTES_MS], + ['us', 'us', SIX_MINUTES_MS], + ['us', 'state/ca', SIX_MINUTES_MS], + ['us', 'congressional_district/CA-46', SIX_MINUTES_MS], + ])( + 'given the default and a %s run on %s then progress follows the %i ms country estimate', + async (countryId, region, countryEstimateMs) => { + // Given - half of the country estimate has passed + const params = mockSocietyWideCalcParams({ countryId, region, populationId: region }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, startedAgo(countryEstimateMs / 2)); + + // Then - unchanged from before region estimates, with no estimate attached + expect(result.status).toBe('pending'); + expect(result.progress).toBeCloseTo(50, 0); + expect(result).not.toHaveProperty('estimatedDurationMs'); + } + ); + + it.each<[CalcParams['countryId'], string, number]>([ + ['uk', 'uk', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['uk', 'constituency/Sheffield Central', UK_REGION_SOCIETY_WIDE_DURATION_MS], + ['us', 'us', US_SOCIETY_WIDE_DURATION_MS], + ['us', 'state/ca', US_STATE_SOCIETY_WIDE_DURATION_MS], + ['us', 'congressional_district/CA-46', US_DISTRICT_SOCIETY_WIDE_DURATION_MS], + ])( + 'given estimateByRegion and a %s run on %s then progress follows the %i ms region estimate', + async (countryId, region, regionEstimateMs) => { + // Given - half of the region estimate has passed + const params = mockSocietyWideCalcParams({ + countryId, + region, + populationId: region, + estimateByRegion: true, + }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, startedAgo(regionEstimateMs / 2)); + + // Then + expect(result.status).toBe('pending'); + expect(result.progress).toBeCloseTo(50, 0); + expect(result.estimatedDurationMs).toBe(regionEstimateMs); + } + ); + + it('given estimateByRegion and no region then estimates a nationwide run', async () => { + // Given + const params = mockSocietyWideCalcParams({ region: undefined, estimateByRegion: true }); + mockFetchSocietyWideCalculation.mockResolvedValue(mockSocietyWideComputingResponse()); + + // When + const result = await strategy.execute(params, startedAgo(0)); + + // Then + expect(result.estimatedDurationMs).toBe(US_SOCIETY_WIDE_DURATION_MS); + }); + + it('given a region without estimateByRegion then the transform ignores the region', () => { + // When + const result = strategy.transformResponseWithMetadata( + mockSocietyWideComputingResponse(), + startedAgo(SIX_MINUTES_MS / 2), + 'us', + { region: 'state/ca' } + ); + + // Then + expect(result.progress).toBeCloseTo(50, 0); + expect(result).not.toHaveProperty('estimatedDurationMs'); + }); + }); + describe('getRefetchConfig', () => { it('given config requested then returns valid refetch configuration', () => { // When diff --git a/app/src/tests/unit/libs/flagship/billTracker.test.ts b/app/src/tests/unit/libs/flagship/billTracker.test.ts new file mode 100644 index 000000000..88b7d9080 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/billTracker.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest'; +import { + BILL_TRACKER_URL, + findTrackedBill, + trackerBillTitle, + trackerBillUrl, + trackerPlace, + trackerUrlForBillId, + withTrackerTitle, +} from '@/libs/flagship/billTracker'; +import { + FEED_IDS, + STATE_NAMES_SAMPLE, + TRACKER_HOME, + TRACKER_ROWS, +} from '@/tests/fixtures/libs/flagship/billTrackerMocks'; + +describe('trackerBillUrl', () => { + test('given the tracker mount then the home is policyengine.org/us/bill-tracker', () => { + expect(BILL_TRACKER_URL).toBe(TRACKER_HOME); + }); + + test('given a state bill then it links to /{state}/{id}, as the tracker routes it', () => { + expect(trackerBillUrl(TRACKER_ROWS.STATE_BILL)).toBe(`${TRACKER_HOME}/ks/ks-hb2620`); + }); + + test('given a federal bill then it links under /us', () => { + expect(trackerBillUrl(TRACKER_ROWS.FEDERAL_BILL)).toBe(`${TRACKER_HOME}/us/us-hr1425`); + expect(trackerBillUrl(TRACKER_ROWS.LEGACY_FEDERAL_BILL)).toBe(`${TRACKER_HOME}/us/us-hr7024`); + }); + + test('given a row with no bill page then it links to the state page or the home', () => { + expect(trackerBillUrl(TRACKER_ROWS.UNTYPED_STATE_ROW)).toBe(`${TRACKER_HOME}/ut`); + expect(trackerBillUrl(TRACKER_ROWS.STATE_BLOG)).toBe(`${TRACKER_HOME}/la`); + expect(trackerBillUrl(TRACKER_ROWS.IN_REVIEW)).toBe(`${TRACKER_HOME}/ga`); + expect(trackerBillUrl(TRACKER_ROWS.LEGACY_FEDERAL)).toBe(TRACKER_HOME); + expect(trackerBillUrl({ id: 'no-state', type: 'bill' })).toBe(TRACKER_HOME); + }); + + test('given an id with URL characters then the id is encoded', () => { + expect(trackerBillUrl({ id: 'sc-h 3492', state: 'SC', type: 'bill' })).toBe( + `${TRACKER_HOME}/sc/sc-h%203492` + ); + }); +}); + +describe('trackerUrlForBillId', () => { + test('given only an id then the place comes from its prefix', () => { + expect(trackerUrlForBillId('ks-hb2620')).toBe(`${TRACKER_HOME}/ks/ks-hb2620`); + expect(trackerUrlForBillId('US-HR1425')).toBe(`${TRACKER_HOME}/us/us-hr1425`); + expect(trackerUrlForBillId('obbba-explorer')).toBe(TRACKER_HOME); + }); +}); + +describe('trackerPlace', () => { + test('given a state code in any case then it reads as the state name', () => { + expect(trackerPlace('KS', STATE_NAMES_SAMPLE)).toBe('Kansas'); + expect(trackerPlace('dc', STATE_NAMES_SAMPLE)).toBe('District of Columbia'); + }); + + test('given the tracker federal codes or none then it reads "Federal"', () => { + for (const code of ['US', 'all', 'federal', '', undefined, null]) { + expect(trackerPlace(code, STATE_NAMES_SAMPLE)).toBe('Federal'); + } + }); + + test('given an unknown code then it reads as the code, upper-case', () => { + expect(trackerPlace('PR', STATE_NAMES_SAMPLE)).toBe('PR'); + expect(trackerPlace(' pr ', STATE_NAMES_SAMPLE)).toBe('PR'); + }); +}); + +describe('trackerBillTitle', () => { + test('given a title then it is the name', () => { + expect(trackerBillTitle({ id: 'ut-sb60', title: ' UT SB60: rate cut ' })).toBe( + 'UT SB60: rate cut' + ); + }); + + test('given no title then the name is the bill number the id carries', () => { + expect(trackerBillTitle({ id: 'ut-sb60', title: null })).toBe('SB60'); + expect(trackerBillTitle({ id: 'ga-hb463-2026' })).toBe('HB463-2026'); + expect(trackerBillTitle({ id: 'budget2026' })).toBe('BUDGET2026'); + }); +}); + +describe('withTrackerTitle', () => { + test('given a row then only its title changes, to the tracker name', () => { + const row = { ...TRACKER_ROWS.UNTYPED_STATE_ROW, title: null }; + + expect(withTrackerTitle(row)).toEqual({ ...row, title: 'SB60' }); + expect(withTrackerTitle(TRACKER_ROWS.STATE_BILL)).toEqual(TRACKER_ROWS.STATE_BILL); + }); +}); + +describe('findTrackedBill', () => { + test('given the tracker id then it finds the bill', () => { + expect(findTrackedBill(FEED_IDS, 'ks-hb2620')).toBe(FEED_IDS[0]); + }); + + test('given the id upper-case or encoded then it finds the same bill', () => { + expect(findTrackedBill(FEED_IDS, 'KS-HB2620')).toBe(FEED_IDS[0]); + expect(findTrackedBill(FEED_IDS, 'us%2Dhr1425')).toBe(FEED_IDS[1]); + }); + + test('given an unknown or malformed id then it finds nothing', () => { + expect(findTrackedBill(FEED_IDS, 'ks-hb9999')).toBeUndefined(); + expect(findTrackedBill(FEED_IDS, '%E0%A4%A')).toBeUndefined(); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/loadError.test.ts b/app/src/tests/unit/libs/flagship/loadError.test.ts new file mode 100644 index 000000000..acb06885d --- /dev/null +++ b/app/src/tests/unit/libs/flagship/loadError.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; +import { isNotFoundError, retryUnlessNotFound } from '@/libs/flagship/loadError'; +import { + REPORT_NOT_FOUND_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; + +describe('isNotFoundError', () => { + test('given a 404 status then the record is not there', () => { + expect(isNotFoundError(REPORT_NOT_FOUND_ERROR)).toBe(true); + }); + + test('given a 404 without a status text then the record is not there', () => { + expect(isNotFoundError(new Error('Failed to fetch report 12: 404 '))).toBe(true); + }); + + test('given the API says not found then the record is not there', () => { + expect(isNotFoundError(new Error('Report #12 not found'))).toBe(true); + }); + + test('given a server error then the request failed, not the record', () => { + expect(isNotFoundError(SIMULATION_UNAVAILABLE_ERROR)).toBe(false); + }); + + test('given a record id that reads 404 then a server error is still a failure', () => { + expect( + isNotFoundError(new Error('Failed to fetch report 404: 500 Internal Server Error')) + ).toBe(false); + }); + + test('given something that is not an error then it is not a missing record', () => { + expect(isNotFoundError(undefined)).toBe(false); + }); +}); + +describe('retryUnlessNotFound', () => { + test('given a missing record then it is not asked for again', () => { + expect(retryUnlessNotFound(0, REPORT_NOT_FOUND_ERROR)).toBe(false); + }); + + test('given a failed request then it is asked for again a few times', () => { + expect(retryUnlessNotFound(0, SIMULATION_UNAVAILABLE_ERROR)).toBe(true); + expect(retryUnlessNotFound(2, SIMULATION_UNAVAILABLE_ERROR)).toBe(true); + expect(retryUnlessNotFound(3, SIMULATION_UNAVAILABLE_ERROR)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/parameterGroups.test.ts b/app/src/tests/unit/libs/flagship/parameterGroups.test.ts index ed967c7e6..d84d26f41 100644 --- a/app/src/tests/unit/libs/flagship/parameterGroups.test.ts +++ b/app/src/tests/unit/libs/flagship/parameterGroups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { bracketFieldLabel, + groupKeyFromPath, humanizeSegment, parameterGroup, } from '@/libs/flagship/parameterGroups'; @@ -95,6 +96,28 @@ describe('parameterGroup', () => { }); }); +describe('groupKeyFromPath', () => { + test('given a grouped member then its key is the group key the index gives', () => { + for (const path of [ + 'gov.irs.deductions.standard.amount.SINGLE', + 'gov.irs.credits.eitc.max[1].amount', + 'gov.usda.snap.allotment.10', + ]) { + expect(groupKeyFromPath(path)).toBe(parameterGroup(path, PARAMETERS, DRAFTABLE)?.key); + } + }); + + test('given members of one setting then they share a key', () => { + expect(groupKeyFromPath('gov.irs.credits.eitc.max[0].threshold')).toBe( + groupKeyFromPath('gov.irs.credits.eitc.max[1].amount') + ); + }); + + test('given an ordinary parameter then its key is its own path', () => { + expect(groupKeyFromPath('gov.irs.credits.ctc.amount')).toBe('gov.irs.credits.ctc.amount'); + }); +}); + describe('humanizeSegment', () => { test('given a shouted enum then it reads as a phrase; sentence case stays', () => { expect(humanizeSegment('HEAD_OF_HOUSEHOLD')).toBe('Head of household'); diff --git a/app/src/tests/unit/libs/flagship/shellRoutes.test.ts b/app/src/tests/unit/libs/flagship/shellRoutes.test.ts new file mode 100644 index 000000000..80e9483dd --- /dev/null +++ b/app/src/tests/unit/libs/flagship/shellRoutes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; +import { isFlagshipPagePath } from '@/libs/flagship/shellRoutes'; + +describe('isFlagshipPagePath', () => { + test.each([ + '/us/build', + '/uk/build/', + '/us/reforms', + '/us/reforms?filter=yours', + '/us/ask', + '/us/report/abc123', + '/us/report/bill/hr-1', + ])('given the v3 page %s then it is a flagship page', (path) => { + expect(isFlagshipPagePath(path)).toBe(true); + }); + + test.each([ + '/us', + '/us/report-output/abc123', + '/us/report-output/abc123/overview', + '/us/reports', + '/us/reports/create', + '/us/simulations', + '/us/policies', + '/us/households', + '/us/report', + '/us/buildings', + ])('given the legacy or other page %s then it is not a flagship page', (path) => { + expect(isFlagshipPagePath(path)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReport.test.tsx b/app/src/tests/unit/pages/flagship/BillReport.test.tsx index 52e6bcb69..ef9423421 100644 --- a/app/src/tests/unit/pages/flagship/BillReport.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReport.test.tsx @@ -1,13 +1,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, within } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { BillEconomy } from '@/hooks/useBillEconomy'; import BillReportPage from '@/pages/flagship/BillReport.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; -import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; +import { TRACKED_BILL, UNLISTED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockCalibrationMatchesForPaths = vi.fn(); @@ -52,6 +53,12 @@ vi.mock('@/libs/flagship/billMetrics', async (importOriginal) => { }); const mockUseBillEconomy = vi.fn(); +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); vi.mock('@/hooks/useBillEconomy', () => ({ useBillEconomy: (...args: unknown[]) => mockUseBillEconomy(...args), @@ -97,6 +104,10 @@ describe('BillReportPage', () => { store.dispatch(clearMetadata()); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + test('given a bill then it uses the saved report sections', async () => { renderReport(); @@ -165,6 +176,33 @@ describe('BillReportPage', () => { ).toBeGreaterThanOrEqual(1); }); + test('given a tracked bill then the overview links to its tracker page', async () => { + renderReport(); + + const link = await screen.findByRole('link', { name: /bill tracker/i }); + expect(link).toHaveAttribute('href', TRACKED_BILL.trackerUrl); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('given the tracker id in upper case then the same report opens', async () => { + renderReport('US-HR1425'); + + expect( + await screen.findByRole('heading', { level: 1, name: TRACKED_BILL.title }) + ).toBeInTheDocument(); + }); + + test('given a bill not in the feed then it links to that bill in the tracker', async () => { + mockFetchTrackerBills.mockResolvedValue([]); + renderReport(UNLISTED_BILL.id); + + expect(await screen.findByText('This bill is not in the feed.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /bill tracker/i })).toHaveAttribute( + 'href', + UNLISTED_BILL.trackerUrl + ); + }); + test('given the full run is calculating then economic impacts show its progress', async () => { const user = userEvent.setup(); mockUseBillEconomy.mockReturnValue(economy({ message: 'In queue (position 2)...' })); @@ -215,7 +253,7 @@ describe('BillReportPage', () => { await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); await user.click(screen.getByRole('button', { name: 'Load full results' })); - expect(screen.getByText('The full results could not be calculated.')).toBeInTheDocument(); + expect(screen.getByText('The full results could not be calculated')).toBeInTheDocument(); expect(screen.getByText('Worker lost')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Try again' })); @@ -233,7 +271,10 @@ describe('BillReportPage', () => { await user.click(await screen.findByRole('tab', { name: 'Validation' })); - expect(screen.getByText('External checks for this bill')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { level: 3, name: 'External estimates' }) + ).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Estimates for this bill' })).toBeInTheDocument(); expect(screen.getByText('-$225.5B')).toBeInTheDocument(); expect(screen.getByText('CRFB')).toBeInTheDocument(); expect(screen.getByText(/later effective date/)).toBeInTheDocument(); @@ -244,6 +285,29 @@ describe('BillReportPage', () => { ).toBeInTheDocument(); }); + test('given a bill without external checks then validation says no estimate is available', async () => { + const user = userEvent.setup(); + // Each request gets its own body: the scorecard feeds answer with no rows. + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockFetchTrackerBills.mockResolvedValue([{ ...TRACKED_BILL, validation: undefined }]); + renderReport(); + + await user.click(await screen.findByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect(within(panel).queryByText(/fiscal-note range/)).not.toBeInTheDocument(); + // The tracker's provenance still says where the stored estimate came from. + expect( + within(panel).getByText(/^Tracker estimate: policyengine-us 1\.729\.3/) + ).toBeInTheDocument(); + }); + test('given a state bill then its data check runs against that state and says so', async () => { const user = userEvent.setup(); const SNAP_STANDARD_DEDUCTION_PATH = 'gov.usda.snap.income.deductions.standard'; @@ -485,6 +549,49 @@ describe('BillReportPage', () => { test('given an unknown bill then says it is not in the feed', async () => { renderReport('missing-bill'); - expect(await screen.findByText('This bill is not in the current feed.')).toBeInTheDocument(); + expect(await screen.findByText('This bill is not in the feed.')).toBeInTheDocument(); + }); + + test('given the feed is loading then the report placeholder holds its place', () => { + mockFetchTrackerBills.mockReturnValue(new Promise(() => {})); + + renderReport(); + + expect(screen.getByRole('status', { name: 'Loading the bill' })).toBeInTheDocument(); + }); + + test('given the feed failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + mockFetchTrackerBills.mockRejectedValue(new Error('tracker down')); + renderReport(); + // The feed retries once before failing, so give it past the retry delay. + await screen.findByText('Could not load the bill feed', {}, { timeout: 5000 }); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect( + await screen.findByRole('heading', { level: 1, name: TRACKED_BILL.title }) + ).toBeInTheDocument(); + }); + + test('given the model metadata failed then the full results say so and ask for it again', async () => { + // Given + const user = userEvent.setup(); + mockFetchMetadata.mockReturnValue(new Promise(() => {})); + seedMetadataError(); + renderReport(); + await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx index 8e2929da5..9873d541a 100644 --- a/app/src/tests/unit/pages/flagship/Build.test.tsx +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -1,11 +1,23 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { act, render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { addDraftProvision, clearDraftReform, startDraftReform } from '@/libs/draftReform'; import BuildPage from '@/pages/flagship/Build.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; -import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { + JOINT_PATH, + seedDeductionMetadata, + SINGLE_PATH, +} from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +const mockFetchMetadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); const CTC = { path: 'gov.irs.credits.ctc.amount.base', @@ -27,6 +39,9 @@ function renderPage() { describe('BuildPage', () => { beforeEach(() => { clearDraftReform(); + mockFetchMetadata.mockReset(); + // A retried fetch stays in flight: the page shows its loading state. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); }); afterEach(() => { @@ -39,7 +54,7 @@ describe('BuildPage', () => { // Then expect(screen.getByRole('heading', { name: 'Build a reform' })).toBeInTheDocument(); - expect(screen.getAllByText('Loading parameters…')).toHaveLength(1); + expect(screen.getAllByRole('status', { name: 'Loading parameters' })).toHaveLength(1); expect(screen.queryByRole('button', { name: 'Rename the reform' })).not.toBeInTheDocument(); }); @@ -72,7 +87,71 @@ describe('BuildPage', () => { // Then expect(screen.getByRole('button', { name: 'Rename the reform' })).toBeInTheDocument(); - expect(screen.getAllByText('Loading parameters…')).toHaveLength(1); + const loading = screen.getAllByRole('status', { name: 'Loading parameters' }); + expect(loading).toHaveLength(1); + // The placeholder is the table's shape, so the table takes its place without a jump. + expect(loading[0]).toHaveTextContent('New value'); expect(screen.queryByRole('button', { name: /open base amount/i })).not.toBeInTheDocument(); }); + + test('given breakdown members before metadata loads then the placeholder has the table rows', () => { + // Given — two filing statuses of one deduction share a row in the table + startDraftReform('us', 'manual'); + addDraftProvision('us', CTC); + for (const path of [SINGLE_PATH, JOINT_PATH]) { + addDraftProvision('us', { ...CTC, path, breadcrumb: 'IRS → Deductions → Standard' }); + } + renderPage(); + const placeholder = screen.getByRole('status', { name: 'Loading parameters' }); + // The header, a row per table row, and the add bar at the foot. + const placeholderRows = placeholder.firstElementChild!.children.length - 2; + + // When + act(() => seedDeductionMetadata()); + + // Then + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(placeholderRows); + expect(placeholderRows).toBe(2); + }); + + test('given metadata failed then the page says so instead of loading for good', () => { + // Given + seedMetadataError(); + + // When + renderPage(); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + expect(screen.queryByRole('status', { name: 'Loading parameters' })).not.toBeInTheDocument(); + }); + + test('given metadata failed then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + seedMetadataError(); + renderPage(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(await screen.findByRole('status', { name: 'Loading parameters' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given a draft and metadata failed then the error stands in for the table', () => { + // Given + startDraftReform('us', 'manual'); + addDraftProvision('us', CTC); + seedMetadataError(); + + // When + renderPage(); + + // Then + expect(screen.getByRole('button', { name: 'Rename the reform' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + }); }); diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index f321fd66c..7d1e58e88 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -202,6 +202,35 @@ describe('ReformsPage', () => { expect(screen.queryByRole('button', { name: /open as draft reform/i })).not.toBeInTheDocument(); }); + test('given US reforms then the header links to the bill tracker home', async () => { + renderReforms(); + + const link = await screen.findByRole('link', { name: /bill tracker/i }); + expect(link).toHaveAttribute('href', 'https://policyengine.org/us/bill-tracker'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + test('given UK reforms then the header has no bill tracker link', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderWithCountry( + + + , + 'uk', + '/uk/reforms' + ); + + expect(await screen.findByRole('heading', { level: 1, name: 'Reforms' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /bill tracker/i })).not.toBeInTheDocument(); + }); + + test('given a narrow screen then the header actions can wrap under the title', async () => { + renderReforms(); + + const title = await screen.findByRole('heading', { level: 1, name: 'Reforms' }); + expect(title.parentElement).toHaveStyle({ flexWrap: 'wrap' }); + }); + test('given a bill opens then no place-and-status line sits over its title', async () => { const user = userEvent.setup(); renderReforms(); diff --git a/app/src/tests/unit/pages/flagship/ReformsStates.test.tsx b/app/src/tests/unit/pages/flagship/ReformsStates.test.tsx new file mode 100644 index 000000000..2b3869e1d --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReformsStates.test.tsx @@ -0,0 +1,194 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { clearDraftReform } from '@/libs/draftReform'; +import ReformsPage from '@/pages/flagship/Reforms.page'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; +import type { Reform } from '@/types/ingredients/Reform'; + +const mockFindMine = vi.fn(); + +vi.mock('@/api/reformStore', async (importOriginal) => ({ + ...(await importOriginal()), + getReformStore: () => ({ + findMine: mockFindMine, + delete: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }), +})); + +vi.mock('@/api/flagshipReportStore', async (importOriginal) => ({ + ...(await importOriginal()), + getFlagshipReportStore: () => ({ findMine: async () => [] }), +})); + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: vi.fn(), isRunning: false, error: null }), +})); + +const mockFetchTrackerBills = vi.fn(); + +vi.mock('@/api/billFeed', async (importOriginal) => ({ + ...(await importOriginal()), + fetchTrackerBills: () => mockFetchTrackerBills(), +})); + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => mockNavigate, + useParams: () => ({ countryId: 'us' }), +})); + +const SAVED_REFORM: Reform = { + id: 'rf-1', + countryId: 'us', + label: 'CTC to $3,600 for children under 6', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base[0].amount', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1' }, + updatedAt: '2026-08-01T00:00:00Z', +}; + +/** A search that matches no bill and no reform. */ +const NO_MATCH_QUERY = 'zzzz quantum flux'; + +function renderReforms() { + // No retry delay, so the feed's own retry settles at once. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + return render( + + + + ); +} + +async function openYours(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Yours' })); +} + +describe('ReformsPage states', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearDraftReform(); + mockFindMine.mockResolvedValue([]); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + }); + + test('given the bill feed is loading then card placeholders hold the grid', () => { + mockFetchTrackerBills.mockReturnValue(new Promise(() => {})); + + renderReforms(); + + expect(screen.getByRole('status', { name: 'Loading bills' })).toBeInTheDocument(); + }); + + test('given saved reforms are loading then card placeholders hold the grid', async () => { + const user = userEvent.setup(); + mockFindMine.mockReturnValue(new Promise(() => {})); + renderReforms(); + + await openYours(user); + + expect(screen.getByRole('status', { name: 'Loading your reforms' })).toBeInTheDocument(); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + }); + + test('given the bill feed failed then try again brings the bills', async () => { + // Given + const user = userEvent.setup(); + mockFetchTrackerBills.mockRejectedValue(new Error('tracker down')); + renderReforms(); + await screen.findByRole('alert'); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(TRACKED_BILL.title)).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given saved reforms failed to load then it says so instead of an empty list', async () => { + const user = userEvent.setup(); + mockFindMine.mockRejectedValue(new Error('store down')); + renderReforms(); + + await openYours(user); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load your reforms'); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + }); + + test('given saved reforms failed then try again lists them', async () => { + // Given + const user = userEvent.setup(); + mockFindMine.mockRejectedValue(new Error('store down')); + renderReforms(); + await openYours(user); + await screen.findByRole('alert'); + mockFindMine.mockResolvedValue([SAVED_REFORM]); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(SAVED_REFORM.label!)).toBeInTheDocument(); + }); + + test('given no saved reforms then build a reform opens Build', async () => { + const user = userEvent.setup(); + renderReforms(); + await openYours(user); + + await user.click(await screen.findByRole('button', { name: 'Build a reform' })); + + expect(screen.getByText('No saved reforms yet.')).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith('/us/build'); + }); + + test('given a search that hides every saved reform then clear search brings them back', async () => { + // Given + const user = userEvent.setup(); + mockFindMine.mockResolvedValue([SAVED_REFORM]); + renderReforms(); + await openYours(user); + await screen.findByText(SAVED_REFORM.label!); + await user.type(screen.getByRole('textbox', { name: 'Search reforms' }), NO_MATCH_QUERY); + expect(screen.getByText('No reforms match.')).toBeInTheDocument(); + expect(screen.queryByText('No saved reforms yet.')).not.toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + // Then + expect(screen.getByText(SAVED_REFORM.label!)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Search reforms' })).toHaveValue(''); + }); + + test('given a search that hides every bill then clear filters brings them back', async () => { + // Given + const user = userEvent.setup(); + renderReforms(); + await screen.findByText(TRACKED_BILL.title); + await user.type(screen.getByRole('textbox', { name: 'Search reforms' }), NO_MATCH_QUERY); + expect(screen.getByText('No bills match.')).toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + + // Then + expect(screen.getByText(TRACKED_BILL.title)).toBeInTheDocument(); + expect(screen.queryByText('No bills match.')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 240b0d6c0..5c5401d20 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -1,12 +1,15 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, userEvent, within } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; import type { FlagshipReportMeta } from '@/libs/flagship/runReport'; import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { scorecardClaim } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockCalcStatus = vi.fn(); +// The report's policies; a baseline policy with parameters is a custom baseline. +const mockPolicies = vi.hoisted(() => ({ current: [] as unknown[] })); const META: FlagshipReportMeta = { title: 'Test Reform', @@ -23,6 +26,7 @@ const META: FlagshipReportMeta = { ], }; let mockMeta: FlagshipReportMeta = META; +const mockStartCalculationOnLoad = vi.fn(); vi.mock('@/hooks/useFlagshipReport', () => ({ isApiReportId: () => true, @@ -32,7 +36,7 @@ vi.mock('@/hooks/useFlagshipReport', () => ({ { id: 's1', populationId: 'us', policyId: '2' }, { id: 's2', populationId: 'us', policyId: '9' }, ], - policies: [], + policies: mockPolicies.current, isLoading: false, }), })); @@ -42,7 +46,7 @@ vi.mock('@/hooks/useCalculationStatus', () => ({ })); vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ - useStartCalculationOnLoad: () => undefined, + useStartCalculationOnLoad: (args: unknown) => mockStartCalculationOnLoad(args), })); vi.mock('@/hooks/useReportValidationSnapshot', () => ({ @@ -86,6 +90,30 @@ describe('FlagshipReportPage', () => { beforeEach(() => { mockCalcStatus.mockReset(); mockMeta = META; + mockPolicies.current = []; + mockStartCalculationOnLoad.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given the report runs then it asks for the region estimate', () => { + mockCalcStatus.mockReturnValue({ + status: 'pending', + isComplete: false, + isError: false, + progress: 0, + result: null, + }); + + renderPage(); + + expect(mockStartCalculationOnLoad).toHaveBeenCalledWith( + expect.objectContaining({ + configs: [expect.objectContaining({ calcId: 'r1', estimateByRegion: true })], + }) + ); }); test('given the run is still calculating then the page is the wait, without the adjust panel', () => { @@ -209,4 +237,93 @@ describe('FlagshipReportPage', () => { clearDraftReform(); }); }); + + test('given a finished custom report then validation shows both checks and says no external estimate exists', async () => { + const user = userEvent.setup(); + // The scorecard links no published score to this reform. + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByText('No external estimate is available for this reform.') + ).toBeInTheDocument(); + expect( + within(panel) + .getAllByRole('heading', { level: 3 }) + .map((heading) => heading.textContent) + ).toEqual(['External estimates', 'Data calibration', 'Baseline comparisons']); + }); + + const COMPLETE = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }; + + test('given a finished custom report with a related published score then validation shows it', async () => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + built: '2026-08-29', + rows: [{ ...scorecardClaim(), matchBasis: 'parameter' }], + unmappedCount: 0, + }) + ) + ); + mockCalcStatus.mockReturnValue(COMPLETE); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + await within(panel).findByRole('table', { name: 'Related reforms' }) + ).toBeInTheDocument(); + expect(within(panel).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); + expect( + within(panel).queryByText('No external estimate is available for this reform.') + ).not.toBeInTheDocument(); + }); + + test('given a finished report against a saved reform then the scorecard is not comparable', async () => { + const user = userEvent.setup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ built: null, rows: [], unmappedCount: 0 })) + ); + mockPolicies.current = [ + { + id: '2', + label: 'Smaller CTC', + parameters: [{ name: 'gov.irs.credits.ctc.refundable.individual_max', values: [] }], + }, + ]; + mockCalcStatus.mockReturnValue(COMPLETE); + + renderPage(); + await user.click(screen.getByRole('tab', { name: 'Validation' })); + + const panel = screen.getByRole('tabpanel'); + expect( + within(panel).getByText( + 'External estimates assume current law. This report compares with Smaller CTC.' + ) + ).toBeInTheDocument(); + expect(within(panel).getAllByText('Not comparable')).toHaveLength(2); + }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx new file mode 100644 index 000000000..3b315ab2c --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; +import { + EXISTING_REPORT, + EXISTING_REPORT_ID, + EXISTING_SIMULATIONS, + MISSING_REPORT_ID, + REPORT_NOT_FOUND_ERROR, + SIMULATION_UNAVAILABLE_ERROR, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; + +const mockUseFlagshipReport = vi.fn(); +const mockRetry = vi.fn(); +const mockCalcStatus = vi.fn(); +const mockFetchMetadata = vi.fn(); + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => mockUseFlagshipReport(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchMetadata(...args), +})); + +const IDLE_STATUS = { status: 'idle', isComplete: false, isError: false, result: null }; +const PENDING_STATUS = { ...IDLE_STATUS, status: 'pending', progress: 20 }; + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +/** What the report hook returns before anything has loaded. */ +function reportData(overrides: Partial = {}): FlagshipReportData { + return { + report: undefined, + simulations: [], + policies: [], + isLoading: false, + error: null, + notFound: false, + retry: mockRetry, + ...overrides, + }; +} + +function renderPage(userReportId: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage load states', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Cleared before each test, not after: the last page is still mounted then. + store.dispatch(clearMetadata()); + mockCalcStatus.mockReturnValue(IDLE_STATUS); + // A retried metadata fetch stays in flight. + mockFetchMetadata.mockReturnValue(new Promise(() => {})); + }); + + test('given the report is loading then a spinner holds the wait', () => { + mockUseFlagshipReport.mockReturnValue(reportData({ isLoading: true })); + + renderPage(EXISTING_REPORT_ID); + + // One status, the spinner's own: no live region around it. + expect(screen.getAllByRole('status')).toEqual([ + screen.getByRole('status', { name: 'Loading report' }), + ]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the run is calculating then the run card is the one status', () => { + mockCalcStatus.mockReturnValue(PENDING_STATUS); + mockUseFlagshipReport.mockReturnValue( + reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + ); + + renderPage(EXISTING_REPORT_ID); + + const [status, ...others] = screen.getAllByRole('status'); + expect(status).toHaveTextContent('Calculating the nationwide results'); + expect(others).toHaveLength(0); + }); + + test('given the results are in and the parameters failed then the overview says so and asks again', async () => { + // Given + const user = userEvent.setup(); + seedMetadataError(); + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + mockUseFlagshipReport.mockReturnValue( + reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + ); + renderPage(EXISTING_REPORT_ID); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(mockFetchMetadata).toHaveBeenCalledWith('us'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given a link that names no report then it says not found, with no retry', () => { + mockUseFlagshipReport.mockReturnValue( + reportData({ error: REPORT_NOT_FOUND_ERROR, notFound: true }) + ); + + renderPage(MISSING_REPORT_ID); + + expect(screen.getByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status', { name: 'Loading report' })).not.toBeInTheDocument(); + }); + + test('given the report failed to load then try again asks for it again', async () => { + // Given + const user = userEvent.setup(); + mockUseFlagshipReport.mockReturnValue(reportData({ error: SIMULATION_UNAVAILABLE_ERROR })); + renderPage(EXISTING_REPORT_ID); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('heading', { name: 'Could not load the report' })).toBeInTheDocument(); + expect(mockRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/types/calculation/CalcParams.ts b/app/src/types/calculation/CalcParams.ts index 572a2236b..94024c4d4 100644 --- a/app/src/types/calculation/CalcParams.ts +++ b/app/src/types/calculation/CalcParams.ts @@ -40,6 +40,14 @@ export interface CalcParams { */ region?: string; + /** + * Estimate a society-wide run's duration from its region (optional, v3) + * When true, the strategy uses getDurationForRegion and returns + * estimatedDurationMs on pending statuses. Off by default: the country + * estimate applies and no estimatedDurationMs is set. + */ + estimateByRegion?: boolean; + /** * Year for the calculation (e.g., '2025') * Used to specify which year's policy rules to apply diff --git a/app/src/types/calculation/CalcStartConfig.ts b/app/src/types/calculation/CalcStartConfig.ts index 5fc238f63..8051e25cf 100644 --- a/app/src/types/calculation/CalcStartConfig.ts +++ b/app/src/types/calculation/CalcStartConfig.ts @@ -51,4 +51,10 @@ export interface CalcStartConfig { * Used to mark the parent report as complete after all simulations finish */ reportId?: string; + + /** + * Estimate a society-wide run's duration from its region (optional, v3) + * Passed through to CalcParams.estimateByRegion. Off by default. + */ + estimateByRegion?: boolean; } diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index 1f30dcb39..c675fc1b6 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -87,6 +87,13 @@ export interface CalcStatus { */ queuePosition?: number; + /** + * Typical duration of this calculation in milliseconds, for time-based + * progress. Set only for pending society-wide calculations whose caller + * opted into the region estimate (CalcParams.estimateByRegion, v3). + */ + estimatedDurationMs?: number; + /** * The calculation result (only present when status is 'complete') */