From e03e9bbbad43e01b7e6c1405be53433a84bc4c4c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:58:40 +0400 Subject: [PATCH 01/17] Load all of v3's variables once in the background on the v2 routes On the v2 routes the metadata store starts with no variables. After a v3 page renders, every variable of the catalog version now loads once from `GET /v2/model-catalog/variables`, in the shape v1's metadata gave each variable, so the household builder, its variable picker and the breakdown read them unchanged. The dated values of each parameter a variable's adds or subtracts names come with them and join the store as well. The response is cached per catalog version for the session, and a response for another country or version is dropped. `useV3Variables` says whether they are in, for views that wait for them. On the v1 routes (still the default), nothing changes and nothing is requested. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogVariables.ts | 112 +++++++++++ .../components/flagship/WorkspaceLayout.tsx | 7 +- app/src/hooks/useV3Variables.ts | 132 +++++++++++++ app/src/reducers/metadataReducer.ts | 31 +++- .../api/publicApi/catalogVariablesMocks.ts | 113 ++++++++++++ .../api/publicApi/catalogVariables.test.ts | 154 ++++++++++++++++ .../flagship/WorkspaceLayoutV2.test.tsx | 25 ++- .../unit/hooks/useV3VariablesV1.test.tsx | 51 +++++ .../unit/hooks/useV3VariablesV2.test.tsx | 174 ++++++++++++++++++ app/src/types/metadata.ts | 12 ++ 10 files changed, 807 insertions(+), 4 deletions(-) create mode 100644 app/src/api/publicApi/catalogVariables.ts create mode 100644 app/src/hooks/useV3Variables.ts create mode 100644 app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts create mode 100644 app/src/tests/unit/api/publicApi/catalogVariables.test.ts create mode 100644 app/src/tests/unit/hooks/useV3VariablesV1.test.tsx create mode 100644 app/src/tests/unit/hooks/useV3VariablesV2.test.tsx diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts new file mode 100644 index 000000000..980d1b912 --- /dev/null +++ b/app/src/api/publicApi/catalogVariables.ts @@ -0,0 +1,112 @@ +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +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 unit, definition period, + * 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; + 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 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'; + +export function v1DatedValues(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, dated.value]; + }) + ) as ValuesList; +} + +export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1ShapedVariable { + 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: variable.data_type, + defaultValue: 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; +} + +/** All of a catalog version's variables, with the values their parameter-named parts read. */ +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: countryId as 'us' | 'uk', + policyengine_version: policyengineVersion, + }, + }, + }) + ); + 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(values), + ]) + ), + }; +} 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/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts new file mode 100644 index 000000000..f0db03ed1 --- /dev/null +++ b/app/src/hooks/useV3Variables.ts @@ -0,0 +1,132 @@ +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 } 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, and calibration matches. + * + * On the v1 routes the whole metadata holds every variable, so they are + * ready once it is. On the v2 routes the store starts with none: all of + * the catalog version's variables load once, in the background, after a + * v3 page renders, and views that read them show their loading state until + * they are in, with no time limit. + */ +export interface V3VariablesStatus { + status: 'loading' | 'error' | 'ready'; + /** + * Whether the variables are being loaded right now: only ever on the v2 + * routes. On v1, 'loading' means the whole metadata is not in yet, which + * views that never waited for it before can ignore. + */ + waiting: boolean; + /** The failure's message, when the status is 'error'. */ + message: string | null; + retry: () => unknown; +} + +/** The query key of a catalog version's variables. */ +export function v2CatalogVariablesKey(countryId: string, policyengineVersion: string | null) { + return ['v2-catalog-variables', countryId, policyengineVersion] as const; +} + +/** A catalog version's variables never change, so they are fetched once per session. */ +const VARIABLES_QUERY_OPTIONS = { staleTime: Infinity, gcTime: Infinity } as const; + +function useV2CatalogVersion(): string | null { + return useSelector((state: RootState) => + state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null + ); +} + +function useV1Variables(): V3VariablesStatus { + const metadata = useFlagshipMetadataStatus(); + return { + status: metadata.status, + waiting: false, + message: metadata.message, + retry: metadata.retry, + }; +} + +function useV2Variables(): V3VariablesStatus { + const countryId = useCurrentCountry(); + const dispatch = useDispatch(); + const metadata = useFlagshipMetadataStatus(); + const policyengineVersion = useV2CatalogVersion(); + const loadedVersion = useSelector((state: RootState) => state.metadata.v2VariablesVersion); + const inStore = policyengineVersion !== null && loadedVersion === policyengineVersion; + const query = useQuery({ + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion), + queryFn: () => fetchCatalogVariables(countryId, policyengineVersion!), + enabled: policyengineVersion !== null, + ...VARIABLES_QUERY_OPTIONS, + }); + + // The query holds the response; the store holds what views read. They + // part only when the store is cleared (a country change) while the query + // is cached, so the store is filled from the cache whenever it lacks them. + useEffect(() => { + if (query.data && policyengineVersion !== null && !inStore) { + dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, ...query.data })); + } + }, [query.data, policyengineVersion, inStore, countryId, dispatch]); + + if (inStore) { + return { status: 'ready', waiting: false, message: null, retry: query.refetch }; + } + if (metadata.status === 'error') { + return { status: 'error', waiting: false, message: metadata.message, retry: metadata.retry }; + } + if (query.isError) { + return { + status: 'error', + waiting: false, + message: query.error instanceof Error ? query.error.message : null, + retry: query.refetch, + }; + } + return { status: 'loading', waiting: true, 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 by the time a household is built or broken + * down. Nothing on the v1 routes, where the whole metadata holds them. + */ +function useV2VariablesInBackground(): void { + const countryId = useCurrentCountry(); + const policyengineVersion = useV2CatalogVersion(); + const queryClient = useQueryClient(); + useEffect(() => { + if (policyengineVersion === null) { + return; + } + void queryClient.prefetchQuery({ + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion), + queryFn: () => fetchCatalogVariables(countryId, policyengineVersion), + ...VARIABLES_QUERY_OPTIONS, + }); + }, [countryId, policyengineVersion, queryClient]); +} + +function noBackgroundLoad(): void {} + +export const useV3VariablesInBackground: () => void = + V3_API_ROUTES === 'v2' ? useV2VariablesInBackground : noBackgroundLoad; diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index def126dec..acff0e342 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -5,6 +5,7 @@ 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 +133,33 @@ 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 + * country or version is dropped. + */ + catalogVariablesLoaded( + state, + action: PayloadAction<{ + countryId: string; + policyengineVersion: string; + variables: Record; + partParameterValues: Record; + }> + ) { + const { countryId, policyengineVersion, variables, partParameterValues } = action.payload; + if ( + state.routes !== 'v2' || + state.currentCountry !== countryId || + state.v2Catalog?.policyengineVersion !== policyengineVersion + ) { + return; + } + state.variables = variables; + state.v2PartParameterValues = partParameterValues; + state.v2VariablesVersion = policyengineVersion; + }, }, extraReducers: (builder) => { builder @@ -205,7 +233,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..523dc7faf --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -0,0 +1,113 @@ +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, + ...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', +}); + +export const RATING_AREA_VARIABLE = variable('medicaid_rating_area', { + label: 'Medicaid rating area', + data_type: 'int', + is_input_variable: true, + hidden_input: true, +}); + +export const FILING_STATUS_VARIABLE = variable('filing_status', { + label: 'Filing status', + data_type: 'Enum', + 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' }); +export const SSI_VARIABLE = variable('ssi', { label: 'SSI' }); + +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, + SNAP_VARIABLE, + SSI_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 }; +} 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..690d4447b --- /dev/null +++ b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts @@ -0,0 +1,154 @@ +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, +} 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', + 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 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({ + policyengine_version: TEST_POLICYENGINE_VERSION, + catalog_format: 2, + variables: [ + { + id: 'variable-unlabelled', + name: 'unlabelled_variable', + label: null, + entity: 'person', + description: null, + data_type: 'float', + possible_values: null, + default_value: 0, + adds: null, + subtracts: null, + }, + ], + part_parameter_values: {}, + }) + ) + ); + 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/components/flagship/WorkspaceLayoutV2.test.tsx b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx index 53c21372c..c323d850c 100644 --- a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx +++ b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx @@ -4,6 +4,7 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { modelCatalogEnvelope, TEST_POLICYENGINE_VERSION, @@ -23,12 +24,16 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ })); const SEARCH_FILE_PATH = '/v2/parameters/search-file'; +const VARIABLES_PATH = '/v2/model-catalog/variables'; 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 +59,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/hooks/useV3VariablesV1.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx new file mode 100644 index 000000000..172dcf36a --- /dev/null +++ b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx @@ -0,0 +1,51 @@ +import { renderHook } from '@testing-library/react'; +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).toMatchObject({ status: 'ready', waiting: false }); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + test('given the whole metadata still loading then nothing waits on the v2 routes', () => { + // Given + stubPublicApiFetch(); + + // When + const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); + + // Then + expect(result.current.waiting).toBe(false); + }); + + 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..94ad2d520 --- /dev/null +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -0,0 +1,174 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; +import { catalogVariablesLoaded, clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + BENEFITS_PARAMETER_PATH, + catalogVariablesEnvelope, +} 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', +})); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +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).toMatchObject({ status: 'loading', waiting: true }); + 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 the store cleared while the response is cached then the store is filled again without a request', 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(1); + }); + + 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(result.current.waiting).toBe(false); + expect(typeof result.current.retry).toBe('function'); + }); + + test('given variables for another catalog version then the store drops them', () => { + // When + store.dispatch( + catalogVariablesLoaded({ + countryId: 'us', + policyengineVersion: '0.0.1', + variables: { stale: { name: 'stale' } }, + partParameterValues: {}, + }) + ); + + // Then + const metadata = store.getState().metadata; + expect(metadata.variables).toEqual({}); + expect(metadata.v2VariablesVersion).toBeUndefined(); + }); + + test('given variables for another country then the store drops them', () => { + // When + store.dispatch( + catalogVariablesLoaded({ + countryId: 'uk', + policyengineVersion: TEST_POLICYENGINE_VERSION, + variables: { stale: { name: 'stale' } }, + partParameterValues: {}, + }) + ); + + // Then + expect(store.getState().metadata.variables).toEqual({}); + }); +}); + +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/types/metadata.ts b/app/src/types/metadata.ts index f5fbba199..be4ce4ab0 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -121,6 +121,18 @@ export interface MetadataState { * the parameters loaded on demand, so their queries are keyed by it. */ v2LoadId?: string; + /** + * On the v2 routes, `variables` starts empty and fills once, in the + * background, with every variable of the catalog version named here; + * absent until they are in. + */ + v2VariablesVersion?: 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; From ea09ed1a26908e8e6535daa9d177d696bf7c66f5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:00:17 +0400 Subject: [PATCH 02/17] Read a parameter-named household breakdown list for the report's year from v2's part values Where a variable's adds or subtracts names a parameter, the household breakdown (`VariableArithmetic`) used the list that parameter holds in the report's year, read from the whole parameter list. On the v2 routes there is no whole parameter list, so it now reads the dated values the variables response carries for that parameter, and the whole parameter list on v1, as before. A parameter with no values lists nothing. Co-Authored-By: Claude Opus 5.5 --- .../household/VariableArithmetic.tsx | 25 +-- .../VariableArithmeticParameterParts.test.tsx | 149 ++++++++++++++++++ .../householdValuesParameterParts.test.ts | 37 +++++ app/src/utils/householdValues.ts | 21 +++ 4 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx create mode 100644 app/src/tests/unit/utils/householdValuesParameterParts.test.ts diff --git a/app/src/components/household/VariableArithmetic.tsx b/app/src/components/household/VariableArithmetic.tsx index 4d8ac1b9f..c71b6084a 100644 --- a/app/src/components/household/VariableArithmetic.tsx +++ b/app/src/components/household/VariableArithmetic.tsx @@ -12,7 +12,7 @@ import { getDisplayStyleConfig } from '@/utils/householdDisplayStyles'; import { getVariableDisplayText } from '@/utils/householdDisplayText'; import { formatVariableValue, - getParameterAtInstant, + getParameterNamedParts, shouldShowVariable, } from '@/utils/householdValues'; @@ -48,6 +48,9 @@ export default function VariableArithmetic({ const reportYear = useReportYear(); const variables = useSelector((state: RootState) => 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/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx b/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx new file mode 100644 index 000000000..9e6b0118e --- /dev/null +++ b/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@testing-library/react'; +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 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. */ +const BENEFITS_PARTS = 'gov.household.household_benefits'; +/** A parameter the catalog version does not have values for. */ +const ABSENT_PARTS = 'gov.example.absent_deductions'; + +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. */ +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. */ +const V1_METADATA = metadataState({ + parameters: { + [BENEFITS_PARTS]: { + type: 'parameter', + parameter: BENEFITS_PARTS, + label: 'Benefits', + values: BENEFITS_PARTS_VALUES, + } as any, + }, +}); + +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; +} + +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/utils/householdValuesParameterParts.test.ts b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts new file mode 100644 index 000000000..d8be6e56a --- /dev/null +++ b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { getParameterNamedParts } from '@/utils/householdValues'; + +const PARTS_PATH = 'gov.household.household_benefits'; +const PART_VALUES = { [PARTS_PATH]: { '0000-01-01': ['snap'], '2026-01-01': ['snap', 'ssi'] } }; + +describe('getParameterNamedParts', () => { + test('given the day before a change then the earlier list applies', () => { + expect(getParameterNamedParts(PARTS_PATH, '2025-12-31', {}, PART_VALUES)).toEqual(['snap']); + }); + + test('given the day a change starts then the new list applies', () => { + expect(getParameterNamedParts(PARTS_PATH, '2026-01-01', {}, PART_VALUES)).toEqual([ + 'snap', + 'ssi', + ]); + }); + + test('given part values and a parameter list then the part values take precedence', () => { + const parameters = { [PARTS_PATH]: { values: { '0000-01-01': ['wic'] } } }; + + expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', parameters, PART_VALUES)).toEqual([ + 'snap', + ]); + }); + + test('given no part values then the parameter list is read, as on v1', () => { + const parameters = { [PARTS_PATH]: { values: { '0000-01-01': ['wic'] } } }; + + expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', parameters)).toEqual(['wic']); + }); + + test('given a parameter with no values or no entry then nothing is listed', () => { + expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', {}, { [PARTS_PATH]: {} })).toEqual([]); + expect(getParameterNamedParts('gov.unknown', '2025-01-01', {})).toEqual([]); + }); +}); 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 From a5c581115e2cd3f96ffda39ffd1d9ba0d61fcea1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:07:03 +0400 Subject: [PATCH 03/17] Have v3's views that read variables wait for them on the v2 routes On the v2 routes the variables load in the background, so the views that read them now show a placeholder until they are in, with no time limit, and a failure says so and asks again: - the report's household section (the builder, its variable picker and the breakdown), behind `V3VariablesGate`; - the budgetary impact by programme chart, whose programme names are variable labels; - calibration matches and the geography fit, whose target names are; - a reform's changes that list variables, in the provision list and in the report's changes. The household builder's default fields and entities come from `/v2/model-catalog` through the metadata store, and a test pins that they, and the variable picker's entered, hidden and enum fields, read correctly from the v2 variables. On the v1 routes (still the default) every view shows as before and nothing is requested. Co-Authored-By: Claude Opus 5.5 --- .../flagship/CalibrationMatches.tsx | 16 +++ app/src/components/flagship/ProvisionList.tsx | 8 +- .../components/flagship/V3VariablesGate.tsx | 64 ++++++++++++ .../flagship/report/EconomicImpactCharts.tsx | 6 +- .../flagship/report/ReportChanges.tsx | 20 +++- .../components/flagship/report/ReportView.tsx | 26 ++--- .../flagship/CalibrationMatchesV2.test.tsx | 68 +++++++++++++ .../flagship/V3VariablesGateV1.test.tsx | 35 +++++++ .../flagship/V3VariablesGateV2.test.tsx | 93 ++++++++++++++++++ .../flagship/VariableListChangesV2.test.tsx | 94 ++++++++++++++++++ .../report/EconomicImpactChartsV2.test.tsx | 97 +++++++++++++++++++ .../householdBuilderOnV2Variables.test.ts | 81 ++++++++++++++++ .../unit/pages/flagship/ReportV2.test.tsx | 32 ++++++ 13 files changed, 623 insertions(+), 17 deletions(-) create mode 100644 app/src/components/flagship/V3VariablesGate.tsx create mode 100644 app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx create mode 100644 app/src/tests/unit/components/flagship/V3VariablesGateV1.test.tsx create mode 100644 app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx create mode 100644 app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx create mode 100644 app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx create mode 100644 app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index ebbb5bf10..b71f58d49 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -4,6 +4,7 @@ import { STATE_NAMES } from '@/api/billFeed'; 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 +327,18 @@ 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.waiting) { + return ( + <> + {heading} + + + ); + } if (fit === undefined) { return ( <> @@ -450,6 +461,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 +493,9 @@ export function CalibrationMatchSection({ if (matches === undefined) { return card(); } + if (variablesStatus.waiting) { + 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..949063c3c 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 { 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.waiting ? ( + + ) : listChange ? ( diff --git a/app/src/components/flagship/V3VariablesGate.tsx b/app/src/components/flagship/V3VariablesGate.tsx new file mode 100644 index 000000000..0fa9e608b --- /dev/null +++ b/app/src/components/flagship/V3VariablesGate.tsx @@ -0,0 +1,64 @@ +import { ErrorState } from '@/components/flagship/states'; +import { Skeleton } from '@/components/ui'; +import { spacing } from '@/designTokens'; +import { useV3Variables } from '@/hooks/useV3Variables'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; + +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 ( + + + + ); +} + +/** + * On the v2 routes, content that reads the model's variables (the household + * builder and breakdown, programme names) waits behind a placeholder until + * the catalog version's variables, loading in the background, are in, with + * no time limit; a failure says so and asks again. On the v1 routes the + * whole metadata holds the variables, so the content shows as before. + */ +function V2VariablesGate({ children, label, placeholder }: V3VariablesGateProps) { + const variables = useV3Variables(); + if (variables.status === 'error') { + return ; + } + if (variables.status !== 'ready') { + return <>{placeholder ?? }; + } + return <>{children}; +} + +function V1VariablesGate({ children }: V3VariablesGateProps) { + return <>{children}; +} + +export default V3_API_ROUTES === 'v2' ? V2VariablesGate : V1VariablesGate; diff --git a/app/src/components/flagship/report/EconomicImpactCharts.tsx b/app/src/components/flagship/report/EconomicImpactCharts.tsx index 64aea08b9..90ecebdd2 100644 --- a/app/src/components/flagship/report/EconomicImpactCharts.tsx +++ b/app/src/components/flagship/report/EconomicImpactCharts.tsx @@ -14,6 +14,7 @@ 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'; @@ -179,7 +180,10 @@ export default function EconomicImpactCharts({ output }: { output: SocietyWideRe csvFilename="budgetary-impact-by-program.csv" csvData={getDetailedBudgetCsvRows(output, metadata)} > - + {/* 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..bcf088e44 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 { 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,15 @@ 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.waiting ? ( + + ) : ( + describeChange(provision.baselineValue, provision.value, String, variableLabel) + ) + ) : ( + format(provision.value) + ), currentLaw, dates: provisionDates(provision), }; 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)} + /> + ) : ( <> ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +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(); + }); +}); 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..543d44b70 --- /dev/null +++ b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx @@ -0,0 +1,93 @@ +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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +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', +})); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +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(); + let attempts = 0; + answerVariables = () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(catalogVariablesEnvelope()); + }; + 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); + }); +}); diff --git a/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx b/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx new file mode 100644 index 000000000..b50e22116 --- /dev/null +++ b/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx @@ -0,0 +1,94 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import ProvisionList from '@/components/flagship/ProvisionList'; +import ReportChanges from '@/components/flagship/report/ReportChanges'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogVariablesEnvelope } 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', +})); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +/** A reform that adds SSI to the benefits a household's total counts. */ +const LIST_PROVISION = { + path: 'gov.household.household_benefits', + breadcrumb: 'Household → Benefits', + unit: null, + baselineValue: ['snap'], + value: ['snap', 'ssi'], +}; + +let answerVariables: () => Response | Promise; + +function renderWithQueries(ui: React.ReactNode) { + return render({ui}); +} + +describe('Lists of variables in v3 changes, 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 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); + }); +}); 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..88a216979 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } 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 } 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') })); + +/** 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)} +
+ ), + }; +}); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +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)'); + }); +}); diff --git a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts new file mode 100644 index 000000000..9b1d00cb6 --- /dev/null +++ b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { getBasicInputFields } from '@/libs/metadataUtils'; +import { catalogVariablesLoaded, clearMetadata } 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'; +import { getInputVariables } from '@/utils/VariableResolver'; + +/** + * The household builder reads its default fields (`basic_inputs`) and + * entities from `/v2/model-catalog`, and its variable picker reads the + * variables `/v2/model-catalog/variables` loads, both through the metadata + * store, as it read v1's whole metadata. + */ +describe('The household builder on the v2 routes', () => { + beforeEach(async () => { + 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, + ...loaded, + }) + ); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + 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([]); + }); + + 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/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index 53bae25fc..99213c330 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -6,6 +6,7 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, requestsTo, @@ -84,6 +85,7 @@ function renderPage(userReportId: string) { } const PARAMETERS_PATH = '/v2/model-catalog/parameters'; +const VARIABLES_PATH = '/v2/model-catalog/variables'; const COMPLETE_STATUS = { status: 'complete', isComplete: true, @@ -157,4 +159,34 @@ 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'); + }); }); From 33d72071404ec2b8b831cd1d4242ee0665888bae Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:12:28 +0400 Subject: [PATCH 04/17] Read each v2 variable's unit and definition period as v1 gave them The Public API's catalog format 2 now stores each variable's unit and definition period, and `/v2/model-catalog/variables` serves both (null for catalog versions published earlier). `fetchCatalogVariables` now maps them into v1's variable shape (`unit`, `definitionPeriod`), so on the v2 routes the household breakdown formats currencies and rates, and the variable picker says "per year" or "per month", as on v1. A null unit reads as v1's variables without a unit do: a plain number, and "Number" in the picker. A null period gives no "per" suffix, as v1 shows for a period that is neither a year nor a month. The generated client on this branch predates the two fields, so a small, documented local extension of its variable type carries them until the client is regenerated from the final Public API branch. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogVariables.ts | 29 ++++++++-- .../api/publicApi/catalogVariablesMocks.ts | 58 +++++++++++++++++-- .../api/publicApi/catalogVariables.test.ts | 26 +++++++++ .../householdBuilderOnV2Variables.test.ts | 34 +++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts index 980d1b912..faa49bd0d 100644 --- a/app/src/api/publicApi/catalogVariables.ts +++ b/app/src/api/publicApi/catalogVariables.ts @@ -15,12 +15,24 @@ import type { ValuesList } from '@/types/subIngredients/valueInterval'; * variables (`part_parameter_values`), so the breakdown never needs the * whole parameter list. * - * The catalog does not publish a variable's unit, definition period, - * category or position in its module, so those stay unset. + * The catalog does not publish a variable's category or position in its + * module, so those stay unset. */ type Schemas = components['schemas']; +/** + * A catalog variable with its unit and definition period. The Public API + * serves both since catalog format 2 (null for versions published earlier), + * but the generated client on this branch predates them. This local + * extension goes when the client is regenerated from the final Public API + * branch, whose `MetadataVariable` carries both fields. + */ +type CatalogVariable = Schemas['MetadataVariable'] & { + unit?: string | null; + definition_period?: string | null; +}; + export interface LoadedCatalogVariables { /** Each variable by name, as v1's metadata described it. */ variables: Record; @@ -35,6 +47,10 @@ export interface V1ShapedVariable { 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; @@ -58,7 +74,7 @@ export function v1DatedValues(values: Schemas['MetadataDatedValue'][]): ValuesLi ) as ValuesList; } -export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1ShapedVariable { +export function v1ShapedVariable(variable: CatalogVariable): V1ShapedVariable { const shaped: V1ShapedVariable = { name: variable.name, // v1 always had a label; a variable without one reads as its name. @@ -66,6 +82,8 @@ export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1Shape entity: variable.entity, documentation: variable.description, valueType: variable.data_type, + unit: variable.unit ?? null, + definitionPeriod: variable.definition_period ?? null, defaultValue: variable.default_value, adds: variable.adds_parameter_path ?? variable.adds, subtracts: variable.subtracts_parameter_path ?? variable.subtracts, @@ -100,7 +118,10 @@ export async function fetchCatalogVariables( ); return { variables: Object.fromEntries( - result.variables.map((variable) => [variable.name, v1ShapedVariable(variable)]) + (result.variables as CatalogVariable[]).map((variable) => [ + variable.name, + v1ShapedVariable(variable), + ]) ), partParameterValues: Object.fromEntries( Object.entries(result.part_parameter_values).map(([path, values]) => [ diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts index 523dc7faf..e776ddc9d 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -3,6 +3,16 @@ import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelC type Schemas = components['schemas']; +/** + * A catalog variable with its unit and definition period, which the Public + * API serves since catalog format 2 but the generated client on this base + * predates (see `CatalogVariable` in api/publicApi/catalogVariables). + */ +type CatalogVariableFixture = Schemas['MetadataVariable'] & { + unit?: string | null; + definition_period?: string | null; +}; + /** 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. */ @@ -10,8 +20,8 @@ export const ABSENT_PARTS_PARAMETER_PATH = 'gov.example.absent_deductions'; function variable( name: string, - fields: Partial = {} -): Schemas['MetadataVariable'] { + fields: Partial = {} +): CatalogVariableFixture { return { id: `variable-${name}`, name, @@ -29,6 +39,8 @@ function variable( adds_parameter_path: null, subtracts_parameter_path: null, enum_values: null, + unit: null, + definition_period: 'year', ...fields, }; } @@ -46,6 +58,30 @@ export const EMPLOYMENT_INCOME_VARIABLE = variable('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', { @@ -67,8 +103,12 @@ export const FILING_STATUS_VARIABLE = variable('filing_status', { ], }); -export const SNAP_VARIABLE = variable('snap', { label: 'SNAP', entity: 'household' }); -export const SSI_VARIABLE = variable('ssi', { label: 'SSI' }); +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', @@ -83,7 +123,10 @@ export const COUNTABLE_INCOME_VARIABLE = variable('countable_income', { }); /** What `GET /v2/model-catalog/variables` answers for the small US catalog version. */ -export const US_CATALOG_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResult'] = { +export const US_CATALOG_VARIABLES_RESULT: Omit< + Schemas['MetadataCatalogVariablesResult'], + 'variables' +> & { variables: CatalogVariableFixture[] } = { policyengine_version: TEST_POLICYENGINE_VERSION, catalog_format: 2, variables: [ @@ -93,8 +136,11 @@ export const US_CATALOG_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResul 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. @@ -107,7 +153,7 @@ export const US_CATALOG_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResul }; export function catalogVariablesEnvelope( - result: Schemas['MetadataCatalogVariablesResult'] = US_CATALOG_VARIABLES_RESULT + result: typeof US_CATALOG_VARIABLES_RESULT = US_CATALOG_VARIABLES_RESULT ) { return { status: 'ok', message: null, result }; } diff --git a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts index 690d4447b..a06097f9d 100644 --- a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts +++ b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts @@ -49,6 +49,8 @@ describe('fetchCatalogVariables', () => { entity: 'person', documentation: 'Wages and salaries', valueType: 'float', + unit: 'currency-USD', + definitionPeriod: 'year', defaultValue: 0, adds: null, subtracts: null, @@ -62,6 +64,30 @@ describe('fetchCatalogVariables', () => { }); }); + 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(); diff --git a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts index 9b1d00cb6..81c3919d9 100644 --- a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts +++ b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; import { createPublicApiClient } from '@/api/publicApi/client'; +import { inputKind } from '@/components/household/VariablePickerDialog'; import { getBasicInputFields } from '@/libs/metadataUtils'; import { catalogVariablesLoaded, clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; @@ -12,6 +13,7 @@ import { } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { formatVariableValue } from '@/utils/householdValues'; import { getInputVariables } from '@/utils/VariableResolver'; /** @@ -78,4 +80,36 @@ describe('The household builder on the v2 routes', () => { { value: 'JOINT', label: 2 }, ]); }); + + 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'); + }); + + 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'); + }); }); From 103150cac35707d2e10e345c11baf61a27403628 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:06:05 +0400 Subject: [PATCH 05/17] Ask for v3's variables again on each model catalog load, and report a failed load in every view that waits On the rebase onto 1.19's final branch: - The variables load is now keyed by the model catalog load (`v2LoadId`), as 1.19 keys parameters, so a new load (a retry, or a newer catalog) empties the store and asks for the variables again instead of refilling it from an earlier load's response; a response asked for under an earlier load is dropped. - The local extension of the generated variable type is gone: the client regenerated from the final Public API branch carries each variable's unit and definition period, and its other catalog format 2 fields are required and nullable. - Every view that waits for the variables on the v2 routes now also says when loading them failed, with Try again: the provision list and the report's changes (where a change lists variables), and calibration matches and the geography fit. The household section and the programme chart already did, through `V3VariablesGate`. `useV3Variables` reports this as `failed`, only ever on the v2 routes. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogVariables.ts | 23 +----- .../flagship/CalibrationMatches.tsx | 12 +++ app/src/components/flagship/ProvisionList.tsx | 4 +- .../components/flagship/V3VariablesGate.tsx | 5 ++ .../flagship/report/ReportChanges.tsx | 4 +- app/src/hooks/useV3Variables.ts | 80 ++++++++++++------- app/src/reducers/metadataReducer.ts | 10 ++- .../api/publicApi/catalogVariablesMocks.ts | 21 +---- .../api/publicApi/catalogVariables.test.ts | 8 ++ .../flagship/CalibrationMatchesV2.test.tsx | 25 +++++- .../flagship/VariableListChangesV2.test.tsx | 30 ++++++- .../unit/hooks/useV3VariablesV2.test.tsx | 26 +++++- .../householdBuilderOnV2Variables.test.ts | 3 +- app/src/types/metadata.ts | 9 ++- 14 files changed, 179 insertions(+), 81 deletions(-) diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts index faa49bd0d..6076d7aa9 100644 --- a/app/src/api/publicApi/catalogVariables.ts +++ b/app/src/api/publicApi/catalogVariables.ts @@ -21,18 +21,6 @@ import type { ValuesList } from '@/types/subIngredients/valueInterval'; type Schemas = components['schemas']; -/** - * A catalog variable with its unit and definition period. The Public API - * serves both since catalog format 2 (null for versions published earlier), - * but the generated client on this branch predates them. This local - * extension goes when the client is regenerated from the final Public API - * branch, whose `MetadataVariable` carries both fields. - */ -type CatalogVariable = Schemas['MetadataVariable'] & { - unit?: string | null; - definition_period?: string | null; -}; - export interface LoadedCatalogVariables { /** Each variable by name, as v1's metadata described it. */ variables: Record; @@ -74,7 +62,7 @@ export function v1DatedValues(values: Schemas['MetadataDatedValue'][]): ValuesLi ) as ValuesList; } -export function v1ShapedVariable(variable: CatalogVariable): V1ShapedVariable { +export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1ShapedVariable { const shaped: V1ShapedVariable = { name: variable.name, // v1 always had a label; a variable without one reads as its name. @@ -82,8 +70,8 @@ export function v1ShapedVariable(variable: CatalogVariable): V1ShapedVariable { entity: variable.entity, documentation: variable.description, valueType: variable.data_type, - unit: variable.unit ?? null, - definitionPeriod: variable.definition_period ?? null, + unit: variable.unit, + definitionPeriod: variable.definition_period, defaultValue: variable.default_value, adds: variable.adds_parameter_path ?? variable.adds, subtracts: variable.subtracts_parameter_path ?? variable.subtracts, @@ -118,10 +106,7 @@ export async function fetchCatalogVariables( ); return { variables: Object.fromEntries( - (result.variables as CatalogVariable[]).map((variable) => [ - variable.name, - v1ShapedVariable(variable), - ]) + result.variables.map((variable) => [variable.name, v1ShapedVariable(variable)]) ), partParameterValues: Object.fromEntries( Object.entries(result.part_parameter_values).map(([path, values]) => [ diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index b71f58d49..43bcbb822 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -1,6 +1,7 @@ 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'; @@ -339,6 +340,14 @@ export function GeographyFitSection({ ); } + if (variablesStatus.failed) { + return ( + <> + {heading} + + + ); + } if (fit === undefined) { return ( <> @@ -496,6 +505,9 @@ export function CalibrationMatchSection({ if (variablesStatus.waiting) { return card(); } + if (variablesStatus.failed) { + 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 949063c3c..14b758d7c 100644 --- a/app/src/components/flagship/ProvisionList.tsx +++ b/app/src/components/flagship/ProvisionList.tsx @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; +import { VariableNamesFailed, VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; import { Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useV3Variables } from '@/hooks/useV3Variables'; @@ -105,6 +105,8 @@ export default function ProvisionList({ > {listChange && variablesStatus.waiting ? ( + ) : listChange && variablesStatus.failed ? ( + ) : listChange ? ( unknown }) { + return ; +} + /** * On the v2 routes, content that reads the model's variables (the household * builder and breakdown, programme names) waits behind a placeholder until diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index bcf088e44..4c70a9f5c 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -3,7 +3,7 @@ import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import type { BillChange } from '@/api/billFeed'; import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; -import { VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; +import { VariableNamesFailed, VariableNamesLoading } from '@/components/flagship/V3VariablesGate'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; @@ -176,6 +176,8 @@ export default function ReportChanges({ after: list ? ( variablesStatus.waiting ? ( + ) : variablesStatus.failed ? ( + ) : ( describeChange(provision.baselineValue, provision.value, String, variableLabel) ) diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts index f0db03ed1..24f341812 100644 --- a/app/src/hooks/useV3Variables.ts +++ b/app/src/hooks/useV3Variables.ts @@ -5,7 +5,11 @@ 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 } from '@/reducers/metadataReducer'; +import { + catalogVariablesLoaded, + selectV2CatalogVersion, + selectV2LoadId, +} from '@/reducers/metadataReducer'; import type { AppDispatch, RootState } from '@/store'; /** @@ -28,30 +32,39 @@ export interface V3VariablesStatus { * views that never waited for it before can ignore. */ waiting: boolean; + /** + * Whether loading them failed: only ever on the v2 routes, where views + * then say so and offer `retry`. On v1 a failed whole metadata is the + * page's own concern, as before. + */ + failed: boolean; /** The failure's message, when the status is 'error'. */ message: string | null; retry: () => unknown; } -/** The query key of a catalog version's variables. */ -export function v2CatalogVariablesKey(countryId: string, policyengineVersion: string | null) { - return ['v2-catalog-variables', countryId, policyengineVersion] as const; +/** + * 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; } -/** A catalog version's variables never change, so they are fetched once per session. */ +/** A catalog version's variables never change, so they are fetched once per model catalog load. */ const VARIABLES_QUERY_OPTIONS = { staleTime: Infinity, gcTime: Infinity } as const; -function useV2CatalogVersion(): string | null { - return useSelector((state: RootState) => - state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null - ); -} - function useV1Variables(): V3VariablesStatus { const metadata = useFlagshipMetadataStatus(); return { status: metadata.status, waiting: false, + failed: false, message: metadata.message, retry: metadata.retry, }; @@ -61,40 +74,48 @@ function useV2Variables(): V3VariablesStatus { const countryId = useCurrentCountry(); const dispatch = useDispatch(); const metadata = useFlagshipMetadataStatus(); - const policyengineVersion = useV2CatalogVersion(); - const loadedVersion = useSelector((state: RootState) => state.metadata.v2VariablesVersion); - const inStore = policyengineVersion !== null && loadedVersion === policyengineVersion; + const policyengineVersion = useSelector(selectV2CatalogVersion); + const loadId = useSelector(selectV2LoadId); + const loadedUnder = useSelector((state: RootState) => state.metadata.v2VariablesLoadId); + const inStore = loadId !== null && loadedUnder === loadId; const query = useQuery({ - queryKey: v2CatalogVariablesKey(countryId, policyengineVersion), + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), queryFn: () => fetchCatalogVariables(countryId, policyengineVersion!), - enabled: policyengineVersion !== null, + enabled: policyengineVersion !== null && loadId !== null, ...VARIABLES_QUERY_OPTIONS, }); - // The query holds the response; the store holds what views read. They - // part only when the store is cleared (a country change) while the query - // is cached, so the store is filled from the cache whenever it lacks them. + // The query holds the response; the store holds what views read. Several + // readers share one query, so whichever sees the response first fills the + // store, for the load it was asked under only. useEffect(() => { - if (query.data && policyengineVersion !== null && !inStore) { - dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, ...query.data })); + if (query.data && policyengineVersion !== null && loadId !== null && !inStore) { + dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, loadId, ...query.data })); } - }, [query.data, policyengineVersion, inStore, countryId, dispatch]); + }, [query.data, policyengineVersion, loadId, inStore, countryId, dispatch]); if (inStore) { - return { status: 'ready', waiting: false, message: null, retry: query.refetch }; + return { status: 'ready', waiting: false, failed: false, message: null, retry: query.refetch }; } if (metadata.status === 'error') { - return { status: 'error', waiting: false, message: metadata.message, retry: metadata.retry }; + return { + status: 'error', + waiting: false, + failed: true, + message: metadata.message, + retry: metadata.retry, + }; } if (query.isError) { return { status: 'error', waiting: false, + failed: true, message: query.error instanceof Error ? query.error.message : null, retry: query.refetch, }; } - return { status: 'loading', waiting: true, message: null, retry: query.refetch }; + return { status: 'loading', waiting: true, failed: false, message: null, retry: query.refetch }; } /** @@ -112,18 +133,19 @@ export const useV3Variables: () => V3VariablesStatus = */ function useV2VariablesInBackground(): void { const countryId = useCurrentCountry(); - const policyengineVersion = useV2CatalogVersion(); + const policyengineVersion = useSelector(selectV2CatalogVersion); + const loadId = useSelector(selectV2LoadId); const queryClient = useQueryClient(); useEffect(() => { - if (policyengineVersion === null) { + if (policyengineVersion === null || loadId === null) { return; } void queryClient.prefetchQuery({ - queryKey: v2CatalogVariablesKey(countryId, policyengineVersion), + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), queryFn: () => fetchCatalogVariables(countryId, policyengineVersion), ...VARIABLES_QUERY_OPTIONS, }); - }, [countryId, policyengineVersion, queryClient]); + }, [countryId, policyengineVersion, loadId, queryClient]); } function noBackgroundLoad(): void {} diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index acff0e342..efa705c54 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -137,20 +137,24 @@ 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 - * country or version is dropped. + * 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, variables, partParameterValues } = action.payload; + const { countryId, policyengineVersion, loadId, variables, partParameterValues } = + action.payload; if ( state.routes !== 'v2' || + state.v2LoadId !== loadId || state.currentCountry !== countryId || state.v2Catalog?.policyengineVersion !== policyengineVersion ) { @@ -158,7 +162,7 @@ const metadataSlice = createSlice({ } state.variables = variables; state.v2PartParameterValues = partParameterValues; - state.v2VariablesVersion = policyengineVersion; + state.v2VariablesLoadId = loadId; }, }, extraReducers: (builder) => { diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts index e776ddc9d..807595008 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -3,16 +3,6 @@ import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelC type Schemas = components['schemas']; -/** - * A catalog variable with its unit and definition period, which the Public - * API serves since catalog format 2 but the generated client on this base - * predates (see `CatalogVariable` in api/publicApi/catalogVariables). - */ -type CatalogVariableFixture = Schemas['MetadataVariable'] & { - unit?: string | null; - definition_period?: string | null; -}; - /** 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. */ @@ -20,8 +10,8 @@ export const ABSENT_PARTS_PARAMETER_PATH = 'gov.example.absent_deductions'; function variable( name: string, - fields: Partial = {} -): CatalogVariableFixture { + fields: Partial = {} +): Schemas['MetadataVariable'] { return { id: `variable-${name}`, name, @@ -123,10 +113,7 @@ export const COUNTABLE_INCOME_VARIABLE = variable('countable_income', { }); /** What `GET /v2/model-catalog/variables` answers for the small US catalog version. */ -export const US_CATALOG_VARIABLES_RESULT: Omit< - Schemas['MetadataCatalogVariablesResult'], - 'variables' -> & { variables: CatalogVariableFixture[] } = { +export const US_CATALOG_VARIABLES_RESULT: Schemas['MetadataCatalogVariablesResult'] = { policyengine_version: TEST_POLICYENGINE_VERSION, catalog_format: 2, variables: [ @@ -153,7 +140,7 @@ export const US_CATALOG_VARIABLES_RESULT: Omit< }; export function catalogVariablesEnvelope( - result: typeof US_CATALOG_VARIABLES_RESULT = US_CATALOG_VARIABLES_RESULT + result: Schemas['MetadataCatalogVariablesResult'] = US_CATALOG_VARIABLES_RESULT ) { return { status: 'ok', message: null, result }; } diff --git a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts index a06097f9d..7577f0e8a 100644 --- a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts +++ b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts @@ -155,6 +155,14 @@ describe('fetchCatalogVariables', () => { default_value: 0, adds: null, subtracts: null, + 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/unit/components/flagship/CalibrationMatchesV2.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx index 509375fdd..e9064a532 100644 --- a/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx +++ b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@test-utils'; +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'; @@ -65,4 +65,27 @@ describe('CalibrationMatchSection with v3 on the v2 routes', () => { ); 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(); + let attempts = 0; + answerVariables = () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(catalogVariablesEnvelope()); + }; + 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/VariableListChangesV2.test.tsx b/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx index b50e22116..05afa31ec 100644 --- a/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx +++ b/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; +import { render, screen, userEvent } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import ProvisionList from '@/components/flagship/ProvisionList'; import ReportChanges from '@/components/flagship/report/ReportChanges'; @@ -91,4 +91,32 @@ describe('Lists of variables in v3 changes, on the v2 routes', () => { // Then expect((await screen.findAllByText(/SSI/)).length).toBeGreaterThan(0); }); + + test.each([ + ['provision list', () => ], + ['report change list', () => ], + ])( + 'given the variables fail then the %s says so and try again asks again', + async (_view, renderView) => { + // Given + const user = userEvent.setup(); + let attempts = 0; + answerVariables = () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(catalogVariablesEnvelope()); + }; + renderWithQueries(renderView()); + 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/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 94ad2d520..0d451646f 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; -import { catalogVariablesLoaded, clearMetadata } from '@/reducers/metadataReducer'; +import { catalogVariablesLoaded, clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { BENEFITS_PARAMETER_PATH, @@ -87,7 +87,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); }); - test('given the store cleared while the response is cached then the store is filled again without a request', async () => { + 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) }); @@ -102,7 +102,23 @@ describe('useV3Variables with v3 on the v2 routes', () => { // Then await waitFor(() => expect(result.current.status).toBe('ready')); expect(store.getState().metadata.variables.age.label).toBe('Age'); - expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); + expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(2); + }); + + test('given variables asked for under an earlier model catalog load then the store drops them', () => { + // When + store.dispatch( + catalogVariablesLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: 'an-earlier-load', + variables: { stale: { name: 'stale' } }, + partParameterValues: {}, + }) + ); + + // Then + expect(store.getState().metadata.variables).toEqual({}); }); test('given the variables route fails then the status says so and can retry', async () => { @@ -127,6 +143,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { catalogVariablesLoaded({ countryId: 'us', policyengineVersion: '0.0.1', + loadId: selectV2LoadId(store.getState())!, variables: { stale: { name: 'stale' } }, partParameterValues: {}, }) @@ -135,7 +152,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { // Then const metadata = store.getState().metadata; expect(metadata.variables).toEqual({}); - expect(metadata.v2VariablesVersion).toBeUndefined(); + expect(metadata.v2VariablesLoadId).toBeUndefined(); }); test('given variables for another country then the store drops them', () => { @@ -144,6 +161,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { catalogVariablesLoaded({ countryId: 'uk', policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: selectV2LoadId(store.getState())!, variables: { stale: { name: 'stale' } }, partParameterValues: {}, }) diff --git a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts index 81c3919d9..730465e12 100644 --- a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts +++ b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts @@ -3,7 +3,7 @@ import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; import { createPublicApiClient } from '@/api/publicApi/client'; import { inputKind } from '@/components/household/VariablePickerDialog'; import { getBasicInputFields } from '@/libs/metadataUtils'; -import { catalogVariablesLoaded, clearMetadata } from '@/reducers/metadataReducer'; +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'; @@ -39,6 +39,7 @@ describe('The household builder on the v2 routes', () => { catalogVariablesLoaded({ countryId: 'us', policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: selectV2LoadId(store.getState())!, ...loaded, }) ); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index be4ce4ab0..2da2c3b04 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -122,11 +122,12 @@ export interface MetadataState { */ v2LoadId?: string; /** - * On the v2 routes, `variables` starts empty and fills once, in the - * background, with every variable of the catalog version named here; - * absent until they are in. + * 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. */ - v2VariablesVersion?: string; + 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 From 3ef3f86b853dadf77a3a744df10d264fd748bcb8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:29:37 +0400 Subject: [PATCH 06/17] Read v2 enum variables as v1's Enum, with v1's defaults The catalog stores each variable's Python type name, and .py publishes every Enum variable with data type "str" and its values. The conversion copied that into v1's `valueType`, so on the v2 routes every enum input (the default fields `state_name`, `region`, `brma`, `local_authority` among them) showed a free-text box instead of a list, and the picker called them "Number". A variable with enum values is now v1's "Enum". v1 also gave a default only for numbers, yes-or-no values and enums (the default value's name), none for a text or date variable; the conversion now does the same. Tests use records serialized from .py's public `Variable` records in the catalog's shape, and the small fixture's enum uses data type "str" as the catalog does. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogVariables.ts | 27 ++- .../api/publicApi/catalogVariablesMocks.ts | 3 +- .../publicApi/catalogVariablesRealShape.ts | 178 ++++++++++++++++++ .../catalogVariablesRealShape.test.ts | 61 ++++++ 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts create mode 100644 app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts index 6076d7aa9..bc219f164 100644 --- a/app/src/api/publicApi/catalogVariables.ts +++ b/app/src/api/publicApi/catalogVariables.ts @@ -62,17 +62,40 @@ export function v1DatedValues(values: Schemas['MetadataDatedValue'][]): ValuesLi ) as ValuesList; } +/** + * 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: variable.data_type, + valueType, unit: variable.unit, definitionPeriod: variable.definition_period, - defaultValue: variable.default_value, + 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, diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts index 807595008..a5a628272 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -81,9 +81,10 @@ export const RATING_AREA_VARIABLE = variable('medicaid_rating_area', { 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: 'Enum', + data_type: 'str', default_value: 'SINGLE', possible_values: ['SINGLE', 'JOINT'], is_input_variable: true, 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..04cfb96ca --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts @@ -0,0 +1,178 @@ +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', + }, +]; 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..c0d6eed78 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; +import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; +import { inputKind } from '@/components/household/VariablePickerDialog'; +import { REAL_SHAPE_US_VARIABLES } from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +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(); + }); +}); From d95dae5539a81d844175beefaa986ec7c776a69f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:32:46 +0400 Subject: [PATCH 07/17] Put background-loaded variables in the store, and skip a model catalog of another country The background load filled only the query cache, so the metadata store got the variables only once a view using `useV3Variables` mounted; a view that reads `state.metadata.variables` directly saw none. The background load now puts them in the store when they arrive, under the model catalog load they were asked for (a failure stays in the query, where waiting views report it). Both the background load and `useV3Variables` now also skip a store whose model catalog is for another country than the page's, which a client-side country change leaves until the new catalog loads, instead of starting a download that would be dropped. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useV3Variables.ts | 35 ++++++++++++------ .../unit/hooks/useV3VariablesV2.test.tsx | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts index 24f341812..b0c8d8193 100644 --- a/app/src/hooks/useV3Variables.ts +++ b/app/src/hooks/useV3Variables.ts @@ -74,6 +74,7 @@ function useV2Variables(): V3VariablesStatus { const countryId = useCurrentCountry(); const dispatch = useDispatch(); const metadata = useFlagshipMetadataStatus(); + const storeCountry = useSelector((state: RootState) => state.metadata.currentCountry); const policyengineVersion = useSelector(selectV2CatalogVersion); const loadId = useSelector(selectV2LoadId); const loadedUnder = useSelector((state: RootState) => state.metadata.v2VariablesLoadId); @@ -81,7 +82,8 @@ function useV2Variables(): V3VariablesStatus { const query = useQuery({ queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), queryFn: () => fetchCatalogVariables(countryId, policyengineVersion!), - enabled: policyengineVersion !== null && loadId !== null, + // Not for a model catalog of another country than the page's (a country change in flight). + enabled: policyengineVersion !== null && loadId !== null && storeCountry === countryId, ...VARIABLES_QUERY_OPTIONS, }); @@ -128,24 +130,37 @@ export const useV3Variables: () => V3VariablesStatus = /** * Starts loading the catalog version's variables once a v3 page has - * rendered, so they are in by the time a household is built or broken - * down. Nothing on the v1 routes, where the whole metadata holds them. + * rendered, and puts them in the metadata store when they arrive, so they + * are in by the time a household is built or broken down, also for views + * that read `state.metadata.variables` without `useV3Variables`. Nothing + * when the store's model catalog is for another country than the page's + * (a country change whose catalog has not loaded yet), and nothing on the + * v1 routes, where the whole metadata holds the variables. */ function useV2VariablesInBackground(): void { const countryId = useCurrentCountry(); + const dispatch = useDispatch(); + const storeCountry = useSelector((state: RootState) => state.metadata.currentCountry); const policyengineVersion = useSelector(selectV2CatalogVersion); const loadId = useSelector(selectV2LoadId); const queryClient = useQueryClient(); useEffect(() => { - if (policyengineVersion === null || loadId === null) { + if (policyengineVersion === null || loadId === null || storeCountry !== countryId) { return; } - void queryClient.prefetchQuery({ - queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), - queryFn: () => fetchCatalogVariables(countryId, policyengineVersion), - ...VARIABLES_QUERY_OPTIONS, - }); - }, [countryId, policyengineVersion, loadId, queryClient]); + queryClient + .fetchQuery({ + queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), + queryFn: () => fetchCatalogVariables(countryId, policyengineVersion), + ...VARIABLES_QUERY_OPTIONS, + }) + .then( + (loaded) => + dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, loadId, ...loaded })), + // A failure stays in the query, where views waiting for the variables report it. + () => undefined + ); + }, [countryId, storeCountry, policyengineVersion, loadId, queryClient, dispatch]); } function noBackgroundLoad(): void {} diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 0d451646f..1962f12f0 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -1,5 +1,9 @@ +import type { ReactNode } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; +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 { catalogVariablesLoaded, clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; import { store } from '@/store'; @@ -87,6 +91,38 @@ describe('useV3Variables with v3 on the v2 routes', () => { 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 + await new Promise((resolve) => setTimeout(resolve, 50)); + 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(); From ee09e9d48ced2197fb49c95882ef96e85ca5098c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:35:56 +0400 Subject: [PATCH 08/17] Share one v2 dated-values converter and type the variables the store takes Parameters (`catalogParameters`) and the parts of variables (`catalogVariables`) each had their own copy of the converter from v2's dated values to v1's, with its earliest-date constants; both now use `v1DatedValues` in `api/publicApi/datedValues`, which checks each value where it arrives with 1.19's `parameterValueFromApi`, so a malformed part value also fails there, naming its parameter. The variables request takes its country through `publicApiCountryId`, like its siblings, instead of a cast, and `catalogVariablesLoaded` takes `V1ShapedVariable` records. The existing tests cover both converters. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogParameters.ts | 21 ++------------ app/src/api/publicApi/catalogVariables.ts | 25 ++++++---------- app/src/api/publicApi/datedValues.ts | 29 +++++++++++++++++++ app/src/reducers/metadataReducer.ts | 3 +- .../unit/hooks/useV3VariablesV2.test.tsx | 11 +++++-- 5 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 app/src/api/publicApi/datedValues.ts 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 index bc219f164..736d5e2ac 100644 --- a/app/src/api/publicApi/catalogVariables.ts +++ b/app/src/api/publicApi/catalogVariables.ts @@ -1,4 +1,10 @@ -import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +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'; @@ -49,19 +55,6 @@ export interface V1ShapedVariable { possibleValues?: Array<{ value: string; label: Schemas['MetadataEnumValue']['label'] }>; } -/** 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'; - -export function v1DatedValues(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, dated.value]; - }) - ) as ValuesList; -} - /** * 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 @@ -121,7 +114,7 @@ export async function fetchCatalogVariables( client.GET('/v2/model-catalog/variables', { params: { query: { - country_id: countryId as 'us' | 'uk', + country_id: publicApiCountryId(countryId), policyengine_version: policyengineVersion, }, }, @@ -134,7 +127,7 @@ export async function fetchCatalogVariables( partParameterValues: Object.fromEntries( Object.entries(result.part_parameter_values).map(([path, values]) => [ path, - v1DatedValues(values), + 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/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index efa705c54..856b3cb3c 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -1,6 +1,7 @@ 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'; @@ -146,7 +147,7 @@ const metadataSlice = createSlice({ policyengineVersion: string; /** The model catalog load the variables were asked for under. */ loadId: string; - variables: Record; + variables: Record; partParameterValues: Record; }> ) { diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 1962f12f0..dae22e7b8 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -3,11 +3,13 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; import { CountryProvider } from '@/contexts/CountryContext'; import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; import { catalogVariablesLoaded, clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { + AGE_VARIABLE, BENEFITS_PARAMETER_PATH, catalogVariablesEnvelope, } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; @@ -31,6 +33,9 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ const VARIABLES_PATH = '/v2/model-catalog/variables'; +/** Variables a dropped response would have brought. */ +const STALE_VARIABLES = { stale: v1ShapedVariable({ ...AGE_VARIABLE, name: 'stale' }) }; + describe('useV3Variables with v3 on the v2 routes', () => { let fetchStub: ReturnType; @@ -148,7 +153,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { countryId: 'us', policyengineVersion: TEST_POLICYENGINE_VERSION, loadId: 'an-earlier-load', - variables: { stale: { name: 'stale' } }, + variables: STALE_VARIABLES, partParameterValues: {}, }) ); @@ -180,7 +185,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { countryId: 'us', policyengineVersion: '0.0.1', loadId: selectV2LoadId(store.getState())!, - variables: { stale: { name: 'stale' } }, + variables: STALE_VARIABLES, partParameterValues: {}, }) ); @@ -198,7 +203,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { countryId: 'uk', policyengineVersion: TEST_POLICYENGINE_VERSION, loadId: selectV2LoadId(store.getState())!, - variables: { stale: { name: 'stale' } }, + variables: STALE_VARIABLES, partParameterValues: {}, }) ); From b027ecb5960a9ea63448d392e1283911d315e9e2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:39:56 +0400 Subject: [PATCH 09/17] Follow the test conventions for 1.20's tests and document the background variables load - Tests import from `@test-utils`. The breakdown test's metadata fixtures move under the fixtures folder. The builder and list tests are split to mirror the modules they cover (`metadataUtils`, `VariableResolver`, `VariablePickerDialog`, `householdValues`, `ProvisionList`, `ReportChanges`), with the store seeding they share in a fixture. - The `v3ApiRoutes` and `modelCatalog` comments and the country ID architecture document describe the background variables load, and the comment on `MetadataState.v2LoadId` says a new load empties the variables too. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/modelCatalog.ts | 4 +- app/src/docs/ARCHITECTURE-COUNTRY-ID.md | 2 +- app/src/libs/flagship/v3ApiRoutes.ts | 8 +- .../api/publicApi/catalogVariablesStore.ts | 45 +++++++ .../variableArithmeticParameterPartsMocks.ts | 82 +++++++++++++ ...esV2.test.tsx => ProvisionListV2.test.tsx} | 59 +++------ .../flagship/report/ReportChangesV2.test.tsx | 92 ++++++++++++++ .../VariableArithmeticParameterParts.test.tsx | 88 +------------ .../household/VariablePickerDialogV2.test.ts | 40 ++++++ .../unit/hooks/useV3VariablesV1.test.tsx | 2 +- .../unit/hooks/useV3VariablesV2.test.tsx | 2 +- .../householdBuilderOnV2Variables.test.ts | 116 ------------------ .../tests/unit/libs/metadataUtilsV2.test.ts | 26 ++++ .../unit/utils/VariableResolverV2.test.ts | 40 ++++++ .../unit/utils/householdValuesV2.test.ts | 27 ++++ app/src/types/metadata.ts | 3 +- 16 files changed, 386 insertions(+), 250 deletions(-) create mode 100644 app/src/tests/fixtures/api/publicApi/catalogVariablesStore.ts create mode 100644 app/src/tests/fixtures/components/household/variableArithmeticParameterPartsMocks.ts rename app/src/tests/unit/components/flagship/{VariableListChangesV2.test.tsx => ProvisionListV2.test.tsx} (59%) create mode 100644 app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx create mode 100644 app/src/tests/unit/components/household/VariablePickerDialogV2.test.ts delete mode 100644 app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts create mode 100644 app/src/tests/unit/libs/metadataUtilsV2.test.ts create mode 100644 app/src/tests/unit/utils/VariableResolverV2.test.ts create mode 100644 app/src/tests/unit/utils/householdValuesV2.test.ts 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/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/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/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/unit/components/flagship/VariableListChangesV2.test.tsx b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx similarity index 59% rename from app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx rename to app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx index 05afa31ec..0552d5fa2 100644 --- a/app/src/tests/unit/components/flagship/VariableListChangesV2.test.tsx +++ b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx @@ -2,7 +2,6 @@ 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 ReportChanges from '@/components/flagship/report/ReportChanges'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { catalogVariablesEnvelope } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; @@ -35,7 +34,7 @@ function renderWithQueries(ui: React.ReactNode) { return render({ui}); } -describe('Lists of variables in v3 changes, on the v2 routes', () => { +describe('ProvisionList with v3 on the v2 routes', () => { beforeEach(async () => { answerVariables = () => jsonResponse(catalogVariablesEnvelope()); stubPublicApiFetch({ [VARIABLES_PATH]: () => answerVariables() }); @@ -70,53 +69,25 @@ describe('Lists of variables in v3 changes, on the v2 routes', () => { ).not.toBeInTheDocument(); }); - test('given the variables still loading then a report change list waits for their names', () => { + test('given the variables fail then the provision list says so and try again asks again', async () => { // Given - answerVariables = () => new Promise(() => {}); - - // When - renderWithQueries(); - - // Then - expect( - screen.getAllByRole('status', { name: 'Loading variable names' }).length - ).toBeGreaterThan(0); - expect(screen.queryByText(/ssi/)).not.toBeInTheDocument(); - }); + const user = userEvent.setup(); + let attempts = 0; + answerVariables = () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(catalogVariablesEnvelope()); + }; + renderWithQueries(); + const [retry] = await screen.findAllByRole('button', { name: 'Try again' }); + expect(screen.getAllByText('Could not load the variable names').length).toBeGreaterThan(0); - test('given the variables arrive then a report change list names them by label', async () => { // When - renderWithQueries(); + await user.click(retry); // Then expect((await screen.findAllByText(/SSI/)).length).toBeGreaterThan(0); + expect(screen.queryByText('Could not load the variable names')).not.toBeInTheDocument(); }); - - test.each([ - ['provision list', () => ], - ['report change list', () => ], - ])( - 'given the variables fail then the %s says so and try again asks again', - async (_view, renderView) => { - // Given - const user = userEvent.setup(); - let attempts = 0; - answerVariables = () => { - attempts += 1; - return attempts === 1 - ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) - : jsonResponse(catalogVariablesEnvelope()); - }; - renderWithQueries(renderView()); - 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/report/ReportChangesV2.test.tsx b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx new file mode 100644 index 000000000..ecf7b52b7 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx @@ -0,0 +1,92 @@ +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 } 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', +})); + +const VARIABLES_PATH = '/v2/model-catalog/variables'; + +/** A reform that adds SSI to the benefits a household's total counts. */ +const LIST_PROVISION = { + path: 'gov.household.household_benefits', + breadcrumb: 'Household → Benefits', + unit: null, + baselineValue: ['snap'], + value: ['snap', 'ssi'], +}; + +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(); + let attempts = 0; + answerVariables = () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(catalogVariablesEnvelope()); + }; + 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 index 9e6b0118e..da2b2b2ba 100644 --- a/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx +++ b/app/src/tests/unit/components/household/VariableArithmeticParameterParts.test.tsx @@ -1,94 +1,18 @@ import type { ReactNode } from 'react'; import { configureStore } from '@reduxjs/toolkit'; -import { render, screen } from '@testing-library/react'; +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 type { HouseholdCalculationOutput } from '@/types/calculation/household'; +import { + household, + V1_METADATA, + V2_METADATA, +} from '@/tests/fixtures/components/household/variableArithmeticParameterPartsMocks'; import type { MetadataState } from '@/types/metadata'; -/** A parameter whose value lists the variables benefits add: SNAP, then SNAP and SSI from 2026. */ -const BENEFITS_PARTS = 'gov.household.household_benefits'; -/** A parameter the catalog version does not have values for. */ -const ABSENT_PARTS = 'gov.example.absent_deductions'; - -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. */ -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. */ -const V1_METADATA = metadataState({ - parameters: { - [BENEFITS_PARTS]: { - type: 'parameter', - parameter: BENEFITS_PARTS, - label: 'Benefits', - values: BENEFITS_PARTS_VALUES, - } as any, - }, -}); - -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; -} - function renderBreakdown(metadata: MetadataState, year: string, variableName: string): void { const store = configureStore({ reducer: { metadata: metadataReducer }, 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 index 172dcf36a..76a379566 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { renderHook } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; import { clearMetadata } from '@/reducers/metadataReducer'; diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index dae22e7b8..51d0b6dd5 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor } from '@test-utils'; import { Provider } from 'react-redux'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; diff --git a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts b/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts deleted file mode 100644 index 730465e12..000000000 --- a/app/src/tests/unit/libs/householdBuilderOnV2Variables.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { fetchCatalogVariables } from '@/api/publicApi/catalogVariables'; -import { createPublicApiClient } from '@/api/publicApi/client'; -import { inputKind } from '@/components/household/VariablePickerDialog'; -import { getBasicInputFields } from '@/libs/metadataUtils'; -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'; -import { formatVariableValue } from '@/utils/householdValues'; -import { getInputVariables } from '@/utils/VariableResolver'; - -/** - * The household builder reads its default fields (`basic_inputs`) and - * entities from `/v2/model-catalog`, and its variable picker reads the - * variables `/v2/model-catalog/variables` loads, both through the metadata - * store, as it read v1's whole metadata. - */ -describe('The household builder on the v2 routes', () => { - beforeEach(async () => { - 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, - }) - ); - }); - - afterEach(() => { - store.dispatch(clearMetadata()); - vi.unstubAllGlobals(); - }); - - 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([]); - }); - - 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 }, - ]); - }); - - 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'); - }); - - 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/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/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/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 2da2c3b04..22ffac06e 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -118,7 +118,8 @@ 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; /** From 3272f2d71326cd6fec20c348f390f0b0f4bde41e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:03:33 +0400 Subject: [PATCH 10/17] Let the programme CSV wait for the variables like its chart The UK "Budgetary impact by programme" card built its CSV outside the waiting chart, so on the v2 routes a CSV downloaded while the variables loaded, or after they failed, named programmes by raw variable name. The card's CSV button now shows disabled until the variables are in, with "Loading programme names" or "Could not load the programme names" as its tooltip (`ReportChartCard`'s new `csvUnavailableReason`); on v1, and once the variables are in, it downloads as before. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/EconomicImpactCharts.tsx | 11 ++++- .../flagship/report/ReportChartCard.tsx | 17 +++++++- .../report/EconomicImpactChartsV2.test.tsx | 40 ++++++++++++++++++- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/src/components/flagship/report/EconomicImpactCharts.tsx b/app/src/components/flagship/report/EconomicImpactCharts.tsx index 90ecebdd2..be0a10be8 100644 --- a/app/src/components/flagship/report/EconomicImpactCharts.tsx +++ b/app/src/components/flagship/report/EconomicImpactCharts.tsx @@ -18,6 +18,7 @@ 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, @@ -74,6 +75,12 @@ 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.waiting + ? 'Loading programme names' + : variablesStatus.failed + ? '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. @@ -178,7 +185,9 @@ 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/ReportChartCard.tsx b/app/src/components/flagship/report/ReportChartCard.tsx index 555a471ca..52787f119 100644 --- a/app/src/components/flagship/report/ReportChartCard.tsx +++ b/app/src/components/flagship/report/ReportChartCard.tsx @@ -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,11 @@ function ChartFrame({ downloadFilename?: string; csvData?: CsvData; csvFilename?: string; + csvUnavailableReason?: string; }) { const contentRef = useRef(null); - const hasCsvDownload = !!csvFilename && !!csvData; + 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. @@ -168,6 +178,7 @@ function ChartFrame({ variant="ghost" size="icon" className="tw:shrink-0" + disabled={csvUnavailable} onClick={() => { trackChartCsvDownloaded(); downloadCsv(csvData!, csvFilename!); @@ -177,7 +188,9 @@ function ChartFrame({ - Download CSV + + {csvUnavailable ? csvUnavailableReason : 'Download CSV'} + )} {downloadFilename && ( diff --git a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx index 88a216979..c94b68e76 100644 --- a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx +++ b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@test-utils'; +import { render, screen, 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'; @@ -94,4 +94,42 @@ describe('EconomicImpactCharts for the UK with v3 on the v2 routes', () => { .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()).toBeDisabled(); + }); + + 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()).toBeDisabled(); + }); + + test('given the variables arrive then the programme CSV can be downloaded', async () => { + // When + renderCharts(); + + // Then + await waitFor(() => expect(programmeCsvButton()).toBeEnabled()); + }); }); From 8d1a7654358039f92e99e121f2c9e1d231dcb77b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:05:01 +0400 Subject: [PATCH 11/17] Start loading the variables on the report and bill report pages too The background variables load ran only from `WorkspaceLayout`, which the report and bill report pages do not use, so on those pages the download started only when a view that reads the variables mounted. Both pages now start it when they render, like the workspace pages; on v1 it does nothing. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/flagship/BillReport.page.tsx | 3 ++ app/src/pages/flagship/Report.page.tsx | 3 ++ .../unit/pages/flagship/BillReportV2.test.tsx | 33 +++++++++++++++++++ .../unit/pages/flagship/ReportV2.test.tsx | 26 +++++++++++++++ 4 files changed, 65 insertions(+) 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/tests/unit/pages/flagship/BillReportV2.test.tsx b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx index 30e7c359a..a36ad6190 100644 --- a/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx @@ -56,6 +56,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 +178,23 @@ 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 + stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); + await seedV2ModelCatalog(); + + // When + renderReport(); + + // Then + expect(mockUseV3VariablesInBackground).toHaveBeenCalled(); + }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index 99213c330..fde36bcd6 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -36,6 +36,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()), @@ -189,4 +203,16 @@ describe('FlagshipReportPage with v3 on the v2 routes', () => { ); 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 + stubPublicApiFetch({ [PARAMETERS_PATH]: answerParameters }); + await seedV2ModelCatalog(); + + // When + renderPage(EXISTING_REPORT_ID); + + // Then + expect(mockUseV3VariablesInBackground).toHaveBeenCalled(); + }); }); From bf55af78bc6ff1bb4bdcc543fae7dbab7f70a586 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:07:45 +0400 Subject: [PATCH 12/17] Share one variables query, keep only the current load cached, and give the views one state to read - `useV3Variables` and the background load now build their query from one helper (`catalogVariablesQuery`), whose fetch also puts the variables in the store under the load they were asked for, so the key, the options, the country check and the store update live in one place. - The query's `gcTime` is now finite, and the background load removes an earlier model catalog load's query when a new load starts, so only the current load's variables stay cached for the session. - `V3VariablesStatus` is one `state` ('ready', 'waiting' or 'failed'), which the lists, the calibration sections, the programme CSV and `V3VariablesGate` all read; on v1 it is always 'ready', so the waiting wrapper is one component and shows its content at once there. Co-Authored-By: Claude Opus 5.5 --- .../flagship/CalibrationMatches.tsx | 8 +- app/src/components/flagship/ProvisionList.tsx | 4 +- .../components/flagship/V3VariablesGate.tsx | 23 +-- .../flagship/report/EconomicImpactCharts.tsx | 11 +- .../flagship/report/ReportChanges.tsx | 4 +- app/src/hooks/useV3Variables.ts | 181 +++++++++--------- .../unit/hooks/useV3VariablesV1.test.tsx | 6 +- .../unit/hooks/useV3VariablesV2.test.tsx | 41 ++-- 8 files changed, 150 insertions(+), 128 deletions(-) diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index 43bcbb822..bb00b898c 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -332,7 +332,7 @@ export function GeographyFitSection({ const variablesStatus = useV3Variables(); const name = geographyName(geography); const heading = {name} data fit; - if (variablesStatus.waiting) { + if (variablesStatus.state === 'waiting') { return ( <> {heading} @@ -340,7 +340,7 @@ export function GeographyFitSection({ ); } - if (variablesStatus.failed) { + if (variablesStatus.state === 'failed') { return ( <> {heading} @@ -502,10 +502,10 @@ export function CalibrationMatchSection({ if (matches === undefined) { return card(); } - if (variablesStatus.waiting) { + if (variablesStatus.state === 'waiting') { return card(); } - if (variablesStatus.failed) { + if (variablesStatus.state === 'failed') { return card(); } const placeName = geographyName(matches.geography); diff --git a/app/src/components/flagship/ProvisionList.tsx b/app/src/components/flagship/ProvisionList.tsx index 14b758d7c..0471df3f7 100644 --- a/app/src/components/flagship/ProvisionList.tsx +++ b/app/src/components/flagship/ProvisionList.tsx @@ -103,9 +103,9 @@ export default function ProvisionList({ textAlign: 'right', }} > - {listChange && variablesStatus.waiting ? ( + {listChange && variablesStatus.state === 'waiting' ? ( - ) : listChange && variablesStatus.failed ? ( + ) : listChange && variablesStatus.state === 'failed' ? ( ) : listChange ? ( unknown }) { } /** - * On the v2 routes, content that reads the model's variables (the household - * builder and breakdown, programme names) waits behind a placeholder until - * the catalog version's variables, loading in the background, are in, with - * no time limit; a failure says so and asks again. On the v1 routes the - * whole metadata holds the variables, so the content shows as before. + * 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. */ -function V2VariablesGate({ children, label, placeholder }: V3VariablesGateProps) { +export default function V3VariablesGate({ children, label, placeholder }: V3VariablesGateProps) { const variables = useV3Variables(); - if (variables.status === 'error') { + if (variables.state === 'failed') { return ; } - if (variables.status !== 'ready') { + if (variables.state === 'waiting') { return <>{placeholder ?? }; } return <>{children}; } - -function V1VariablesGate({ children }: V3VariablesGateProps) { - return <>{children}; -} - -export default V3_API_ROUTES === 'v2' ? V2VariablesGate : V1VariablesGate; diff --git a/app/src/components/flagship/report/EconomicImpactCharts.tsx b/app/src/components/flagship/report/EconomicImpactCharts.tsx index be0a10be8..de32b2765 100644 --- a/app/src/components/flagship/report/EconomicImpactCharts.tsx +++ b/app/src/components/flagship/report/EconomicImpactCharts.tsx @@ -76,11 +76,12 @@ export default function EconomicImpactCharts({ output }: { output: SocietyWideRe const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); const variablesStatus = useV3Variables(); - const programmeCsvReason = variablesStatus.waiting - ? 'Loading programme names' - : variablesStatus.failed - ? 'Could not load the programme names' - : undefined; + const programmeCsvReason = + variablesStatus.state === 'waiting' + ? 'Loading programme names' + : variablesStatus.state === 'failed' + ? '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. diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index 4c70a9f5c..a11e1f2e7 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -174,9 +174,9 @@ export default function ReportChanges({ ? undefined : formatCurrentLaw(provision.baselineValue, provision.unit), after: list ? ( - variablesStatus.waiting ? ( + variablesStatus.state === 'waiting' ? ( - ) : variablesStatus.failed ? ( + ) : variablesStatus.state === 'failed' ? ( ) : ( describeChange(provision.baselineValue, provision.value, String, variableLabel) diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts index b0c8d8193..17d0d89fe 100644 --- a/app/src/hooks/useV3Variables.ts +++ b/app/src/hooks/useV3Variables.ts @@ -16,29 +16,19 @@ 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, and calibration matches. + * provision and change lists that name variables, programme names and + * calibration matches. * - * On the v1 routes the whole metadata holds every variable, so they are - * ready once it is. On the v2 routes the store starts with none: all of - * the catalog version's variables load once, in the background, after a - * v3 page renders, and views that read them show their loading state until - * they are in, with no time limit. + * 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 they are 'waiting' (with no time + * limit), or say they 'failed' and offer `retry`. */ export interface V3VariablesStatus { - status: 'loading' | 'error' | 'ready'; - /** - * Whether the variables are being loaded right now: only ever on the v2 - * routes. On v1, 'loading' means the whole metadata is not in yet, which - * views that never waited for it before can ignore. - */ - waiting: boolean; - /** - * Whether loading them failed: only ever on the v2 routes, where views - * then say so and offer `retry`. On v1 a failed whole metadata is the - * page's own concern, as before. - */ - failed: boolean; - /** The failure's message, when the status is 'error'. */ + state: 'ready' | 'waiting' | 'failed'; + /** The failure's message, when the state is 'failed'. */ message: string | null; retry: () => unknown; } @@ -53,71 +43,94 @@ export function v2CatalogVariablesKey( policyengineVersion: string | null, loadId: string | null ) { - return ['v2-catalog-variables', countryId, policyengineVersion, loadId] as const; + return [V2_CATALOG_VARIABLES, countryId, policyengineVersion, loadId] as const; } -/** A catalog version's variables never change, so they are fetched once per model catalog load. */ -const VARIABLES_QUERY_OPTIONS = { staleTime: Infinity, gcTime: Infinity } as const; +const V2_CATALOG_VARIABLES = 'v2-catalog-variables'; -function useV1Variables(): V3VariablesStatus { - const metadata = useFlagshipMetadataStatus(); - return { - status: metadata.status, - waiting: false, - failed: false, - message: metadata.message, - retry: metadata.retry, - }; +/** + * How long an unread variables query stays cached. The store keeps the + * variables for the current load; an earlier load's query is removed when a + * new load starts (see useV2VariablesInBackground), so this only bounds a + * query nothing reads any more. + */ +const VARIABLES_QUERY_GC_TIME = 5 * 60 * 1000; + +/** 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; + /** Not for a model catalog of another country than the page's (a country change in flight). */ + enabled: boolean; } -function useV2Variables(): V3VariablesStatus { +function useV2VariablesTarget(): VariablesTarget { const countryId = useCurrentCountry(); - const dispatch = useDispatch(); - const metadata = useFlagshipMetadataStatus(); 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; - const query = useQuery({ - queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), - queryFn: () => fetchCatalogVariables(countryId, policyengineVersion!), - // Not for a model catalog of another country than the page's (a country change in flight). + return { + countryId, + policyengineVersion, + loadId, enabled: policyengineVersion !== null && loadId !== null && storeCountry === countryId, - ...VARIABLES_QUERY_OPTIONS, - }); + }; +} - // The query holds the response; the store holds what views read. Several - // readers share one query, so whichever sees the response first fills the - // store, for the load it was asked under only. - useEffect(() => { - if (query.data && policyengineVersion !== null && loadId !== null && !inStore) { - dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, loadId, ...query.data })); - } - }, [query.data, policyengineVersion, loadId, inStore, countryId, dispatch]); +/** + * 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. + */ +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: VARIABLES_QUERY_GC_TIME, + }; +} + +function useV1Variables(): V3VariablesStatus { + const metadata = useFlagshipMetadataStatus(); + return { state: 'ready', message: null, retry: metadata.retry }; +} + +function useV2Variables(): V3VariablesStatus { + const dispatch = useDispatch(); + const metadata = useFlagshipMetadataStatus(); + const target = useV2VariablesTarget(); + const loadedUnder = useSelector((state: RootState) => state.metadata.v2VariablesLoadId); + const query = useQuery({ ...catalogVariablesQuery(dispatch, target), enabled: target.enabled }); - if (inStore) { - return { status: 'ready', waiting: false, failed: false, message: null, retry: query.refetch }; + if (target.loadId !== null && loadedUnder === target.loadId) { + return { state: 'ready', message: null, retry: query.refetch }; } if (metadata.status === 'error') { - return { - status: 'error', - waiting: false, - failed: true, - message: metadata.message, - retry: metadata.retry, - }; + return { state: 'failed', message: metadata.message, retry: metadata.retry }; } if (query.isError) { return { - status: 'error', - waiting: false, - failed: true, + state: 'failed', message: query.error instanceof Error ? query.error.message : null, retry: query.refetch, }; } - return { status: 'loading', waiting: true, failed: false, message: null, retry: query.refetch }; + return { state: 'waiting', message: null, retry: query.refetch }; } /** @@ -130,37 +143,33 @@ export const useV3Variables: () => V3VariablesStatus = /** * Starts loading the catalog version's variables once a v3 page has - * rendered, and puts them in the metadata store when they arrive, so they - * are in by the time a household is built or broken down, also for views - * that read `state.metadata.variables` without `useV3Variables`. Nothing - * when the store's model catalog is for another country than the page's - * (a country change whose catalog has not loaded yet), and nothing on the + * 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 countryId = useCurrentCountry(); const dispatch = useDispatch(); - const storeCountry = useSelector((state: RootState) => state.metadata.currentCountry); - const policyengineVersion = useSelector(selectV2CatalogVersion); - const loadId = useSelector(selectV2LoadId); + const target = useV2VariablesTarget(); const queryClient = useQueryClient(); + const { countryId, policyengineVersion, loadId, enabled } = target; useEffect(() => { - if (policyengineVersion === null || loadId === null || storeCountry !== countryId) { + 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({ - queryKey: v2CatalogVariablesKey(countryId, policyengineVersion, loadId), - queryFn: () => fetchCatalogVariables(countryId, policyengineVersion), - ...VARIABLES_QUERY_OPTIONS, - }) - .then( - (loaded) => - dispatch(catalogVariablesLoaded({ countryId, policyengineVersion, loadId, ...loaded })), - // A failure stays in the query, where views waiting for the variables report it. - () => undefined - ); - }, [countryId, storeCountry, policyengineVersion, loadId, queryClient, dispatch]); + .fetchQuery( + catalogVariablesQuery(dispatch, { countryId, policyengineVersion, loadId, enabled }) + ) + .catch(() => undefined); + }, [countryId, policyengineVersion, loadId, enabled, queryClient, dispatch]); } function noBackgroundLoad(): void {} diff --git a/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx index 76a379566..933bba151 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx @@ -22,11 +22,11 @@ describe('useV3Variables on the v1 routes (before the switch)', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current).toMatchObject({ status: 'ready', waiting: false }); + expect(result.current.state).toBe('ready'); expect(fetchStub).not.toHaveBeenCalled(); }); - test('given the whole metadata still loading then nothing waits on the v2 routes', () => { + test('given the whole metadata still loading then the variables views do not wait', () => { // Given stubPublicApiFetch(); @@ -34,7 +34,7 @@ describe('useV3Variables on the v1 routes (before the switch)', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.waiting).toBe(false); + expect(result.current.state).toBe('ready'); }); test('given a page that rendered then nothing loads in the background', () => { diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 51d0b6dd5..346b93dcf 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -55,8 +55,8 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current).toMatchObject({ status: 'loading', waiting: true }); - await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.state).toBe('waiting'); + await waitFor(() => expect(result.current.state).toBe('ready')); const requests = requestsTo(fetchStub, VARIABLES_PATH); expect(requests).toHaveLength(1); expect(requests[0].searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); @@ -77,8 +77,8 @@ describe('useV3Variables with v3 on the v2 routes', () => { 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')); + await waitFor(() => expect(first.result.current.state).toBe('ready')); + await waitFor(() => expect(second.result.current.state).toBe('ready')); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); }); @@ -92,7 +92,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { // 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')); + await waitFor(() => expect(result.current.state).toBe('ready')); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); }); @@ -125,14 +125,14 @@ describe('useV3Variables with v3 on the v2 routes', () => { // Then await new Promise((resolve) => setTimeout(resolve, 50)); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(0); - expect(reader.result.current.status).toBe('loading'); + expect(reader.result.current.state).toBe('waiting'); }); 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')); + await waitFor(() => expect(first.result.current.state).toBe('ready')); first.unmount(); await seedV2ModelCatalog(); expect(store.getState().metadata.variables).toEqual({}); @@ -141,11 +141,31 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); // Then - await waitFor(() => expect(result.current.status).toBe('ready')); + await waitFor(() => expect(result.current.state).toBe('ready')); expect(store.getState().metadata.variables.age.label).toBe('Age'); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(2); }); + 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 variables asked for under an earlier model catalog load then the store drops them', () => { // When store.dispatch( @@ -173,8 +193,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.waiting).toBe(false); + await waitFor(() => expect(result.current.state).toBe('failed')); expect(typeof result.current.retry).toBe('function'); }); @@ -227,7 +246,7 @@ describe('useV3Variables before the model catalog has loaded', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.status).toBe('loading'); + expect(result.current.state).toBe('waiting'); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(0); }); }); From 63220dcc7f42c4008b5cad25cdbb8d9e1d1e8072 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:13:32 +0400 Subject: [PATCH 13/17] Test 1.20 on a real parameter-named variable and tidy its tests - The real-shape fixture now also holds `above_the_line_deductions`, as the catalog publishes it, and the dated values of `gov.irs.ald.deductions` that name its parts, in the route's date format. A breakdown test on it checks that a 2017 report adds `domestic_production_ald` and a 2018 report does not (it passes, and fails when the breakdown ignores the part values). - The `catalogVariablesLoaded` reducer cases sit with `catalogParametersLoaded`'s in the metadata reducer's v2 tests. - The variables route path, the list provision and a fail-once-then-answer route are shared fixtures, and the country-change test waits for the query client to be idle instead of a fixed 50 ms. Co-Authored-By: Claude Opus 5.5 --- .../api/publicApi/catalogVariablesMocks.ts | 29 ++++ .../publicApi/catalogVariablesRealShape.ts | 131 ++++++++++++++++++ .../variableArithmeticRealShapeMocks.ts | 65 +++++++++ .../flagship/CalibrationMatchesV2.test.tsx | 16 +-- .../flagship/ProvisionListV2.test.tsx | 26 +--- .../flagship/V3VariablesGateV2.test.tsx | 16 +-- .../flagship/WorkspaceLayoutV2.test.tsx | 6 +- .../report/EconomicImpactChartsV2.test.tsx | 7 +- .../flagship/report/ReportChangesV2.test.tsx | 26 +--- .../VariableArithmeticRealShape.test.tsx | 48 +++++++ .../unit/hooks/useV3VariablesV2.test.tsx | 63 +-------- .../unit/pages/flagship/ReportV2.test.tsx | 6 +- .../unit/reducers/metadataReducerV2.test.ts | 61 ++++++++ 13 files changed, 376 insertions(+), 124 deletions(-) create mode 100644 app/src/tests/fixtures/components/household/variableArithmeticRealShapeMocks.ts create mode 100644 app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts index a5a628272..12c9b145d 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -145,3 +145,32 @@ export function catalogVariablesEnvelope( ) { 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' }, + }); + }; +} diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts index 04cfb96ca..8c69ee2a2 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts @@ -176,3 +176,134 @@ export const REAL_SHAPE_US_VARIABLES: components['schemas']['MetadataVariable'][ 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', + ], + }, +]; 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/components/flagship/CalibrationMatchesV2.test.tsx b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx index e9064a532..5596ab2bb 100644 --- a/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx +++ b/app/src/tests/unit/components/flagship/CalibrationMatchesV2.test.tsx @@ -4,7 +4,11 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, stubPublicApiFetch, @@ -18,8 +22,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - let answerVariables: () => Response | Promise; function renderSection() { @@ -69,13 +71,7 @@ describe('CalibrationMatchSection with v3 on the v2 routes', () => { test('given the variables fail then the matches say so and try again asks again', async () => { // Given const user = userEvent.setup(); - let attempts = 0; - answerVariables = () => { - attempts += 1; - return attempts === 1 - ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) - : jsonResponse(catalogVariablesEnvelope()); - }; + answerVariables = failOnceThenAnswerVariables(); renderSection(); expect(await screen.findByText('Could not load the variable names')).toBeInTheDocument(); diff --git a/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx index 0552d5fa2..147d9246b 100644 --- a/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx +++ b/app/src/tests/unit/components/flagship/ProvisionListV2.test.tsx @@ -4,7 +4,12 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + LIST_PROVISION, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, stubPublicApiFetch, @@ -17,17 +22,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - -/** A reform that adds SSI to the benefits a household's total counts. */ -const LIST_PROVISION = { - path: 'gov.household.household_benefits', - breadcrumb: 'Household → Benefits', - unit: null, - baselineValue: ['snap'], - value: ['snap', 'ssi'], -}; - let answerVariables: () => Response | Promise; function renderWithQueries(ui: React.ReactNode) { @@ -72,13 +66,7 @@ describe('ProvisionList with v3 on the v2 routes', () => { test('given the variables fail then the provision list says so and try again asks again', async () => { // Given const user = userEvent.setup(); - let attempts = 0; - answerVariables = () => { - attempts += 1; - return attempts === 1 - ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) - : jsonResponse(catalogVariablesEnvelope()); - }; + answerVariables = failOnceThenAnswerVariables(); renderWithQueries(); const [retry] = await screen.findAllByRole('button', { name: 'Try again' }); expect(screen.getAllByText('Could not load the variable names').length).toBeGreaterThan(0); diff --git a/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx index 543d44b70..a03d9b70f 100644 --- a/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx +++ b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx @@ -4,7 +4,11 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, requestsTo, @@ -18,8 +22,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - function renderGate() { return render( @@ -71,13 +73,7 @@ describe('V3VariablesGate with v3 on the v2 routes', () => { test('given the variables fail then the failure shows and try again asks again', async () => { // Given const user = userEvent.setup(); - let attempts = 0; - answerVariables = () => { - attempts += 1; - return attempts === 1 - ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) - : jsonResponse(catalogVariablesEnvelope()); - }; + answerVariables = failOnceThenAnswerVariables(); renderGate(); expect(await screen.findByRole('alert')).toHaveTextContent( "Could not load the model's variables" diff --git a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx index c323d850c..421716271 100644 --- a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx +++ b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx @@ -4,7 +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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { modelCatalogEnvelope, TEST_POLICYENGINE_VERSION, @@ -24,7 +27,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ })); const SEARCH_FILE_PATH = '/v2/parameters/search-file'; -const VARIABLES_PATH = '/v2/model-catalog/variables'; describe('WorkspaceLayout with v3 on the v2 routes', () => { let fetchStub: ReturnType; diff --git a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx index c94b68e76..668442d3b 100644 --- a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx +++ b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx @@ -5,7 +5,10 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, stubPublicApiFetch, @@ -37,8 +40,6 @@ vi.mock('recharts', async () => { }; }); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - let answerVariables: () => Response | Promise; function renderCharts() { diff --git a/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx index ecf7b52b7..8e1416352 100644 --- a/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportChangesV2.test.tsx @@ -4,7 +4,12 @@ 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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + failOnceThenAnswerVariables, + LIST_PROVISION, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, stubPublicApiFetch, @@ -17,17 +22,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - -/** A reform that adds SSI to the benefits a household's total counts. */ -const LIST_PROVISION = { - path: 'gov.household.household_benefits', - breadcrumb: 'Household → Benefits', - unit: null, - baselineValue: ['snap'], - value: ['snap', 'ssi'], -}; - let answerVariables: () => Response | Promise; function renderWithQueries(ui: React.ReactNode) { @@ -71,13 +65,7 @@ describe('ReportChanges with v3 on the v2 routes', () => { test('given the variables fail then the report change list says so and try again asks again', async () => { // Given const user = userEvent.setup(); - let attempts = 0; - answerVariables = () => { - attempts += 1; - return attempts === 1 - ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) - : jsonResponse(catalogVariablesEnvelope()); - }; + answerVariables = failOnceThenAnswerVariables(); renderWithQueries(); const [retry] = await screen.findAllByRole('button', { name: 'Try again' }); expect(screen.getAllByText('Could not load the variable names').length).toBeGreaterThan(0); 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..8ce4e3cfa --- /dev/null +++ b/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx @@ -0,0 +1,48 @@ +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', () => { + renderBreakdown('2017'); + + 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', () => { + renderBreakdown('2018'); + + expect(screen.queryByText(/Domestic production deduction/)).not.toBeInTheDocument(); + expect(screen.getByText(/Student loan interest deduction/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 346b93dcf..78d06f5a5 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -3,15 +3,14 @@ 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 { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; import { CountryProvider } from '@/contexts/CountryContext'; import { useV3Variables, useV3VariablesInBackground } from '@/hooks/useV3Variables'; -import { catalogVariablesLoaded, clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; +import { clearMetadata, selectV2LoadId } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { - AGE_VARIABLE, BENEFITS_PARAMETER_PATH, catalogVariablesEnvelope, + VARIABLES_PATH, } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; import { @@ -31,11 +30,6 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); -const VARIABLES_PATH = '/v2/model-catalog/variables'; - -/** Variables a dropped response would have brought. */ -const STALE_VARIABLES = { stale: v1ShapedVariable({ ...AGE_VARIABLE, name: 'stale' }) }; - describe('useV3Variables with v3 on the v2 routes', () => { let fetchStub: ReturnType; @@ -123,7 +117,8 @@ describe('useV3Variables with v3 on the v2 routes', () => { const reader = renderHook(() => useV3Variables(), { wrapper: ukWrapper }); // Then - await new Promise((resolve) => setTimeout(resolve, 50)); + // 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.state).toBe('waiting'); }); @@ -166,22 +161,6 @@ describe('useV3Variables with v3 on the v2 routes', () => { expect(cached.map((query) => query.queryKey[3])).toEqual([currentLoad]); }); - test('given variables asked for under an earlier model catalog load then the store drops them', () => { - // When - store.dispatch( - catalogVariablesLoaded({ - countryId: 'us', - policyengineVersion: TEST_POLICYENGINE_VERSION, - loadId: 'an-earlier-load', - variables: STALE_VARIABLES, - partParameterValues: {}, - }) - ); - - // Then - expect(store.getState().metadata.variables).toEqual({}); - }); - test('given the variables route fails then the status says so and can retry', async () => { // Given vi.unstubAllGlobals(); @@ -196,40 +175,6 @@ describe('useV3Variables with v3 on the v2 routes', () => { await waitFor(() => expect(result.current.state).toBe('failed')); expect(typeof result.current.retry).toBe('function'); }); - - test('given variables for another catalog version then the store drops them', () => { - // When - store.dispatch( - catalogVariablesLoaded({ - countryId: 'us', - policyengineVersion: '0.0.1', - loadId: selectV2LoadId(store.getState())!, - variables: STALE_VARIABLES, - partParameterValues: {}, - }) - ); - - // Then - const metadata = store.getState().metadata; - expect(metadata.variables).toEqual({}); - expect(metadata.v2VariablesLoadId).toBeUndefined(); - }); - - test('given variables for another country then the store drops them', () => { - // When - store.dispatch( - catalogVariablesLoaded({ - countryId: 'uk', - policyengineVersion: TEST_POLICYENGINE_VERSION, - loadId: selectV2LoadId(store.getState())!, - variables: STALE_VARIABLES, - partParameterValues: {}, - }) - ); - - // Then - expect(store.getState().metadata.variables).toEqual({}); - }); }); describe('useV3Variables before the model catalog has loaded', () => { diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index fde36bcd6..306b64538 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -6,7 +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 } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { + catalogVariablesEnvelope, + VARIABLES_PATH, +} from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; import { jsonResponse, requestsTo, @@ -99,7 +102,6 @@ function renderPage(userReportId: string) { } const PARAMETERS_PATH = '/v2/model-catalog/parameters'; -const VARIABLES_PATH = '/v2/model-catalog/variables'; const COMPLETE_STATUS = { status: 'complete', isComplete: true, 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(); + }); }); From b2789c66b758c484b381589a335bfbb04bc72a5d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:44:17 +0400 Subject: [PATCH 14/17] Keep the current load's variables cached and ask for them only when the store lacks them The variables query kept its entry for five minutes, and the background load fetches without an observer, so on a page with no reader the entry went after five minutes and the next mount downloaded the whole response again, although the store already held that load's variables. The current load's entry is now kept for the session (an earlier load's entry is still removed when a new load starts), and neither the background load nor `useV3Variables` asks for the variables when the store already holds those of the current load. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useV3Variables.ts | 44 ++++++++++++------- .../unit/hooks/useV3VariablesV2.test.tsx | 33 ++++++++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts index 17d0d89fe..737b21b7f 100644 --- a/app/src/hooks/useV3Variables.ts +++ b/app/src/hooks/useV3Variables.ts @@ -48,20 +48,18 @@ export function v2CatalogVariablesKey( const V2_CATALOG_VARIABLES = 'v2-catalog-variables'; -/** - * How long an unread variables query stays cached. The store keeps the - * variables for the current load; an earlier load's query is removed when a - * new load starts (see useV2VariablesInBackground), so this only bounds a - * query nothing reads any more. - */ -const VARIABLES_QUERY_GC_TIME = 5 * 60 * 1000; - /** 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; - /** Not for a model catalog of another country than the page's (a country change in flight). */ + /** 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; } @@ -70,11 +68,15 @@ function useV2VariablesTarget(): VariablesTarget { 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, - enabled: policyengineVersion !== null && loadId !== null && storeCountry === countryId, + inStore, + enabled: + !inStore && policyengineVersion !== null && loadId !== null && storeCountry === countryId, }; } @@ -82,7 +84,10 @@ function useV2VariablesTarget(): VariablesTarget { * 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. + * 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; @@ -101,7 +106,7 @@ function catalogVariablesQuery(dispatch: AppDispatch, target: VariablesTarget) { return loaded; }, staleTime: Infinity, - gcTime: VARIABLES_QUERY_GC_TIME, + gcTime: Infinity, }; } @@ -114,10 +119,9 @@ function useV2Variables(): V3VariablesStatus { const dispatch = useDispatch(); const metadata = useFlagshipMetadataStatus(); const target = useV2VariablesTarget(); - const loadedUnder = useSelector((state: RootState) => state.metadata.v2VariablesLoadId); const query = useQuery({ ...catalogVariablesQuery(dispatch, target), enabled: target.enabled }); - if (target.loadId !== null && loadedUnder === target.loadId) { + if (target.inStore) { return { state: 'ready', message: null, retry: query.refetch }; } if (metadata.status === 'error') { @@ -154,7 +158,7 @@ function useV2VariablesInBackground(): void { const dispatch = useDispatch(); const target = useV2VariablesTarget(); const queryClient = useQueryClient(); - const { countryId, policyengineVersion, loadId, enabled } = target; + const { countryId, policyengineVersion, loadId, inStore, enabled } = target; useEffect(() => { if (!enabled) { return; @@ -166,10 +170,16 @@ function useV2VariablesInBackground(): void { // A failure stays in the query, where views waiting for the variables report it. queryClient .fetchQuery( - catalogVariablesQuery(dispatch, { countryId, policyengineVersion, loadId, enabled }) + catalogVariablesQuery(dispatch, { + countryId, + policyengineVersion, + loadId, + inStore, + enabled, + }) ) .catch(() => undefined); - }, [countryId, policyengineVersion, loadId, enabled, queryClient, dispatch]); + }, [countryId, policyengineVersion, loadId, inStore, enabled, queryClient, dispatch]); } function noBackgroundLoad(): void {} diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index 78d06f5a5..bd63f0483 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -141,6 +141,39 @@ describe('useV3Variables with v3 on the v2 routes', () => { 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.state).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(); From 5bb6d3f3b7c6a06bfb3a04c54da6e5b65d4d4f83 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:47:39 +0400 Subject: [PATCH 15/17] Make the programme CSV's unavailable reason reachable The CSV button showed why it was unavailable only in a tooltip on a `disabled` button, which cannot take focus or open its tooltip, so the reason was never shown. While unavailable the button now stays focusable with `aria-disabled`, does nothing when clicked, opens its tooltip on hover or focus, and carries the reason ("Loading programme names" or "Could not load the programme names") as its accessible description. An available CSV button renders as before. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/ReportChartCard.tsx | 16 ++++++++-- .../report/EconomicImpactChartsV2.test.tsx | 30 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/src/components/flagship/report/ReportChartCard.tsx b/app/src/components/flagship/report/ReportChartCard.tsx index 52787f119..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, @@ -159,6 +159,7 @@ function ChartFrame({ csvUnavailableReason?: string; }) { const contentRef = useRef(null); + const csvReasonId = useId(); const csvUnavailable = !!csvFilename && !!csvUnavailableReason; const hasCsvDownload = !!csvFilename && (!!csvData || csvUnavailable); @@ -174,18 +175,29 @@ function ChartFrame({ {hasCsvDownload && ( + {/* While unavailable it stays focusable (aria-disabled, not disabled), so its + tooltip opens, and the reason is also its description. */} diff --git a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx index 668442d3b..40592fdc6 100644 --- a/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx +++ b/app/src/tests/unit/components/flagship/report/EconomicImpactChartsV2.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor, within } from '@test-utils'; +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'; @@ -21,6 +21,13 @@ 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()), @@ -111,7 +118,8 @@ describe('EconomicImpactCharts for the UK with v3 on the v2 routes', () => { renderCharts(); // Then - expect(programmeCsvButton()).toBeDisabled(); + expect(programmeCsvButton()).toHaveAttribute('aria-disabled', 'true'); + expect(programmeCsvButton()).toHaveAccessibleDescription('Loading programme names'); }); test('given the variables fail then the programme CSV stays unavailable', async () => { @@ -123,7 +131,8 @@ describe('EconomicImpactCharts for the UK with v3 on the v2 routes', () => { // Then expect(await screen.findByRole('alert')).toBeInTheDocument(); - expect(programmeCsvButton()).toBeDisabled(); + 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 () => { @@ -131,6 +140,19 @@ describe('EconomicImpactCharts for the UK with v3 on the v2 routes', () => { renderCharts(); // Then - await waitFor(() => expect(programmeCsvButton()).toBeEnabled()); + 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(); }); }); From 1a33ad35f9bb933d9ed258a985e06193f7ed05e7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:50:31 +0400 Subject: [PATCH 16/17] Fail clearly on a catalog version before format 2, and name the variables status as its siblings do - `fetchCatalogVariables` now fails, naming the catalog version, when the response's `catalog_format` is not 2: such a version publishes no input flags, enum labels, modules or parameter-named parts, so v3's builder and breakdown would otherwise get variables with none of them and show no inputs, no dropdowns and wrong parts lists. Views report it as a failed load, and `V3VariablesGate` shows the message. - `V3VariablesStatus` uses the same field and values as `V3ParametersStatus` and `V3SearchCatalog`: `status`, one of 'ready', 'loading' and 'error'. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogVariables.ts | 16 +++++++++- .../flagship/CalibrationMatches.tsx | 8 ++--- app/src/components/flagship/ProvisionList.tsx | 4 +-- .../components/flagship/V3VariablesGate.tsx | 12 ++++++-- .../flagship/report/EconomicImpactCharts.tsx | 4 +-- .../flagship/report/ReportChanges.tsx | 4 +-- app/src/hooks/useV3Variables.ts | 19 ++++++------ .../publicApi/catalogVariablesRealShape.ts | 23 +++++++++++++++ .../catalogVariablesRealShape.test.ts | 29 +++++++++++++++++-- .../flagship/V3VariablesGateV2.test.tsx | 16 ++++++++++ .../unit/hooks/useV3VariablesV1.test.tsx | 4 +-- .../unit/hooks/useV3VariablesV2.test.tsx | 22 +++++++------- 12 files changed, 122 insertions(+), 39 deletions(-) diff --git a/app/src/api/publicApi/catalogVariables.ts b/app/src/api/publicApi/catalogVariables.ts index 736d5e2ac..9c4f61f53 100644 --- a/app/src/api/publicApi/catalogVariables.ts +++ b/app/src/api/publicApi/catalogVariables.ts @@ -104,7 +104,14 @@ export function v1ShapedVariable(variable: Schemas['MetadataVariable']): V1Shape return shaped; } -/** All of a catalog version's variables, with the values their parameter-named parts read. */ +/** 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, @@ -120,6 +127,13 @@ export async function fetchCatalogVariables( }, }) ); + 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)]) diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index bb00b898c..9a3c47634 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -332,7 +332,7 @@ export function GeographyFitSection({ const variablesStatus = useV3Variables(); const name = geographyName(geography); const heading = {name} data fit; - if (variablesStatus.state === 'waiting') { + if (variablesStatus.status === 'loading') { return ( <> {heading} @@ -340,7 +340,7 @@ export function GeographyFitSection({ ); } - if (variablesStatus.state === 'failed') { + if (variablesStatus.status === 'error') { return ( <> {heading} @@ -502,10 +502,10 @@ export function CalibrationMatchSection({ if (matches === undefined) { return card(); } - if (variablesStatus.state === 'waiting') { + if (variablesStatus.status === 'loading') { return card(); } - if (variablesStatus.state === 'failed') { + if (variablesStatus.status === 'error') { return card(); } const placeName = geographyName(matches.geography); diff --git a/app/src/components/flagship/ProvisionList.tsx b/app/src/components/flagship/ProvisionList.tsx index 0471df3f7..279f5eaf9 100644 --- a/app/src/components/flagship/ProvisionList.tsx +++ b/app/src/components/flagship/ProvisionList.tsx @@ -103,9 +103,9 @@ export default function ProvisionList({ textAlign: 'right', }} > - {listChange && variablesStatus.state === 'waiting' ? ( + {listChange && variablesStatus.status === 'loading' ? ( - ) : listChange && variablesStatus.state === 'failed' ? ( + ) : listChange && variablesStatus.status === 'error' ? ( ) : listChange ? ( unknown }) { */ export default function V3VariablesGate({ children, label, placeholder }: V3VariablesGateProps) { const variables = useV3Variables(); - if (variables.state === 'failed') { - return ; + if (variables.status === 'error') { + return ( + + ); } - if (variables.state === 'waiting') { + if (variables.status === 'loading') { return <>{placeholder ?? }; } return <>{children}; diff --git a/app/src/components/flagship/report/EconomicImpactCharts.tsx b/app/src/components/flagship/report/EconomicImpactCharts.tsx index de32b2765..25e57d625 100644 --- a/app/src/components/flagship/report/EconomicImpactCharts.tsx +++ b/app/src/components/flagship/report/EconomicImpactCharts.tsx @@ -77,9 +77,9 @@ export default function EconomicImpactCharts({ output }: { output: SocietyWideRe const metadata = useSelector((state: RootState) => state.metadata); const variablesStatus = useV3Variables(); const programmeCsvReason = - variablesStatus.state === 'waiting' + variablesStatus.status === 'loading' ? 'Loading programme names' - : variablesStatus.state === 'failed' + : variablesStatus.status === 'error' ? 'Could not load the programme names' : undefined; const [decileMode, setDecileMode] = useState('absolute'); diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index a11e1f2e7..fbe9f9ffa 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -174,9 +174,9 @@ export default function ReportChanges({ ? undefined : formatCurrentLaw(provision.baselineValue, provision.unit), after: list ? ( - variablesStatus.state === 'waiting' ? ( + variablesStatus.status === 'loading' ? ( - ) : variablesStatus.state === 'failed' ? ( + ) : variablesStatus.status === 'error' ? ( ) : ( describeChange(provision.baselineValue, provision.value, String, variableLabel) diff --git a/app/src/hooks/useV3Variables.ts b/app/src/hooks/useV3Variables.ts index 737b21b7f..742ee5bba 100644 --- a/app/src/hooks/useV3Variables.ts +++ b/app/src/hooks/useV3Variables.ts @@ -23,12 +23,13 @@ import type { AppDispatch, RootState } from '@/store'; * 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 they are 'waiting' (with no time - * limit), or say they 'failed' and offer `retry`. + * 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 { - state: 'ready' | 'waiting' | 'failed'; - /** The failure's message, when the state is 'failed'. */ + status: 'ready' | 'loading' | 'error'; + /** The failure's message, when the status is 'error'. */ message: string | null; retry: () => unknown; } @@ -112,7 +113,7 @@ function catalogVariablesQuery(dispatch: AppDispatch, target: VariablesTarget) { function useV1Variables(): V3VariablesStatus { const metadata = useFlagshipMetadataStatus(); - return { state: 'ready', message: null, retry: metadata.retry }; + return { status: 'ready', message: null, retry: metadata.retry }; } function useV2Variables(): V3VariablesStatus { @@ -122,19 +123,19 @@ function useV2Variables(): V3VariablesStatus { const query = useQuery({ ...catalogVariablesQuery(dispatch, target), enabled: target.enabled }); if (target.inStore) { - return { state: 'ready', message: null, retry: query.refetch }; + return { status: 'ready', message: null, retry: query.refetch }; } if (metadata.status === 'error') { - return { state: 'failed', message: metadata.message, retry: metadata.retry }; + return { status: 'error', message: metadata.message, retry: metadata.retry }; } if (query.isError) { return { - state: 'failed', + status: 'error', message: query.error instanceof Error ? query.error.message : null, retry: query.refetch, }; } - return { state: 'waiting', message: null, retry: query.refetch }; + return { status: 'loading', message: null, retry: query.refetch }; } /** diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts index 8c69ee2a2..fcee86a2f 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesRealShape.ts @@ -307,3 +307,26 @@ export const REAL_SHAPE_PART_PARAMETER_VALUES: components['schemas']['MetadataDa ], }, ]; + +/** + * 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/unit/api/publicApi/catalogVariablesRealShape.test.ts b/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts index c0d6eed78..4b9026058 100644 --- a/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts +++ b/app/src/tests/unit/api/publicApi/catalogVariablesRealShape.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, test } from 'vitest'; -import { v1ShapedVariable } from '@/api/publicApi/catalogVariables'; +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 { REAL_SHAPE_US_VARIABLES } from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; +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) { @@ -58,4 +64,21 @@ describe('v1ShapedVariable on records as the catalog publishes them', () => { 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/components/flagship/V3VariablesGateV2.test.tsx b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx index a03d9b70f..f95924e81 100644 --- a/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx +++ b/app/src/tests/unit/components/flagship/V3VariablesGateV2.test.tsx @@ -9,6 +9,7 @@ import { failOnceThenAnswerVariables, VARIABLES_PATH, } from '@/tests/fixtures/api/publicApi/catalogVariablesMocks'; +import { PRE_FORMAT_2_VARIABLES_RESULT } from '@/tests/fixtures/api/publicApi/catalogVariablesRealShape'; import { jsonResponse, requestsTo, @@ -86,4 +87,19 @@ describe('V3VariablesGate with v3 on the v2 routes', () => { 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/hooks/useV3VariablesV1.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx index 933bba151..6dc1e5807 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV1.test.tsx @@ -22,7 +22,7 @@ describe('useV3Variables on the v1 routes (before the switch)', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.state).toBe('ready'); + expect(result.current.status).toBe('ready'); expect(fetchStub).not.toHaveBeenCalled(); }); @@ -34,7 +34,7 @@ describe('useV3Variables on the v1 routes (before the switch)', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.state).toBe('ready'); + expect(result.current.status).toBe('ready'); }); test('given a page that rendered then nothing loads in the background', () => { diff --git a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx index bd63f0483..bba78a3a0 100644 --- a/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3VariablesV2.test.tsx @@ -49,8 +49,8 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.state).toBe('waiting'); - await waitFor(() => expect(result.current.state).toBe('ready')); + 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); @@ -71,8 +71,8 @@ describe('useV3Variables with v3 on the v2 routes', () => { const second = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); // Then - await waitFor(() => expect(first.result.current.state).toBe('ready')); - await waitFor(() => expect(second.result.current.state).toBe('ready')); + 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); }); @@ -86,7 +86,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { // Then await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); - await waitFor(() => expect(result.current.state).toBe('ready')); + await waitFor(() => expect(result.current.status).toBe('ready')); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); }); @@ -120,14 +120,14 @@ describe('useV3Variables with v3 on the v2 routes', () => { // 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.state).toBe('waiting'); + 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.state).toBe('ready')); + await waitFor(() => expect(first.result.current.status).toBe('ready')); first.unmount(); await seedV2ModelCatalog(); expect(store.getState().metadata.variables).toEqual({}); @@ -136,7 +136,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); // Then - await waitFor(() => expect(result.current.state).toBe('ready')); + await waitFor(() => expect(result.current.status).toBe('ready')); expect(store.getState().metadata.variables.age.label).toBe('Age'); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(2); }); @@ -169,7 +169,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { const reader = renderHook(() => useV3Variables(), { wrapper: v3Wrapper(queryClient) }); // Then - expect(reader.result.current.state).toBe('ready'); + expect(reader.result.current.status).toBe('ready'); await waitFor(() => expect(queryClient.isFetching()).toBe(0)); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1); }); @@ -205,7 +205,7 @@ describe('useV3Variables with v3 on the v2 routes', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - await waitFor(() => expect(result.current.state).toBe('failed')); + await waitFor(() => expect(result.current.status).toBe('error')); expect(typeof result.current.retry).toBe('function'); }); }); @@ -224,7 +224,7 @@ describe('useV3Variables before the model catalog has loaded', () => { const { result } = renderHook(() => useV3Variables(), { wrapper: v3Wrapper() }); // Then - expect(result.current.state).toBe('waiting'); + expect(result.current.status).toBe('loading'); expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(0); }); }); From 1c274ddff938cd74bfe0f68569cb9d5f2374ee6c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:54:09 +0400 Subject: [PATCH 17/17] Test the dated-values module, and give 1.20's tests their sections, fixtures and request checks - `v1DatedValues` has its own tests: real dated values keyed by the day each starts, Python's earliest date written as v1 wrote it, no values, and a value that is not a parameter value failing with the parameter's name. - The breakdown and parts tests have Given, When and Then sections; their parts values, and the catalog test's unlabelled variable, are fixtures. - The report and bill report page tests check that the variables request is sent, not only that the background load hook ran. Co-Authored-By: Claude Opus 5.5 --- .../api/publicApi/catalogVariablesMocks.ts | 21 ++++++ .../api/publicApi/catalogVariables.test.ts | 31 +-------- .../unit/api/publicApi/datedValues.test.ts | 43 ++++++++++++ .../VariableArithmeticRealShape.test.tsx | 4 ++ .../unit/pages/flagship/BillReportV2.test.tsx | 8 ++- .../unit/pages/flagship/ReportV2.test.tsx | 6 +- .../householdValuesParameterParts.test.ts | 68 +++++++++++++++---- 7 files changed, 135 insertions(+), 46 deletions(-) create mode 100644 app/src/tests/unit/api/publicApi/datedValues.test.ts diff --git a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts index 12c9b145d..7c838ce2d 100644 --- a/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/catalogVariablesMocks.ts @@ -174,3 +174,24 @@ export function failOnceThenAnswerVariables(): () => Response { }); }; } + +/** 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/unit/api/publicApi/catalogVariables.test.ts b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts index 7577f0e8a..b769d46ca 100644 --- a/app/src/tests/unit/api/publicApi/catalogVariables.test.ts +++ b/app/src/tests/unit/api/publicApi/catalogVariables.test.ts @@ -5,6 +5,7 @@ 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'; @@ -139,35 +140,7 @@ describe('fetchCatalogVariables', () => { test('given a variable without a label then it reads as its name', async () => { // Given const fetchMock = vi.fn(async () => - jsonResponse( - catalogVariablesEnvelope({ - policyengine_version: TEST_POLICYENGINE_VERSION, - catalog_format: 2, - variables: [ - { - id: 'variable-unlabelled', - name: 'unlabelled_variable', - label: null, - entity: 'person', - description: null, - data_type: 'float', - possible_values: null, - default_value: 0, - adds: null, - subtracts: null, - 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: {}, - }) - ) + jsonResponse(catalogVariablesEnvelope(UNLABELLED_VARIABLES_RESULT)) ); const client = createPublicApiClient({ baseUrl: TEST_API_BASE, 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/household/VariableArithmeticRealShape.test.tsx b/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx index 8ce4e3cfa..1c20a06a9 100644 --- a/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx +++ b/app/src/tests/unit/components/household/VariableArithmeticRealShape.test.tsx @@ -33,15 +33,19 @@ function renderBreakdown(year: string): void { 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/pages/flagship/BillReportV2.test.tsx b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx index a36ad6190..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, @@ -181,7 +185,8 @@ describe('BillReportPage with v3 on the v2 routes', () => { test('given the page renders then it starts loading the variables in the background', async () => { // Given - stubPublicApiFetch({ + const fetchStub = stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), [PARAMETERS_PATH]: (url) => jsonResponse({ status: 'ok', @@ -196,5 +201,6 @@ describe('BillReportPage with v3 on the v2 routes', () => { // 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 306b64538..76605e3bc 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -208,7 +208,10 @@ describe('FlagshipReportPage with v3 on the v2 routes', () => { test('given the page renders then it starts loading the variables in the background', async () => { // Given - stubPublicApiFetch({ [PARAMETERS_PATH]: answerParameters }); + const fetchStub = stubPublicApiFetch({ + [VARIABLES_PATH]: () => jsonResponse(catalogVariablesEnvelope()), + [PARAMETERS_PATH]: answerParameters, + }); await seedV2ModelCatalog(); // When @@ -216,5 +219,6 @@ describe('FlagshipReportPage with v3 on the v2 routes', () => { // Then expect(mockUseV3VariablesInBackground).toHaveBeenCalled(); + await waitFor(() => expect(requestsTo(fetchStub, VARIABLES_PATH)).toHaveLength(1)); }); }); diff --git a/app/src/tests/unit/utils/householdValuesParameterParts.test.ts b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts index d8be6e56a..3aa1a9046 100644 --- a/app/src/tests/unit/utils/householdValuesParameterParts.test.ts +++ b/app/src/tests/unit/utils/householdValuesParameterParts.test.ts @@ -1,37 +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'; -const PARTS_PATH = 'gov.household.household_benefits'; -const PART_VALUES = { [PARTS_PATH]: { '0000-01-01': ['snap'], '2026-01-01': ['snap', 'ssi'] } }; +/** 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', () => { - expect(getParameterNamedParts(PARTS_PATH, '2025-12-31', {}, PART_VALUES)).toEqual(['snap']); + // 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', () => { - expect(getParameterNamedParts(PARTS_PATH, '2026-01-01', {}, PART_VALUES)).toEqual([ - 'snap', - 'ssi', - ]); + // 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', () => { - const parameters = { [PARTS_PATH]: { values: { '0000-01-01': ['wic'] } } }; + // When + const parts = getParameterNamedParts( + BENEFITS_PARAMETER_PATH, + '2025-01-01', + V1_PARAMETERS, + BENEFITS_PART_VALUES + ); - expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', parameters, PART_VALUES)).toEqual([ - 'snap', - ]); + // Then + expect(parts).toEqual(['snap']); }); test('given no part values then the parameter list is read, as on v1', () => { - const parameters = { [PARTS_PATH]: { values: { '0000-01-01': ['wic'] } } }; + // When + const parts = getParameterNamedParts(BENEFITS_PARAMETER_PATH, '2025-01-01', V1_PARAMETERS); - expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', parameters)).toEqual(['wic']); + // Then + expect(parts).toEqual(['wic']); }); test('given a parameter with no values or no entry then nothing is listed', () => { - expect(getParameterNamedParts(PARTS_PATH, '2025-01-01', {}, { [PARTS_PATH]: {} })).toEqual([]); - expect(getParameterNamedParts('gov.unknown', '2025-01-01', {})).toEqual([]); + // 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([]); }); });