diff --git a/app/src/api/publicApi/catalogParameters.ts b/app/src/api/publicApi/catalogParameters.ts index 28c195271..5f9b850f5 100644 --- a/app/src/api/publicApi/catalogParameters.ts +++ b/app/src/api/publicApi/catalogParameters.ts @@ -4,11 +4,10 @@ import { requireResult, type PublicApiClient, } from '@/api/publicApi/client'; -import { parameterValueFromApi } from '@/api/publicApi/jsonValues'; +import { v1DatedValues } from '@/api/publicApi/datedValues'; import type { components } from '@/api/publicApi/schema'; import { v1FallbackLabel } from '@/libs/flagship/v2ParameterSearch'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; -import type { ValuesList } from '@/types/subIngredients/valueInterval'; /** * Parameters fetched by name from the Public API's @@ -32,22 +31,6 @@ export interface LoadedCatalogParameters { missingNames: string[]; } -/** v1 dated a value in force since the earliest date 0000-01-01; Python's earliest is year 1. */ -const V2_EARLIEST_DATE = '0001-01-01'; -const V1_EARLIEST_DATE = '0000-01-01'; - -function v1Values(parameterName: string, values: Schemas['MetadataDatedValue'][]): ValuesList { - return Object.fromEntries( - values.map((dated) => { - const date = dated.start_date.slice(0, 10); - return [ - date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, - parameterValueFromApi(dated.value, parameterName), - ]; - }) - ); -} - function addResult( loaded: LoadedCatalogParameters, result: Schemas['MetadataCatalogParametersResult'] @@ -66,7 +49,7 @@ function addResult( label: parameter.label ?? v1FallbackLabel(parameter.name), description: parameter.description, unit: parameter.unit, - values: v1Values(parameter.name, parameter.values), + values: v1DatedValues(parameter.name, parameter.values), economy: parameter.economy ?? true, household: parameter.household ?? true, }; diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts new file mode 100644 index 000000000..9c4f61f53 --- /dev/null +++ b/app/src/api/publicApi/catalogVariables.ts @@ -0,0 +1,148 @@ +import { + createPublicApiClient, + publicApiCountryId, + requireResult, + type PublicApiClient, +} from '@/api/publicApi/client'; +import { v1DatedValues } from '@/api/publicApi/datedValues'; +import type { components } from '@/api/publicApi/schema'; +import type { ValuesList } from '@/types/subIngredients/valueInterval'; + +/** + * Every variable of a catalog version, from the Public API's + * `GET /v2/model-catalog/variables`, in the shape v1's + * `GET /{country}/metadata` gave each variable, so the household builder, + * its variable picker and the household breakdown read them unchanged. + * + * Where a variable's `adds` or `subtracts` names a parameter, v1 gave the + * parameter's path as the value, and the breakdown read that parameter + * from the whole parameter list for the report's year. On v2 the path is + * kept the same way, and the parameter's dated values come with the + * variables (`part_parameter_values`), so the breakdown never needs the + * whole parameter list. + * + * The catalog does not publish a variable's category or position in its + * module, so those stay unset. + */ + +type Schemas = components['schemas']; + +export interface LoadedCatalogVariables { + /** Each variable by name, as v1's metadata described it. */ + variables: Record; + /** The dated values of each parameter a variable's adds or subtracts names. */ + partParameterValues: Record; +} + +/** The fields of a v1 metadata variable that the catalog publishes. */ +export interface V1ShapedVariable { + name: string; + label: string; + entity: string; + documentation: string | null; + valueType: string | null; + /** Such as 'currency-USD' or '/1'; null for a variable without one, as on v1. */ + unit: string | null; + /** 'year', 'month', 'day' or 'eternity'; null for versions before catalog format 2. */ + definitionPeriod: string | null; + defaultValue: unknown; + /** A list of variable names, or the path of a parameter whose value is that list. */ + adds: string[] | string | null; + subtracts: string[] | string | null; + isInputVariable: boolean; + hidden_input: boolean; + moduleName: string | null; + possibleValues?: Array<{ value: string; label: Schemas['MetadataEnumValue']['label'] }>; +} + +/** + * v1's type name for a variable. The catalog stores the Python type's name, + * and .py publishes every Enum variable as "str" with its values, so a + * variable with enum values is v1's "Enum". + */ +function v1ValueType(variable: Schemas['MetadataVariable']): string | null { + return variable.enum_values ? 'Enum' : variable.data_type; +} + +/** + * v1 gave a default only for numbers, yes-or-no values and enums (the + * default value's name); a text or date variable had none. + */ +function v1DefaultValue(valueType: string | null, defaultValue: unknown): unknown { + if (valueType === 'Enum') { + return defaultValue; + } + return typeof defaultValue === 'number' || typeof defaultValue === 'boolean' + ? defaultValue + : null; +} + +export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1ShapedVariable { + const valueType = v1ValueType(variable); + const shaped: V1ShapedVariable = { + name: variable.name, + // v1 always had a label; a variable without one reads as its name. + label: variable.label ?? variable.name, + entity: variable.entity, + documentation: variable.description, + valueType, + unit: variable.unit, + definitionPeriod: variable.definition_period, + defaultValue: v1DefaultValue(valueType, variable.default_value), + adds: variable.adds_parameter_path ?? variable.adds, + subtracts: variable.subtracts_parameter_path ?? variable.subtracts, + isInputVariable: variable.is_input_variable ?? false, + hidden_input: variable.hidden_input ?? false, + moduleName: variable.module_name ?? null, + }; + if (variable.enum_values) { + shaped.possibleValues = variable.enum_values.map((enumValue) => ({ + value: enumValue.value, + label: enumValue.label, + })); + } + return shaped; +} + +/** The catalog format whose variables carry the input flags, enum labels, modules and parts v3 reads. */ +const CATALOG_FORMAT_WITH_VARIABLE_FIELDS = 2; + +/** + * All of a catalog version's variables, with the values their + * parameter-named parts read. A version published before catalog format 2 + * fails, rather than giving variables without what v3 reads. + */ +export async function fetchCatalogVariables( + countryId: string, + policyengineVersion: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const result = await requireResult( + client.GET('/v2/model-catalog/variables', { + params: { + query: { + country_id: publicApiCountryId(countryId), + policyengine_version: policyengineVersion, + }, + }, + }) + ); + if (result.catalog_format !== CATALOG_FORMAT_WITH_VARIABLE_FIELDS) { + // Without format 2's fields there are no input flags, enum labels or + // parameter-named parts, so v3's builder and breakdown cannot use them. + throw new Error( + `The catalog version ${result.policyengine_version} was published before catalog format 2, so it has no variable details v3 can use.` + ); + } + return { + variables: Object.fromEntries( + result.variables.map((variable) => [variable.name, v1ShapedVariable(variable)]) + ), + partParameterValues: Object.fromEntries( + Object.entries(result.part_parameter_values).map(([path, values]) => [ + path, + v1DatedValues(path, values), + ]) + ), + }; +} diff --git a/app/src/api/publicApi/datedValues.ts b/app/src/api/publicApi/datedValues.ts new file mode 100644 index 000000000..e977992a0 --- /dev/null +++ b/app/src/api/publicApi/datedValues.ts @@ -0,0 +1,29 @@ +import { parameterValueFromApi } from '@/api/publicApi/jsonValues'; +import type { components } from '@/api/publicApi/schema'; +import type { ValuesList } from '@/types/subIngredients/valueInterval'; + +/** v1 dated a value in force since the earliest date 0000-01-01; Python's earliest is year 1. */ +const V2_EARLIEST_DATE = '0001-01-01'; +const V1_EARLIEST_DATE = '0000-01-01'; + +/** + * A parameter's dated values from the Public API's v2 catalog routes, as + * v1's metadata dated them: each value under the date it starts, the + * earliest date written as v1 wrote it. Each value is checked where it + * arrives (`parameterValueFromApi`), so a malformed response fails here, + * naming the parameter. + */ +export function v1DatedValues( + parameterName: string, + values: components['schemas']['MetadataDatedValue'][] +): ValuesList { + return Object.fromEntries( + values.map((dated) => { + const date = dated.start_date.slice(0, 10); + return [ + date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, + parameterValueFromApi(dated.value, parameterName), + ]; + }) + ); +} diff --git a/app/src/api/publicApi/modelCatalog.ts b/app/src/api/publicApi/modelCatalog.ts index 5f0137795..98c51d37c 100644 --- a/app/src/api/publicApi/modelCatalog.ts +++ b/app/src/api/publicApi/modelCatalog.ts @@ -16,7 +16,9 @@ import type { MetadataApiPayload, MetadataRegionEntry } from '@/types/metadata'; * * Parameters and variables are not part of it. v3 loads them on demand * (the parameters of what is open, the parameter tree one level at a time, - * and a search file in the background), so they start empty here. v1's + * and a search file in the background) and loads every variable once in the + * background after a page renders (`useV3VariablesInBackground`), so they + * start empty here. v1's * variable modules, modelled policies and SPM settings are not published * by v2 and stay empty too. */ diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index ebbb5bf10..9a3c47634 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { STATE_NAMES } from '@/api/billFeed'; +import { VariableNamesFailed } from '@/components/flagship/V3VariablesGate'; import { Text } from '@/components/ui'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { colors, spacing, typography } from '@/designTokens'; +import { useV3Variables } from '@/hooks/useV3Variables'; import { CalibrationCountry, calibrationFitForGeography, @@ -326,8 +328,26 @@ export function GeographyFitSection({ }) { const fit = useGeographyCalibrationFit(geography, countryId); const variables = useSelector((state: RootState) => state.metadata.variables); + // On v2 the variables (target names) load in the background; wait for them. + const variablesStatus = useV3Variables(); const name = geographyName(geography); const heading = {name} data fit; + if (variablesStatus.status === 'loading') { + return ( + <> + {heading} + + + ); + } + if (variablesStatus.status === 'error') { + return ( + <> + {heading} + + + ); + } if (fit === undefined) { return ( <> @@ -450,6 +470,8 @@ export function CalibrationMatchSection({ pin?: CalibrationPin; }) { const variables = useSelector((state: RootState) => state.metadata.variables); + // On v2 the variables (target names) load in the background; wait for them. + const variablesStatus = useV3Variables(); const countryId: CalibrationCountry = matches?.countryId ?? 'us'; const uk = countryId === 'uk'; // UK nations and the UK itself are the dashboard's "country" level. @@ -480,6 +502,12 @@ export function CalibrationMatchSection({ if (matches === undefined) { return card(); } + if (variablesStatus.status === 'loading') { + return card(); + } + if (variablesStatus.status === 'error') { + return card(); + } const placeName = geographyName(matches.geography); const related = matches.matches.length === 0 ? (matches.related ?? []) : []; if (matches.reachedCount === 0 && uk) { diff --git a/app/src/components/flagship/ProvisionList.tsx b/app/src/components/flagship/ProvisionList.tsx index d3ff5b273..279f5eaf9 100644 --- a/app/src/components/flagship/ProvisionList.tsx +++ b/app/src/components/flagship/ProvisionList.tsx @@ -1,6 +1,8 @@ import { useSelector } from 'react-redux'; +import { VariableNamesFailed, VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; import { Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; +import { useV3Variables } from '@/hooks/useV3Variables'; import { describeChange } from '@/libs/flagship/draftLabels'; import { RootState } from '@/store'; import { formatCurrentLaw, formatValue } from '@/utils/parameterValues'; @@ -31,6 +33,8 @@ export default function ProvisionList({ }) { const variables = useSelector((state: RootState) => state.metadata.variables); const variableLabel = (name: string) => variables?.[name]?.label ?? name; + // On v2 the variables load in the background: a list of them waits for their labels. + const variablesStatus = useV3Variables(); return (
- {listChange ? ( + {listChange && variablesStatus.status === 'loading' ? ( + + ) : listChange && variablesStatus.status === 'error' ? ( + + ) : listChange ? ( diff --git a/app/src/components/flagship/V3VariablesGate.tsx b/app/src/components/flagship/V3VariablesGate.tsx new file mode 100644 index 000000000..b3e9ead57 --- /dev/null +++ b/app/src/components/flagship/V3VariablesGate.tsx @@ -0,0 +1,68 @@ +import { ErrorState } from '@/components/flagship/states'; +import { Skeleton } from '@/components/ui'; +import { spacing } from '@/designTokens'; +import { useV3Variables } from '@/hooks/useV3Variables'; + +interface V3VariablesGateProps { + children: React.ReactNode; + /** What is waiting, for screen readers ("Loading the household builder"). */ + label: string; + /** Placeholder while the variables load; a few skeleton rows by default. */ + placeholder?: React.ReactNode; +} + +function DefaultPlaceholder({ label }: { label: string }) { + return ( +
+ + + +
+ ); +} + +/** + * An inline placeholder for text made of variable names (a list a reform + * changes), while the variables load on the v2 routes. + */ +export function VariableNamesLoading() { + return ( + + + + ); +} + +/** Where variable names should be, when loading them failed on the v2 routes. */ +export function VariableNamesFailed({ onRetry }: { onRetry: () => unknown }) { + return ; +} + +/** + * Content that reads the model's variables (the household builder and + * breakdown, programme names), shown once `useV3Variables` says they are + * 'ready'. On the v2 routes it waits behind a placeholder while they load in + * the background, with no time limit, and a failure says so and asks again; + * on the v1 routes they are always ready, so the content shows as before. + */ +export default function V3VariablesGate({ children, label, placeholder }: V3VariablesGateProps) { + const variables = useV3Variables(); + if (variables.status === 'error') { + return ( + + ); + } + if (variables.status === 'loading') { + return <>{placeholder ?? }; + } + return <>{children}; +} diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index 7ec072b26..4dee7da96 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -1,6 +1,7 @@ import ModelCatalogGate from '@/components/flagship/ModelCatalogGate'; import { spacing } from '@/designTokens'; import { useV3SearchFileInBackground } from '@/hooks/useV3SearchCatalog'; +import { useV3VariablesInBackground } from '@/hooks/useV3Variables'; interface WorkspaceLayoutProps { children: React.ReactNode; @@ -15,9 +16,11 @@ interface WorkspaceLayoutProps { */ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLayoutProps) { const contentWidth = wide ? 1400 : 760; - // On the v2 routes, the search file starts downloading once the page has - // rendered, so it is in by the time someone searches (nothing on v1). + // On the v2 routes, the search file and the catalog version's variables + // start downloading once the page has rendered, so they are in by the + // time someone searches or builds a household (nothing on v1). useV3SearchFileInBackground(); + useV3VariablesInBackground(); return (
diff --git a/app/src/components/flagship/report/EconomicImpactCharts.tsx b/app/src/components/flagship/report/EconomicImpactCharts.tsx index 64aea08b9..25e57d625 100644 --- a/app/src/components/flagship/report/EconomicImpactCharts.tsx +++ b/app/src/components/flagship/report/EconomicImpactCharts.tsx @@ -14,9 +14,11 @@ import PovertyImpactByGenderSubPage from '@/components/flagship/report/charts/Po import PovertyImpactByRaceSubPage from '@/components/flagship/report/charts/PovertyImpactByRaceSubPage'; import WinnersLosersIncomeDecileSubPage from '@/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage'; import ReportChartCard from '@/components/flagship/report/ReportChartCard'; +import V3VariablesGate from '@/components/flagship/V3VariablesGate'; import { SegmentedControl, Stack } from '@/components/ui'; import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useV3Variables } from '@/hooks/useV3Variables'; import { getBudgetCsvRows, getDetailedBudgetCsvRows, @@ -73,6 +75,13 @@ const DECILE_MODE_OPTIONS = [ export default function EconomicImpactCharts({ output }: { output: SocietyWideReportOutput }) { const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); + const variablesStatus = useV3Variables(); + const programmeCsvReason = + variablesStatus.status === 'loading' + ? 'Loading programme names' + : variablesStatus.status === 'error' + ? 'Could not load the programme names' + : undefined; const [decileMode, setDecileMode] = useState('absolute'); const [wealthDecileMode, setWealthDecileMode] = useState('absolute'); // The UK's results also break the budget down by program and rank households by wealth. @@ -177,9 +186,14 @@ export default function EconomicImpactCharts({ output }: { output: SocietyWideRe fullWidth downloadFilename="budgetary-impact-by-program.svg" csvFilename="budgetary-impact-by-program.csv" - csvData={getDetailedBudgetCsvRows(output, metadata)} + // Its rows name programmes by variable label: on v2 they wait for the variables. + csvData={programmeCsvReason ? undefined : getDetailedBudgetCsvRows(output, metadata)} + csvUnavailableReason={programmeCsvReason} > - + {/* Programme names are variable labels (v2 loads them in the background). */} + + + )} diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index ef7f00344..fbe9f9ffa 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -1,10 +1,13 @@ +import type { ReactNode } from 'react'; import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import type { BillChange } from '@/api/billFeed'; import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; +import { VariableNamesFailed, VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useV3Variables } from '@/hooks/useV3Variables'; import { sameParameterValue } from '@/libs/draftReform'; import { changeNamesPath } from '@/libs/flagship/billProvisions'; import { changeDateLabel, describeChange, reportProvisionName } from '@/libs/flagship/draftLabels'; @@ -54,7 +57,8 @@ interface ChangeRow { /** Where in the bill, e.g. "Section 1, amending C.G.S. § 12-700". */ section?: string; before?: string; - after: string; + /** Text, or a placeholder while the variable names it lists load. */ + after: ReactNode; /** The value is today's law already, so there is no "before" to show. */ currentLaw?: boolean; /** When the change applies (ISO dates); absent when it cannot be told. */ @@ -120,6 +124,8 @@ export default function ReportChanges({ const parameters = useSelector((state: RootState) => state.metadata.parameters); const variables = useSelector((state: RootState) => state.metadata.variables); const variableLabel = (name: string) => variables?.[name]?.label ?? name; + // On v2 the variables load in the background: a list of them waits for their labels. + const variablesStatus = useV3Variables(); const scored = Boolean(changes && changes.length > 0); // A tracker row has no dates of its own: it takes those of the parameter @@ -167,9 +173,17 @@ export default function ReportChanges({ currentLaw || list ? undefined : formatCurrentLaw(provision.baselineValue, provision.unit), - after: list - ? describeChange(provision.baselineValue, provision.value, String, variableLabel) - : format(provision.value), + after: list ? ( + variablesStatus.status === 'loading' ? ( + + ) : variablesStatus.status === 'error' ? ( + + ) : ( + describeChange(provision.baselineValue, provision.value, String, variableLabel) + ) + ) : ( + format(provision.value) + ), currentLaw, dates: provisionDates(provision), }; diff --git a/app/src/components/flagship/report/ReportChartCard.tsx b/app/src/components/flagship/report/ReportChartCard.tsx index 555a471ca..05bfd0631 100644 --- a/app/src/components/flagship/report/ReportChartCard.tsx +++ b/app/src/components/flagship/report/ReportChartCard.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from 'react'; +import { useId, useRef, useState, type ReactNode } from 'react'; import { IconArrowsMaximize, IconDownload } from '@tabler/icons-react'; import { Button, @@ -30,6 +30,11 @@ interface ReportChartCardProps { csvData?: CsvData; /** CSV download filename; renders a CSV download button when csvData is provided. */ csvFilename?: string; + /** + * Why the CSV cannot be downloaded yet (its labels are loading, or failed + * to): the button shows, disabled, with this as its tooltip. + */ + csvUnavailableReason?: string; } /** @@ -46,6 +51,7 @@ export default function ReportChartCard({ downloadFilename, csvData, csvFilename, + csvUnavailableReason, }: ReportChartCardProps) { const [focused, setFocused] = useState(false); const chart = (enlarged: boolean) => ( @@ -54,6 +60,7 @@ export default function ReportChartCard({ downloadFilename={downloadFilename} csvFilename={csvFilename} csvData={csvData} + csvUnavailableReason={csvUnavailableReason} headerActions={ !enlarged && ( @@ -141,6 +148,7 @@ function ChartFrame({ downloadFilename, csvData, csvFilename, + csvUnavailableReason, }: { children: ReactNode; headerActions?: ReactNode; @@ -148,9 +156,12 @@ function ChartFrame({ downloadFilename?: string; csvData?: CsvData; csvFilename?: string; + csvUnavailableReason?: string; }) { const contentRef = useRef(null); - const hasCsvDownload = !!csvFilename && !!csvData; + const csvReasonId = useId(); + const csvUnavailable = !!csvFilename && !!csvUnavailableReason; + const hasCsvDownload = !!csvFilename && (!!csvData || csvUnavailable); return ( // Fills the card, so the bordered body below can take the rest of its height. @@ -164,20 +175,34 @@ function ChartFrame({ {hasCsvDownload && ( + {/* While unavailable it stays focusable (aria-disabled, not disabled), so its + tooltip opens, and the reason is also its description. */} - Download CSV + + {csvUnavailable ? csvUnavailableReason : 'Download CSV'} + )} {downloadFilename && ( diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index 1c9181275..86324d886 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -21,6 +21,7 @@ import ReportContents, { type DistrictsCardCopy, type ReportMetric, } from '@/components/flagship/ReportContents'; +import V3VariablesGate from '@/components/flagship/V3VariablesGate'; import type { ModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Stack, Tabs, TabsContent, TabsList, TabsTrigger, Text, Title } from '@/components/ui'; import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistrictDataContext'; @@ -530,17 +531,20 @@ export default function ReportView({ {/* A household runs on the report's own policies, once they exist. */} {reformPolicyId && baselinePolicyId ? ( - provision.path)} - reformChangesNothing={!!output && isSocietyWideReportNoOp(output)} - /> + // The builder and breakdown read every variable (v2 loads them in the background). + + provision.path)} + reformChangesNothing={!!output && isSocietyWideReportNoOp(output)} + /> + ) : ( <> state.metadata.variables); const parameters = useSelector((state: RootState) => state.metadata.parameters); + const partParameterValues = useSelector( + (state: RootState) => state.metadata.v2PartParameterValues + ); const entities = useSelector((state: RootState) => state.metadata.entities); const metadata = useMemo( () => ({ variables, parameters, entities }) as MetadataState, @@ -67,13 +70,12 @@ export default function VariableArithmetic({ let addsArray: string[] = []; let subtractsArray: string[] = []; + // A parameter name stands for the list it holds in the report's year: from + // the whole parameter list on v1, from the variables' part values on v2. + const instant = `${reportYear}-01-01`; if (variable.adds) { if (typeof variable.adds === 'string') { - // It's a parameter name - resolve it - const parameter = parameters[variable.adds]; - if (parameter) { - addsArray = getParameterAtInstant(parameter, `${reportYear}-01-01`) || []; - } + addsArray = getParameterNamedParts(variable.adds, instant, parameters, partParameterValues); } else if (Array.isArray(variable.adds)) { addsArray = variable.adds; } @@ -81,11 +83,12 @@ export default function VariableArithmetic({ if (variable.subtracts) { if (typeof variable.subtracts === 'string') { - // It's a parameter name - resolve it - const parameter = parameters[variable.subtracts]; - if (parameter) { - subtractsArray = getParameterAtInstant(parameter, `${reportYear}-01-01`) || []; - } + subtractsArray = getParameterNamedParts( + variable.subtracts, + instant, + parameters, + partParameterValues + ); } else if (Array.isArray(variable.subtracts)) { subtractsArray = variable.subtracts; } diff --git a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md index e6824ad65..7c0ba26f6 100644 --- a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md +++ b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md @@ -43,7 +43,7 @@ Components read the country from this hook, not from Redux. `MetadataBootstrap` in `providers.tsx` copies the country into Redux with `setCurrentCountry(countryId)` and calls `useFetchMetadata(countryId, routes)`. Loading is non-blocking: pages render while metadata loads. -`routes` comes from `metadataRoutesForPage(useSelectedLayoutSegments())` (`src/libs/flagship/v3ApiRoutes.ts`): `'v1'` (`GET /{country}/metadata`, the whole model) for the older report builder, the routes `V3_API_ROUTES` names for the v3 workspace (`'v2'` loads only the small `GET /v2/model-catalog`, and v3 loads parameters on demand), and, once `V3_API_ROUTES` is `'v2'`, `null` (load nothing) for pages that only redirect into v3: the country index, the old Home launcher (`(calculator)/home`, to Build), the old Library and bill tracker (`(calculator)/library` and `(calculator)/tracker`, to Reforms) and the paused Ask page (`(workspace)/ask`, to Build). The page each one lands on loads its own metadata. +`routes` comes from `metadataRoutesForPage(useSelectedLayoutSegments())` (`src/libs/flagship/v3ApiRoutes.ts`): `'v1'` (`GET /{country}/metadata`, the whole model) for the older report builder, the routes `V3_API_ROUTES` names for the v3 workspace (`'v2'` loads only the small `GET /v2/model-catalog`; v3 loads parameters on demand and every variable once in the background, after a page renders, from `GET /v2/model-catalog/variables`, skipping a model catalog of another country than the page's), and, once `V3_API_ROUTES` is `'v2'`, `null` (load nothing) for pages that only redirect into v3: the country index, the old Home launcher (`(calculator)/home`, to Build), the old Library and bill tracker (`(calculator)/library` and `(calculator)/tracker`, to Reforms) and the paused Ask page (`(workspace)/ask`, to Build). The page each one lands on loads its own metadata. `useFetchMetadata` (`src/hooks/useMetadata.ts`) dispatches `fetchMetadataThunk(metadataRequest(countryId, routes))` only when there is no metadata yet, it belongs to another country, or it came from other routes, so moving between pages of one country does not refetch. Hooks that need metadata for the current country compare `metadata.currentCountry` with the country they were given. diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts new file mode 100644 index 000000000..742ee5bba --- /dev/null +++ b/app/src/hooks/useV3Variables.ts @@ -0,0 +1,189 @@ +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { + catalogVariablesLoaded, + selectV2CatalogVersion, + selectV2LoadId, +} from '@/reducers/metadataReducer'; +import type { AppDispatch, RootState } from '@/store'; + +/** + * Whether every variable of the model is in the metadata store + * (`state.metadata.variables`), for the v3 views that read them: the + * household builder and its variable picker, the household breakdown, + * provision and change lists that name variables, programme names and + * calibration matches. + * + * On the v1 routes the whole metadata holds every variable, and the pages + * handle its loading as before, so for these views the variables are always + * 'ready'. On the v2 routes the store starts with none: all of the catalog + * version's variables load once per model catalog load, in the background, + * and views show their loading state while the status is 'loading' (with no + * time limit), or on 'error' say so and offer `retry`. The names match + * V3ParametersStatus and V3SearchCatalog. + */ +export interface V3VariablesStatus { + status: 'ready' | 'loading' | 'error'; + /** The failure's message, when the status is 'error'. */ + message: string | null; + retry: () => unknown; +} + +/** + * The query key of a catalog version's variables, under one load of the + * model catalog: a new load (a retry, or a newer catalog) empties the store + * and asks for them again. + */ +export function v2CatalogVariablesKey( + countryId: string, + policyengineVersion: string | null, + loadId: string | null +) { + return [V2_CATALOG_VARIABLES, countryId, policyengineVersion, loadId] as const; +} + +const V2_CATALOG_VARIABLES = 'v2-catalog-variables'; + +/** What a variables load is for: the page's country and the store's current model catalog load. */ +interface VariablesTarget { + countryId: string; + policyengineVersion: string | null; + loadId: string | null; + /** The store already holds the variables of this load. */ + inStore: boolean; + /** + * Whether to ask for them: not when the store holds them already, and not + * for a model catalog of another country than the page's (a country + * change in flight). + */ + enabled: boolean; +} + +function useV2VariablesTarget(): VariablesTarget { + const countryId = useCurrentCountry(); + const storeCountry = useSelector((state: RootState) => state.metadata.currentCountry); + const policyengineVersion = useSelector(selectV2CatalogVersion); + const loadId = useSelector(selectV2LoadId); + const loadedUnder = useSelector((state: RootState) => state.metadata.v2VariablesLoadId); + const inStore = loadId !== null && loadedUnder === loadId; + return { + countryId, + policyengineVersion, + loadId, + inStore, + enabled: + !inStore && policyengineVersion !== null && loadId !== null && storeCountry === countryId, + }; +} + +/** + * The one query both the readers and the background load use: it fetches + * the catalog version's variables and puts them in the store, under the + * load they were asked for (a response for an earlier load is dropped). + * A catalog version's variables never change, so it is never stale, and the + * current load's entry is kept for the session (`gcTime: Infinity`); an + * earlier load's entry is removed when a new load starts (see + * useV2VariablesInBackground), so only one load's variables stay cached. + */ +function catalogVariablesQuery(dispatch: AppDispatch, target: VariablesTarget) { + const { countryId, policyengineVersion, loadId } = target; + return { + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), + queryFn: async () => { + const loaded = await fetchCatalogVariables(countryId, policyengineVersion!); + dispatch( + catalogVariablesLoaded({ + countryId, + policyengineVersion: policyengineVersion!, + loadId: loadId!, + ...loaded, + }) + ); + return loaded; + }, + staleTime: Infinity, + gcTime: Infinity, + }; +} + +function useV1Variables(): V3VariablesStatus { + const metadata = useFlagshipMetadataStatus(); + return { status: 'ready', message: null, retry: metadata.retry }; +} + +function useV2Variables(): V3VariablesStatus { + const dispatch = useDispatch(); + const metadata = useFlagshipMetadataStatus(); + const target = useV2VariablesTarget(); + const query = useQuery({ ...catalogVariablesQuery(dispatch, target), enabled: target.enabled }); + + if (target.inStore) { + return { status: 'ready', message: null, retry: query.refetch }; + } + if (metadata.status === 'error') { + return { status: 'error', message: metadata.message, retry: metadata.retry }; + } + if (query.isError) { + return { + status: 'error', + message: query.error instanceof Error ? query.error.message : null, + retry: query.refetch, + }; + } + return { status: 'loading', message: null, retry: query.refetch }; +} + +/** + * Whether the model's variables are in the metadata store for the routes v3 + * uses (V3_API_ROUTES, a constant, so every render calls the same hooks); on + * the v2 routes, loads them if they are not. + */ +export const useV3Variables: () => V3VariablesStatus = + V3_API_ROUTES === 'v2' ? useV2Variables : useV1Variables; + +/** + * Starts loading the catalog version's variables once a v3 page has + * rendered, so they are in the store by the time a household is built or + * broken down, also for views that read `state.metadata.variables` without + * `useV3Variables`. An earlier model catalog load's query is removed first, + * so only the current load's variables stay cached. Nothing when the store's + * model catalog is for another country than the page's, and nothing on the + * v1 routes, where the whole metadata holds the variables. + */ +function useV2VariablesInBackground(): void { + const dispatch = useDispatch(); + const target = useV2VariablesTarget(); + const queryClient = useQueryClient(); + const { countryId, policyengineVersion, loadId, inStore, enabled } = target; + useEffect(() => { + if (!enabled) { + return; + } + queryClient.removeQueries({ + queryKey: [V2_CATALOG_VARIABLES], + predicate: (cached) => cached.queryKey[3] !== loadId, + }); + // A failure stays in the query, where views waiting for the variables report it. + queryClient + .fetchQuery( + catalogVariablesQuery(dispatch, { + countryId, + policyengineVersion, + loadId, + inStore, + enabled, + }) + ) + .catch(() => undefined); + }, [countryId, policyengineVersion, loadId, inStore, enabled, queryClient, dispatch]); +} + +function noBackgroundLoad(): void {} + +export const useV3VariablesInBackground: () => void = + V3_API_ROUTES === 'v2' ? useV2VariablesInBackground : noBackgroundLoad; diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index dfaaae401..bff28a762 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -7,9 +7,11 @@ import type { MetadataRoutes } from '@/types/metadata'; * reforms as `/v2/user-policies` rows pointing at v1 policies * (`POST /{country}/policy`, `legacy_policy_id`). * - 'v2': only the small `/v2/model-catalog` at start; parameters, the - * parameter tree and search load on demand from the v2 catalog routes; - * saved reforms are `/v2/user-policies` rows pointing at `/v2/policies` - * (`policy_id`). + * parameter tree and search load on demand from the v2 catalog routes, + * and every variable of the catalog version loads once in the background + * (`/v2/model-catalog/variables`) for the household builder and + * breakdown; saved reforms are `/v2/user-policies` rows pointing at + * `/v2/policies` (`policy_id`). * * This constant is the only place v3 chooses. It is not a runtime flag or an * environment variable, so every build behaves the same. It stays 'v1' until diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index b3b562e58..b852c6bd3 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -23,6 +23,7 @@ import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; +import { useV3VariablesInBackground } from '@/hooks/useV3Variables'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; import { billAlreadyCurrentLaw, @@ -242,6 +243,8 @@ const STORED_NOTE: Partial> = { export default function BillReportPage({ billId: propId }: BillReportPageProps) { const params = useParams<{ billId: string }>(); const billId = propId ?? params.billId ?? ''; + // On v2, the variables (provision lists, calibration names) start loading at once. + useV3VariablesInBackground(); const countryId = useCurrentCountry(); const parameters = useSelector((state: RootState) => state.metadata.parameters); const feed = useTrackedBills(countryId); diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 53bf2c7d6..01928e3a7 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -39,6 +39,7 @@ import { useReportYearEconomies } from '@/hooks/useReportYearEconomy'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; +import { useV3VariablesInBackground } from '@/hooks/useV3Variables'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; import { isUntitledReform, @@ -107,6 +108,8 @@ function runStartedAt(...stamps: Array): number | und export default function FlagshipReportPage({ userReportId: propId }: FlagshipReportPageProps) { const params = useParams<{ userReportId: string }>(); const userReportId = propId ?? params.userReportId ?? ''; + // On v2, the variables (household builder, breakdown, programme names) start loading at once. + useV3VariablesInBackground(); // A report's link is its saved report's ID, which anyone can open with its // owner's details. Without accounts (sign-in off, or outside the US and // UK) nothing is shared, so the link is the v1 report's number; where diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index def126dec..856b3cb3c 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -1,10 +1,12 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; // Import the API functions import { fetchMetadata as fetchMetadataApi } from '@/api/metadata'; +import type { V1ShapedVariable } from '@/api/publicApi/catalogVariables'; import { fetchV2ModelCatalogMetadata } from '@/api/publicApi/modelCatalog'; import { buildParameterTree } from '@/libs/buildParameterTree'; import { MetadataRoutes, MetadataState, V2CatalogReference } from '@/types/metadata'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; +import type { ValuesList } from '@/types/subIngredients/valueInterval'; /** * What to load: a country alone reads `GET /{country}/metadata` (v1), as @@ -132,6 +134,37 @@ const metadataSlice = createSlice({ ]; } }, + /** + * Every variable of a catalog version, loaded once in the background on + * the v2 routes, joins the store in v1's shape, with the dated values of + * the parameters their adds or subtracts name. A response for an earlier + * model catalog load, country or version is dropped. + */ + catalogVariablesLoaded( + state, + action: PayloadAction<{ + countryId: string; + policyengineVersion: string; + /** The model catalog load the variables were asked for under. */ + loadId: string; + variables: Record; + partParameterValues: Record; + }> + ) { + const { countryId, policyengineVersion, loadId, variables, partParameterValues } = + action.payload; + if ( + state.routes !== 'v2' || + state.v2LoadId !== loadId || + state.currentCountry !== countryId || + state.v2Catalog?.policyengineVersion !== policyengineVersion + ) { + return; + } + state.variables = variables; + state.v2PartParameterValues = partParameterValues; + state.v2VariablesLoadId = loadId; + }, }, extraReducers: (builder) => { builder @@ -205,7 +238,8 @@ const metadataSlice = createSlice({ }, }); -export const { setCurrentCountry, clearMetadata, catalogParametersLoaded } = metadataSlice.actions; +export const { setCurrentCountry, clearMetadata, catalogParametersLoaded, catalogVariablesLoaded } = + metadataSlice.actions; /** The v2 catalog version and model the store holds, once its model catalog is in; null otherwise. */ export function selectV2Catalog(state: { metadata: MetadataState }): V2CatalogReference | null { diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts new file mode 100644 index 000000000..7c838ce2d --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -0,0 +1,197 @@ +import type { components } from '@/api/publicApi/schema'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; + +type Schemas = components['schemas']; + +/** A parameter whose value lists the variables `household_benefits` adds. */ +export const BENEFITS_PARAMETER_PATH = 'gov.household.household_benefits'; +/** A parameter `countable_income` subtracts, which the catalog version does not have. */ +export const ABSENT_PARTS_PARAMETER_PATH = 'gov.example.absent_deductions'; + +function variable( + name: string, + fields: Partial = {} +): Schemas['MetadataVariable'] { + return { + id: `variable-${name}`, + name, + label: null, + entity: 'person', + description: null, + data_type: 'float', + possible_values: null, + default_value: 0, + adds: null, + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: null, + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: null, + definition_period: 'year', + ...fields, + }; +} + +export const AGE_VARIABLE = variable('age', { + label: 'Age', + data_type: 'int', + default_value: 40, + is_input_variable: true, + module_name: 'household.demographic.age', +}); + +export const EMPLOYMENT_INCOME_VARIABLE = variable('employment_income', { + label: 'Employment income', + description: 'Wages and salaries', + is_input_variable: true, + module_name: 'household.income.person.employment_income', + unit: 'currency-USD', +}); + +/** Rent is paid by the month. */ +export const RENT_VARIABLE = variable('rent', { + label: 'Rent', + entity: 'household', + is_input_variable: true, + unit: 'currency-USD', + definition_period: 'month', +}); + +/** A rate, as a share of one. */ +export const TAX_RATE_VARIABLE = variable('marginal_tax_rate', { + label: 'Marginal tax rate', + unit: '/1', +}); + +/** A version published before catalog format 2 has neither a unit nor a period. */ +export const UNDESCRIBED_VARIABLE = variable('undescribed_income', { + label: 'Undescribed income', + is_input_variable: true, + unit: null, + definition_period: null, +}); + +export const RATING_AREA_VARIABLE = variable('medicaid_rating_area', { + label: 'Medicaid rating area', + data_type: 'int', + is_input_variable: true, + hidden_input: true, +}); + +/** As the catalog publishes an enum: data type "str", with its values. */ +export const FILING_STATUS_VARIABLE = variable('filing_status', { + label: 'Filing status', + data_type: 'str', + default_value: 'SINGLE', + possible_values: ['SINGLE', 'JOINT'], + is_input_variable: true, + enum_values: [ + { value: 'SINGLE', label: 'Single' }, + { value: 'JOINT', label: 2 }, + ], +}); + +export const SNAP_VARIABLE = variable('snap', { + label: 'SNAP', + entity: 'household', + unit: 'currency-USD', +}); +export const SSI_VARIABLE = variable('ssi', { label: 'SSI', unit: 'currency-USD' }); + +export const HOUSEHOLD_BENEFITS_VARIABLE = variable('household_benefits', { + label: 'Benefits', + entity: 'household', + adds_parameter_path: BENEFITS_PARAMETER_PATH, +}); + +export const COUNTABLE_INCOME_VARIABLE = variable('countable_income', { + label: 'Countable income', + adds: ['employment_income'], + subtracts_parameter_path: ABSENT_PARTS_PARAMETER_PATH, +}); + +/** What `GET /v2/model-catalog/variables` answers for the small US catalog version. */ +export const US_CATALOG_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResult'] = { + policyengine_version: TEST_POLICYENGINE_VERSION, + catalog_format: 2, + variables: [ + AGE_VARIABLE, + COUNTABLE_INCOME_VARIABLE, + EMPLOYMENT_INCOME_VARIABLE, + FILING_STATUS_VARIABLE, + HOUSEHOLD_BENEFITS_VARIABLE, + RATING_AREA_VARIABLE, + RENT_VARIABLE, + SNAP_VARIABLE, + SSI_VARIABLE, + TAX_RATE_VARIABLE, + UNDESCRIBED_VARIABLE, + ], + part_parameter_values: { + // Benefits add SNAP until 2025, then SNAP and SSI from 2026. + [BENEFITS_PARAMETER_PATH]: [ + { start_date: '0001-01-01T00:00:00', end_date: '2025-12-31T00:00:00', value: ['snap'] }, + { start_date: '2026-01-01T00:00:00', end_date: null, value: ['snap', 'ssi'] }, + ], + [ABSENT_PARTS_PARAMETER_PATH]: [], + }, +}; + +export function catalogVariablesEnvelope( + result: Schemas['MetadataCatalogVariablesResult'] = US_CATALOG_VARIABLES_RESULT +) { + return { status: 'ok', message: null, result }; +} + +/** The route every variables test stubs. */ +export const VARIABLES_PATH = '/v2/model-catalog/variables'; + +/** A reform that adds SSI to the benefits a household's total counts: a change that lists variables. */ +export const LIST_PROVISION = { + path: 'gov.household.household_benefits', + breadcrumb: 'Household → Benefits', + unit: null, + baselineValue: ['snap'], + value: ['snap', 'ssi'], +}; + +/** A variables route that fails once, then answers: for "try again" tests. */ +export function failOnceThenAnswerVariables(): () => Response { + let attempts = 0; + return () => { + attempts += 1; + return attempts === 1 + ? new Response(JSON.stringify({ status: 'error', message: 'Unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify(catalogVariablesEnvelope()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; +} + +/** A variable without a label and without catalog format 2's details. */ +export const UNLABELLED_VARIABLE = variable('unlabelled_variable', { + is_input_variable: null, + hidden_input: null, + unit: null, + definition_period: null, +}); + +/** The variables route's answer holding only the unlabelled variable. */ +export const UNLABELLED_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResult'] = { + policyengine_version: TEST_POLICYENGINE_VERSION, + catalog_format: 2, + variables: [UNLABELLED_VARIABLE], + part_parameter_values: {}, +}; + +/** A parameter's list of parts by date, as the store holds part values. */ +export const BENEFITS_PART_VALUES = { + [BENEFITS_PARAMETER_PATH]: { '0000-01-01': ['snap'], '2026-01-01': ['snap', 'ssi'] }, +}; diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts new file mode 100644 index 000000000..fcee86a2f --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts @@ -0,0 +1,332 @@ +import type { components } from '@/api/publicApi/schema'; + +/** + * US variables as `GET /v2/model-catalog/variables` publishes them: each + * record serialized from .py's public `Variable` records (the branch adding + * catalog format 2's fields, on .py 6.10.0) and named as the Public API's + * catalog stores them (`data_type` is the Python type's name). Enum + * variables have `data_type` "str" there, while v1's metadata said "Enum"; + * enum value lists are cut to their first three values to keep this short. + */ +export const REAL_SHAPE_US_VARIABLES: components['schemas']['MetadataVariable'][] = [ + { + id: 'variable-state_name', + name: 'state_name', + label: 'State', + entity: 'household', + description: null, + data_type: 'str', + possible_values: ['AL', 'AK', 'AZ'], + default_value: 'CA', + adds: null, + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: 'input.geography', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: [ + { + value: 'AL', + label: 'Alabama', + }, + { + value: 'AK', + label: 'Alaska', + }, + { + value: 'AZ', + label: 'Arizona', + }, + ], + unit: null, + definition_period: 'year', + }, + { + id: 'variable-age', + name: 'age', + label: 'age', + entity: 'person', + description: null, + data_type: 'float', + possible_values: null, + default_value: 40.0, + adds: null, + subtracts: null, + is_input_variable: true, + hidden_input: false, + module_name: 'household.demographic.age.age', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: null, + definition_period: 'year', + }, + { + id: 'variable-employment_income', + name: 'employment_income', + label: 'employment income', + entity: 'person', + description: 'Wages and salaries, including tips and commissions.', + data_type: 'float', + possible_values: null, + default_value: 0, + adds: ['employment_income_before_lsr', 'employment_income_behavioral_response'], + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: 'input.employment_income', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: 'currency-USD', + definition_period: 'year', + }, + { + id: 'variable-is_disabled', + name: 'is_disabled', + label: 'Is disabled', + entity: 'person', + description: null, + data_type: 'bool', + possible_values: null, + default_value: false, + adds: null, + subtracts: null, + is_input_variable: true, + hidden_input: false, + module_name: 'household.demographic.person.is_disabled', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: null, + definition_period: 'year', + }, + { + id: 'variable-rent', + name: 'rent', + label: 'Rent', + entity: 'person', + description: null, + data_type: 'float', + possible_values: null, + default_value: 0, + adds: null, + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: 'household.expense.housing.rent', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: 'currency-USD', + definition_period: 'year', + }, + { + id: 'variable-filing_status', + name: 'filing_status', + label: 'Filing status for the tax unit', + entity: 'tax_unit', + description: null, + data_type: 'str', + possible_values: ['SINGLE', 'JOINT', 'SEPARATE'], + default_value: 'SINGLE', + adds: null, + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: 'household.demographic.tax_unit.filing_status', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: [ + { + value: 'SINGLE', + label: 'Single', + }, + { + value: 'JOINT', + label: 'Joint', + }, + { + value: 'SEPARATE', + label: 'Separate', + }, + ], + unit: null, + definition_period: 'year', + }, + { + id: 'variable-household_benefits', + name: 'household_benefits', + label: 'benefits', + entity: 'household', + description: null, + data_type: 'float', + possible_values: null, + default_value: 0, + adds: null, + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: 'household.income.household.household_benefits', + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: 'currency-USD', + definition_period: 'year', + }, +]; + +/** + * A real US variable whose parts a parameter names, as the catalog + * publishes it, and that parameter's dated values in the date format + * `part_parameter_values` uses: its list drops `domestic_production_ald` + * from 2018 and `capped_qualified_tuition_expenses_ald` from 2021. + */ +export const REAL_SHAPE_PARTS_VARIABLE: components['schemas']['MetadataVariable'] = { + id: 'variable-above_the_line_deductions', + name: 'above_the_line_deductions', + label: 'Above-the-line deductions', + entity: 'tax_unit', + description: 'Deductions applied to reach adjusted gross income from gross income.', + data_type: 'float', + possible_values: null, + default_value: 0, + adds: [ + 'loss_ald', + 'self_employment_tax_ald', + 'student_loan_interest_ald', + 'early_withdrawal_penalty', + 'alimony_expense_ald', + 'educator_expense', + 'health_savings_account_ald', + 'self_employed_health_insurance_ald', + 'self_employed_pension_contribution_ald', + 'traditional_ira_contributions', + 'qualified_adoption_assistance_expense', + 'us_bonds_for_higher_ed', + 'specified_possession_income', + 'puerto_rico_income', + ], + subtracts: null, + is_input_variable: false, + hidden_input: false, + module_name: + 'gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.above_the_line_deductions', + adds_parameter_path: 'gov.irs.ald.deductions', + subtracts_parameter_path: null, + enum_values: null, + unit: 'currency-USD', + definition_period: 'year', +}; + +export const REAL_SHAPE_PARTS_PARAMETER = 'gov.irs.ald.deductions'; + +export const REAL_SHAPE_PART_PARAMETER_VALUES: components['schemas']['MetadataDatedValue'][] = [ + { + start_date: '2010-01-01T00:00:00', + end_date: '2017-12-31T00:00:00', + value: [ + 'loss_ald', + 'self_employment_tax_ald', + 'student_loan_interest_ald', + 'early_withdrawal_penalty', + 'alimony_expense_ald', + 'educator_expense', + 'capped_qualified_tuition_expenses_ald', + 'domestic_production_ald', + 'health_savings_account_ald', + 'self_employed_health_insurance_ald', + 'self_employed_pension_contribution_ald', + 'traditional_ira_contributions', + 'qualified_adoption_assistance_expense', + 'us_bonds_for_higher_ed', + 'specified_possession_income', + 'puerto_rico_income', + ], + }, + { + start_date: '2018-01-01T00:00:00', + end_date: '2020-12-31T00:00:00', + value: [ + 'loss_ald', + 'self_employment_tax_ald', + 'student_loan_interest_ald', + 'early_withdrawal_penalty', + 'alimony_expense_ald', + 'educator_expense', + 'capped_qualified_tuition_expenses_ald', + 'health_savings_account_ald', + 'self_employed_health_insurance_ald', + 'self_employed_pension_contribution_ald', + 'traditional_ira_contributions', + 'qualified_adoption_assistance_expense', + 'us_bonds_for_higher_ed', + 'specified_possession_income', + 'puerto_rico_income', + ], + }, + { + start_date: '2021-01-01T00:00:00', + end_date: '2025-12-31T00:00:00', + value: [ + 'loss_ald', + 'self_employment_tax_ald', + 'student_loan_interest_ald', + 'early_withdrawal_penalty', + 'alimony_expense_ald', + 'educator_expense', + 'health_savings_account_ald', + 'self_employed_health_insurance_ald', + 'self_employed_pension_contribution_ald', + 'traditional_ira_contributions', + 'qualified_adoption_assistance_expense', + 'us_bonds_for_higher_ed', + 'specified_possession_income', + 'puerto_rico_income', + ], + }, + { + start_date: '2026-01-01T00:00:00', + end_date: null, + value: [ + 'loss_ald', + 'self_employment_tax_ald', + 'student_loan_interest_ald', + 'early_withdrawal_penalty', + 'alimony_expense_ald', + 'educator_expense', + 'health_savings_account_ald', + 'self_employed_health_insurance_ald', + 'self_employed_pension_contribution_ald', + 'traditional_ira_contributions', + 'qualified_adoption_assistance_expense', + 'us_bonds_for_higher_ed', + 'specified_possession_income', + 'puerto_rico_income', + ], + }, +]; + +/** + * What `/v2/model-catalog/variables` answers for a catalog version published + * before catalog format 2: `catalog_format` null, and every format 2 field + * of each variable null. + */ +export const PRE_FORMAT_2_VARIABLES_RESULT: components['schemas']['MetadataCatalogVariablesResult'] = + { + policyengine_version: '6.7.5', + catalog_format: null, + variables: REAL_SHAPE_US_VARIABLES.map((variable) => ({ + ...variable, + is_input_variable: null, + hidden_input: null, + module_name: null, + adds_parameter_path: null, + subtracts_parameter_path: null, + enum_values: null, + unit: null, + definition_period: null, + })), + part_parameter_values: {}, + }; diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesStore.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesStore.ts new file mode 100644 index 000000000..4201d2539 --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesStore.ts @@ -0,0 +1,45 @@ +import { vi } from 'vitest'; +import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { catalogVariablesLoaded, clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogVariablesEnvelope } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; +import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** + * The store as v3 has it after the switch once the background load is in: + * the small model catalog (default fields, entities) and every variable of + * the catalog version, loaded through the variables route's conversion. + */ +export async function seedV2CatalogVariables(): Promise { + stubPublicApiFetch(); + await seedV2ModelCatalog(); + const fetchMock = vi.fn(async () => jsonResponse(catalogVariablesEnvelope())); + const loaded = await fetchCatalogVariables( + 'us', + TEST_POLICYENGINE_VERSION, + createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }) + ); + store.dispatch( + catalogVariablesLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: selectV2LoadId(store.getState())!, + ...loaded, + }) + ); +} + +export function clearV2CatalogVariables(): void { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); +} diff --git a/app/src/tests/fixtures/components/household/variableArithmeticParameterPartsMocks.ts b/app/src/tests/fixtures/components/household/variableArithmeticParameterPartsMocks.ts new file mode 100644 index 000000000..060dec7cd --- /dev/null +++ b/app/src/tests/fixtures/components/household/variableArithmeticParameterPartsMocks.ts @@ -0,0 +1,82 @@ +import type { HouseholdCalculationOutput } from '@/types/calculation/household'; +import type { MetadataState } from '@/types/metadata'; + +/** A parameter whose value lists the variables benefits add: SNAP, then SNAP and SSI from 2026. */ +export const BENEFITS_PARTS = 'gov.household.household_benefits'; +/** A parameter the catalog version does not have values for. */ +export const ABSENT_PARTS = 'gov.example.absent_deductions'; + +export const BENEFITS_PARTS_VALUES = { '0000-01-01': ['snap'], '2026-01-01': ['snap', 'ssi'] }; + +function householdVariable(label: string, fields: Record = {}) { + return { entity: 'household', label, unit: 'currency-USD', valueType: 'float', ...fields } as any; +} + +const VARIABLES: MetadataState['variables'] = { + household_benefits: householdVariable('benefits', { adds: BENEFITS_PARTS }), + countable_income: householdVariable('countable income', { + adds: ['earnings'], + subtracts: ABSENT_PARTS, + }), + earnings: householdVariable('earnings'), + snap: householdVariable('SNAP'), + ssi: householdVariable('SSI'), +}; + +function metadataState(fields: Partial): MetadataState { + return { + loading: false, + error: null, + currentCountry: 'us', + progress: 100, + variables: VARIABLES, + parameters: {}, + entities: { household: { plural: 'households', label: 'Household' } as any }, + variableModules: {}, + economyOptions: { region: [], time_period: [], datasets: [] }, + currentLawId: 2, + basicInputs: [], + modelledPolicies: { core: {}, filtered: {} }, + version: '6.11.1', + parameterTree: null, + ...fields, + }; +} + +/** On the v2 routes: no parameter list, and the parts' dated values from the variables route. */ +export const V2_METADATA = metadataState({ + routes: 'v2', + v2PartParameterValues: { [BENEFITS_PARTS]: BENEFITS_PARTS_VALUES, [ABSENT_PARTS]: {} }, +}); + +/** On the v1 routes: the parts' parameter is in the whole parameter list. */ +export const V1_METADATA = metadataState({ + parameters: { + [BENEFITS_PARTS]: { + type: 'parameter', + parameter: BENEFITS_PARTS, + label: 'Benefits', + values: BENEFITS_PARTS_VALUES, + } as any, + }, +}); + +export function household(year: string): HouseholdCalculationOutput { + return { + id: 'household-1', + countryId: 'us', + householdData: { + people: {}, + households: { + 'your household': { + members: [], + household_benefits: { [year]: 300 }, + countable_income: { [year]: 1000 }, + earnings: { [year]: 1000 }, + snap: { [year]: 100 }, + ssi: { [year]: 200 }, + }, + }, + }, + } as HouseholdCalculationOutput; +} diff --git a/app/src/tests/fixtures/components/household/variableArithmeticRealShapeMocks.ts b/app/src/tests/fixtures/components/household/variableArithmeticRealShapeMocks.ts new file mode 100644 index 000000000..d21bc7ded --- /dev/null +++ b/app/src/tests/fixtures/components/household/variableArithmeticRealShapeMocks.ts @@ -0,0 +1,65 @@ +import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; +import { v1DatedValues } from '@/api/publicApi/datedValues'; +import { + REAL_SHAPE_PART_PARAMETER_VALUES, + REAL_SHAPE_PARTS_PARAMETER, + REAL_SHAPE_PARTS_VARIABLE, +} from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +import type { HouseholdCalculationOutput } from '@/types/calculation/household'; +import type { MetadataState } from '@/types/metadata'; + +/** Two of the real list's parts: one it drops from 2018, one it keeps. */ +export const DROPPED_FROM_2018 = 'domestic_production_ald'; +export const KEPT_THROUGHOUT = 'student_loan_interest_ald'; + +function taxUnitVariable(name: string, label: string) { + return { name, label, entity: 'tax_unit', unit: 'currency-USD', valueType: 'float' } as any; +} + +/** The store on the v2 routes once the real variable and its part values are in. */ +export const REAL_SHAPE_V2_METADATA: MetadataState = { + loading: false, + error: null, + currentCountry: 'us', + progress: 100, + variables: { + [REAL_SHAPE_PARTS_VARIABLE.name]: v1ShapedVariable(REAL_SHAPE_PARTS_VARIABLE), + [DROPPED_FROM_2018]: taxUnitVariable(DROPPED_FROM_2018, 'Domestic production deduction'), + [KEPT_THROUGHOUT]: taxUnitVariable(KEPT_THROUGHOUT, 'Student loan interest deduction'), + }, + parameters: {}, + entities: { tax_unit: { key: 'tax_unit', plural: 'tax_units', is_person: false } as any }, + variableModules: {}, + economyOptions: { region: [], time_period: [], datasets: [] }, + currentLawId: 2, + basicInputs: [], + modelledPolicies: { core: {}, filtered: {} }, + version: '6.10.0', + parameterTree: null, + routes: 'v2', + v2PartParameterValues: { + [REAL_SHAPE_PARTS_PARAMETER]: v1DatedValues( + REAL_SHAPE_PARTS_PARAMETER, + REAL_SHAPE_PART_PARAMETER_VALUES + ), + }, +}; + +/** A tax unit with each of the two deductions, for one year. */ +export function taxUnitWithDeductions(year: string): HouseholdCalculationOutput { + return { + id: 'household-1', + countryId: 'us', + householdData: { + people: {}, + taxUnits: { + 'your tax unit': { + members: [], + [REAL_SHAPE_PARTS_VARIABLE.name]: { [year]: 300 }, + [DROPPED_FROM_2018]: { [year]: 100 }, + [KEPT_THROUGHOUT]: { [year]: 200 }, + }, + }, + }, + } as HouseholdCalculationOutput; +} diff --git a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts new file mode 100644 index 000000000..b769d46ca --- /dev/null +++ b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test, vi } from 'vitest'; +import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { + ABSENT_PARTS_PARAMETER_PATH, + BENEFITS_PARAMETER_PATH, + catalogVariablesEnvelope, + UNLABELLED_VARIABLES_RESULT, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { jsonResponse } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +function variablesClient() { + const fetchMock = vi.fn(async () => jsonResponse(catalogVariablesEnvelope())); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +describe('fetchCatalogVariables', () => { + test('given a catalog version then one request asks for all its variables', async () => { + // Given + const { client, fetchMock } = variablesClient(); + + // When + await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL((fetchMock.mock.calls[0] as unknown as [Request])[0].url); + expect(url.pathname).toBe('/v2/model-catalog/variables'); + expect(url.searchParams.get('country_id')).toBe('us'); + expect(url.searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); + }); + + test('given catalog variables then each reads as v1 metadata described it', async () => { + // Given + const { client } = variablesClient(); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.employment_income).toEqual({ + name: 'employment_income', + label: 'Employment income', + entity: 'person', + documentation: 'Wages and salaries', + valueType: 'float', + unit: 'currency-USD', + definitionPeriod: 'year', + defaultValue: 0, + adds: null, + subtracts: null, + isInputVariable: true, + hidden_input: false, + moduleName: 'household.income.person.employment_income', + }); + expect(variables.medicaid_rating_area).toMatchObject({ + isInputVariable: true, + hidden_input: true, + }); + }); + + test('given units and periods then each reads as v1 gave them', async () => { + // Given + const { client } = variablesClient(); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.rent).toMatchObject({ unit: 'currency-USD', definitionPeriod: 'month' }); + expect(variables.marginal_tax_rate).toMatchObject({ unit: '/1', definitionPeriod: 'year' }); + expect(variables.age).toMatchObject({ unit: null, definitionPeriod: 'year' }); + }); + + test('given a version published before catalog format 2 then the unit and period are null', async () => { + // Given + const { client } = variablesClient(); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.undescribed_income).toMatchObject({ unit: null, definitionPeriod: null }); + }); + + test('given an enum variable then its values keep their labels and declared types', async () => { + // Given + const { client } = variablesClient(); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.filing_status.valueType).toBe('Enum'); + expect(variables.filing_status.defaultValue).toBe('SINGLE'); + expect(variables.filing_status.possibleValues).toEqual([ + { value: 'SINGLE', label: 'Single' }, + { value: 'JOINT', label: 2 }, + ]); + }); + + test('given parts named by a parameter then the path stands in for the list, as on v1', async () => { + // Given + const { client } = variablesClient(); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.household_benefits.adds).toBe(BENEFITS_PARAMETER_PATH); + expect(variables.countable_income.adds).toEqual(['employment_income']); + expect(variables.countable_income.subtracts).toBe(ABSENT_PARTS_PARAMETER_PATH); + }); + + test('given part parameter values then they are dated as v1 dated parameter values', async () => { + // Given + const { client } = variablesClient(); + + // When + const { partParameterValues } = await fetchCatalogVariables( + 'us', + TEST_POLICYENGINE_VERSION, + client + ); + + // Then + expect(partParameterValues[BENEFITS_PARAMETER_PATH]).toEqual({ + '0000-01-01': ['snap'], + '2026-01-01': ['snap', 'ssi'], + }); + expect(partParameterValues[ABSENT_PARTS_PARAMETER_PATH]).toEqual({}); + }); + + test('given a variable without a label then it reads as its name', async () => { + // Given + const fetchMock = vi.fn(async () => + jsonResponse(catalogVariablesEnvelope(UNLABELLED_VARIABLES_RESULT)) + ); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const { variables } = await fetchCatalogVariables('us', TEST_POLICYENGINE_VERSION, client); + + // Then + expect(variables.unlabelled_variable).toMatchObject({ + label: 'unlabelled_variable', + isInputVariable: false, + hidden_input: false, + moduleName: null, + }); + }); +}); diff --git a/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts b/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts new file mode 100644 index 000000000..4b9026058 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, vi } from 'vitest'; +import { fetchCatalogVariables, v1ShapedVariable } from '@/api/publicApi/catalogVariables'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { inputKind } from '@/components/household/VariablePickerDialog'; +import { + PRE_FORMAT_2_VARIABLES_RESULT, + REAL_SHAPE_US_VARIABLES, +} from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +import { jsonResponse } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; +import { getNormalizedVariableMetadata } from '@/utils/variableMetadata'; + +function realVariable(name: string) { + const variable = REAL_SHAPE_US_VARIABLES.find((candidate) => candidate.name === name); + if (!variable) { + throw new Error(`No real-shape fixture for ${name}`); + } + return variable; +} + +describe('v1ShapedVariable on records as the catalog publishes them', () => { + test.each(['state_name', 'filing_status'])( + 'given the enum variable %s published with data type "str" then it reads as v1\'s Enum', + (name) => { + // When + const shaped = v1ShapedVariable(realVariable(name)); + + // Then + expect(shaped.valueType).toBe('Enum'); + expect(shaped.possibleValues?.length).toBeGreaterThan(0); + expect(getNormalizedVariableMetadata(shaped).valueType).toBe('Enum'); + } + ); + + test('given an enum input then the picker offers it as a choice from a list', () => { + // When + const stateName = getNormalizedVariableMetadata(v1ShapedVariable(realVariable('state_name'))); + + // Then + expect(inputKind(stateName as Parameters[0])).toBe('One of a list'); + }); + + test('given an enum variable then its default is the default value name, as on v1', () => { + expect(v1ShapedVariable(realVariable('state_name')).defaultValue).toBe('CA'); + }); + + test.each([ + ['age', 'float', 40], + ['employment_income', 'float', 0], + ['is_disabled', 'bool', false], + ])('given %s then its value type and default read as v1 gave them', (name, type, value) => { + const shaped = v1ShapedVariable(realVariable(name)); + + expect(shaped.valueType).toBe(type); + expect(shaped.defaultValue).toBe(value); + }); + + test('given a text or date variable then it has no default, as v1 gave none', () => { + // Given + const textVariable = { ...realVariable('age'), data_type: 'str', default_value: 'none' }; + const dateVariable = { ...realVariable('age'), data_type: 'date', default_value: '2000-01-01' }; + + // Then + expect(v1ShapedVariable(textVariable).defaultValue).toBeNull(); + expect(v1ShapedVariable(dateVariable).defaultValue).toBeNull(); + }); + + test('given a catalog version published before catalog format 2 then loading its variables fails clearly', async () => { + // Given + const fetchMock = vi.fn(async () => + jsonResponse({ status: 'ok', message: null, result: PRE_FORMAT_2_VARIABLES_RESULT }) + ); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const loading = fetchCatalogVariables('us', '6.7.5', client); + + // Then + await expect(loading).rejects.toThrow(/published before catalog format 2/); + }); +}); diff --git a/app/src/tests/unit/api/publicApi/datedValues.test.ts b/app/src/tests/unit/api/publicApi/datedValues.test.ts new file mode 100644 index 000000000..7acc47b35 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/datedValues.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest'; +import { v1DatedValues } from '@/api/publicApi/datedValues'; +import { + REAL_SHAPE_PART_PARAMETER_VALUES, + REAL_SHAPE_PARTS_PARAMETER, +} from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; + +describe('v1DatedValues', () => { + test('given dated values as the route sends them then each is keyed by the day it starts', () => { + // When + const values = v1DatedValues(REAL_SHAPE_PARTS_PARAMETER, REAL_SHAPE_PART_PARAMETER_VALUES); + + // Then + expect(Object.keys(values)).toEqual(['2010-01-01', '2018-01-01', '2021-01-01', '2026-01-01']); + expect(values['2018-01-01']).not.toContain('domestic_production_ald'); + }); + + test("given a value in force since Python's earliest date then it is dated as v1 dated it", () => { + // When + const values = v1DatedValues('gov.example.rate', [ + { start_date: '0001-01-01T00:00:00', end_date: null, value: 0.1 }, + ]); + + // Then + expect(values).toEqual({ '0000-01-01': 0.1 }); + }); + + test('given no dated values then there are none', () => { + expect(v1DatedValues('gov.example.rate', [])).toEqual({}); + }); + + test('given a value that is not a parameter value then it fails, naming the parameter', () => { + // Given + const notAValue = new Date('2025-01-01') as unknown as number; + + // Then + expect(() => + v1DatedValues('gov.example.rate', [ + { start_date: '2025-01-01T00:00:00', end_date: null, value: notAValue }, + ]) + ).toThrow(/gov\.example\.rate/); + }); +}); diff --git a/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx new file mode 100644 index 000000000..5596ab2bb --- /dev/null +++ b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx @@ -0,0 +1,87 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CalibrationMatchSection } from '@/components/flagship/CalibrationMatches'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +let answerVariables: () => Response | Promise; + +function renderSection() { + return render( + + + + ); +} + +describe('CalibrationMatchSection with v3 on the v2 routes', () => { + beforeEach(async () => { + answerVariables = () => jsonResponse(catalogVariablesEnvelope()); + stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the variables still loading then the matches wait for their names', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderSection(); + + // Then + expect(screen.getByRole('status', { name: 'Loading variable names' })).toBeInTheDocument(); + }); + + test('given the variables arrive then the matches show', async () => { + // When + renderSection(); + + // Then + await waitFor(() => + expect( + screen.queryByRole('status', { name: 'Loading variable names' }) + ).not.toBeInTheDocument() + ); + expect(screen.getByRole('link', { name: /View dashboard/ })).toBeInTheDocument(); + }); + + test('given the variables fail then the matches say so and try again asks again', async () => { + // Given + const user = userEvent.setup(); + answerVariables = failOnceThenAnswerVariables(); + renderSection(); + expect(await screen.findByText('Could not load the variable names')).toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => + expect(screen.queryByText('Could not load the variable names')).not.toBeInTheDocument() + ); + expect(screen.getByRole('link', { name: /View dashboard/ })).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx new file mode 100644 index 000000000..147d9246b --- /dev/null +++ b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx @@ -0,0 +1,81 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import ProvisionList from '@/components/flagship/ProvisionList'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + LIST_PROVISION, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +let answerVariables: () => Response | Promise; + +function renderWithQueries(ui: React.ReactNode) { + return render({ui}); +} + +describe('ProvisionList with v3 on the v2 routes', () => { + beforeEach(async () => { + answerVariables = () => jsonResponse(catalogVariablesEnvelope()); + stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the variables still loading then a provision list waits for their names', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderWithQueries(); + + // Then + expect(screen.getByRole('status', { name: 'Loading variable names' })).toBeInTheDocument(); + expect(screen.queryByText(/ssi/)).not.toBeInTheDocument(); + }); + + test('given the variables arrive then a provision list names them by label', async () => { + // When + renderWithQueries(); + + // Then + expect(await screen.findByText(/SSI/)).toBeInTheDocument(); + expect( + screen.queryByRole('status', { name: 'Loading variable names' }) + ).not.toBeInTheDocument(); + }); + + test('given the variables fail then the provision list says so and try again asks again', async () => { + // Given + const user = userEvent.setup(); + answerVariables = failOnceThenAnswerVariables(); + renderWithQueries(); + const [retry] = await screen.findAllByRole('button', { name: 'Try again' }); + expect(screen.getAllByText('Could not load the variable names').length).toBeGreaterThan(0); + + // When + await user.click(retry); + + // Then + expect((await screen.findAllByText(/SSI/)).length).toBeGreaterThan(0); + expect(screen.queryByText('Could not load the variable names')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/V3VariablesGateV1.test.tsx b/app/src/tests/unit/components/flagship/V3VariablesGateV1.test.tsx new file mode 100644 index 000000000..2f578bcdd --- /dev/null +++ b/app/src/tests/unit/components/flagship/V3VariablesGateV1.test.tsx @@ -0,0 +1,35 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import V3VariablesGate from '@/components/flagship/V3VariablesGate'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { stubPublicApiFetch } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +describe('V3VariablesGate on the v1 routes (before the switch)', () => { + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given any metadata state then the content shows at once, with no request', () => { + // Given + const fetchStub = stubPublicApiFetch(); + store.dispatch(clearMetadata()); + + // When + render( + + +

Household builder

+
+
+ ); + + // Then + expect(screen.getByText('Household builder')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx new file mode 100644 index 000000000..f95924e81 --- /dev/null +++ b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx @@ -0,0 +1,105 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import V3VariablesGate from '@/components/flagship/V3VariablesGate'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { PRE_FORMAT_2_VARIABLES_RESULT } from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +function renderGate() { + return render( + + +

Household builder

+
+
+ ); +} + +/** What the variables route answers in the current test. */ +let answerVariables: () => Response | Promise; + +describe('V3VariablesGate with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + answerVariables = () => jsonResponse(catalogVariablesEnvelope()); + fetchStub = stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the variables still loading then a placeholder stands in, with no time limit', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderGate(); + + // Then + expect(screen.getByRole('status', { name: 'Loading the household builder' })).toBeVisible(); + expect(screen.queryByText('Household builder')).not.toBeInTheDocument(); + }); + + test('given the variables arrive then the content shows', async () => { + // When + renderGate(); + + // Then + expect(await screen.findByText('Household builder')).toBeInTheDocument(); + expect(store.getState().metadata.variables.age.label).toBe('Age'); + }); + + test('given the variables fail then the failure shows and try again asks again', async () => { + // Given + const user = userEvent.setup(); + answerVariables = failOnceThenAnswerVariables(); + renderGate(); + expect(await screen.findByRole('alert')).toHaveTextContent( + "Could not load the model's variables" + ); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText('Household builder')).toBeInTheDocument(); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(2); + }); + + test('given a catalog version published before catalog format 2 then the failure says why', async () => { + // Given + answerVariables = () => + jsonResponse({ status: 'ok', message: null, result: PRE_FORMAT_2_VARIABLES_RESULT }); + + // When + renderGate(); + + // Then + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent("Could not load the model's variables"); + expect(alert).toHaveTextContent(/published before catalog format 2/); + expect(screen.queryByText('Household builder')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx index 53c21372c..421716271 100644 --- a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx +++ b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; import { clearMetadata, fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import { store } from '@/store'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { modelCatalogEnvelope, TEST_POLICYENGINE_VERSION, @@ -28,7 +32,10 @@ describe('WorkspaceLayout with v3 on the v2 routes', () => { let fetchStub: ReturnType; beforeEach(async () => { - fetchStub = stubPublicApiFetch({ [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE) }); + fetchStub = stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), + }); await seedV2ModelCatalog(); }); @@ -54,6 +61,24 @@ describe('WorkspaceLayout with v3 on the v2 routes', () => { requestsTo(fetchStub, SEARCH_FILE_PATH)[0].searchParams.get('policyengine_version') ).toBe(TEST_POLICYENGINE_VERSION); }); + + test('given a page renders then the catalog version variables load behind it, once', async () => { + // When + render( + + +

Page content

+
+
+ ); + + // Then + expect(screen.getByText('Page content')).toBeInTheDocument(); + await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); + expect(requestsTo(fetchStub, VARIABLES_PATH)[0].searchParams.get('policyengine_version')).toBe( + TEST_POLICYENGINE_VERSION + ); + }); }); function renderLayout() { diff --git a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx new file mode 100644 index 000000000..40592fdc6 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import EconomicImpactCharts from '@/components/flagship/report/EconomicImpactCharts'; +import { clearMetadata, fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + REPORT_4332_OUTPUT, + withUKBreakdowns, +} from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks'; +import { testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +vi.mock('@/hooks/useCurrentCountry', () => ({ useCurrentCountry: vi.fn(() => 'uk') })); + +const mockDownloadCsv = vi.fn(); + +vi.mock('@/utils/chartUtils', async (importOriginal) => ({ + ...(await importOriginal()), + downloadCsv: (...args: unknown[]) => mockDownloadCsv(...args), +})); + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +// jsdom has no layout, so the container hands each chart a fixed size. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactElement }) => ( +
+ {React.cloneElement(children, { width: 700, height: 260 } as any)} +
+ ), + }; +}); + +let answerVariables: () => Response | Promise; + +function renderCharts() { + return render( + + + , + 'uk' + ); +} + +describe('EconomicImpactCharts for the UK with v3 on the v2 routes', () => { + beforeEach(async () => { + answerVariables = () => jsonResponse(catalogVariablesEnvelope()); + stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); + store.dispatch(clearMetadata()); + await store.dispatch(fetchMetadataThunk(metadataRequest('uk', 'v2'))); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the variables still loading then the programme card waits for their names', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderCharts(); + + // Then + const programmes = screen + .getByRole('button', { name: 'Expand Budgetary impact by programme' }) + .closest('section'); + expect(programmes).toContainElement( + screen.getByRole('status', { name: 'Loading programme names' }) + ); + }); + + test('given the variables arrive then the programme card shows its chart', async () => { + // When + renderCharts(); + + // Then + await waitFor(() => + expect( + screen.queryByRole('status', { name: 'Loading programme names' }) + ).not.toBeInTheDocument() + ); + const programmes = screen + .getByRole('button', { name: 'Expand Budgetary impact by programme' }) + .closest('section'); + expect(programmes).toHaveTextContent('Budgetary impact (£bn)'); + }); + + function programmeCsvButton() { + const programmes = screen + .getByRole('button', { name: 'Expand Budgetary impact by programme' }) + .closest('section') as HTMLElement; + return within(programmes).getByRole('button', { name: 'Download CSV' }); + } + + test('given the variables still loading then the programme CSV waits for their names', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderCharts(); + + // Then + expect(programmeCsvButton()).toHaveAttribute('aria-disabled', 'true'); + expect(programmeCsvButton()).toHaveAccessibleDescription('Loading programme names'); + }); + + test('given the variables fail then the programme CSV stays unavailable', async () => { + // Given + answerVariables = () => jsonResponse({ status: 'error', message: 'Unavailable' }, 503); + + // When + renderCharts(); + + // Then + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(programmeCsvButton()).toHaveAttribute('aria-disabled', 'true'); + expect(programmeCsvButton()).toHaveAccessibleDescription('Could not load the programme names'); + }); + + test('given the variables arrive then the programme CSV can be downloaded', async () => { + // When + renderCharts(); + + // Then + await waitFor(() => expect(programmeCsvButton()).not.toHaveAttribute('aria-disabled')); + }); + + test('given the programme CSV unavailable then clicking it downloads nothing', async () => { + // Given + const user = userEvent.setup(); + answerVariables = () => new Promise(() => {}); + renderCharts(); + + // When + await user.click(programmeCsvButton()); + + // Then + expect(mockDownloadCsv).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx new file mode 100644 index 000000000..8e1416352 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx @@ -0,0 +1,80 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportChanges from '@/components/flagship/report/ReportChanges'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + LIST_PROVISION, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +let answerVariables: () => Response | Promise; + +function renderWithQueries(ui: React.ReactNode) { + return render({ui}); +} + +describe('ReportChanges with v3 on the v2 routes', () => { + beforeEach(async () => { + answerVariables = () => jsonResponse(catalogVariablesEnvelope()); + stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the variables still loading then a report change list waits for their names', () => { + // Given + answerVariables = () => new Promise(() => {}); + + // When + renderWithQueries(); + + // Then + expect( + screen.getAllByRole('status', { name: 'Loading variable names' }).length + ).toBeGreaterThan(0); + expect(screen.queryByText(/ssi/)).not.toBeInTheDocument(); + }); + + test('given the variables arrive then a report change list names them by label', async () => { + // When + renderWithQueries(); + + // Then + expect((await screen.findAllByText(/SSI/)).length).toBeGreaterThan(0); + }); + + test('given the variables fail then the report change list says so and try again asks again', async () => { + // Given + const user = userEvent.setup(); + answerVariables = failOnceThenAnswerVariables(); + renderWithQueries(); + const [retry] = await screen.findAllByRole('button', { name: 'Try again' }); + expect(screen.getAllByText('Could not load the variable names').length).toBeGreaterThan(0); + + // When + await user.click(retry); + + // Then + expect((await screen.findAllByText(/SSI/)).length).toBeGreaterThan(0); + expect(screen.queryByText('Could not load the variable names')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx b/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx new file mode 100644 index 000000000..da2b2b2ba --- /dev/null +++ b/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@test-utils'; +import { Provider } from 'react-redux'; +import { describe, expect, test } from 'vitest'; +import VariableArithmetic from '@/components/household/VariableArithmetic'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; +import metadataReducer from '@/reducers/metadataReducer'; +import { + household, + V1_METADATA, + V2_METADATA, +} from '@/tests/fixtures/components/household/variableArithmeticParameterPartsMocks'; +import type { MetadataState } from '@/types/metadata'; + +function renderBreakdown(metadata: MetadataState, year: string, variableName: string): void { + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { metadata }, + }); + const ui: ReactNode = ( + + ); + render( + + {ui} + + ); +} + +describe('VariableArithmetic with parts named by a parameter', () => { + test('given v2 metadata and a 2025 report then benefits add the list in force in 2025', () => { + // When + renderBreakdown(V2_METADATA, '2025', 'household_benefits'); + + // Then + expect(screen.getByText(/Your SNAP/)).toBeInTheDocument(); + expect(screen.queryByText(/Your SSI/)).not.toBeInTheDocument(); + }); + + test('given v2 metadata and a report for the year the list changes then the new list applies', () => { + // When + renderBreakdown(V2_METADATA, '2026', 'household_benefits'); + + // Then + expect(screen.getByText(/Your SNAP/)).toBeInTheDocument(); + expect(screen.getByText(/Your SSI/)).toBeInTheDocument(); + }); + + test('given v2 metadata and a parameter without values then nothing is subtracted', () => { + // When + renderBreakdown(V2_METADATA, '2026', 'countable_income'); + + // Then + expect(screen.getByText(/Your earnings/)).toBeInTheDocument(); + expect(screen.queryByText(/Your SNAP/)).not.toBeInTheDocument(); + }); + + test('given v1 metadata then the parts still come from the whole parameter list', () => { + // When + renderBreakdown(V1_METADATA, '2025', 'household_benefits'); + + // Then + expect(screen.getByText(/Your SNAP/)).toBeInTheDocument(); + expect(screen.queryByText(/Your SSI/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx b/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx new file mode 100644 index 000000000..1c20a06a9 --- /dev/null +++ b/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx @@ -0,0 +1,52 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@test-utils'; +import { Provider } from 'react-redux'; +import { describe, expect, test } from 'vitest'; +import VariableArithmetic from '@/components/household/VariableArithmetic'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; +import metadataReducer from '@/reducers/metadataReducer'; +import { REAL_SHAPE_PARTS_VARIABLE } from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +import { + REAL_SHAPE_V2_METADATA, + taxUnitWithDeductions, +} from '@/tests/fixtures/components/household/variableArithmeticRealShapeMocks'; + +function renderBreakdown(year: string): void { + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { metadata: REAL_SHAPE_V2_METADATA }, + }); + render( + + + + + + ); +} + +describe('VariableArithmetic on a real variable whose parts a parameter names', () => { + test('given a 2017 report then the list in force in 2017 applies', () => { + // When + renderBreakdown('2017'); + + // Then + expect(screen.getByText(/Domestic production deduction/)).toBeInTheDocument(); + expect(screen.getByText(/Student loan interest deduction/)).toBeInTheDocument(); + }); + + test('given a 2018 report then the list from 2018 applies, without the dropped part', () => { + // When + renderBreakdown('2018'); + + // Then + expect(screen.queryByText(/Domestic production deduction/)).not.toBeInTheDocument(); + expect(screen.getByText(/Student loan interest deduction/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/household/VariablePickerDialogV2.test.ts b/app/src/tests/unit/components/household/VariablePickerDialogV2.test.ts new file mode 100644 index 000000000..cad03739c --- /dev/null +++ b/app/src/tests/unit/components/household/VariablePickerDialogV2.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { inputKind } from '@/components/household/VariablePickerDialog'; +import { store } from '@/store'; +import { + clearV2CatalogVariables, + seedV2CatalogVariables, +} from '@/tests/fixtures/api/publicApi/catalogVariablesStore'; +import { getInputVariables } from '@/utils/VariableResolver'; + +describe('inputKind on the v2 variables', () => { + beforeEach(async () => { + await seedV2CatalogVariables(); + }); + + afterEach(() => { + clearV2CatalogVariables(); + }); + + test('given units and periods then the picker describes each input as on v1', () => { + // Given + const inputs = new Map( + getInputVariables(store.getState().metadata).map((variable) => [variable.name, variable]) + ); + + // Then + expect(inputKind(inputs.get('employment_income')!)).toBe('Dollars per year'); + expect(inputKind(inputs.get('rent')!)).toBe('Dollars per month'); + expect(inputKind(inputs.get('age')!)).toBe('Number'); + }); + + test('given no unit or period then the picker describes the input as v1 does a variable without them', () => { + // Given + const undescribed = getInputVariables(store.getState().metadata).find( + (variable) => variable.name === 'undescribed_income' + ); + + // Then + expect(inputKind(undescribed!)).toBe('Number'); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx new file mode 100644 index 000000000..6dc1e5807 --- /dev/null +++ b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx @@ -0,0 +1,51 @@ +import { renderHook } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { stubPublicApiFetch } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +describe('useV3Variables on the v1 routes (before the switch)', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the whole metadata loaded then the variables are ready, with no request', () => { + // Given + const fetchStub = stubPublicApiFetch(); + seedDeductionMetadata(); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + expect(result.current.status).toBe('ready'); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + test('given the whole metadata still loading then the variables views do not wait', () => { + // Given + stubPublicApiFetch(); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + expect(result.current.status).toBe('ready'); + }); + + test('given a page that rendered then nothing loads in the background', () => { + // Given + const fetchStub = stubPublicApiFetch(); + seedDeductionMetadata(); + + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper() }); + + // Then + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx new file mode 100644 index 000000000..bba78a3a0 --- /dev/null +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -0,0 +1,230 @@ +import type { ReactNode } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@test-utils'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; +import { clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + BENEFITS_PARAMETER_PATH, + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + seedV2ModelCatalog, + testQueryClient, + v3Wrapper, +} from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +describe('useV3Variables with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), + }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given the model catalog only then the variables load once, into the store', async () => { + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + expect(result.current.status).toBe('loading'); + await waitFor(() => expect(result.current.status).toBe('ready')); + const requests = requestsTo(fetchStub, VARIABLES_PATH); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); + const metadata = store.getState().metadata; + expect(metadata.variables.employment_income.label).toBe('Employment income'); + expect(metadata.v2PartParameterValues?.[BENEFITS_PARAMETER_PATH]).toEqual({ + '0000-01-01': ['snap'], + '2026-01-01': ['snap', 'ssi'], + }); + }); + + test('given several readers then one request serves them all', async () => { + // Given + const queryClient = testQueryClient(); + + // When + const first = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + const second = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + + // Then + await waitFor(() => expect(first.result.current.status).toBe('ready')); + await waitFor(() => expect(second.result.current.status).toBe('ready')); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); + }); + + test('given a page that rendered then the variables start loading in the background', async () => { + // Given + const queryClient = testQueryClient(); + + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper(queryClient) }); + + // Then + await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); + }); + + test('given only the background load then the store fills, for readers without the hook', async () => { + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper(testQueryClient()) }); + + // Then + await waitFor(() => + expect(store.getState().metadata.variables.employment_income?.label).toBe('Employment income') + ); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); + }); + + test('given the page has moved to another country before its model catalog loads then nothing is requested', async () => { + // Given + const queryClient = testQueryClient(); + const ukWrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: ukWrapper }); + const reader = renderHook(() => useV3Variables(), { wrapper: ukWrapper }); + + // Then + // Both hooks have run their effects; nothing is fetching, and nothing was asked for. + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(0); + expect(reader.result.current.status).toBe('loading'); + }); + + test('given the model catalog loads again then the variables are requested again', async () => { + // Given + const queryClient = testQueryClient(); + const first = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + await waitFor(() => expect(first.result.current.status).toBe('ready')); + first.unmount(); + await seedV2ModelCatalog(); + expect(store.getState().metadata.variables).toEqual({}); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + + // Then + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(store.getState().metadata.variables.age.label).toBe('Age'); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(2); + }); + + test('given the current load cached then its entry outlives the old five-minute lifetime', async () => { + // Given + const queryClient = testQueryClient(); + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper(queryClient) }); + + // When + await waitFor(() => expect(store.getState().metadata.v2VariablesLoadId).toBeDefined()); + + // Then + const [cached] = queryClient.getQueryCache().findAll({ queryKey: ['v2-catalog-variables'] }); + expect(cached.gcTime).toBe(Infinity); + }); + + test("given the store already holds this load's variables then a later page asks for nothing, even without the cached entry", async () => { + // Given + const queryClient = testQueryClient(); + const first = renderHook(() => useV3VariablesInBackground(), { + wrapper: v3Wrapper(queryClient), + }); + await waitFor(() => expect(store.getState().metadata.v2VariablesLoadId).toBeDefined()); + first.unmount(); + queryClient.removeQueries({ queryKey: ['v2-catalog-variables'] }); + + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper(queryClient) }); + const reader = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); + + // Then + expect(reader.result.current.status).toBe('ready'); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); + }); + + test('given a new model catalog load then only its variables stay cached', async () => { + // Given + const queryClient = testQueryClient(); + const first = renderHook(() => useV3VariablesInBackground(), { + wrapper: v3Wrapper(queryClient), + }); + await waitFor(() => expect(store.getState().metadata.v2VariablesLoadId).toBeDefined()); + first.unmount(); + await seedV2ModelCatalog(); + const currentLoad = selectV2LoadId(store.getState()); + + // When + renderHook(() => useV3VariablesInBackground(), { wrapper: v3Wrapper(queryClient) }); + + // Then + await waitFor(() => expect(store.getState().metadata.v2VariablesLoadId).toBe(currentLoad)); + const cached = queryClient.getQueryCache().findAll({ queryKey: ['v2-catalog-variables'] }); + expect(cached.map((query) => query.queryKey[3])).toEqual([currentLoad]); + }); + + test('given the variables route fails then the status says so and can retry', async () => { + // Given + vi.unstubAllGlobals(); + stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse({ status: 'error', message: 'unavailable' }, 503), + }); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + await waitFor(() => expect(result.current.status).toBe('error')); + expect(typeof result.current.retry).toBe('function'); + }); +}); + +describe('useV3Variables before the model catalog has loaded', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given no catalog version yet then nothing is requested and the status waits', () => { + // Given + const fetchStub = stubPublicApiFetch(); + store.dispatch(clearMetadata()); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + expect(result.current.status).toBe('loading'); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(0); + }); +}); diff --git a/app/src/tests/unit/libs/metadataUtilsV2.test.ts b/app/src/tests/unit/libs/metadataUtilsV2.test.ts new file mode 100644 index 000000000..7abc9c3a3 --- /dev/null +++ b/app/src/tests/unit/libs/metadataUtilsV2.test.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { getBasicInputFields } from '@/libs/metadataUtils'; +import { store } from '@/store'; +import { + clearV2CatalogVariables, + seedV2CatalogVariables, +} from '@/tests/fixtures/api/publicApi/catalogVariablesStore'; + +describe('getBasicInputFields on the v2 routes: default fields from /v2/model-catalog, entities from the variables', () => { + beforeEach(async () => { + await seedV2CatalogVariables(); + }); + + afterEach(() => { + clearV2CatalogVariables(); + }); + + test('given the model catalog and its variables then the default fields sit under their entities', () => { + // When + const fields = getBasicInputFields(store.getState()); + + // Then + expect(fields.person).toEqual(['age', 'employment_income']); + expect(fields.household).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx index 30e7c359a..ec57510b8 100644 --- a/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx @@ -6,6 +6,10 @@ import BillReportPage from '@/pages/flagship/BillReport.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, requestsTo, @@ -56,6 +60,20 @@ vi.mock('@/libs/flagship/billMetrics', async (importOriginal) => { }); const mockUseBillEconomy = vi.fn(); +const mockUseV3VariablesInBackground = vi.fn(); + +/** The real variables hooks, with the page's background load observed. */ +vi.mock('@/hooks/useV3Variables', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useV3VariablesInBackground: () => { + mockUseV3VariablesInBackground(); + actual.useV3VariablesInBackground(); + }, + }; +}); + /** v3 after the switch: V3_API_ROUTES is 'v2'. */ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ ...(await importOriginal()), @@ -164,4 +182,25 @@ describe('BillReportPage with v3 on the v2 routes', () => { expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument() ); }); + + test('given the page renders then it starts loading the variables in the background', async () => { + // Given + const fetchStub = stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); + await seedV2ModelCatalog(); + + // When + renderReport(); + + // Then + expect(mockUseV3VariablesInBackground).toHaveBeenCalled(); + await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); + }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index 53bae25fc..76605e3bc 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -6,6 +6,10 @@ import FlagshipReportPage from '@/pages/flagship/Report.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, requestsTo, @@ -35,6 +39,20 @@ vi.mock('@/hooks/useCalculationStatus', () => ({ useCalculationStatus: () => mockCalcStatus(), })); +const mockUseV3VariablesInBackground = vi.fn(); + +/** The real variables hooks, with the page's background load observed. */ +vi.mock('@/hooks/useV3Variables', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useV3VariablesInBackground: () => { + mockUseV3VariablesInBackground(); + actual.useV3VariablesInBackground(); + }, + }; +}); + /** v3 after the switch: V3_API_ROUTES is 'v2'. */ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ ...(await importOriginal()), @@ -157,4 +175,50 @@ describe('FlagshipReportPage with v3 on the v2 routes', () => { expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument() ); }); + + test('given the variables still loading then the household tab waits for them', async () => { + // Given + const user = userEvent.setup(); + let answerVariables: () => void = () => {}; + stubPublicApiFetch({ + [PARAMETERS_PATH]: answerParameters, + [VARIABLES_PATH]: () => + new Promise((resolve) => { + answerVariables = () => resolve(jsonResponse(catalogVariablesEnvelope())); + }), + }); + await seedV2ModelCatalog(); + renderPage(EXISTING_REPORT_ID); + + // When + await user.click(await screen.findByRole('tab', { name: 'Household' })); + + // Then + expect( + await screen.findByRole('status', { name: 'Loading the household builder' }) + ).toBeInTheDocument(); + answerVariables(); + await waitFor(() => + expect( + screen.queryByRole('status', { name: 'Loading the household builder' }) + ).not.toBeInTheDocument() + ); + expect(store.getState().metadata.variables.employment_income.label).toBe('Employment income'); + }); + + test('given the page renders then it starts loading the variables in the background', async () => { + // Given + const fetchStub = stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), + [PARAMETERS_PATH]: answerParameters, + }); + await seedV2ModelCatalog(); + + // When + renderPage(EXISTING_REPORT_ID); + + // Then + expect(mockUseV3VariablesInBackground).toHaveBeenCalled(); + await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); + }); }); diff --git a/app/src/tests/unit/reducers/metadataReducerV2.test.ts b/app/src/tests/unit/reducers/metadataReducerV2.test.ts index 197c4752e..520ed4550 100644 --- a/app/src/tests/unit/reducers/metadataReducerV2.test.ts +++ b/app/src/tests/unit/reducers/metadataReducerV2.test.ts @@ -1,10 +1,16 @@ import { configureStore } from '@reduxjs/toolkit'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; import metadataReducer, { catalogParametersLoaded, + catalogVariablesLoaded, fetchMetadataThunk, metadataRequest, } from '@/reducers/metadataReducer'; +import { + AGE_VARIABLE, + BENEFITS_PARAMETER_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { TEST_POLICYENGINE_VERSION, TEST_TAX_BENEFIT_MODEL_ID, @@ -194,4 +200,59 @@ describe('metadataReducer with the v2 routes', () => { expect(store.getState().metadata.v2LoadId).not.toBe(earlierLoad); expect(store.getState().metadata.parameters).toEqual({}); }); + + test('given variables loaded under the current model catalog load then they join the store', async () => { + // Given + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + const loadId = store.getState().metadata.v2LoadId!; + + // When + store.dispatch( + catalogVariablesLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId, + variables: { age: v1ShapedVariable(AGE_VARIABLE) }, + partParameterValues: { [BENEFITS_PARAMETER_PATH]: { '0000-01-01': ['snap'] } }, + }) + ); + + // Then + const state = store.getState().metadata; + expect(state.variables.age.label).toBe('Age'); + expect(state.v2PartParameterValues).toEqual({ + [BENEFITS_PARAMETER_PATH]: { '0000-01-01': ['snap'] }, + }); + expect(state.v2VariablesLoadId).toBe(loadId); + }); + + test.each([ + ['an earlier model catalog load', { loadId: 'an-earlier-load' }], + ['another catalog version', { policyengineVersion: '0.0.1' }], + ['another country', { countryId: 'uk' }], + ])('given variables asked for under %s then they are dropped', async (_case, mismatch) => { + // Given + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // When + store.dispatch( + catalogVariablesLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: store.getState().metadata.v2LoadId!, + variables: { stale: v1ShapedVariable({ ...AGE_VARIABLE, name: 'stale' }) }, + partParameterValues: {}, + ...mismatch, + }) + ); + + // Then + const state = store.getState().metadata; + expect(state.variables).toEqual({}); + expect(state.v2VariablesLoadId).toBeUndefined(); + }); }); diff --git a/app/src/tests/unit/utils/VariableResolverV2.test.ts b/app/src/tests/unit/utils/VariableResolverV2.test.ts new file mode 100644 index 000000000..3b53618b1 --- /dev/null +++ b/app/src/tests/unit/utils/VariableResolverV2.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { store } from '@/store'; +import { + clearV2CatalogVariables, + seedV2CatalogVariables, +} from '@/tests/fixtures/api/publicApi/catalogVariablesStore'; +import { getInputVariables } from '@/utils/VariableResolver'; + +describe('getInputVariables on the v2 variables', () => { + beforeEach(async () => { + await seedV2CatalogVariables(); + }); + + afterEach(() => { + clearV2CatalogVariables(); + }); + + test('given v2 variables then the picker offers entered variables and hides hidden inputs', () => { + // When + const names = getInputVariables(store.getState().metadata).map((variable) => variable.name); + + // Then + expect(names).toEqual(expect.arrayContaining(['age', 'employment_income', 'filing_status'])); + expect(names).not.toContain('medicaid_rating_area'); + expect(names).not.toContain('snap'); + }); + + test('given an enum variable then the picker reads its value labels', () => { + // When + const filingStatus = getInputVariables(store.getState().metadata).find( + (variable) => variable.name === 'filing_status' + ); + + // Then + expect(filingStatus?.possibleValues).toEqual([ + { value: 'SINGLE', label: 'Single' }, + { value: 'JOINT', label: 2 }, + ]); + }); +}); diff --git a/app/src/tests/unit/utils/householdValuesParameterParts.test.ts b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts new file mode 100644 index 000000000..3aa1a9046 --- /dev/null +++ b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest'; +import { + BENEFITS_PARAMETER_PATH, + BENEFITS_PART_VALUES, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { getParameterNamedParts } from '@/utils/householdValues'; + +/** The same parameter in v1's whole parameter list, with a different list. */ +const V1_PARAMETERS = { [BENEFITS_PARAMETER_PATH]: { values: { '0000-01-01': ['wic'] } } }; + +describe('getParameterNamedParts', () => { + test('given the day before a change then the earlier list applies', () => { + // When + const parts = getParameterNamedParts( + BENEFITS_PARAMETER_PATH, + '2025-12-31', + {}, + BENEFITS_PART_VALUES + ); + + // Then + expect(parts).toEqual(['snap']); + }); + + test('given the day a change starts then the new list applies', () => { + // When + const parts = getParameterNamedParts( + BENEFITS_PARAMETER_PATH, + '2026-01-01', + {}, + BENEFITS_PART_VALUES + ); + + // Then + expect(parts).toEqual(['snap', 'ssi']); + }); + + test('given part values and a parameter list then the part values take precedence', () => { + // When + const parts = getParameterNamedParts( + BENEFITS_PARAMETER_PATH, + '2025-01-01', + V1_PARAMETERS, + BENEFITS_PART_VALUES + ); + + // Then + expect(parts).toEqual(['snap']); + }); + + test('given no part values then the parameter list is read, as on v1', () => { + // When + const parts = getParameterNamedParts(BENEFITS_PARAMETER_PATH, '2025-01-01', V1_PARAMETERS); + + // Then + expect(parts).toEqual(['wic']); + }); + + test('given a parameter with no values or no entry then nothing is listed', () => { + // When + const withoutValues = getParameterNamedParts( + BENEFITS_PARAMETER_PATH, + '2025-01-01', + {}, + { + [BENEFITS_PARAMETER_PATH]: {}, + } + ); + const withoutEntry = getParameterNamedParts('gov.unknown', '2025-01-01', {}); + + // Then + expect(withoutValues).toEqual([]); + expect(withoutEntry).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/utils/householdValuesV2.test.ts b/app/src/tests/unit/utils/householdValuesV2.test.ts new file mode 100644 index 000000000..31812b220 --- /dev/null +++ b/app/src/tests/unit/utils/householdValuesV2.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { store } from '@/store'; +import { + clearV2CatalogVariables, + seedV2CatalogVariables, +} from '@/tests/fixtures/api/publicApi/catalogVariablesStore'; +import { formatVariableValue } from '@/utils/householdValues'; + +describe('formatVariableValue on the v2 variables', () => { + beforeEach(async () => { + await seedV2CatalogVariables(); + }); + + afterEach(() => { + clearV2CatalogVariables(); + }); + + test('given units then the breakdown formats currencies and rates as on v1', () => { + // Given + const { variables } = store.getState().metadata; + + // Then + expect(formatVariableValue(variables.employment_income, 30000)).toBe('$30,000'); + expect(formatVariableValue(variables.marginal_tax_rate, 0.25)).toBe('25%'); + expect(formatVariableValue(variables.undescribed_income, 1200)).toBe('1,200'); + }); +}); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index f5fbba199..22ffac06e 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -118,9 +118,23 @@ export interface MetadataState { v2MissingParameterNames?: string[]; /** * Which load of the v2 model catalog the store holds: a new load empties - * the parameters loaded on demand, so their queries are keyed by it. + * the parameters loaded on demand and the variables loaded in the + * background, so their queries are keyed by it. */ v2LoadId?: string; + /** + * On the v2 routes, `variables` starts empty and fills once per model + * catalog load, in the background, with every variable of the catalog + * version: this is the load (`v2LoadId`) they were loaded under, absent + * until they are in. + */ + v2VariablesLoadId?: string; + /** + * On the v2 routes, the dated values of each parameter a variable's adds + * or subtracts names (a list of variable names per date), which the + * household breakdown reads for the report's year. + */ + v2PartParameterValues?: Record; // Computed parameter tree for policy creation UI (built when metadata is fetched) parameterTree: ParameterTreeNode | null; diff --git a/app/src/utils/householdValues.ts b/app/src/utils/householdValues.ts index f5a4c259a..a3c2dcd3a 100644 --- a/app/src/utils/householdValues.ts +++ b/app/src/utils/householdValues.ts @@ -141,6 +141,27 @@ export function getParameterAtInstant(parameter: any, instant: string): any { return result || []; } +/** + * The variables a parameter-named `adds` or `subtracts` lists at an instant. + * + * On the v1 routes the parameter is in the whole parameter list. On the v2 + * routes there is no whole parameter list: the variables response carries + * the dated values of every parameter that adds or subtracts name + * (`partParameterValues`), and those take precedence. A parameter with no + * value at the instant lists nothing. + */ +export function getParameterNamedParts( + parameterPath: string, + instant: string, + parameters: Record, + partParameterValues?: Record> +): string[] { + const partValues = partParameterValues?.[parameterPath]; + const parameter = partValues ? { values: partValues } : parameters[parameterPath]; + const parts = parameter ? getParameterAtInstant(parameter, instant) : []; + return Array.isArray(parts) ? parts : []; +} + /** * Determines if a variable should be shown based on whether it has non-zero values * in baseline and/or reform scenarios