diff --git a/app/src/api/savedHouseholdStore.ts b/app/src/api/savedHouseholdStore.ts new file mode 100644 index 000000000..c07bbf3fd --- /dev/null +++ b/app/src/api/savedHouseholdStore.ts @@ -0,0 +1,317 @@ +import { + AccountAssociationRoutes, + BrowserOnlyIdError, + isAccountId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { clampStoreText } from '@/libs/flagship/storeText'; +import type { Household } from '@/models/Household'; +import { householdFromV2Item, householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import type { + UserHouseholdCreateBody, + UserHouseholdItem, + UserHouseholdPatchBody, + V2HouseholdItem, +} from '@/types/v2Households'; +import type { AssociationCountryId, V2ItemResult } from '@/types/v2User'; + +/** + * v3's saved households, in the account (signed in, or signed out as this + * browser's unauthenticated user). A household's content is stored once + * on the Public API's `/v2/households`, which never changes a household + * and returns the existing one for identical content; the user's saved + * household is a `/v2/user-households` row holding their name for it and + * the household's v2 id. An edit stores the new content and points the + * saved household at it, so the saved household keeps its id. Without + * accounts there are no saved households. + */ + +/** A saved household that opens: the user's row and the household it points at. */ +export interface SavedHousehold { + /** The saved-household row (an account UUID). */ + id: string; + countryId: AssociationCountryId; + /** The user's name for it; null when saved without one. */ + label: string | null; + /** The stored v2 household. */ + householdId: string; + household: Household; + createdAt: string; + updatedAt: string; + unavailable?: false; +} + +/** + * A saved household that is listed but cannot open: its row names no v2 + * household (only a v1 one), or its household failed to load. + */ +export interface UnavailableSavedHousehold { + id: string; + countryId: AssociationCountryId; + label: string | null; + householdId: string | null; + createdAt: string; + updatedAt: string; + unavailable: true; +} + +export type ListedSavedHousehold = SavedHousehold | UnavailableSavedHousehold; + +/** A saved row after a rename: the row's own fields, without its household. */ +export type SavedHouseholdRow = Omit; + +/** What changed in a saved household: its content, its label, or both. */ +export interface SavedHouseholdChanges { + /** The edited household; left out when its content is unchanged. */ + household?: Household; + /** The new label; left out when it is unchanged. */ + label?: string; +} + +export interface SavedHouseholdStore { + /** This session's saved households, most recently updated first. */ + list: () => Promise; + /** A saved household with its household; null when it does not exist (or is someone else's). */ + get: (savedHouseholdId: string) => Promise; + save: (args: { household: Household; label: string }) => Promise; + rename: (savedHouseholdId: string, label: string) => Promise; + /** Deletes the saved row; the stored household stays, as households are shared. */ + remove: (savedHouseholdId: string) => Promise; + /** + * Saves changes to a saved household, keeping its id: edited content is + * stored and the saved household pointed at it, in one request with any + * new label. + */ + saveChanges: (saved: SavedHousehold, changes: SavedHouseholdChanges) => Promise; +} + +/** + * Where a store keeps stored households. A stored household never + * changes, so each is fetched once, not on every read of the list. + */ +export interface StoredHouseholdCache { + /** The stored household; `fetchHousehold` runs only when it is not kept, and a failed fetch is not kept. */ + get: ( + countryId: string, + householdId: string, + fetchHousehold: () => Promise + ) => Promise; + /** Keeps a stored household the store already has, such as one it has just saved. */ + set: (countryId: string, householdId: string, household: Household) => void; +} + +/** Stored households kept for one store's life: for a store used on its own. */ +export function storeLifetimeHouseholdCache(): StoredHouseholdCache { + const kept = new Map>(); + const keyOf = (countryId: string, householdId: string) => `${countryId}/${householdId}`; + return { + get: (countryId, householdId, fetchHousehold) => { + const key = keyOf(countryId, householdId); + const existing = kept.get(key); + if (existing) { + return existing; + } + const fetched = fetchHousehold(); + kept.set(key, fetched); + fetched.catch(() => kept.delete(key)); + return fetched; + }, + set: (countryId, householdId, household) => { + kept.set(keyOf(countryId, householdId), Promise.resolve(household)); + }, + }; +} + +/** A saved row that names no v2 household cannot be opened in v3. */ +export class SavedHouseholdUnavailableError extends Error { + constructor() { + super('This saved household cannot be opened here: it was saved before households moved.'); + this.name = 'SavedHouseholdUnavailableError'; + } +} + +/** + * The saved household could not be changed: it still holds what it held + * before. Edited content may already be stored, which changes nothing, as + * a stored household belongs to no one until a saved household points at it. + */ +export class SavedHouseholdChangesNotSavedError extends Error { + constructor(cause: unknown) { + super( + `Your changes were not saved: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause } + ); + this.name = 'SavedHouseholdChangesNotSavedError'; + } +} + +/** Most recently updated first. */ +function byRecentUpdate(items: T[]): T[] { + return [...items].sort((first, second) => + (second.updatedAt || second.createdAt).localeCompare(first.updatedAt || first.createdAt) + ); +} + +function rowOf(item: UserHouseholdItem): SavedHouseholdRow { + return { + id: item.id, + countryId: item.country_id, + label: item.name, + householdId: item.household_id, + createdAt: item.created_at, + updatedAt: item.updated_at, + }; +} + +function savedHouseholdOf( + item: UserHouseholdItem, + householdId: string, + household: Household +): SavedHousehold { + return { ...rowOf(item), householdId, household }; +} + +/** The user's name for a household, as the Public API takes it: at most 255 characters, else none. */ +function nameFor(label: string): string | null { + return clampStoreText(label.trim()) || null; +} + +export class ApiSavedHouseholdStore implements SavedHouseholdStore { + private readonly routes: AccountAssociationRoutes; + + constructor( + private readonly context: ApiStoreContext, + /** Where stored households are kept; the session's, from the hooks. */ + private readonly storedHouseholds: StoredHouseholdCache = storeLifetimeHouseholdCache() + ) { + this.routes = new AccountAssociationRoutes(context, 'user-households', 'Saved household'); + } + + async list(): Promise { + const rows = await this.routes.list(); + // A household that fails to load is listed as unavailable, rather than failing the list. + const loaded = await Promise.allSettled( + rows.map((row) => + row.household_id + ? this.fetchHousehold(row.country_id, row.household_id) + : Promise.reject(new SavedHouseholdUnavailableError()) + ) + ); + return byRecentUpdate( + rows.map((row, index): ListedSavedHousehold => { + const outcome = loaded[index]; + return outcome.status === 'fulfilled' && row.household_id + ? savedHouseholdOf(row, row.household_id, outcome.value) + : { ...rowOf(row), unavailable: true }; + }) + ); + } + + async get(savedHouseholdId: string): Promise { + const row = await this.routes.get(savedHouseholdId); + if (!row) { + return null; + } + if (!row.household_id) { + throw new SavedHouseholdUnavailableError(); + } + return savedHouseholdOf( + row, + row.household_id, + await this.fetchHousehold(row.country_id, row.household_id) + ); + } + + async save({ household, label }: { household: Household; label: string }) { + const stored = await this.storeHousehold(household); + const row = await this.routes.create({ + country_id: stored.country_id, + household_id: stored.id, + name: nameFor(label), + }); + return savedHouseholdOf(row, stored.id, this.remember(stored)); + } + + async rename(savedHouseholdId: string, label: string): Promise { + const body: UserHouseholdPatchBody = { name: nameFor(label) }; + return rowOf(await this.routes.patch(savedHouseholdId, body)); + } + + remove(savedHouseholdId: string): Promise { + return this.routes.delete(savedHouseholdId); + } + + async saveChanges( + saved: SavedHousehold, + { household, label }: SavedHouseholdChanges + ): Promise { + // Checked before anything is stored: a household saved only in this + // browser has no saved row to point at the edit. + if (!isAccountId(saved.id)) { + throw new BrowserOnlyIdError(); + } + let stored: V2HouseholdItem | null = null; + try { + stored = household ? await this.storeHousehold(household) : null; + } catch (error) { + // Nothing has changed: the saved household still points at what it did. + throw new SavedHouseholdChangesNotSavedError(error); + } + const body: UserHouseholdPatchBody = {}; + // Identical content is the stored household the saved one already points at. + if (stored && stored.id !== saved.householdId) { + body.household_id = stored.id; + } + if (label !== undefined && nameFor(label) !== saved.label) { + body.name = nameFor(label); + } + const nextHousehold = stored ? this.remember(stored) : saved.household; + if (Object.keys(body).length === 0) { + return { ...saved, household: nextHousehold }; + } + let row: UserHouseholdItem; + try { + row = await this.routes.patch(saved.id, body); + } catch (error) { + throw new SavedHouseholdChangesNotSavedError(error); + } + return savedHouseholdOf(row, row.household_id ?? saved.householdId, nextHousehold); + } + + /** Stores a household's content (`POST /v2/households`), or finds the identical stored one. */ + private async storeHousehold(household: Household): Promise { + const body = householdToV2CreateBody(household); + const result = await this.context.client.request>( + 'POST', + 'households', + { query: { country_id: body.country_id }, body } + ); + return result.item; + } + + /** A stored household as the app's model, kept for later reads. */ + private remember(item: V2HouseholdItem): Household { + const household = householdFromV2Item(item); + this.storedHouseholds.set(item.country_id, item.id, household); + return household; + } + + private fetchHousehold(countryId: string, householdId: string): Promise { + return this.storedHouseholds.get(countryId, householdId, async () => { + const result = await this.context.client.request>( + 'GET', + `households/${encodeURIComponent(householdId)}`, + { query: { country_id: countryId } } + ); + return householdFromV2Item(result.item); + }); + } +} + +/** The saved-household store for a session: the account's; none without accounts. */ +export function getSavedHouseholdStore( + account: ApiStoreContext | null | undefined, + storedHouseholds?: StoredHouseholdCache +): SavedHouseholdStore | null { + return account ? new ApiSavedHouseholdStore(account, storedHouseholds) : null; +} diff --git a/app/src/components/flagship/DeleteConfirmationDialog.tsx b/app/src/components/flagship/DeleteConfirmationDialog.tsx new file mode 100644 index 000000000..8233e2712 --- /dev/null +++ b/app/src/components/flagship/DeleteConfirmationDialog.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { IconTrash } from '@tabler/icons-react'; +import { AlertDialog } from 'radix-ui'; +import { Button } from '@/components/ui'; + +/** + * A Delete button that asks first, for v3's saved work: a delete cannot be + * undone. The dialog names what is deleted and says what the delete does. + */ +export default function DeleteConfirmationDialog({ + name, + description, + confirmLabel, + disabled, + onConfirm, + compact = false, +}: { + /** What is deleted, as the dialog's title names it. */ + name: string; + /** What the delete does, under the title. */ + description: ReactNode; + /** The button that deletes, e.g. "Delete reform". */ + confirmLabel: string; + disabled?: boolean; + onConfirm: () => void; + /** A small, borderless Delete button, for a row of a list. */ + compact?: boolean; +}) { + return ( + + + + + + + + + Delete “{name}”? + + + {description} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/app/src/components/flagship/DeleteReformDialog.tsx b/app/src/components/flagship/DeleteReformDialog.tsx index e6d7b0cf5..1713abc9c 100644 --- a/app/src/components/flagship/DeleteReformDialog.tsx +++ b/app/src/components/flagship/DeleteReformDialog.tsx @@ -1,6 +1,4 @@ -import { IconTrash } from '@tabler/icons-react'; -import { AlertDialog } from 'radix-ui'; -import { Button } from '@/components/ui'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; /** What the delete does to the reports run from the reform. */ function reportsNote(reportCount: number): string { @@ -29,34 +27,12 @@ export default function DeleteReformDialog({ onConfirm: () => void; }) { return ( - - - - - - - - - Delete “{name}”? - - - This removes the reform for good.{reportsNote(reportCount)} - -
- - - - - - -
-
-
-
+ ); } diff --git a/app/src/components/flagship/household/HouseholdBuilderPanel.tsx b/app/src/components/flagship/household/HouseholdBuilderPanel.tsx new file mode 100644 index 000000000..c4282c0fa --- /dev/null +++ b/app/src/components/flagship/household/HouseholdBuilderPanel.tsx @@ -0,0 +1,136 @@ +import { useCallback, useMemo, type ComponentProps } from 'react'; +import { useSelector } from 'react-redux'; +import { + commonHouseholdInputs, + householdInputDescriptionOf, + householdInputNameOf, + householdTopicIcon, +} from '@/components/flagship/report/household/householdPickerInputs'; +import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; +import type { CountryId } from '@/libs/countries'; +import { householdState } from '@/libs/flagship/householdDescription'; +import { + describesAHousehold, + HOUSEHOLD_INCOME_SUBGROUPS, + HOUSEHOLD_VARIABLE_GROUPS, + householdVariableGroupOf, + householdVariableSubgroupOf, + listsForState, + twinsABasicInput, +} from '@/libs/flagship/householdVariableGroups'; +import { getBasicInputFields } from '@/libs/metadataUtils'; +import type { Household } from '@/models/Household'; +import type { RootState } from '@/store'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +type BuilderFormProps = ComponentProps; + +/** + * A person's name as a country's builder shows it: a UK household's + * dependants are its children ("your first child"), as the UK model's + * benefits count them; the US keeps "dependent", which a tax unit's + * dependants are called. Only the name shown changes, never the key. + */ +export function householdPersonNameOf( + countryId: string +): ((personKey: string) => string) | undefined { + return countryId === 'uk' + ? (personKey) => personKey.replace(/\bdependent\b/, 'child') + : undefined; +} + +/** + * The household builder as v3 configures it, for the report section and + * the standalone household report: every input the model takes, grouped + * by topic with the country's featured inputs first; the variable search + * lists only the household's own state's programs, only inputs that + * describe a household (no survey-reported totals), and not the twin of a + * field the builder already shows; the US SPM geography choice; and each + * input labelled. + */ +export default function HouseholdBuilderPanel({ + household, + countryId, + year, + onChange, + disabled, + suggestedVariables, + onVariablePickerOpen, + onVariableAdd, +}: { + household: Household; + countryId: CountryId; + year: string; + onChange: (household: Household) => void; + disabled?: boolean; + /** Inputs the reform reads, which lead the variable search as related to it. */ + suggestedVariables?: string[]; + onVariablePickerOpen?: BuilderFormProps['onVariablePickerOpen']; + onVariableAdd?: BuilderFormProps['onVariableAdd']; +}) { + const metadata = useSelector((state: RootState) => state.metadata); + const basicInputFields = useSelector(getBasicInputFields); + const composition = useMemo(() => household.deriveBuilderComposition(year), [household, year]); + const basicNonPersonFields = useMemo( + () => + Object.entries(basicInputFields) + .filter(([key]) => key !== 'person') + .flatMap(([, fields]) => fields), + [basicInputFields] + ); + const stateCode = householdState(household, year, countryId); + const basicPersonFields = basicInputFields.person; + const variableFilter = useCallback( + (variable: VariableInfo) => + listsForState(variable, stateCode) && + describesAHousehold(variable, countryId) && + !twinsABasicInput(variable, basicPersonFields ?? []), + [stateCode, countryId, basicPersonFields] + ); + // The picker's groups, featured inputs and names, for the country's model. + const picker = useMemo( + () => ({ + groupOf: householdVariableGroupOf(countryId), + subgroupOf: householdVariableSubgroupOf(countryId), + common: commonHouseholdInputs(countryId), + nameOf: householdInputNameOf(countryId), + describe: householdInputDescriptionOf(countryId), + personNameOf: householdPersonNameOf(countryId), + }), + [countryId] + ); + + return ( + onChange(household.withBuilderMaritalStatus(year, status))} + onNumChildrenChange={(count) => onChange(household.withBuilderChildCount(year, count))} + disabled={disabled} + layout="columns" + suggestedVariables={suggestedVariables} + suggestedLabel="Related to this reform" + searchRanking="relevance" + variableGroupOf={picker.groupOf} + variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} + variableFilter={variableFilter} + variableSubgroupOf={picker.subgroupOf} + variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} + searchByEntity + variablePickerCommon={picker.common} + variableGroupIcon={householdTopicIcon} + variableNameOf={picker.nameOf} + variableDescribe={picker.describe} + personDisplayNameOf={picker.personNameOf} + labelInputs + onVariablePickerOpen={onVariablePickerOpen} + onVariableAdd={onVariableAdd} + /> + ); +} diff --git a/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx new file mode 100644 index 000000000..800f292cb --- /dev/null +++ b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx @@ -0,0 +1,90 @@ +import { useId, useState } from 'react'; +import HouseholdBreakdowns, { + HOUSEHOLD_BREAKDOWN_VIEWS, + type HouseholdBreakdownSource, + type HouseholdBreakdownView, +} from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + householdHeadlineFigureStyle, + householdHeadlineLabelStyle, + householdMoney, + HouseholdOutputTabs, + householdResultCardStyle, +} from '@/components/flagship/report/household/householdResultParts'; +import SPMMethodologyFootnote from '@/components/household/SPMMethodologyFootnote'; +import { Stack, Text, Title } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import type { CountryId } from '@/libs/countries'; +import type { HouseholdCalculationProvenance } from '@/libs/flagship/householdRun'; + +/** + * A household's result under one policy, with no reform to compare: its + * annual net income, then the v2 breakdowns of it (the net income tree, + * varying earnings, marginal tax rates), each under that policy alone. + */ +export default function HouseholdNetIncomeResult({ + netIncome, + summary, + breakdowns, + countryId, + methodology, +}: { + /** The household's annual net income under the policy. */ + netIncome: number; + /** Who the result is for, e.g. "Married · 2 children · California · 2026". */ + summary: string; + /** The run the breakdowns read; its baseline and reform policies are the same one. */ + breakdowns: HouseholdBreakdownSource; + /** The household's country: its currency. */ + countryId: CountryId; + /** + * How the calculation was made: a US calculation's SPM settings and + * receipt show as a methodology footnote under the net income tree. + */ + methodology?: HouseholdCalculationProvenance; +}) { + const [view, setView] = useState('net-income'); + const id = useId(); + + return ( +
+ + + Household net income + + + {summary} + + + +
+ + Annual net income + + + {householdMoney(netIncome, countryId)} + +
+ + + +
+ +
+ + {/* The variation views show their own calculation's methodology. */} + {methodology && view === 'net-income' && } +
+ ); +} diff --git a/app/src/components/flagship/household/SaveHouseholdForm.tsx b/app/src/components/flagship/household/SaveHouseholdForm.tsx new file mode 100644 index 000000000..89420a20d --- /dev/null +++ b/app/src/components/flagship/household/SaveHouseholdForm.tsx @@ -0,0 +1,168 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { IconDeviceFloppy } from '@tabler/icons-react'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import { SAVED_HOUSEHOLDS_UNAVAILABLE } from '@/components/flagship/household/savedHouseholdDisplay'; +import { Button, Input, Label, Stack, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useSavedHouseholdStore, + useSaveHousehold, + useSaveHouseholdChanges, +} from '@/hooks/useSavedHouseholds'; +import type { Household } from '@/models/Household'; +import { haveSameStoredContent } from '@/models/household/v2DocumentCodec'; +import { copyHouseholdToYear } from '@/pages/reportBuilder/utils/changeReportYear'; + +/** + * A saved household as the builder holds it in `year`: a report moves a + * household it opens into its own year, and that move alone is not an + * edit. A household that cannot move is compared as it is. + */ +function inYear(household: Household, year: number | null): Household { + if (year === null || household.year === year) { + return household; + } + try { + return copyHouseholdToYear(household, String(year)); + } catch { + return household; + } +} + +/** + * A label field and a Save button for the household in the builder. A new + * household is saved under the label. A saved household's changes are + * saved to it, keeping its id: an edit is stored as a new household + * (stored households never change) and the saved household pointed at + * it; a new label alone only renames it. + */ +export default function SaveHouseholdForm({ + household, + saved, + onSaved, +}: { + /** The household as the builder holds it. */ + household: Household; + /** + * The saved household the builder is editing; a new one is saved without + * it. Once the form saves one, it edits that one, whether or not the + * caller passes it back. + */ + saved?: SavedHousehold | null; + /** + * The saved household after the save. Not called for a save that + * finishes after the form has gone, as when the page opens another + * saved household (which gives the form a new key) while it runs. + */ + onSaved?: (saved: SavedHousehold) => void; +}) { + const labelId = useId(); + const { store } = useSavedHouseholdStore(); + const saveHousehold = useSaveHousehold(); + const saveChanges = useSaveHouseholdChanges(); + const [label, setLabel] = useState(saved?.label ?? ''); + const [saving, setSaving] = useState(false); + // The saved household this form's last successful save made or changed. + const [savedByThisForm, setSavedByThisForm] = useState(null); + const editing = saved ?? savedByThisForm; + // What the last save stored: "Saved" shows only while the household and + // label on screen are still what it stored. + const [lastSaved, setLastSaved] = useState<{ household: Household; label: string } | null>(null); + const [errorMessage, setErrorMessage] = useState(null); + const savedInBuilderYear = useMemo( + () => (editing ? inYear(editing.household, household.year) : null), + [editing, household.year] + ); + const shown = useRef(true); + useEffect(() => { + shown.current = true; + return () => { + shown.current = false; + }; + }, []); + + if (!store) { + return ( + + {SAVED_HOUSEHOLDS_UNAVAILABLE} + + ); + } + + const contentChanged = + !savedInBuilderYear || !haveSameStoredContent(household, savedInBuilderYear); + const labelChanged = !editing || label.trim() !== (editing.label ?? ''); + const nothingToSave = !!editing && !contentChanged && !labelChanged; + const showsSaved = + !!lastSaved && + lastSaved.label === label.trim() && + haveSameStoredContent(household, lastSaved.household); + + const save = async () => { + setSaving(true); + setErrorMessage(null); + try { + const result = editing + ? await saveChanges.mutateAsync({ + saved: editing, + changes: { + ...(contentChanged ? { household } : {}), + ...(labelChanged ? { label } : {}), + }, + }) + : await saveHousehold.mutateAsync({ household, label }); + if (!shown.current) { + return; + } + setSavedByThisForm(result); + setLastSaved({ household, label: label.trim() }); + onSaved?.(result); + } catch (error) { + setLastSaved(null); + setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }; + + return ( + + +
+ setLabel(event.target.value)} + className="tw:max-w-xs" + /> + + {showsSaved && ( + + Saved + + )} +
+ {errorMessage && ( + + {errorMessage} + + )} +
+ ); +} diff --git a/app/src/components/flagship/household/SavedHouseholdPicker.tsx b/app/src/components/flagship/household/SavedHouseholdPicker.tsx new file mode 100644 index 000000000..fe5de3622 --- /dev/null +++ b/app/src/components/flagship/household/SavedHouseholdPicker.tsx @@ -0,0 +1,107 @@ +import { IconHome } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import { + savedHouseholdDescription, + savedHouseholdName, + savedHouseholdsNotice, +} from '@/components/flagship/household/savedHouseholdDisplay'; +import { colors, spacing, typography } from '@/designTokens'; +import { useSavedHouseholds } from '@/hooks/useSavedHouseholds'; +import type { RootState } from '@/store'; +import type { AssociationCountryId } from '@/types/v2User'; + +/** + * The session's saved households in a country, to open one: each by its + * label, with who it describes. One that cannot open (saved before + * households moved to the Public API's v2 routes, or failing to load) is + * listed but cannot be chosen. + */ +export default function SavedHouseholdPicker({ + countryId, + selectedId, + onSelect, + disabled = false, +}: { + countryId: AssociationCountryId; + /** The saved household the builder holds, marked as chosen. */ + selectedId?: string; + onSelect: (saved: SavedHousehold) => void; + /** No saved household can be chosen, as while the builder's household is calculating. */ + disabled?: boolean; +}) { + const variables = useSelector((state: RootState) => state.metadata?.variables); + const { available, savedHouseholds, isLoading, error } = useSavedHouseholds(); + const households = savedHouseholds.filter((saved) => saved.countryId === countryId); + const notice = savedHouseholdsNotice({ + available, + isLoading, + error, + count: households.length, + }); + if (notice) { + return notice; + } + + return ( +
    + {households.map((saved) => { + const selected = saved.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/app/src/components/flagship/household/SavedHouseholdsList.tsx b/app/src/components/flagship/household/SavedHouseholdsList.tsx new file mode 100644 index 000000000..f495ff25a --- /dev/null +++ b/app/src/components/flagship/household/SavedHouseholdsList.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import { IconPencil } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import type { ListedSavedHousehold } from '@/api/savedHouseholdStore'; +import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; +import { + savedHouseholdDescription, + savedHouseholdName, + savedHouseholdsNotice, +} from '@/components/flagship/household/savedHouseholdDisplay'; +import { Button, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useDeleteSavedHousehold, + useRenameSavedHousehold, + useSavedHouseholds, +} from '@/hooks/useSavedHouseholds'; +import type { RootState } from '@/store'; + +/** + * The session's saved households, each with who it describes, to rename + * or delete. A saved household that cannot open is listed, and can still + * be renamed or deleted. A failed rename keeps its dialog open with the + * reason; a failed delete names the household and the reason above the list. + */ +export default function SavedHouseholdsList() { + const variables = useSelector((state: RootState) => state.metadata?.variables); + const { available, savedHouseholds, isLoading, error } = useSavedHouseholds(); + const renameHousehold = useRenameSavedHousehold(); + const deleteHousehold = useDeleteSavedHousehold(); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + + const notice = savedHouseholdsNotice({ + available, + isLoading, + error, + count: savedHouseholds.length, + }); + if (notice) { + return notice; + } + + const openRename = (saved: ListedSavedHousehold) => { + // An earlier rename's failure belongs to the household it was tried on. + renameHousehold.reset(); + setRenaming(saved); + }; + + const remove = (saved: ListedSavedHousehold) => { + setDeleting(saved); + deleteHousehold.mutate(saved.id); + }; + + return ( + <> + {deleteHousehold.isError && deleting && ( + + “{savedHouseholdName(deleting)}” could not be deleted. {deleteHousehold.error.message} + + )} +
    + {savedHouseholds.map((saved) => { + const name = savedHouseholdName(saved); + return ( +
  • + + + {name} + + + {savedHouseholdDescription(saved, variables)} + + + + + remove(saved)} + /> + +
  • + ); + })} +
+ setRenaming(null)} + currentLabel={renaming?.label ?? ''} + ingredientType="household" + isLoading={renameHousehold.isPending} + submissionError={renameHousehold.error?.message ?? null} + onRename={async (label) => { + if (!renaming) { + return; + } + try { + await renameHousehold.mutateAsync({ savedHouseholdId: renaming.id, label }); + setRenaming(null); + } catch { + // The dialog shows the reason and stays open. + } + }} + /> + + ); +} diff --git a/app/src/components/flagship/household/savedHouseholdDisplay.tsx b/app/src/components/flagship/household/savedHouseholdDisplay.tsx new file mode 100644 index 000000000..4a87e5e84 --- /dev/null +++ b/app/src/components/flagship/household/savedHouseholdDisplay.tsx @@ -0,0 +1,84 @@ +import type { CSSProperties, ReactElement } from 'react'; +import type { ListedSavedHousehold } from '@/api/savedHouseholdStore'; +import { Spinner, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +/** + * What v3's saved-household controls (the save form, the picker and the + * list) share: how a saved household is named and described, and what + * they say before there is a saved household to show. + */ + +/** Without accounts there are no saved households; each control says so instead. */ +export const SAVED_HOUSEHOLDS_UNAVAILABLE = 'Saved households are not available here.'; + +const quietText: CSSProperties = { + margin: 0, + fontSize: typography.fontSize.sm, + color: colors.text.secondary, +}; + +/** A saved household's name, or what it is when saved without one. */ +export function savedHouseholdName(saved: Pick): string { + return saved.label || 'Untitled household'; +} + +/** + * Who a saved household describes ("Married · 2 children · California"), + * in the year its inputs are in; for one that cannot open, that it cannot. + */ +export function savedHouseholdDescription( + saved: ListedSavedHousehold, + variables?: Record +): string { + if (saved.unavailable) { + return 'Cannot be opened here'; + } + const { household } = saved; + return household.year === null + ? 'No year set' + : describeHousehold(household, String(household.year), saved.countryId, variables); +} + +/** + * What a list of saved households shows instead of its households: that + * there are none here (no accounts), that they are loading, why they + * could not load, or that none are saved. Null once there are some to show. + */ +export function savedHouseholdsNotice({ + available, + isLoading, + error, + count, +}: { + available: boolean; + isLoading: boolean; + error: Error | null; + /** How many saved households there are to show. */ + count: number; +}): ReactElement | null { + if (!available) { + return {SAVED_HOUSEHOLDS_UNAVAILABLE}; + } + if (isLoading) { + return ( +
+ + Loading your households... +
+ ); + } + if (error) { + return ( + + Your households could not be loaded. {error.message} + + ); + } + if (count === 0) { + return No saved households yet.; + } + return null; +} diff --git a/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx b/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx index 93ae4e0d0..8884ec54b 100644 --- a/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx +++ b/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx @@ -43,6 +43,11 @@ export interface HouseholdBreakdownSource { reform: HouseholdCalculationData; /** Whether the reform raises the household's net income: the breakdown's accent. */ gains: boolean; + /** + * The household's own amount inputs, which the earnings variation offers + * beside its calculated variables (issue #1096); none unless given. + */ + householdInputs?: string[]; } /** @@ -184,7 +189,7 @@ export default function HouseholdBreakdowns({ }; content = view === 'earnings-variation' ? ( - + ) : ( ); diff --git a/app/src/components/flagship/report/household/HouseholdImpactResult.tsx b/app/src/components/flagship/report/household/HouseholdImpactResult.tsx index 3a71f11da..161ae9f0f 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactResult.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactResult.tsx @@ -4,6 +4,14 @@ import HouseholdBreakdowns, { type HouseholdBreakdownSource, type HouseholdBreakdownView, } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + householdHeadlineFigureStyle, + householdHeadlineLabelStyle, + HouseholdOutputTabs, + householdResultCardStyle, + householdMoney as money, + signedHouseholdMoney as signedMoney, +} from '@/components/flagship/report/household/householdResultParts'; import SPMMethodologyFootnote from '@/components/household/SPMMethodologyFootnote'; import { Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -14,20 +22,6 @@ import type { HouseholdLineChange, } from '@/libs/flagship/householdRun'; import { trackHouseholdOutputViewed, type HouseholdOutputTab } from '@/utils/analytics'; -import { formatCurrency } from '@/utils/formatters'; - -/** Whole dollars (or pounds), with a true minus sign: "−$1,200", "−£1,200". */ -function money(value: number, countryId: CountryId): string { - const rounded = Math.round(value); - const text = formatCurrency(Math.abs(rounded), countryId, { maximumFractionDigits: 0 }); - return rounded < 0 ? `−${text}` : text; -} - -/** A change, always signed: "+$1,200", "−$300", "$0". */ -function signedMoney(value: number, countryId: CountryId): string { - const rounded = Math.round(value); - return rounded > 0 ? `+${money(rounded, countryId)}` : money(rounded, countryId); -} /** Gains read in the brand teal, losses in red. */ const effectColor = (effect: number) => @@ -176,7 +170,10 @@ function SummaryTables({ ); } -const VIEW_OPTIONS = [{ value: 'summary', label: 'Summary' }, ...HOUSEHOLD_BREAKDOWN_VIEWS]; +const VIEW_OPTIONS: ReadonlyArray<{ value: 'summary' | HouseholdBreakdownView; label: string }> = [ + { value: 'summary', label: 'Summary' }, + ...HOUSEHOLD_BREAKDOWN_VIEWS, +]; /** Each output tab as analytics names it. */ const VIEW_ANALYTICS_NAMES: Record<'summary' | HouseholdBreakdownView, HouseholdOutputTab> = { @@ -234,18 +231,7 @@ export default function HouseholdImpactResult({ .slice(0, 3); return ( -
+
Household impact @@ -272,25 +258,10 @@ export default function HouseholdImpactResult({ background: change > 0 ? colors.primary[50] : colors.gray[50], }} > - <span - id={`${id}-net`} - style={{ - fontSize: typography.fontSize.xs, - fontWeight: typography.fontWeight.medium, - color: colors.text.secondary, - }} - > + <span id={`${id}-net`} style={householdHeadlineLabelStyle}> Change in annual net income </span> - <span - style={{ - fontSize: typography.fontSize['4xl'], - lineHeight: 1.1, - fontWeight: typography.fontWeight.semibold, - fontVariantNumeric: 'tabular-nums', - color: effectColor(change), - }} - > + <span style={{ ...householdHeadlineFigureStyle, color: effectColor(change) }}> {signedMoney(change, countryId)} </span> <span @@ -353,53 +324,17 @@ export default function HouseholdImpactResult({ {breakdowns && ( // A tab per output: the summary, then the v2 household report's breakdowns. - <div - role="tablist" - aria-label="Household outputs" - style={{ - display: 'flex', - gap: spacing.lg, - overflowX: 'auto', - borderBottom: `1px solid ${colors.border.light}`, + <HouseholdOutputTabs + options={VIEW_OPTIONS} + selected={view} + onSelect={(next) => { + // Sent per tab opened: a click on the open tab sends nothing. + if (next !== view) { + trackHouseholdOutputViewed({ countryId, tab: VIEW_ANALYTICS_NAMES[next] }); + } + setView(next); }} - > - {VIEW_OPTIONS.map((option) => { - const selected = view === option.value; - return ( - <button - key={option.value} - type="button" - role="tab" - aria-selected={selected} - onClick={() => { - const next = option.value as typeof view; - // Sent per tab opened: a click on the open tab sends nothing. - if (next !== view) { - trackHouseholdOutputViewed({ countryId, tab: VIEW_ANALYTICS_NAMES[next] }); - } - setView(next); - }} - className="tw:cursor-pointer tw:hover:text-gray-900" - style={{ - border: 'none', - background: 'none', - padding: `${spacing.sm} 0`, - marginBottom: -1, - borderBottom: `2px solid ${selected ? colors.primary[600] : 'transparent'}`, - whiteSpace: 'nowrap', - fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, - fontWeight: selected - ? typography.fontWeight.semibold - : typography.fontWeight.normal, - color: selected ? colors.text.primary : colors.text.secondary, - }} - > - {option.label} - </button> - ); - })} - </div> + /> )} {breakdowns && view !== 'summary' ? ( diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index 88d7d5dc5..11a280839 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -1,38 +1,27 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; +import { commonHouseholdInputs } from '@/components/flagship/report/household/householdPickerInputs'; import { - commonHouseholdInputs, - householdInputDescriptionOf, - householdInputNameOf, - householdTopicIcon, -} from '@/components/flagship/report/household/householdPickerInputs'; -import RunProgress from '@/components/flagship/report/RunProgress'; -import { ErrorState } from '@/components/flagship/states'; -import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; + HouseholdCalculateRow, + HouseholdRunFailure, + HouseholdRunProgress, +} from '@/components/flagship/report/household/householdRunParts'; import { Button, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -import { useFlagshipHouseholdPayload } from '@/hooks/useFlagshipHousehold'; +import { useRequestedHouseholdRun } from '@/hooks/useFlagshipHousehold'; import type { CountryId } from '@/libs/countries'; -import { describeHousehold, householdState } from '@/libs/flagship/householdDescription'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; +import { newV3Household } from '@/libs/flagship/householdReport'; import { suggestedHouseholdInputs } from '@/libs/flagship/householdSuggestions'; -import { - describesAHousehold, - HOUSEHOLD_INCOME_SUBGROUPS, - HOUSEHOLD_VARIABLE_GROUPS, - householdVariableGroupOf, - householdVariableSubgroupOf, - listsForState, - twinsABasicInput, -} from '@/libs/flagship/householdVariableGroups'; +import { describesAHousehold } from '@/libs/flagship/householdVariableGroups'; import { hasParameterDependencies, loadParameterDependencies, } from '@/libs/flagship/parameterDependencies'; -import { getBasicInputFields } from '@/libs/metadataUtils'; -import { Household } from '@/models/Household'; -import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; +import type { Household } from '@/models/Household'; import type { RootState } from '@/store'; import { householdErrorCategory, @@ -44,70 +33,11 @@ import { trackHouseholdVariableAdded, } from '@/utils/analytics'; import { HouseholdValidation } from '@/utils/HouseholdValidation'; -import { getInputVariables, type VariableInfo } from '@/utils/VariableResolver'; +import { getInputVariables } from '@/utils/VariableResolver'; /** The countries whose results, inputs and currency the section knows. */ const HOUSEHOLD_COUNTRIES = new Set(['us', 'uk']); -/** Both policies run at once; a household new to the API takes about three minutes. */ -const EXPECTED_MS = 3 * 60 * 1000; - -/** - * Where a household in each UK nation lives unless the reader says - * otherwise: the nation's region, with its capital's local authority and - * Broad Rental Market Area, so the three name one place. England has no - * one region; its households keep the model's defaults. - */ -const UK_NATION_PLACES: Record<string, Record<string, string>> = { - scotland: { region: 'SCOTLAND', local_authority: 'CITY_OF_EDINBURGH', brma: 'LOTHIAN' }, - wales: { region: 'WALES', local_authority: 'CARDIFF', brma: 'CARDIFF' }, - northern_ireland: { region: 'NORTHERN_IRELAND', local_authority: 'BELFAST', brma: 'BELFAST' }, -}; - -/** A UK report's nation's place, for "country/scotland"; none for any other region. */ -export function ukNationPlace(region: string | undefined): Record<string, string> | null { - const match = /^country\/(.+)$/i.exec(region ?? ''); - return (match && UK_NATION_PLACES[match[1].toLowerCase()]) || null; -} - -/** - * A new household: the household builder's own start, one adult and - * nothing else set. A US household starts on national poverty - * thresholds, which the builder's SPM geography choice can change to a - * county's; only the US model has them. A UK household in a report on - * Scotland, Wales or Northern Ireland starts there. - */ -function newHousehold(countryId: CountryId, year: string, region?: string): Household { - const household = Household.starter(countryId, year); - if (countryId === 'us') { - return household.withSPM({ geography_kind: 'national' }); - } - const place = countryId === 'uk' ? ukNationPlace(region) : null; - const group = household.getPreferredGroupName('households'); - if (!place || !group) { - return household; - } - return Object.entries(place).reduce( - (current, [name, value]) => - current.setEntityVariableAtYear('households', group, name, year, value), - household - ); -} - -/** - * A person's name as a country's builder shows it: a UK household's - * dependants are its children ("your first child"), as the UK model's - * benefits count them; the US keeps "dependent", which a tax unit's - * dependants are called. Only the name shown changes, never the key. - */ -export function householdPersonNameOf( - countryId: string -): ((personKey: string) => string) | undefined { - return countryId === 'uk' - ? (personKey) => personKey.replace(/\bdependent\b/, 'child') - : undefined; -} - /** What the builder sets for each person it adds: not an input the reader chose. */ const ADDED_PERSON_DEFAULTS = new Set(['age', 'is_tax_unit_dependent']); @@ -125,7 +55,7 @@ export function nonDefaultInputCount( ): number { const keyOf = (scope: string, name: string, value: unknown) => `${scope}|${name}|${JSON.stringify(value)}`; - const starter = newHousehold(countryId, year, region); + const starter = newV3Household(countryId, year, region); const starterPeople = new Set(starter.personNames); const starterKeys = new Set([ ...starter.personNames.flatMap((person) => @@ -355,50 +285,9 @@ function HouseholdCard({ // The household as the builder edits it: every input the model takes, // per person and per household, as in the household builder. const metadata = useSelector((state: RootState) => state.metadata); - const basicInputFields = useSelector(getBasicInputFields); - const [household, setHousehold] = useState(() => newHousehold(countryId, year, region)); - const [requested, setRequested] = useState<{ - payload: V1HouseholdCreateEnvelope; - summary: string; - } | null>(null); - const run = useFlagshipHouseholdPayload({ - year, - baselinePolicyId, - reformPolicyId, - payload: requested?.payload ?? null, - }); - const composition = useMemo(() => household.deriveBuilderComposition(year), [household, year]); - const basicNonPersonFields = useMemo( - () => - Object.entries(basicInputFields) - .filter(([key]) => key !== 'person') - .flatMap(([, fields]) => fields), - [basicInputFields] - ); - // The variable search lists only the household's own state's programs, - // only inputs that describe a household (no survey-reported totals), and - // not the twin of a field the builder already shows. - const stateCode = householdState(household, year, countryId); - const basicPersonFields = basicInputFields.person; - const variableFilter = useCallback( - (variable: VariableInfo) => - listsForState(variable, stateCode) && - describesAHousehold(variable, countryId) && - !twinsABasicInput(variable, basicPersonFields ?? []), - [stateCode, countryId, basicPersonFields] - ); - // The picker's groups, featured inputs and names, for the country's model. - const picker = useMemo( - () => ({ - groupOf: householdVariableGroupOf(countryId), - subgroupOf: householdVariableSubgroupOf(countryId), - common: commonHouseholdInputs(countryId), - nameOf: householdInputNameOf(countryId), - describe: householdInputDescriptionOf(countryId), - personNameOf: householdPersonNameOf(countryId), - }), - [countryId] - ); + const [household, setHousehold] = useState(() => newV3Household(countryId, year, region)); + const { requested, run, running, failed, result, request, retry, shows } = + useRequestedHouseholdRun({ year, baselinePolicyId, reformPolicyId }); // Whether the household can run, as the household builder checks it. const validation = useMemo( () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), @@ -424,22 +313,9 @@ function HouseholdCard({ const calculate = () => { startRun(); - const payload = household.toV1CreationPayload(); - // The same household again, after a failure, asks once more. - if (requested && JSON.stringify(requested.payload) === JSON.stringify(payload)) { - void run.refetch(); - } else { - setRequested({ - payload, - summary: describeHousehold(household, year, countryId, metadata?.variables), - }); - } + request(household); setEditing(false); }; - - const running = !!requested && run.isFetching; - const failed = !!requested && !running && run.isError; - const result = requested && !running ? run.data : undefined; // Once a household runs, its result leads: the inputs fold to one line // until the reader edits them again. const [editing, setEditing] = useState(true); @@ -475,21 +351,11 @@ function HouseholdCard({ }); }, [running, runData, runError, run.isError, countryId, householdCount]); - const retry = () => { + const tryAgain = () => { trackHouseholdCalculationRetried({ countryId }); - // The household as it is now: an input fixed after the failure counts. - if (validation.isValid) { - calculate(); - } else { - startRun(); - void run.refetch(); - } + startRun(); + retry(household, validation.isValid); }; - // The household on screen, or on its way, is the one in the builder. - const sameAsShown = - !!requested && - !failed && - JSON.stringify(requested.payload) === JSON.stringify(household.toV1CreationPayload()); return ( <section @@ -553,83 +419,30 @@ function HouseholdCard({ </div> )} <div style={{ minWidth: 0, display: showInputs ? undefined : 'none' }}> - <HouseholdBuilderForm + <HouseholdBuilderPanel household={household} - metadata={metadata} + countryId={countryId} year={year} - maritalStatus={composition.maritalStatus} - numChildren={composition.numChildren} - basicPersonFields={basicInputFields.person || []} - basicNonPersonFields={basicNonPersonFields} onChange={setHousehold} - onMaritalStatusChange={(status) => - setHousehold(household.withBuilderMaritalStatus(year, status)) - } - onNumChildrenChange={(count) => - setHousehold(household.withBuilderChildCount(year, count)) - } disabled={running} - layout="columns" suggestedVariables={suggestedVariables} - suggestedLabel="Related to this reform" - searchRanking="relevance" - variableGroupOf={picker.groupOf} - variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} - variableFilter={variableFilter} - variableSubgroupOf={picker.subgroupOf} - variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} - searchByEntity - variablePickerCommon={picker.common} - variableGroupIcon={householdTopicIcon} - variableNameOf={picker.nameOf} - variableDescribe={picker.describe} - personDisplayNameOf={picker.personNameOf} - labelInputs onVariablePickerOpen={(entity) => trackHouseholdPickerOpened({ countryId, entity })} onVariableAdd={({ variable, source, entity }) => trackHouseholdVariableAdded({ countryId, variable, source, entity }) } /> - <div - style={{ - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - gap: spacing.md, - marginTop: spacing.lg, - }} - > - <Button - size="sm" - onClick={calculate} - disabled={running || !validation.isValid || sameAsShown} - > - Calculate - </Button> - {!validation.isValid && validation.errors[0] && ( - <span style={{ fontSize: typography.fontSize.xs, color: colors.error }}> - {validation.errors[0].message} - </span> - )} - </div> + <HouseholdCalculateRow + onCalculate={calculate} + disabled={running || !validation.isValid || shows(household)} + validation={validation} + style={{ marginTop: spacing.lg }} + /> </div> <Stack style={{ gap: spacing.md, minWidth: 0 }}> - {running && <RunProgress title="Calculating this household" expectedMs={EXPECTED_MS} />} + {running && <HouseholdRunProgress />} - {failed && ( - <ErrorState - compact - title="This household could not be calculated" - description={ - // An API message can carry a long unbroken path. - run.error?.message && ( - <span style={{ overflowWrap: 'anywhere' }}>{run.error.message}</span> - ) - } - onRetry={retry} - /> - )} + {failed && <HouseholdRunFailure error={run.error} onRetry={tryAgain} />} {result && requested && ( <HouseholdImpactResult @@ -640,7 +453,7 @@ function HouseholdCard({ // One calculation served both policies: its methodology shows once. reform: baselinePolicyId === reformPolicyId ? null : result.provenance.reform, }} - summary={`${requested.summary} · ${year}`} + summary={`${describeHousehold(requested.household, year, countryId, metadata?.variables)} · ${year}`} breakdowns={{ countryId, year, diff --git a/app/src/components/flagship/report/household/householdResultParts.tsx b/app/src/components/flagship/report/household/householdResultParts.tsx new file mode 100644 index 000000000..3351a364a --- /dev/null +++ b/app/src/components/flagship/report/household/householdResultParts.tsx @@ -0,0 +1,107 @@ +import type { CSSProperties } from 'react'; +import { colors, spacing, typography } from '@/designTokens'; +import type { CountryId } from '@/libs/countries'; +import { formatCurrency } from '@/utils/formatters'; + +/** + * What v3's household results share: the household's change in net + * income with a reform (HouseholdImpactResult) and its net income under + * one policy (HouseholdNetIncomeResult). + */ + +/** Whole dollars (or pounds), with a true minus sign: "−$1,200", "−£1,200". */ +export function householdMoney(value: number, countryId: CountryId): string { + const rounded = Math.round(value); + const text = formatCurrency(Math.abs(rounded), countryId, { maximumFractionDigits: 0 }); + return rounded < 0 ? `−${text}` : text; +} + +/** A change, always signed: "+$1,200", "−$300", "$0". */ +export function signedHouseholdMoney(value: number, countryId: CountryId): string { + const rounded = Math.round(value); + return rounded > 0 + ? `+${householdMoney(rounded, countryId)}` + : householdMoney(rounded, countryId); +} + +/** The bordered card each household result sits in. */ +export const householdResultCardStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: spacing.lg, + padding: spacing.lg, + border: `1px solid ${colors.border.light}`, + borderRadius: spacing.radius.container, + background: colors.background.primary, +}; + +/** The headline's caption, e.g. "Annual net income". */ +export const householdHeadlineLabelStyle: CSSProperties = { + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.text.secondary, +}; + +/** The headline's amount, in large figures that line up. */ +export const householdHeadlineFigureStyle: CSSProperties = { + fontSize: typography.fontSize['4xl'], + lineHeight: 1.1, + fontWeight: typography.fontWeight.semibold, + fontVariantNumeric: 'tabular-nums', +}; + +/** A tab per household output, under the result's headline. */ +export function HouseholdOutputTabs<View extends string>({ + options, + selected, + onSelect, +}: { + options: ReadonlyArray<{ value: View; label: string }>; + selected: View; + onSelect: (view: View) => void; +}) { + return ( + <div + role="tablist" + aria-label="Household outputs" + style={{ + display: 'flex', + gap: spacing.lg, + overflowX: 'auto', + borderBottom: `1px solid ${colors.border.light}`, + }} + > + {options.map((option) => { + const isSelected = option.value === selected; + return ( + <button + key={option.value} + type="button" + role="tab" + aria-selected={isSelected} + onClick={() => onSelect(option.value)} + // The text color comes from classes, so the hover color can apply. + className={`tw:cursor-pointer ${ + isSelected ? 'tw:text-text-primary' : 'tw:text-text-secondary tw:hover:text-gray-900' + }`} + style={{ + border: 'none', + background: 'none', + padding: `${spacing.sm} 0`, + marginBottom: -1, + borderBottom: `2px solid ${isSelected ? colors.primary[600] : 'transparent'}`, + whiteSpace: 'nowrap', + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + fontWeight: isSelected + ? typography.fontWeight.semibold + : typography.fontWeight.normal, + }} + > + {option.label} + </button> + ); + })} + </div> + ); +} diff --git a/app/src/components/flagship/report/household/householdRunParts.tsx b/app/src/components/flagship/report/household/householdRunParts.tsx new file mode 100644 index 000000000..180dc2000 --- /dev/null +++ b/app/src/components/flagship/report/household/householdRunParts.tsx @@ -0,0 +1,70 @@ +import type { CSSProperties } from 'react'; +import RunProgress from '@/components/flagship/report/RunProgress'; +import { ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import type { ValidationResult } from '@/utils/HouseholdValidation'; + +/** + * What v3's household calculations show around a run, in the report + * section and the standalone household report alike: the Calculate + * button, the run's progress and its failure. + */ + +/** Both policies run at once; a household new to the Public API takes about three minutes. */ +export const HOUSEHOLD_RUN_EXPECTED_MS = 3 * 60 * 1000; + +/** Calculate, with the first reason the household cannot run beside it. */ +export function HouseholdCalculateRow({ + onCalculate, + disabled, + validation, + style, +}: { + onCalculate: () => void; + /** Disabled while running, while the household cannot run, or while its result is shown. */ + disabled: boolean; + /** Whether the household can run, as the household builder checks it. */ + validation: ValidationResult; + style?: CSSProperties; +}) { + const reason = validation.isValid ? null : validation.errors[0]?.message; + return ( + <div + style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: spacing.md, ...style }} + > + <Button size="sm" onClick={onCalculate} disabled={disabled}> + Calculate + </Button> + {reason && ( + <span style={{ fontSize: typography.fontSize.xs, color: colors.error }}>{reason}</span> + )} + </div> + ); +} + +/** A household run on its way. */ +export function HouseholdRunProgress() { + return <RunProgress title="Calculating this household" expectedMs={HOUSEHOLD_RUN_EXPECTED_MS} />; +} + +/** A household run that failed, with the Public API's reason and a way to try again. */ +export function HouseholdRunFailure({ + error, + onRetry, +}: { + error: Error | null; + onRetry: () => void; +}) { + return ( + <ErrorState + compact + title="This household could not be calculated" + description={ + // A Public API message can carry a long unbroken path. + error?.message && <span style={{ overflowWrap: 'anywhere' }}>{error.message}</span> + } + onRetry={onRetry} + /> + ); +} diff --git a/app/src/hooks/useFlagshipHousehold.ts b/app/src/hooks/useFlagshipHousehold.ts index d82d928e4..82ffb5b47 100644 --- a/app/src/hooks/useFlagshipHousehold.ts +++ b/app/src/hooks/useFlagshipHousehold.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters/PolicyAdapter'; import { fetchPolicyById } from '@/api/policy'; @@ -5,6 +6,7 @@ import { useParameterMetadata } from '@/hooks/useParameterMetadata'; import { useParallelQueries } from '@/hooks/utils/queryUtils'; import { runFlagshipHouseholdPayload } from '@/libs/flagship/householdRun'; import { policyKeys } from '@/libs/queryKeys'; +import type { Household } from '@/models/Household'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import type { Policy } from '@/types/ingredients/Policy'; @@ -44,6 +46,83 @@ export function useFlagshipHouseholdPayload({ }); } +/** The household a run was asked for, and the payload sent for it. */ +export interface RequestedHousehold { + household: Household; + payload: V1HouseholdCreateEnvelope; +} + +const samePayload = (first: V1HouseholdCreateEnvelope, second: V1HouseholdCreateEnvelope) => + JSON.stringify(first) === JSON.stringify(second); + +/** + * A v3 household result's run, asked for when the reader presses + * Calculate, for the report section's households and the standalone + * household report alike. The household asked for is fixed when asked, + * so the result keeps describing it while the reader edits the builder. + */ +export function useRequestedHouseholdRun({ + year, + baselinePolicyId, + reformPolicyId, +}: { + year: string; + baselinePolicyId: string; + reformPolicyId: string; +}) { + const [requested, setRequested] = useState<RequestedHousehold | null>(null); + const run = useFlagshipHouseholdPayload({ + year, + baselinePolicyId, + reformPolicyId, + payload: requested?.payload ?? null, + }); + const running = !!requested && run.isFetching; + const failed = !!requested && !running && run.isError; + + /** + * Asks for a household's run. The same household again is the same + * query, which keeps its error until asked again, so it asks once more. + */ + const request = (household: Household) => { + const payload = household.toV1CreationPayload(); + if (requested && samePayload(requested.payload, payload)) { + void run.refetch(); + } else { + setRequested({ household, payload }); + } + }; + + return { + /** The household asked for; null until Calculate. */ + requested, + /** The run's query, for its data and error. */ + run, + running, + failed, + /** The run's result, once it has one and is not running again. */ + result: requested && !running ? run.data : undefined, + request, + /** + * After a failure: the household as it is now when it can run, so an + * input fixed after the failure counts; otherwise the household that + * failed, once more, never one that cannot run. + */ + retry: (household: Household, canRun: boolean) => { + if (canRun) { + request(household); + } else { + void run.refetch(); + } + }, + /** Forgets the request and its result: the next result waits for Calculate. */ + clear: () => setRequested(null), + /** Whether the household on screen, or on its way, is this one. */ + shows: (household: Household) => + !!requested && !failed && samePayload(requested.payload, household.toV1CreationPayload()), + }; +} + /** * The report's policies in the app's model, for the v2 household * breakdowns that send a policy's parameters with their request (earnings diff --git a/app/src/hooks/useSavedHouseholds.ts b/app/src/hooks/useSavedHouseholds.ts new file mode 100644 index 000000000..07d9f4c0f --- /dev/null +++ b/app/src/hooks/useSavedHouseholds.ts @@ -0,0 +1,168 @@ +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getSavedHouseholdStore, + type SavedHousehold, + type SavedHouseholdChanges, + type SavedHouseholdStore, + type StoredHouseholdCache, +} from '@/api/savedHouseholdStore'; +import { useV3SavedWork } from '@/hooks/useFlagshipStores'; +import type { Household } from '@/models/Household'; +import type { AssociationCountryId } from '@/types/v2User'; + +/** + * v3's saved households for this session: in the account, signed in or + * signed out as this browser's unauthenticated user. Without accounts + * there are none, and nothing is saved anywhere else. + */ + +/** + * Cached saved households are scoped to whose they are (see V3SavedWork) + * and to the country, as the store lists one country's saved households. + */ +export const savedHouseholdKeys = { + all: (scope: string, countryId: string) => ['saved-households', scope, countryId] as const, + list: (scope: string, countryId: string) => + ['saved-households', scope, countryId, 'list'] as const, + one: (scope: string, countryId: string, savedHouseholdId: string) => + ['saved-households', scope, countryId, 'one', savedHouseholdId] as const, +}; + +const STALE_TIME = 5 * 60 * 1000; + +/** + * A stored household by its country and v2 id. Its content never changes, + * so it is the same for every user and kept until unused for a while. + */ +const storedHouseholdKey = (countryId: string, householdId: string) => + ['stored-households', countryId, householdId] as const; +const STORED_HOUSEHOLD_GC_TIME = 30 * 60 * 1000; + +/** Saving without accounts: v3 keeps no household in the browser. */ +export class SavedHouseholdsUnavailableError extends Error { + constructor() { + super('Saved households need an account.'); + this.name = 'SavedHouseholdsUnavailableError'; + } +} + +export function useSavedHouseholdStore(): { + store: SavedHouseholdStore | null; + scope: string; + /** The country the store reads and writes; none without accounts. */ + countryId: AssociationCountryId | null; +} { + const { account, scope } = useV3SavedWork(); + const queryClient = useQueryClient(); + // Every store this session makes keeps stored households in the query + // client, so the list, the picker and the save form fetch each once. + const storedHouseholds = useMemo<StoredHouseholdCache>( + () => ({ + get: (countryId, householdId, fetchHousehold) => + queryClient.fetchQuery({ + queryKey: storedHouseholdKey(countryId, householdId), + queryFn: fetchHousehold, + staleTime: Infinity, + gcTime: STORED_HOUSEHOLD_GC_TIME, + }), + set: (countryId, householdId, household) => { + queryClient.setQueryData(storedHouseholdKey(countryId, householdId), household); + }, + }), + [queryClient] + ); + return useMemo( + () => ({ + store: getSavedHouseholdStore(account, storedHouseholds), + scope, + countryId: account?.countryId ?? null, + }), + [account, scope, storedHouseholds] + ); +} + +function requireStore(store: SavedHouseholdStore | null): SavedHouseholdStore { + if (!store) { + throw new SavedHouseholdsUnavailableError(); + } + return store; +} + +/** This session's saved households, most recently updated first. */ +export function useSavedHouseholds() { + const { store, scope, countryId } = useSavedHouseholdStore(); + const query = useQuery({ + queryKey: savedHouseholdKeys.list(scope, countryId ?? ''), + queryFn: () => requireStore(store).list(), + enabled: !!store, + staleTime: STALE_TIME, + }); + return { + /** Whether saved households exist here: only with accounts. */ + available: !!store, + savedHouseholds: query.data ?? [], + isLoading: !!store && query.isLoading, + error: query.error, + refetch: query.refetch, + }; +} + +/** One saved household with its household; null when it does not exist. */ +export function useSavedHousehold(savedHouseholdId: string | undefined) { + const { store, scope, countryId } = useSavedHouseholdStore(); + return useQuery({ + queryKey: savedHouseholdKeys.one(scope, countryId ?? '', savedHouseholdId ?? ''), + queryFn: () => requireStore(store).get(savedHouseholdId!), + enabled: !!store && !!savedHouseholdId, + staleTime: STALE_TIME, + }); +} + +/** Reads the session's saved households, the list and each one opened, again after a change. */ +function useInvalidateSavedHouseholds() { + const queryClient = useQueryClient(); + const { scope, countryId } = useSavedHouseholdStore(); + return () => + queryClient.invalidateQueries({ queryKey: savedHouseholdKeys.all(scope, countryId ?? '') }); +} + +export function useSaveHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ household, label }: { household: Household; label: string }) => + requireStore(store).save({ household, label }), + onSuccess: invalidate, + }); +} + +export function useRenameSavedHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ savedHouseholdId, label }: { savedHouseholdId: string; label: string }) => + requireStore(store).rename(savedHouseholdId, label), + onSuccess: invalidate, + }); +} + +export function useDeleteSavedHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: (savedHouseholdId: string) => requireStore(store).remove(savedHouseholdId), + onSuccess: invalidate, + }); +} + +/** Saves changes to a saved household (its content, its label or both), keeping its id. */ +export function useSaveHouseholdChanges() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ saved, changes }: { saved: SavedHousehold; changes: SavedHouseholdChanges }) => + requireStore(store).saveChanges(saved, changes), + onSuccess: invalidate, + }); +} diff --git a/app/src/libs/flagship/householdReport.ts b/app/src/libs/flagship/householdReport.ts new file mode 100644 index 000000000..f01909e96 --- /dev/null +++ b/app/src/libs/flagship/householdReport.ts @@ -0,0 +1,88 @@ +import type { CountryId } from '@/libs/countries'; +import { Household } from '@/models/Household'; + +/** + * Helpers for v3's household reports: the report section's households and + * the standalone household report (one household, run under a baseline + * and, optionally, a reform). + */ + +/** + * Where a household in each UK nation lives unless the reader says + * otherwise: the nation's region, with its capital's local authority and + * Broad Rental Market Area, so the three name one place. England has no + * one region; its households keep the model's defaults. + */ +const UK_NATION_PLACES: Record<string, Record<string, string>> = { + scotland: { region: 'SCOTLAND', local_authority: 'CITY_OF_EDINBURGH', brma: 'LOTHIAN' }, + wales: { region: 'WALES', local_authority: 'CARDIFF', brma: 'CARDIFF' }, + northern_ireland: { region: 'NORTHERN_IRELAND', local_authority: 'BELFAST', brma: 'BELFAST' }, +}; + +/** A UK report's nation's place, for "country/scotland"; none for any other region. */ +export function ukNationPlace(region: string | undefined): Record<string, string> | null { + const match = /^country\/(.+)$/i.exec(region ?? ''); + return (match && UK_NATION_PLACES[match[1].toLowerCase()]) || null; +} + +/** + * A new household for v3's report section or standalone household report: + * the household builder's own start, one adult and nothing else set. A US + * household starts on national poverty thresholds, which the builder's SPM + * geography choice can change to a county's; only the US model has them. + * A UK household in a report on Scotland, Wales or Northern Ireland starts + * there. + */ +export function newV3Household(countryId: CountryId, year: string, region?: string): Household { + const household = Household.starter(countryId, year); + if (countryId === 'us') { + return household.withSPM({ geography_kind: 'national' }); + } + const place = countryId === 'uk' ? ukNationPlace(region) : null; + const group = household.getPreferredGroupName('households'); + if (!place || !group) { + return household; + } + return Object.entries(place).reduce( + (current, [name, value]) => + current.setEntityVariableAtYear('households', group, name, year, value), + household + ); +} + +/** + * Inputs every household carries that say nothing on a chart against + * earnings: the earnings the chart varies, and age, which the household + * builder sets for everyone. + */ +const NOT_OFFERED_ON_EARNINGS_CHART = new Set(['employment_income', 'age']); + +/** Whether an input's value, or any of its yearly values, is a nonzero amount. */ +function setsAnAmount(value: unknown): boolean { + const amounts = + value && typeof value === 'object' && !Array.isArray(value) + ? Object.values(value as Record<string, unknown>) + : [value]; + return amounts.some((amount) => typeof amount === 'number' && amount !== 0); +} + +/** + * The amounts the household itself sets, by variable name, in the order + * first met: tip income for a household with tips. The earnings variation + * offers these beside its calculated variables (issue #1096). + */ +export function householdAmountInputs(household: Household): string[] { + const variables = [ + ...household + .getAllPeople() + .flatMap((person) => household.getPersonVariableEntries(person.name)), + ...household.getConfiguredGroupsWithVariables().flatMap((group) => group.variables), + ]; + const names = variables + .filter( + (variable) => + !NOT_OFFERED_ON_EARNINGS_CHART.has(variable.name) && setsAnAmount(variable.value) + ) + .map((variable) => variable.name); + return [...new Set(names)]; +} diff --git a/app/src/libs/flagship/householdRun.ts b/app/src/libs/flagship/householdRun.ts index 40943ed87..88851534c 100644 --- a/app/src/libs/flagship/householdRun.ts +++ b/app/src/libs/flagship/householdRun.ts @@ -331,9 +331,9 @@ export interface FlagshipHouseholdRun extends FlagshipHouseholdImpact { reform: HouseholdCalculationData; /** * How each policy's calculation was made, as its response says. The - * SPM methodology footnote reads the SPM settings and receipt; the .py - * bundle is kept so Reproduce in Python can name the versions that - * calculated the result, which v3 does not offer yet. + * SPM methodology footnote reads the SPM settings and receipt; the + * standalone household report's Reproduce in Python pins each block to + * the .py bundle that calculated it. */ provenance: { baseline: HouseholdCalculationProvenance; reform: HouseholdCalculationProvenance }; } diff --git a/app/src/models/household/v2DocumentCodec.ts b/app/src/models/household/v2DocumentCodec.ts new file mode 100644 index 000000000..b60ba7c1e --- /dev/null +++ b/app/src/models/household/v2DocumentCodec.ts @@ -0,0 +1,260 @@ +import type { CountryId } from '@/libs/countries'; +import { Household } from '@/models/Household'; +import type { + V1HouseholdData, + V1HouseholdGroupData, + V1HouseholdPersonData, +} from '@/models/household/v1Types'; +import type { SPMSelection } from '@/types/spm'; +import type { + V2HouseholdCreateBody, + V2HouseholdDocument, + V2HouseholdEntityRecord, + V2HouseholdItem, + V2HouseholdPersonRecord, + V2UKHouseholdDocument, + V2USHouseholdDocument, +} from '@/types/v2Households'; +import { isAssociationCountry } from '@/types/v2User'; + +/** + * The app's household model to and from the Public API's v2 household + * document (`/v2/households`). + * + * The document is built exactly as the Public API translates a v1 + * household (`translate_legacy_household`): people and each collection's + * groups take ids in name order ("person-1", "tax_unit-2"), each keeps its + * v1 name as `source_name`, each group's members become every person's + * `memberships`, and the SPM settings move onto the document. So a + * household v3 saves is the same stored household as the dual-write copy + * of the identical v1 household: the Public API deduplicates them. + */ + +/** A v2 document's collections of groups: every key but its people and its SPM settings. */ +type V2GroupCollection = Exclude< + keyof V2USHouseholdDocument | keyof V2UKHouseholdDocument, + 'people' | 'spm' +>; + +/** A v1 household's collections of groups: every key but its people. */ +type V1GroupCollection = Exclude<keyof V1HouseholdData, 'people'>; + +/** Either country's v2 document, collection by collection: a UK document has no tax units. */ +type V2DocumentCollections = Partial<Record<V2GroupCollection, V2HouseholdEntityRecord[]>>; + +/** Each country's v2 collections, beside the v1 collection each comes from. */ +const COLLECTIONS: Record< + 'us' | 'uk', + ReadonlyArray<readonly [v2Name: V2GroupCollection, v1Name: V1GroupCollection]> +> = { + us: [ + ['household', 'households'], + ['family', 'families'], + ['tax_unit', 'tax_units'], + ['spm_unit', 'spm_units'], + ['marital_unit', 'marital_units'], + ], + uk: [ + ['household', 'households'], + ['benunit', 'benunits'], + ], +}; + +/** A group of a collection, as a person "is not in any …": "marital unit". */ +const COLLECTION_NOUNS: Record<V2GroupCollection, string> = { + household: 'household', + family: 'family', + tax_unit: 'tax unit', + spm_unit: 'SPM unit', + marital_unit: 'marital unit', + benunit: 'benefit unit', +}; + +/** A household that cannot be written as, or read from, a v2 document. */ +export class V2HouseholdDocumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'V2HouseholdDocumentError'; + } +} + +function collectionsFor(countryId: string) { + if (!isAssociationCountry(countryId)) { + throw new V2HouseholdDocumentError(`v2 households are not available for ${countryId}.`); + } + return COLLECTIONS[countryId]; +} + +/** + * Ids in name order, as the Public API gives them (Python's `sorted`, + * which orders by code point, as JavaScript's default sort does). + */ +function stableIds(kind: string, names: string[]): Map<string, string> { + return new Map([...names].sort().map((name, index) => [name, `${kind}-${index + 1}`])); +} + +/** A v1 household's data as a v2 document. */ +export function v2DocumentFromV1Data( + countryId: string, + data: V1HouseholdData, + spm?: SPMSelection +): V2HouseholdDocument { + const collections = collectionsFor(countryId); + const people = data.people ?? {}; + const personNames = Object.keys(people); + const personIds = stableIds('person', personNames); + const memberships = new Map<string, Record<string, string>>( + personNames.map((name) => [name, {}]) + ); + const groupsByCollection: V2DocumentCollections = {}; + + for (const [collection, v1Name] of collections) { + const groups: Record<string, V1HouseholdGroupData> = data[v1Name] ?? {}; + const groupIds = stableIds(collection, Object.keys(groups)); + groupsByCollection[collection] = [...groupIds.keys()].map( + (groupName): V2HouseholdEntityRecord => { + const { members, ...values } = groups[groupName]; + for (const member of members ?? []) { + const personMemberships = memberships.get(member); + if (!personMemberships) { + throw new V2HouseholdDocumentError( + `${groupName} names ${member}, who is not a person.` + ); + } + const previous = personMemberships[collection]; + if (previous && previous !== groupIds.get(groupName)) { + throw new V2HouseholdDocumentError( + `${member} is in more than one ${COLLECTION_NOUNS[collection]}.` + ); + } + personMemberships[collection] = groupIds.get(groupName)!; + } + return { id: groupIds.get(groupName)!, source_name: groupName, values }; + } + ); + } + + const personRecords = [...personIds.keys()].map((name): V2HouseholdPersonRecord => { + const personMemberships = memberships.get(name)!; + for (const [collection] of collections) { + if (!personMemberships[collection]) { + throw new V2HouseholdDocumentError( + `${name} is not in any ${COLLECTION_NOUNS[collection]}.` + ); + } + } + return { + id: personIds.get(name)!, + source_name: name, + values: { ...people[name] }, + memberships: personMemberships, + }; + }); + // The country's collections, as `collectionsFor` lists them, then its people and SPM settings. + return { + ...groupsByCollection, + people: personRecords, + ...(countryId === 'us' && spm ? { spm } : {}), + } as V2HouseholdDocument; +} + +/** A v2 document as a v1 household's data, named by each record's v1 name (else its id). */ +export function v1DataFromV2Document( + countryId: string, + document: V2HouseholdDocument +): { data: V1HouseholdData; spm?: SPMSelection } { + const collections = collectionsFor(countryId); + const groupsByCollection: V2DocumentCollections = document; + const nameOf = (record: V2HouseholdEntityRecord) => record.source_name ?? record.id; + // People in the household builder's order ("you", "your partner", then + // dependants), not the document's name order. + const personByName = new Map(document.people.map((person) => [nameOf(person), person])); + const people = Household.sortPersonNames([...personByName.keys()]).map( + (name) => personByName.get(name)! + ); + // Values are passed through as stored: each is keyed by period, as in a v1 household. + const data: V1HouseholdData = { + people: Object.fromEntries( + people.map((person) => [nameOf(person), { ...person.values } as V1HouseholdPersonData]) + ), + }; + for (const [collection, v1Name] of collections) { + const groups = groupsByCollection[collection] ?? []; + data[v1Name] = Object.fromEntries( + groups.map((group) => [ + nameOf(group), + { + members: people + .filter((person) => person.memberships[collection] === group.id) + .map(nameOf), + ...group.values, + } as V1HouseholdGroupData, + ]) + ); + } + const spm = 'spm' in document ? document.spm : undefined; + return { data, ...(spm ? { spm } : {}) }; +} + +/** What `POST /v2/households` takes for a household: its content, in its year, without its label. */ +export function householdToV2CreateBody(household: Household): V2HouseholdCreateBody { + const countryId = household.countryId; + collectionsFor(countryId); + const payload = household.toV1CreationPayload(); + const year = household.year; + if (year === null || !Number.isInteger(year)) { + throw new V2HouseholdDocumentError('The household has no year to save it in.'); + } + return { + country_id: countryId as V2HouseholdCreateBody['country_id'], + default_year: year, + household_data: v2DocumentFromV1Data(countryId, payload.data, payload.spm), + }; +} + +/** JSON with every object's keys sorted, so the order keys were set in never reads as a change. */ +function sortedKeyJson(value: unknown): string { + return JSON.stringify(value, (_key, nested) => + nested && typeof nested === 'object' && !Array.isArray(nested) + ? Object.fromEntries( + Object.keys(nested) + .sort() + .map((name) => [name, (nested as Record<string, unknown>)[name]]) + ) + : nested + ); +} + +/** + * Whether two households would be the same stored household: compared as + * `POST /v2/households` takes them, whatever their ids, labels and the + * order of their people, groups and inputs. A household read back from + * the Public API lists its groups in name order, so it can differ in order + * alone from the same household as the builder holds it. + */ +export function haveSameStoredContent(first: Household, second: Household): boolean { + try { + return ( + sortedKeyJson(householdToV2CreateBody(first)) === + sortedKeyJson(householdToV2CreateBody(second)) + ); + } catch { + // A household that cannot be stored is compared as changed; saving it says why. + return false; + } +} + +/** + * A stored v2 household as the app's household model, with the stored id, + * in the stored default year: not the latest year its values name, which + * differs for inputs in several years, and which values without a year + * do not give. + */ +export function householdFromV2Item(item: V2HouseholdItem): Household { + const { data, spm } = v1DataFromV2Document(item.country_id, item.household_data); + const household = Household.fromV1CreationPayload( + { country_id: item.country_id as CountryId, data, ...(spm ? { spm } : {}) }, + { id: item.id } + ); + return Household.fromAppInput({ ...household.toJSON(), year: item.default_year }); +} diff --git a/app/src/pages/flagship/HouseholdReport.page.tsx b/app/src/pages/flagship/HouseholdReport.page.tsx new file mode 100644 index 000000000..7e14276ac --- /dev/null +++ b/app/src/pages/flagship/HouseholdReport.page.tsx @@ -0,0 +1,403 @@ +import { useId, useMemo, useState, type ReactNode } from 'react'; +import { useSelector } from 'react-redux'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; +import HouseholdNetIncomeResult from '@/components/flagship/household/HouseholdNetIncomeResult'; +import SavedHouseholdPicker from '@/components/flagship/household/SavedHouseholdPicker'; +import SaveHouseholdForm from '@/components/flagship/household/SaveHouseholdForm'; +import type { HouseholdBreakdownSource } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; +import { householdResultCardStyle } from '@/components/flagship/report/household/householdResultParts'; +import { + HouseholdCalculateRow, + HouseholdRunFailure, + HouseholdRunProgress, +} from '@/components/flagship/report/household/householdRunParts'; +import { ErrorState } from '@/components/flagship/states'; +import { Label, Spinner, Stack, Text, Title } from '@/components/ui'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useFlagshipHouseholdPolicies, + useRequestedHouseholdRun, +} from '@/hooks/useFlagshipHousehold'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; +import { householdAmountInputs, newV3Household } from '@/libs/flagship/householdReport'; +import type { HouseholdCalculationProvenance } from '@/libs/flagship/householdRun'; +import { getTaxYears } from '@/libs/metadataUtils'; +import type { Household } from '@/models/Household'; +import HouseholdSubPage from '@/pages/report-output/HouseholdSubPage'; +import PolicySubPage from '@/pages/report-output/PolicySubPage'; +import HouseholdReproducibility from '@/pages/report-output/reproduce-in-python/HouseholdReproducibility'; +import { copyHouseholdToYear } from '@/pages/reportBuilder/utils/changeReportYear'; +import type { RootState } from '@/store'; +import type { AssociationCountryId } from '@/types/v2User'; +import { HouseholdValidation } from '@/utils/HouseholdValidation'; +import { convertPoliciesToV1Format, type HouseholdReproduction } from '@/utils/reproducibilityCode'; + +export interface HouseholdReportPageProps { + /** The household's country: its model, currency and inputs. */ + countryId: AssociationCountryId; + /** + * The year the report opens in; the household it opens with moves to it. + * The reader can choose another. + */ + year: string; + /** The household the report opens with; a new household when absent. */ + household?: Household; + /** The saved household the report opens with: the builder holds it, and saving edits it. */ + savedHousehold?: SavedHousehold | null; + /** The baseline policy: current law unless given. */ + baselinePolicyId?: string; + /** What the baseline is called: "current law" unless given. */ + baselineLabel?: string; + /** The reform policy; without it, or when it is the baseline, the report is baseline-only. */ + reformPolicyId?: string; + /** What the reform is called, for the heading. */ + reformLabel?: string; + title?: string; +} + +/** A calculation's model versions, from its .py bundle, else its SPM receipt. */ +function versionsOf(provenance: HouseholdCalculationProvenance, countryId: string) { + return { + policyengineVersion: + provenance.policyengineBundle?.policyengine_version ?? + provenance.spmProvenance?.runtime_versions?.policyengine ?? + null, + modelVersion: + provenance.policyengineBundle?.model_version ?? + provenance.spmProvenance?.runtime_versions?.[`policyengine-${countryId}`] ?? + null, + }; +} + +/** A household moved into a year, or the reason it cannot move (inputs in several years). */ +function moveToYear( + household: Household, + year: string +): { household: Household; error: null } | { household: null; error: string } { + try { + return { household: copyHouseholdToYear(household, year), error: null }; + } catch (error) { + return { household: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * The household the report opens with, moved into the year it opens in, + * as choosing a year or a saved household moves it. A household that + * cannot move opens in its own year, with the reason, so the year shown + * is always the one its inputs are in. + */ +function openingHousehold( + household: Household, + year: string +): { household: Household; year: string; yearError: string | null } { + const moved = moveToYear(household, year); + if (moved.household) { + return { household: moved.household, year, yearError: null }; + } + return { + household, + year: household.year === null ? year : String(household.year), + yearError: moved.error, + }; +} + +/** One section of the report, named for assistive technology. */ +function ReportSection({ label, children }: { label: string; children: ReactNode }) { + return ( + <section aria-label={label} style={{ ...householdResultCardStyle, minWidth: 0 }}> + {children} + </section> + ); +} + +/** + * v3's standalone household report: one household, run under a baseline + * and, optionally, a reform, in a year the reader chooses (issue #498). + * It shows the net income headline (the change in it when there is a + * reform), the v2 breakdowns (net income tree, varying earnings with the + * household's own amounts offered, issue #1096, and marginal tax rates), + * the household's inputs, the policies and Reproduce in Python (with a + * pandas DataFrame option, issue #1095). With accounts, a saved household + * can be opened, and the household saved or its changes saved. + * + * It calculates through the Public API's v1 household routes, as the + * report section does, until v3's household calculations move to v2. No + * route renders it yet: see `pages/flagship/householdEntryPoints.ts`. + */ +export default function HouseholdReportPage({ + countryId, + year: initialYear, + household: initialHousehold, + savedHousehold: initialSaved = null, + baselinePolicyId: givenBaselinePolicyId, + baselineLabel = 'current law', + reformPolicyId, + reformLabel, + title = 'Household report', +}: HouseholdReportPageProps) { + const yearId = useId(); + const metadata = useSelector((state: RootState) => state.metadata); + const taxYears = useSelector(getTaxYears); + const baselinePolicyId = givenBaselinePolicyId ?? String(metadata.currentLawId); + const runsReform = !!reformPolicyId && reformPolicyId !== baselinePolicyId; + const [opening] = useState(() => + openingHousehold( + initialSaved?.household ?? initialHousehold ?? newV3Household(countryId, initialYear), + initialYear + ) + ); + const [year, setYear] = useState(opening.year); + const [household, setHousehold] = useState<Household>(opening.household); + const [yearError, setYearError] = useState<string | null>(opening.yearError); + const [saved, setSaved] = useState<SavedHousehold | null>(initialSaved); + // The save form's key: how many saved households the picker has opened. + // The form starts again, with the opened household's label, only then; a + // save keeps it, so its confirmation or its error stays on screen. + const [savedHouseholdOpenings, setSavedHouseholdOpenings] = useState(0); + const { requested, run, running, failed, result, request, retry, clear, shows } = + useRequestedHouseholdRun({ + year, + baselinePolicyId, + reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, + }); + + const policyIds = useMemo( + () => (runsReform ? [baselinePolicyId, reformPolicyId] : [baselinePolicyId]), + [runsReform, baselinePolicyId, reformPolicyId] + ); + const policyQuery = useFlagshipHouseholdPolicies({ countryId, policyIds, enabled: !!result }); + + // The amounts the calculated household sets, once per calculation: a new + // list on every render would make the earnings variation rebuild its + // options from every model variable each time the page renders. + const householdInputs = useMemo( + () => (requested ? householdAmountInputs(requested.household) : []), + [requested] + ); + const yearOptions = useMemo(() => { + const years = taxYears.map((option) => String(option.value)); + return years.includes(year) ? years : [year, ...years]; + }, [taxYears, year]); + const validation = useMemo( + () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), + [household, countryId, year, metadata] + ); + + /** A household moved into the report's year; null, with the reason shown, when it cannot be. */ + const inReportYear = (next: Household, nextYear: string): Household | null => { + const moved = moveToYear(next, nextYear); + setYearError(moved.error); + return moved.household; + }; + + const changeYear = (nextYear: string) => { + const moved = inReportYear(household, nextYear); + if (moved) { + setYear(nextYear); + setHousehold(moved); + // A result belongs to its year: the next one waits for Calculate. + clear(); + } + }; + + // A saved household opens as the report opens one: in the report's year, + // or, when it cannot move, in its own year with the reason. + const openSaved = (next: SavedHousehold) => { + const opened = openingHousehold(next.household, year); + setSaved(next); + setSavedHouseholdOpenings((openings) => openings + 1); + setHousehold(opened.household); + setYear(opened.year); + setYearError(opened.yearError); + clear(); + }; + + // Who the result is for: the household calculated, not the builder's + // household if the reader has edited it since (a year change clears the result). + const summary = requested + ? `${describeHousehold(requested.household, year, countryId, metadata?.variables)} · ${year}` + : ''; + const comparison = runsReform + ? `${reformLabel ?? 'Reform'} compared with ${baselineLabel}` + : `Under ${baselineLabel}`; + + const breakdowns: HouseholdBreakdownSource | null = result + ? { + countryId, + year, + householdId: result.householdId, + baselinePolicyId, + reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, + baseline: result.baseline, + reform: runsReform ? result.reform : result.baseline, + gains: runsReform && Math.round(result.netIncome.change) > 0, + householdInputs, + } + : null; + + const reproductions: HouseholdReproduction[] = + result && requested && !policyQuery.isLoading + ? policyIds.map((policyId, index) => { + const role = index === 0 ? 'baseline' : 'reform'; + const provenance = result.provenance[role]; + // Each block's own policy, by id: when one policy fails to load, + // the other is never given to its block, which says it cannot load. + const policy = policyQuery.policies.find((loaded) => loaded.id === policyId); + return { + role, + household: requested.household, + policy: policy ? convertPoliciesToV1Format([policy]).baseline.data : null, + spmConfig: provenance.spmConfig, + spmProvenance: provenance.spmProvenance, + ...versionsOf(provenance, countryId), + }; + }) + : []; + + const quietText = { margin: 0, fontSize: typography.fontSize.sm, color: colors.text.secondary }; + + return ( + <Stack style={{ width: '100%', minWidth: 0, gap: spacing.xl }}> + <Stack style={{ gap: spacing.xs }}> + <Title order={1} style={{ margin: 0 }}> + {title} + + {comparison} + + + + + + + Saved households + + + + +
+ + + + + {yearError && ( + + {yearError} + + )} +
+ +
+ +
+ + request(household)} + disabled={running || !validation.isValid || shows(household)} + validation={validation} + /> + + +
+
+ + {running && } + + {failed && ( + retry(household, validation.isValid)} + /> + )} + + {result && requested && breakdowns && ( + <> + {runsReform ? ( + + ) : ( + + )} + + + + + + + {policyQuery.error ? ( + + ) : policyQuery.isLoading ? ( +
+ + Loading the report's policies... +
+ ) : ( + + + + )} +
+ + + + + + )} + + ); +} diff --git a/app/src/pages/flagship/SavedHouseholds.page.tsx b/app/src/pages/flagship/SavedHouseholds.page.tsx new file mode 100644 index 000000000..23718f1e6 --- /dev/null +++ b/app/src/pages/flagship/SavedHouseholds.page.tsx @@ -0,0 +1,25 @@ +import SavedHouseholdsList from '@/components/flagship/household/SavedHouseholdsList'; +import { Stack, Text, Title } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +/** + * v3's list of saved households, to rename or delete. Saved households + * live on the Public API's `/v2/households` and `/v2/user-households`, so + * no route renders this page until v3 moves to those routes: see + * `pages/flagship/householdEntryPoints.ts`. + */ +export default function SavedHouseholdsPage() { + return ( + + + + Your households + + + Households you have saved. Choose one when you run a household report. + + + + + ); +} diff --git a/app/src/pages/flagship/householdEntryPoints.ts b/app/src/pages/flagship/householdEntryPoints.ts new file mode 100644 index 000000000..c7703ac47 --- /dev/null +++ b/app/src/pages/flagship/householdEntryPoints.ts @@ -0,0 +1,37 @@ +/** + * v3's household features that work only on the Public API's v2 routes, + * gathered in one place: the standalone household report and saved + * households. + * + * Saved households live on the Public API's `/v2/households` and + * `/v2/user-households`, and household reports will be saved through + * `/v2/reports`. So until v3 moves to the v2 routes, no route or control + * reaches anything exported here, and nothing routed imports this module + * or the modules behind it (`householdEntryPoints.test.tsx` checks both). + * + * What the change that moves v3 to the v2 routes does to reach them: + * + * 1. Build's population picker (`components/flagship/reform/PopulationPicker.tsx`) + * lists "A household" as a disabled option marked "Soon". The change + * enables it: choosing it offers `SavedHouseholdPicker` or a new + * household, and Build's run opens `HouseholdReportPage` with that + * household, the draft reform, and the run bar's year and baseline. + * 2. A report whose simulations are households shows `ReportNotSocietyWide` + * (`pages/flagship/Report.page.tsx`, its `householdReport` branch). The + * change renders `HouseholdReportPage` there for household reports saved + * through `/v2/reports` and `/v2/user-reports`. + * 3. Saved households have no route. The change adds + * `calculator-app/src/app/[countryId]/(workspace)/households/page.tsx` + * rendering `SavedHouseholdsPage`, a "households" screen in + * `workspaceRoutes.ts` and `libs/flagship/shellRoutes.ts`, and a link in + * the workspace navigation. + * + * Before then, `HouseholdReportPage`'s calculations move from the Public + * API's v1 household routes to `/v2/household-calculations`, and saving + * changes to a saved household needs `PATCH /v2/user-households` to accept + * `household_id`. + */ +export { default as HouseholdReportPage } from '@/pages/flagship/HouseholdReport.page'; +export { default as SavedHouseholdsPage } from '@/pages/flagship/SavedHouseholds.page'; +export { default as SavedHouseholdPicker } from '@/components/flagship/household/SavedHouseholdPicker'; +export { default as SaveHouseholdForm } from '@/components/flagship/household/SaveHouseholdForm'; diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index f393941f7..6218697ea 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -40,6 +40,12 @@ interface Props { reformVariation?: HouseholdCalculationOutput | null; /** What shows while the variation calculates; a spinner and a line by default. */ renderLoading?: () => ReactNode; + /** + * Inputs the household itself sets, offered beside the calculated + * variables (issue #1096): tip income, for a household with tips. Other + * inputs stay out. + */ + alsoOfferInputs?: string[]; } /** @@ -57,6 +63,7 @@ export default function EarningsVariationSubPage({ baselineVariation: providedBaselineVariation = null, reformVariation: providedReformVariation = null, renderLoading, + alsoOfferInputs, }: Props) { const [selectedVariable, setSelectedVariable] = useState('household_net_income'); const [searchOpen, setSearchOpen] = useState(false); @@ -133,7 +140,9 @@ export default function EarningsVariationSubPage({ const resolvedBaselineVariation = providedBaselineVariation ?? baselineVariation ?? null; const resolvedReformVariation = providedReformVariation ?? reformVariation ?? null; - // Build variable options (only non-input variables with array values) + // Build variable options: calculated variables with array values, and + // any of the household's own inputs it was asked to offer. + const offeredInputs = useMemo(() => new Set(alsoOfferInputs ?? []), [alsoOfferInputs]); const variableOptions = useMemo(() => { if (!reportYear || !resolvedBaselineVariation?.householdData?.people) { return []; @@ -142,8 +151,12 @@ export default function EarningsVariationSubPage({ return Object.keys(metadata.variables) .filter((varName) => { const variable = metadata.variables[varName]; - // Exclude input variables and marginal_tax_rate (has its own page) - if (!variable || variable.isInputVariable || varName === 'marginal_tax_rate') { + // Exclude other inputs and marginal_tax_rate (has its own page) + if ( + !variable || + (variable.isInputVariable && !offeredInputs.has(varName)) || + varName === 'marginal_tax_rate' + ) { return false; } @@ -161,7 +174,7 @@ export default function EarningsVariationSubPage({ value: varName, label: metadata.variables[varName]?.label || varName, })); - }, [metadata, normalizedReportYear, reportYear, resolvedBaselineVariation]); + }, [metadata, normalizedReportYear, offeredInputs, reportYear, resolvedBaselineVariation]); const sortedVariableOptions = useMemo( () => [...variableOptions].sort( diff --git a/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx b/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx index 95edd5012..b766da9a1 100644 --- a/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx +++ b/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx @@ -29,14 +29,18 @@ interface HouseholdReproducibilityProps { countryId: string; year: string | null; simulations: HouseholdReproduction[]; + /** Offers a switch that ends each block with a pandas DataFrame of its results. */ + offerDataFrame?: boolean; } export default function HouseholdReproducibility({ countryId, year: reportYear, simulations, + offerDataFrame = false, }: HouseholdReproducibilityProps) { const [earningVariation, setEarningVariation] = useState(false); + const [includeDataFrame, setIncludeDataFrame] = useState(false); const year = reportYear && /^\d{4}$/.test(reportYear) ? Number(reportYear) : null; return ( @@ -55,6 +59,16 @@ export default function HouseholdReproducibility({ onCheckedChange={setEarningVariation} /> + {offerDataFrame && ( + + Include a pandas DataFrame + + + )} {simulations.length === 0 ? No simulations are available to reproduce. : null} {simulations.map((simulation) => ( ))} @@ -74,16 +89,21 @@ function HouseholdSimulationCode({ simulation, year, earningVariation, + includeDataFrame, }: { countryId: string; simulation: HouseholdReproduction; year: number | null; earningVariation: boolean; + includeDataFrame: boolean; }) { const [copied, setCopied] = useState(false); const { policyengineVersion, modelVersion } = simulation; const title = simulation.role === 'baseline' ? 'Baseline simulation' : 'Reform simulation'; - const lines = getHouseholdReproducibilityCode(countryId, simulation, year, earningVariation); + const lines = getHouseholdReproducibilityCode(countryId, simulation, year, { + earningVariation, + includeDataFrame, + }); const unavailableReason = getHouseholdReproductionUnavailableReason(countryId, simulation, year); const codeText = lines.join('\n'); diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts new file mode 100644 index 000000000..d4523a9b9 --- /dev/null +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -0,0 +1,164 @@ +import type { vi } from 'vitest'; +import { Household } from '@/models/Household'; +import { householdFromV2Item, householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import { PE_USER_ID, TIMESTAMPS } from '@/tests/fixtures/api/v2UserMocks'; +import type { + UserHouseholdItem, + V2HouseholdCreateBody, + V2HouseholdItem, +} from '@/types/v2Households'; + +export const SAVED_HOUSEHOLD_YEAR = '2026'; + +/** The user's saved-household rows. */ +export const SAVED_HOUSEHOLD_IDS = { + FAMILY: '22222222-2222-4222-8222-222222222222', + RETIRED: '66666666-6666-4666-8666-666666666666', + V1_ONLY: '77777777-7777-4777-8777-777777777777', + UK_HOME: '88888888-8888-4888-8888-888888888888', +} as const; + +/** Stored v2 households. */ +export const V2_HOUSEHOLD_IDS = { + FAMILY: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + EDITED_FAMILY: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + RETIRED: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + TWO_YEAR_FAMILY: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + UK_HOME: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', +} as const; + +export const FAMILY_LABEL = 'Our family'; +export const RETIRED_LABEL = 'Retired couple'; +/** A row saved before households moved to v2: it names only a v1 household. */ +export const V1_ONLY_LABEL = 'Saved long ago'; + +/** A couple with two children, as the household builder makes it. */ +export const FAMILY_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) + .withBuilderMaritalStatus(SAVED_HOUSEHOLD_YEAR, 'married') + .withBuilderChildCount(SAVED_HOUSEHOLD_YEAR, 2) + .withSPM({ geography_kind: 'national' }); + +/** The same family, after the reader raises "you"'s earnings. */ +export const EDITED_FAMILY_HOUSEHOLD = FAMILY_HOUSEHOLD.setPersonVariableAtYear( + 'you', + 'employment_income', + SAVED_HOUSEHOLD_YEAR, + 65000 +); + +/** The family with next year's earnings also set: its inputs are in two years. */ +export const TWO_YEAR_FAMILY_HOUSEHOLD = FAMILY_HOUSEHOLD.setPersonVariableAtYear( + 'you', + 'employment_income', + String(Number(SAVED_HOUSEHOLD_YEAR) + 1), + 50000 +); + +export const RETIRED_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) + .withBuilderMaritalStatus(SAVED_HOUSEHOLD_YEAR, 'married') + .setPersonVariableAtYear('you', 'age', SAVED_HOUSEHOLD_YEAR, 70) + .withSPM({ geography_kind: 'national' }); + +/** A household as `/v2/households` returns it. */ +export function v2HouseholdItem(household: Household, id: string): V2HouseholdItem { + return storedV2HouseholdItem(householdToV2CreateBody(household), id); +} + +/** The household `POST /v2/households` was sent, as it returns it stored under `id`. */ +export function storedV2HouseholdItem(body: V2HouseholdCreateBody, id: string): V2HouseholdItem { + return { + id, + country_id: body.country_id, + default_year: body.default_year, + household_data: body.household_data, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.CREATED, + }; +} + +/** A saved household as `/v2/user-households` returns it. */ +export function userHouseholdItem(overrides: Partial = {}): UserHouseholdItem { + return { + id: SAVED_HOUSEHOLD_IDS.FAMILY, + country_id: 'us', + user_id: PE_USER_ID, + household_id: V2_HOUSEHOLD_IDS.FAMILY, + legacy_household_id: null, + name: FAMILY_LABEL, + description: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, + ...overrides, + }; +} + +/** One request the store sent, as a handler sees it. */ +export interface RoutedRequest { + method: string; + path: string; + query: Record; + body: any; +} + +type RouteHandler = (request: RoutedRequest) => Response | Promise; + +/** + * Answers the store's requests by method and path, as the Public API + * would, and keeps each request in order. A request no route expects + * fails the test. + */ +export function routeRequests( + fetchMock: ReturnType, + routes: Record +): RoutedRequest[] { + const requests: RoutedRequest[] = []; + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + const parsed = new URL(url); + const request: RoutedRequest = { + method: init.method ?? 'GET', + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams.entries()), + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; + requests.push(request); + const handler = routes[`${request.method} ${request.path}`]; + if (!handler) { + throw new Error(`Unexpected request ${request.method} ${request.path}`); + } + return handler(request); + }); + return requests; +} + +/** + * The family as `/v2/households` returns it and the store reads it back: + * the same content, with its groups in the document's name order ("your + * first dependent's marital unit" before "your marital unit"). + */ +export const FAMILY_HOUSEHOLD_AS_STORED = householdFromV2Item( + v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY) +); + +/** The saved family as the store opens it. */ +export const SAVED_FAMILY = { + id: SAVED_HOUSEHOLD_IDS.FAMILY, + countryId: 'us' as const, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + household: FAMILY_HOUSEHOLD.withId(V2_HOUSEHOLD_IDS.FAMILY), + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, +}; + +export const UK_HOME_LABEL = 'Our flat in Leeds'; + +/** A UK household saved by the same user. */ +export const UK_HOME_HOUSEHOLD = Household.starter('uk', SAVED_HOUSEHOLD_YEAR); + +/** The UK household's saved row, as `/v2/user-households?country_id=uk` returns it. */ +export const UK_HOME_ROW = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.UK_HOME, + country_id: 'uk', + household_id: V2_HOUSEHOLD_IDS.UK_HOME, + name: UK_HOME_LABEL, +}); diff --git a/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts b/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts new file mode 100644 index 000000000..c296cbc26 --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts @@ -0,0 +1,66 @@ +import { Household } from '@/models/Household'; +import type { HouseholdCalculationOutput } from '@/types/calculation/household'; + +export const REPORT_YEAR = '2026'; + +/** Points across earnings in a variation, as the Public API returns them (401 in production). */ +const VARIATION_POINTS = 5; +const acrossEarnings = (step: number) => + Array.from({ length: VARIATION_POINTS }, (_, pointIndex) => pointIndex * step); +const constantAcrossEarnings = (value: number) => acrossEarnings(0).map(() => value); + +/** + * A US household with tip income (issue #1096), earnings the chart varies, + * and a rent input left at zero, which says nothing about the household. + */ +export const HOUSEHOLD_WITH_TIPS = Household.starter('us', REPORT_YEAR) + .setPersonVariableAtYear('you', 'employment_income', REPORT_YEAR, 40000) + .setPersonVariableAtYear('you', 'tip_income', REPORT_YEAR, 6000) + .setPersonVariableAtYear('you', 'rent', REPORT_YEAR, 0) + .setGroupVariableAtYear('households', 'your household', 'state_name', REPORT_YEAR, 'NV'); + +/** The model's metadata for the variation's variables: tip income and age are inputs. */ +export const VARIATION_METADATA_VARIABLES = { + employment_income: { entity: 'person', label: 'Employment income', isInputVariable: false }, + tip_income: { entity: 'person', label: 'Tip income', isInputVariable: true }, + age: { entity: 'person', label: 'Age', isInputVariable: true }, + marginal_tax_rate: { entity: 'person', label: 'Marginal tax rate' }, + household_net_income: { entity: 'household', label: 'Net income', isInputVariable: false }, +}; + +/** The household calculated across earnings: every variable an array of points. */ +export const TIPS_VARIATION: HouseholdCalculationOutput = { + countryId: 'us', + householdData: { + people: { + you: { + age: { [REPORT_YEAR]: constantAcrossEarnings(30) }, + employment_income: { [REPORT_YEAR]: acrossEarnings(10000) }, + tip_income: { [REPORT_YEAR]: constantAcrossEarnings(6000) }, + }, + }, + households: { + 'your household': { + members: ['you'], + household_net_income: { [REPORT_YEAR]: acrossEarnings(9000) }, + }, + }, + }, +}; + +/** The same household calculated once, at its own earnings. */ +export const TIPS_POINT_OUTPUT: HouseholdCalculationOutput = { + countryId: 'us', + householdData: { + people: { + you: { + age: { [REPORT_YEAR]: 30 }, + employment_income: { [REPORT_YEAR]: 40000 }, + tip_income: { [REPORT_YEAR]: 6000 }, + }, + }, + households: { + 'your household': { members: ['you'], household_net_income: { [REPORT_YEAR]: 41000 } }, + }, + }, +}; diff --git a/app/src/tests/fixtures/models/household/v2DocumentCodecMocks.ts b/app/src/tests/fixtures/models/household/v2DocumentCodecMocks.ts new file mode 100644 index 000000000..b1ad2122f --- /dev/null +++ b/app/src/tests/fixtures/models/household/v2DocumentCodecMocks.ts @@ -0,0 +1,180 @@ +import { Household } from '@/models/Household'; +import type { + V2HouseholdCreateBody, + V2HouseholdDocument, + V2HouseholdItem, +} from '@/types/v2Households'; + +export const V2_CODEC_YEAR = '2026'; +export const STORED_V2_HOUSEHOLD_ID = '5d2c1a7e-3f40-4b8a-9c1d-2e6f7a8b9c0d'; +const STORED_AT = '2026-10-20T09:00:00Z'; + +/** A single US parent with one child in California, on national SPM thresholds. */ +export const US_SINGLE_PARENT = Household.starter('us', V2_CODEC_YEAR) + .withBuilderChildCount(V2_CODEC_YEAR, 1) + .setGroupVariableAtYear('households', 'your household', 'state_name', V2_CODEC_YEAR, 'CA') + .withSPM({ geography_kind: 'national' }); + +/** + * The same household as the Public API translates its v1 form + * (translate_legacy_household): people and groups take ids in name order, + * each keeps its v1 name as source_name, group members become each + * person's memberships, and the SPM settings move onto the document. + */ +export const US_SINGLE_PARENT_V2_BODY: V2HouseholdCreateBody = { + country_id: 'us', + default_year: 2026, + household_data: { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 30 }, employment_income: { 2026: 0 } }, + memberships: { + household: 'household-1', + family: 'family-1', + tax_unit: 'tax_unit-1', + spm_unit: 'spm_unit-1', + marital_unit: 'marital_unit-2', + }, + }, + { + id: 'person-2', + source_name: 'your first dependent', + values: { + age: { 2026: 10 }, + is_tax_unit_dependent: { 2026: true }, + employment_income: { 2026: 0 }, + }, + memberships: { + household: 'household-1', + family: 'family-1', + tax_unit: 'tax_unit-1', + spm_unit: 'spm_unit-1', + marital_unit: 'marital_unit-1', + }, + }, + ], + household: [ + { id: 'household-1', source_name: 'your household', values: { state_name: { 2026: 'CA' } } }, + ], + family: [{ id: 'family-1', source_name: 'your family', values: {} }], + tax_unit: [{ id: 'tax_unit-1', source_name: 'your tax unit', values: {} }], + spm_unit: [{ id: 'spm_unit-1', source_name: 'your household', values: {} }], + marital_unit: [ + { id: 'marital_unit-1', source_name: "your first dependent's marital unit", values: {} }, + { id: 'marital_unit-2', source_name: 'your marital unit', values: {} }, + ], + spm: { geography_kind: 'national' }, + }, +}; + +/** A UK parent with one child in the North East. */ +export const UK_SINGLE_PARENT = Household.starter('uk', V2_CODEC_YEAR) + .withBuilderChildCount(V2_CODEC_YEAR, 1) + .setGroupVariableAtYear('households', 'your household', 'region', V2_CODEC_YEAR, 'NORTH_EAST'); + +export const UK_SINGLE_PARENT_V2_BODY: V2HouseholdCreateBody = { + country_id: 'uk', + default_year: 2026, + household_data: { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 30 }, employment_income: { 2026: 0 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + { + id: 'person-2', + source_name: 'your first dependent', + values: { age: { 2026: 10 }, employment_income: { 2026: 0 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [ + { + id: 'household-1', + source_name: 'your household', + values: { region: { 2026: 'NORTH_EAST' } }, + }, + ], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], + }, +}; + +/** A married US couple with two children: four people, three marital units. */ +export const US_COUPLE_WITH_TWO_CHILDREN = Household.starter('us', V2_CODEC_YEAR) + .withBuilderMaritalStatus(V2_CODEC_YEAR, 'married') + .withBuilderChildCount(V2_CODEC_YEAR, 2) + .withSPM({ geography_kind: 'county' }) + .setGroupVariableAtYear('households', 'your household', 'county_fips', V2_CODEC_YEAR, '06037'); + +/** A stored v2 household as the Public API returns it. */ +export function storedV2Household( + countryId: V2HouseholdItem['country_id'], + householdData: V2HouseholdDocument +): V2HouseholdItem { + return { + id: STORED_V2_HOUSEHOLD_ID, + country_id: countryId, + default_year: 2026, + household_data: householdData, + created_at: STORED_AT, + updated_at: STORED_AT, + }; +} + +/** A UK household stored without v1 names: its ids name its people and groups. */ +export const UK_DOCUMENT_WITHOUT_SOURCE_NAMES: V2HouseholdDocument = { + people: [ + { + id: 'adult', + values: { age: { 2026: 45 } }, + memberships: { household: 'home', benunit: 'family' }, + }, + ], + household: [{ id: 'home', values: {} }], + benunit: [{ id: 'family', values: {} }], +}; + +/** A US household where the child is in no marital unit: v2 needs every membership. */ +export const US_PERSON_WITHOUT_MARITAL_UNIT = Household.fromV1CreationPayload({ + country_id: 'us', + data: { + people: { you: { age: { 2026: 30 } }, child: { age: { 2026: 5 } } }, + households: { home: { members: ['you', 'child'] } }, + families: { family: { members: ['you', 'child'] } }, + tax_units: { 'tax unit': { members: ['you', 'child'] } }, + spm_units: { 'spm unit': { members: ['you', 'child'] } }, + marital_units: { 'marital unit': { members: ['you'] } }, + }, +}); + +/** A UK household stored with inputs in two years: its default year is the earlier one. */ +export const UK_DOCUMENT_WITH_TWO_YEARS: V2HouseholdDocument = { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 45 }, employment_income: { 2026: 30000, 2027: 31000 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [{ id: 'household-1', source_name: 'your household', values: {} }], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], +}; + +/** A UK household stored with values that name no year, which the v2 document allows. */ +export const UK_DOCUMENT_WITHOUT_YEARS: V2HouseholdDocument = { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: 45 }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [{ id: 'household-1', source_name: 'your household', values: {} }], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], +}; diff --git a/app/src/tests/fixtures/pages/flagship/householdReportPageMocks.ts b/app/src/tests/fixtures/pages/flagship/householdReportPageMocks.ts new file mode 100644 index 000000000..7f148773d --- /dev/null +++ b/app/src/tests/fixtures/pages/flagship/householdReportPageMocks.ts @@ -0,0 +1,34 @@ +/** Fixtures for v3's standalone household report page. */ + +/** A policy as the Public API returns it: no parameter changes unless given. */ +export function policyMetadata(countryId: string, policyId: string, policyJson = {}) { + return { + id: policyId, + country_id: countryId, + api_version: '1', + label: null, + policy_json: policyJson, + }; +} + +/** A UK reform's parameter change, which only the reform's Python block may set. */ +export const REFORM_PARAMETER = 'gov.dwp.universal_credit.elements.child.amount'; +export const REFORM_POLICY_JSON = { [REFORM_PARAMETER]: { '2026-01-01.2100-12-31': 3000 } }; + +/** The model's metadata for that parameter, which reading the reform's policy needs. */ +export const REFORM_PARAMETER_METADATA = { + [REFORM_PARAMETER]: { + type: 'parameter', + parameter: REFORM_PARAMETER, + values: { '2026-01-01': 2900 }, + }, +}; + +/** What the household validation says of a household that can run, and of one that cannot. */ +export const READY_TO_RUN = { isValid: true, errors: [] }; +export const NOT_READY_TO_RUN = { isValid: false, errors: [{ message: 'Choose a county' }] }; + +/** The years the model's metadata lists for the report. */ +export const MODEL_YEARS = [2026, 2027]; +/** A model year after the households' own (they are built in 2026). */ +export const LATER_MODEL_YEAR = '2027'; diff --git a/app/src/tests/unit/api/savedHouseholdStore.test.ts b/app/src/tests/unit/api/savedHouseholdStore.test.ts new file mode 100644 index 000000000..27332dcdb --- /dev/null +++ b/app/src/tests/unit/api/savedHouseholdStore.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, test } from 'vitest'; +import { + ApiSavedHouseholdStore, + getSavedHouseholdStore, + SavedHouseholdChangesNotSavedError, + SavedHouseholdUnavailableError, +} from '@/api/savedHouseholdStore'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; +import { MAX_STORE_NAME_LENGTH } from '@/libs/flagship/storeText'; +import { householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_HOUSEHOLD, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +function savedHouseholdStore() { + const { fetchMock, context } = createTestApiContext('us'); + return { store: new ApiSavedHouseholdStore(context), fetchMock }; +} + +const familyRow = userHouseholdItem(); +const familyHouseholdItem = v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY); +const editedHouseholdItem = v2HouseholdItem( + EDITED_FAMILY_HOUSEHOLD, + V2_HOUSEHOLD_IDS.EDITED_FAMILY +); + +const householdReads = (requests: { method: string; path: string }[]) => + requests.filter( + (request) => request.method === 'GET' && request.path.startsWith('/v2/households/') + ); + +describe('ApiSavedHouseholdStore', () => { + test('given the list read again then each stored household is fetched once, as stored households never change', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + + await store.list(); + const [listedAgain] = await store.list(); + await store.get(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(householdReads(requests)).toHaveLength(1); + expect(listedAgain.unavailable).toBeFalsy(); + }); + + test('given a stored household that failed to load then the next list asks for it again', async () => { + const { store, fetchMock } = savedHouseholdStore(); + let householdAnswers = 0; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + householdAnswers++ === 0 + ? errorResponse(503, 'Households are temporarily unavailable.') + : itemResponse(familyHouseholdItem), + }); + + const [first] = await store.list(); + const [second] = await store.list(); + + expect(first.unavailable).toBe(true); + expect(second.unavailable).toBeFalsy(); + expect(householdReads(requests)).toHaveLength(2); + }); + + test('given a household just saved then listing it does not fetch it again', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + 'GET /v2/user-households': () => pageResponse([familyRow]), + }); + + await store.save({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }); + const [listed] = await store.list(); + + expect(householdReads(requests)).toHaveLength(0); + expect(listed.unavailable).toBeFalsy(); + }); + + test('given a household and a label then its content is stored, and the saved row points at it by its v2 id', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + }); + + const saved = await store.save({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + 'POST /v2/user-households', + ]); + expect(requests[0].query).toEqual({ country_id: 'us' }); + expect(requests[0].body).toEqual(householdToV2CreateBody(FAMILY_HOUSEHOLD)); + expect(requests[1].body).toEqual({ + country_id: 'us', + household_id: V2_HOUSEHOLD_IDS.FAMILY, + name: FAMILY_LABEL, + }); + expect(saved).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + }); + expect(saved.household.toV1CreationPayload()).toEqual(FAMILY_HOUSEHOLD.toV1CreationPayload()); + }); + + test('given a long or empty label then it is shortened to the limit, or saved without a name', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + }); + + await store.save({ household: FAMILY_HOUSEHOLD, label: 'A'.repeat(300) }); + await store.save({ household: FAMILY_HOUSEHOLD, label: ' ' }); + + expect(requests[1].body.name).toHaveLength(MAX_STORE_NAME_LENGTH); + expect(requests[3].body.name).toBeNull(); + }); + + test('given saved households then each is listed with its household, most recently updated first', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const retiredRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + name: RETIRED_LABEL, + updated_at: '2026-10-13T09:00:00+00:00', + }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow, retiredRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + itemResponse(v2HouseholdItem(RETIRED_HOUSEHOLD, V2_HOUSEHOLD_IDS.RETIRED)), + }); + + const listed = await store.list(); + + expect(listed.map((saved) => saved.label)).toEqual([RETIRED_LABEL, FAMILY_LABEL]); + expect(listed.every((saved) => !saved.unavailable)).toBe(true); + }); + + test('given a row without a v2 household, or one that fails to load, then it is listed as unavailable, not failing the list', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => + pageResponse([ + familyRow, + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + }), + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + }), + ]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + errorResponse(503, 'V2 household persistence is unavailable'), + }); + + const listed = await store.list(); + + const unavailable = listed.filter((saved) => saved.unavailable).map((saved) => saved.id); + expect(unavailable.sort()).toEqual( + [SAVED_HOUSEHOLD_IDS.RETIRED, SAVED_HOUSEHOLD_IDS.V1_ONLY].sort() + ); + }); + + test('given a saved household then it opens with its household; an unknown one is null', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.RETIRED}`]: () => + errorResponse(404, 'Not found'), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + + const opened = await store.get(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(opened?.label).toBe(FAMILY_LABEL); + expect(opened?.household.toV1CreationPayload()).toEqual(FAMILY_HOUSEHOLD.toV1CreationPayload()); + expect(await store.get(SAVED_HOUSEHOLD_IDS.RETIRED)).toBeNull(); + }); + + test('given a row that names only a v1 household then opening it says so', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.V1_ONLY}`]: () => + itemResponse( + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + }) + ), + }); + + await expect(store.get(SAVED_HOUSEHOLD_IDS.V1_ONLY)).rejects.toBeInstanceOf( + SavedHouseholdUnavailableError + ); + }); + + test('given a new label then only the name is patched', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, name: RETIRED_LABEL }), + }); + + await store.rename(SAVED_HOUSEHOLD_IDS.FAMILY, RETIRED_LABEL); + + expect(requests).toHaveLength(1); + expect(requests[0].body).toEqual({ name: RETIRED_LABEL }); + }); + + test('given delete then the saved row is deleted, and the stored household stays', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + [`DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => noContentResponse(), + }); + + await store.remove(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + `DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + }); + + describe('given changes to a saved household', () => { + async function savedFamily(store: ApiSavedHouseholdStore, fetchMock: any) { + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + return (await store.get(SAVED_HOUSEHOLD_IDS.FAMILY))!; + } + + test('given an edited household then it is stored, and the saved household keeps its id and points at it', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }), + }); + + const updated = await store.saveChanges(saved, { household: EDITED_FAMILY_HOUSEHOLD }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[0].body).toEqual(householdToV2CreateBody(EDITED_FAMILY_HOUSEHOLD)); + expect(requests[1].query).toEqual({ country_id: 'us' }); + expect(requests[1].body).toEqual({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }); + expect(updated).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }); + expect(updated.household.toV1CreationPayload()).toEqual( + EDITED_FAMILY_HOUSEHOLD.toV1CreationPayload() + ); + }); + + test('given an edited household and a new label then one request changes both', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ + ...familyRow, + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }), + }); + + const updated = await store.saveChanges(saved, { + household: EDITED_FAMILY_HOUSEHOLD, + label: RETIRED_LABEL, + }); + + expect(requests.filter((request) => request.method === 'PATCH')).toHaveLength(1); + expect(requests[1].body).toEqual({ + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }); + expect(updated.label).toBe(RETIRED_LABEL); + }); + + test('given a new label alone then only the name is patched, and no household is stored', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, name: RETIRED_LABEL }), + }); + + const updated = await store.saveChanges(saved, { label: RETIRED_LABEL }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[0].body).toEqual({ name: RETIRED_LABEL }); + expect(updated).toMatchObject({ label: RETIRED_LABEL, householdId: V2_HOUSEHOLD_IDS.FAMILY }); + }); + + test('given an edit back to the same content and the same label then no saved row changes', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + // The Public API deduplicates: identical content is the stored household. + 'POST /v2/households': () => itemResponse(familyHouseholdItem), + }); + + const updated = await store.saveChanges(saved, { + household: FAMILY_HOUSEHOLD, + label: FAMILY_LABEL, + }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + ]); + expect(updated).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + updatedAt: saved.updatedAt, + }); + }); + + test('given the saved household cannot be pointed at the edit then the changes are not saved, and it says so', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + + const update = store.saveChanges(saved, { household: EDITED_FAMILY_HOUSEHOLD }); + + await expect(update).rejects.toBeInstanceOf(SavedHouseholdChangesNotSavedError); + await expect(update).rejects.toThrow( + 'Your changes were not saved: Saved work is temporarily unavailable.' + ); + }); + + test('given the edited content cannot be stored then the changes are not saved, and it says so the same way', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => errorResponse(503, 'Households are temporarily unavailable.'), + }); + + const update = store.saveChanges(saved, { household: EDITED_FAMILY_HOUSEHOLD }); + + await expect(update).rejects.toBeInstanceOf(SavedHouseholdChangesNotSavedError); + await expect(update).rejects.toThrow( + 'Your changes were not saved: Households are temporarily unavailable.' + ); + expect(requests.some((request) => request.method === 'PATCH')).toBe(false); + }); + + test('given a household saved only in this browser then nothing is sent', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, {}); + + await expect( + store.saveChanges({ ...saved, id: 'lfh-browser-only' }, { label: RETIRED_LABEL }) + ).rejects.toBeInstanceOf(BrowserOnlyIdError); + expect(requests).toEqual([]); + }); + }); +}); + +describe('getSavedHouseholdStore', () => { + test('given an account then saved households go to the Public API; without one there are none', () => { + expect(getSavedHouseholdStore(createTestApiContext().context)).toBeInstanceOf( + ApiSavedHouseholdStore + ); + expect(getSavedHouseholdStore(null)).toBeNull(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx b/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx new file mode 100644 index 000000000..20f98b324 --- /dev/null +++ b/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; + +const ITEM_NAME = 'Our family'; +const DESCRIPTION = 'This removes it from your households for good.'; +const CONFIRM_LABEL = 'Delete household'; + +function renderDialog(props: Partial> = {}) { + const onConfirm = vi.fn(); + render( + + ); + return { onConfirm }; +} + +describe('DeleteConfirmationDialog', () => { + test('given Delete then it asks first, naming what is deleted and what the delete does', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('alertdialog', { name: `Delete “${ITEM_NAME}”?` })).toHaveTextContent( + DESCRIPTION + ); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + test('given the delete confirmed then it is done', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: CONFIRM_LABEL })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('given Cancel then nothing is deleted', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + test('given a delete already under way then Delete cannot be pressed', () => { + renderDialog({ disabled: true }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx b/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx new file mode 100644 index 000000000..139244497 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx @@ -0,0 +1,154 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen, userEvent } from '@test-utils'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; +import { commonHouseholdInputs } from '@/components/flagship/report/household/householdPickerInputs'; +import { Household } from '@/models/Household'; +import metadataReducer from '@/reducers/metadataReducer'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +const PANEL_YEAR = '2026'; + +/** Inputs the variable search could list, each standing for a rule the panel applies. */ +const CANDIDATE_INPUTS: VariableInfo[] = [ + { name: 'tip_income', moduleName: 'household.income.person.tip_income' }, + { name: 'ca_rent', moduleName: 'gov.states.ca.tax.income.credits.renters' }, + { name: 'ny_rent', moduleName: 'gov.states.ny.tax.income.credits.renters' }, + { name: 'employment_income_reported', moduleName: 'household.income.person' }, + { name: 'employment_income_before_lsr', moduleName: 'household.income.person' }, +] as VariableInfo[]; + +// The household builder, tested on its own: a stand-in shows how the panel +// configures it and makes the calls it makes. +vi.mock('@/components/household/HouseholdBuilderForm', () => ({ + default: (props: any) => ( +
input.name) + .join(' ')} + data-listed={CANDIDATE_INPUTS.filter(props.variableFilter) + .map((variable) => variable.name) + .join(' ')} + data-child-name={ + props.personDisplayNameOf?.('your first dependent') ?? 'your first dependent' + } + data-label-inputs={String(!!props.labelInputs)} + data-suggested={`${props.suggestedLabel}: ${(props.suggestedVariables ?? []).join(' ')}`} + data-disabled={String(!!props.disabled)} + > + + + +
+ ), +})); + +const californiaHousehold = Household.starter('us', PANEL_YEAR).setGroupVariableAtYear( + 'households', + 'your household', + 'state_name', + PANEL_YEAR, + 'CA' +); + +/** Model information whose builder always shows a person's age and employment income. */ +function storeWithBasicInputs() { + return configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...metadataReducer(undefined, { type: 'test/init' }), + currentCountry: 'us', + basicInputs: ['age', 'employment_income'], + variables: { + age: { name: 'age', entity: 'person' }, + employment_income: { name: 'employment_income', entity: 'person' }, + } as any, + entities: { person: { plural: 'people', is_person: true } } as any, + }, + }, + }); +} + +function renderPanel(props: Partial> = {}) { + const onChange = vi.fn(); + render( + + + + ); + return { onChange, builder: screen.getByLabelText('Household builder') }; +} + +describe('HouseholdBuilderPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("given a US household then the search lists its own state's programs, and no survey totals or twins of basic fields", () => { + const { builder } = renderPanel(); + + expect(builder).toHaveAttribute('data-listed', 'tip_income ca_rent'); + }); + + test("given a country then the builder features that country's inputs and names", () => { + const { builder } = renderPanel({ + household: Household.starter('uk', PANEL_YEAR), + countryId: 'uk', + }); + + expect(builder).toHaveAttribute( + 'data-featured', + commonHouseholdInputs('uk') + .map((input) => input.name) + .join(' ') + ); + expect(builder).toHaveAttribute('data-child-name', 'your first child'); + expect(builder).toHaveAttribute('data-label-inputs', 'true'); + }); + + test('given suggested inputs then the builder leads with them as related to the reform', () => { + const { builder } = renderPanel({ suggestedVariables: ['tip_income'] }); + + expect(builder).toHaveAttribute('data-suggested', 'Related to this reform: tip_income'); + }); + + test('given the marital status and children chosen then the household changes to match', async () => { + const user = userEvent.setup(); + const { onChange, builder } = renderPanel(); + expect(builder).toHaveAttribute('data-composition', 'single · 0'); + + await user.click(screen.getByRole('button', { name: 'Married' })); + await user.click(screen.getByRole('button', { name: 'Two children' })); + + expect(onChange.mock.calls[0][0].deriveBuilderComposition(PANEL_YEAR).maritalStatus).toBe( + 'married' + ); + expect(onChange.mock.calls[1][0].deriveBuilderComposition(PANEL_YEAR).numChildren).toBe(2); + }); + + test("given the caller's analytics then the builder tells it when a search opens", async () => { + const user = userEvent.setup(); + const onVariablePickerOpen = vi.fn(); + renderPanel({ onVariablePickerOpen }); + + await user.click(screen.getByRole('button', { name: 'Open search' })); + + expect(onVariablePickerOpen).toHaveBeenCalledWith('person'); + }); +}); diff --git a/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx b/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx new file mode 100644 index 000000000..f214a9e82 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, userEvent, within } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import HouseholdNetIncomeResult from '@/components/flagship/household/HouseholdNetIncomeResult'; +import type { HouseholdBreakdownSource } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + NATIONAL_SPM_CONFIG, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; +import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; + +// The v2 breakdowns, tested on their own: a stand-in names the one shown +// and the policies it compares. +vi.mock('@/components/flagship/report/household/HouseholdBreakdowns', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/components/flagship/report/household/HouseholdBreakdowns') + >()), + default: ({ view, source }: { view: string; source: HouseholdBreakdownSource }) => ( +
{`${view} · ${source.baselinePolicyId} · ${source.reformPolicyId}`}
+ ), +})); + +/** One policy's run: the breakdowns compare the policy with itself. */ +const BASELINE_ONLY_SOURCE: HouseholdBreakdownSource = { + countryId: 'us', + year: HOUSEHOLD_YEAR, + householdId: String(CREATED_HOUSEHOLD_ID), + baselinePolicyId: BASELINE_POLICY_ID, + reformPolicyId: BASELINE_POLICY_ID, + baseline: BASELINE_RESULT, + reform: BASELINE_RESULT, + gains: false, +}; + +const NATIONAL_THRESHOLDS_PROVENANCE = { + policyengineBundle: null, + spmConfig: NATIONAL_SPM_CONFIG, + spmProvenance: SPM_RECEIPT, +}; + +const SUMMARY = 'Married · 2 children · New York · 2026'; + +const pointMethodology = () => + screen.queryByRole('region', { name: 'SPM point calculation methodology' }); + +describe('HouseholdNetIncomeResult', () => { + test('given a US household then its annual net income reads in whole dollars, with who it is for', () => { + render( + + ); + + expect(screen.getByRole('group', { name: 'Annual net income' })).toHaveTextContent('$52,300'); + expect(screen.getByText(SUMMARY)).toBeInTheDocument(); + }); + + test('given a UK household then its net income reads in pounds', () => { + render( + + ); + + expect(screen.getByRole('group', { name: 'Annual net income' })).toHaveTextContent('£25,426'); + }); + + test('given the result then the net income tree shows first, under the one policy, and no summary is offered', () => { + render( + + ); + + const tabs = within(screen.getByRole('tablist', { name: 'Household outputs' })); + expect(tabs.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'Net income', + 'Varying earnings', + 'Marginal tax rates', + ]); + expect(tabs.getByRole('tab', { name: 'Net income' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByLabelText('Breakdown')).toHaveTextContent( + `net-income · ${BASELINE_POLICY_ID} · ${BASELINE_POLICY_ID}` + ); + }); + + test('given SPM provenance then the methodology shows under the net income tree, and not under the variation views', async () => { + const user = userEvent.setup(); + render( + + ); + expect(pointMethodology()).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Varying earnings' })); + + expect(screen.getByLabelText('Breakdown')).toHaveTextContent('earnings-variation'); + expect(pointMethodology()).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx b/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx new file mode 100644 index 000000000..b376ba212 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx @@ -0,0 +1,249 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SaveHouseholdForm from '@/components/flagship/household/SaveHouseholdForm'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD_AS_STORED, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_FAMILY, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +function signedInSession() { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + return fetchMock; +} + +function renderForm(props: Partial> = {}) { + const onSaved = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + return { onSaved }; +} + +const routedPaths = (requests: { method: string; path: string }[]) => + requests.map((request) => `${request.method} ${request.path}`); + +describe('SaveHouseholdForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given a label then the household is saved with it', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + const { onSaved } = renderForm(); + + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual(['POST /v2/households', 'POST /v2/user-households']); + expect(requests[1].body.name).toBe(FAMILY_LABEL); + expect(onSaved.mock.calls[0][0]).toMatchObject({ id: SAVED_HOUSEHOLD_IDS.FAMILY }); + expect(screen.getByRole('status')).toHaveTextContent('Saved'); + }); + + test('given a new household saved by a caller that does not pass it back then the form edits it from then on', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + expect(await screen.findByRole('button', { name: 'Save changes' })).toBeDisabled(); + expect(routedPaths(requests)).toEqual(['POST /v2/households', 'POST /v2/user-households']); + }); + + test('given the household edited after a save then it no longer says it is saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const formFor = (household: typeof FAMILY_HOUSEHOLD) => ( + + + + ); + const view = render(formFor(FAMILY_HOUSEHOLD)); + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + expect(await screen.findByRole('status')).toHaveTextContent('Saved'); + + view.rerender(formFor(EDITED_FAMILY_HOUSEHOLD)); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + test('given an edited saved household then the edit is stored and the saved household points at it, keeping its id', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY })), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual([ + 'POST /v2/households', + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[1].body).toEqual({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }); + expect(onSaved.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }); + expect(screen.getByRole('status')).toHaveTextContent('Saved'); + }); + + test('given an edited saved household and a new label then one request saves both', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse( + userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, name: RETIRED_LABEL }) + ), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + const label = screen.getByRole('textbox', { name: 'Household label' }); + await user.clear(label); + await user.type(label, RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(requests[1].body).toEqual({ + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }); + expect(onSaved.mock.calls[0][0]).toMatchObject({ label: RETIRED_LABEL }); + }); + + test('given the saved household cannot be pointed at the edit then it says the changes were not saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your changes were not saved: Saved work is temporarily unavailable.' + ); + expect(onSaved).not.toHaveBeenCalled(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + test('given only a new label for a saved household then only its name changes', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ name: RETIRED_LABEL })), + }); + const { onSaved } = renderForm({ household: SAVED_FAMILY.household, saved: SAVED_FAMILY }); + + const label = screen.getByRole('textbox', { name: 'Household label' }); + await user.clear(label); + await user.type(label, RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual([ + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(onSaved.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: RETIRED_LABEL, + }); + }); + + test('given a saved household unchanged then there is nothing to save', () => { + signedInSession(); + renderForm({ household: SAVED_FAMILY.household, saved: SAVED_FAMILY }); + + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given the builder household and the same household as stored then there is nothing to save', () => { + signedInSession(); + renderForm({ + household: FAMILY_HOUSEHOLD, + saved: { ...SAVED_FAMILY, household: FAMILY_HOUSEHOLD_AS_STORED }, + }); + + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given a failed save then it says why, and nothing is reported saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => errorResponse(503, 'V2 household persistence is unavailable'), + }); + const { onSaved } = renderForm(); + + await user.click(screen.getByRole('button', { name: 'Save household' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'V2 household persistence is unavailable' + ); + expect(onSaved).not.toHaveBeenCalled(); + }); + + test('given no accounts then it says saved households are not available, with nothing to press', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderForm(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save household' })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx b/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx new file mode 100644 index 000000000..f4706c5e2 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx @@ -0,0 +1,167 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SavedHouseholdPicker from '@/components/flagship/household/SavedHouseholdPicker'; +import { + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_HOUSEHOLD, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V1_ONLY_LABEL, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +function renderPicker(props: Partial> = {}) { + const onSelect = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + return { onSelect }; +} + +const retiredRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + name: RETIRED_LABEL, +}); + +/** A session whose account lists these saved rows, and holds the family and the retired couple. */ +function signedInWith(rows: unknown[]) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + itemResponse(v2HouseholdItem(RETIRED_HOUSEHOLD, V2_HOUSEHOLD_IDS.RETIRED)), + }); +} + +describe('SavedHouseholdPicker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given saved households then each is offered by its label, with who it describes', async () => { + signedInWith([userHouseholdItem(), retiredRow]); + renderPicker(); + + const list = await screen.findByRole('list', { name: 'Saved households' }); + expect(within(list).getByRole('button', { name: new RegExp(FAMILY_LABEL) })).toHaveTextContent( + 'Married · 2 children' + ); + expect(within(list).getByRole('button', { name: new RegExp(RETIRED_LABEL) })).toHaveTextContent( + 'Married · no children' + ); + }); + + test('given a choice then the saved household is handed on with its household', async () => { + const user = userEvent.setup(); + signedInWith([userHouseholdItem()]); + const { onSelect } = renderPicker(); + + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + }); + expect(onSelect.mock.calls[0][0].household.toV1CreationPayload().data.people).toEqual( + FAMILY_HOUSEHOLD.toV1CreationPayload().data.people + ); + }); + + test('given the saved household the builder holds then it is marked as chosen', async () => { + signedInWith([userHouseholdItem(), retiredRow]); + renderPicker({ selectedId: SAVED_HOUSEHOLD_IDS.RETIRED }); + + expect(await screen.findByRole('button', { name: new RegExp(RETIRED_LABEL) })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(screen.getByRole('button', { name: new RegExp(FAMILY_LABEL) })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); + + test('given the picker disabled then no saved household can be chosen', async () => { + signedInWith([userHouseholdItem(), retiredRow]); + renderPicker({ disabled: true }); + + expect(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })).toBeDisabled(); + expect(screen.getByRole('button', { name: new RegExp(RETIRED_LABEL) })).toBeDisabled(); + }); + + test('given a saved household that cannot open then it is listed, but cannot be chosen', async () => { + signedInWith([ + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + name: V1_ONLY_LABEL, + }), + ]); + renderPicker(); + + const option = await screen.findByRole('button', { name: new RegExp(V1_ONLY_LABEL) }); + expect(option).toBeDisabled(); + expect(option).toHaveTextContent('Cannot be opened here'); + }); + + test('given a saved household without a label then it is offered as an untitled household', async () => { + signedInWith([userHouseholdItem({ name: null })]); + renderPicker(); + + expect(await screen.findByRole('button', { name: /Untitled household/ })).toBeInTheDocument(); + }); + + test('given none saved then it says so', async () => { + signedInWith([]); + renderPicker(); + + expect(await screen.findByText('No saved households yet.')).toBeInTheDocument(); + }); + + test('given the list fails to load then it says why', async () => { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + renderPicker(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your households could not be loaded. Saved work is temporarily unavailable.' + ); + }); + + test('given no accounts then it says saved households are not available', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderPicker(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx b/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx new file mode 100644 index 000000000..420da4104 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import HouseholdBreakdowns, { + type HouseholdBreakdownSource, + type HouseholdBreakdownView, +} from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + REFORM_POLICY_ID, + REFORM_RESULT, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; + +vi.mock('@/api/policy', () => ({ + fetchPolicyById: async (_countryId: string, policyId: string) => ({ + id: policyId, + country_id: 'us', + api_version: '1', + policy_json: {}, + label: null, + }), +})); +// The model's parameters, loaded: the policies parse at once. +vi.mock('@/hooks/useParameterMetadata', () => ({ + useParameterMetadata: () => ({ parameters: {}, isReady: true }), +})); +// The v2 variation pages, tested on their own: stand-ins show the inputs +// the earnings variation is asked to offer. +vi.mock('@/pages/report-output/earnings-variation/EarningsVariationSubPage', () => ({ + default: ({ alsoOfferInputs }: { alsoOfferInputs?: string[] }) => ( +
{(alsoOfferInputs ?? []).join(' ') || 'none'}
+ ), +})); +vi.mock('@/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage', () => ({ + default: () =>
, +})); + +const SOURCE: HouseholdBreakdownSource = { + countryId: 'us', + year: HOUSEHOLD_YEAR, + householdId: String(CREATED_HOUSEHOLD_ID), + baselinePolicyId: BASELINE_POLICY_ID, + reformPolicyId: REFORM_POLICY_ID, + baseline: BASELINE_RESULT, + reform: REFORM_RESULT, + gains: true, +}; + +function renderBreakdowns(view: HouseholdBreakdownView, source: HouseholdBreakdownSource) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); +} + +describe('HouseholdBreakdowns household inputs (issue #1096)', () => { + test("given the household's own amount inputs then the earnings variation offers them", async () => { + renderBreakdowns('earnings-variation', { ...SOURCE, householdInputs: ['tip_income'] }); + + expect(await screen.findByLabelText('Earnings variation')).toHaveTextContent('tip_income'); + }); + + test('given no household inputs then the earnings variation offers none, as in the report section', async () => { + renderBreakdowns('earnings-variation', SOURCE); + + expect(await screen.findByLabelText('Earnings variation')).toHaveTextContent('none'); + }); +}); diff --git a/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx new file mode 100644 index 000000000..0abc9757e --- /dev/null +++ b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + useDeleteSavedHousehold, + useRenameSavedHousehold, + useSavedHousehold, + useSavedHouseholds, + useSaveHousehold, + useSaveHouseholdChanges, +} from '@/hooks/useSavedHouseholds'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + UK_HOME_HOUSEHOLD, + UK_HOME_LABEL, + UK_HOME_ROW, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +const SESSION_SCOPE = 'account:auth0|test-user'; + +function renderWithQueries(hook: () => Result) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(hook, { wrapper }); +} + +/** A session with an account, whose requests the fetch mock answers. */ +function signedInSession() { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: SESSION_SCOPE }); + return fetchMock; +} + +const familyRow = userHouseholdItem(); +const familyHousehold = v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY); + +describe('useSavedHouseholds', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given no accounts then saved households are unavailable and nothing is asked for', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + + const { result } = renderWithQueries(() => useSavedHouseholds()); + + expect(result.current.available).toBe(false); + expect(result.current.savedHouseholds).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + test("given an account then the session's saved households are listed", async () => { + const fetchMock = signedInSession(); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + + const { result } = renderWithQueries(() => useSavedHouseholds()); + + await waitFor(() => expect(result.current.savedHouseholds).toHaveLength(1)); + expect(result.current.available).toBe(true); + expect(result.current.savedHouseholds[0].label).toBe(FAMILY_LABEL); + }); + + test("given the country changes for the same user then that country's saved households are listed", async () => { + const { fetchMock: usFetch, context: usContext } = createTestApiContext('us'); + const { fetchMock: ukFetch, context: ukContext } = createTestApiContext('uk'); + routeRequests(usFetch, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + const ukRequests = routeRequests(ukFetch, { + 'GET /v2/user-households': () => pageResponse([UK_HOME_ROW]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.UK_HOME}`]: () => + itemResponse(v2HouseholdItem(UK_HOME_HOUSEHOLD, V2_HOUSEHOLD_IDS.UK_HOME)), + }); + mockSavedWork.mockReturnValue({ account: usContext, scope: SESSION_SCOPE }); + const { result, rerender } = renderWithQueries(() => useSavedHouseholds()); + await waitFor(() => expect(result.current.savedHouseholds).toHaveLength(1)); + + mockSavedWork.mockReturnValue({ account: ukContext, scope: SESSION_SCOPE }); + rerender(); + + await waitFor(() => + expect(result.current.savedHouseholds.map((saved) => saved.label)).toEqual([UK_HOME_LABEL]) + ); + expect(ukRequests[0].query.country_id).toBe('uk'); + }); + + test('given a household saved then the list shows it', async () => { + const fetchMock = signedInSession(); + let rows: unknown[] = []; + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + 'POST /v2/households': () => itemResponse(familyHousehold, 201), + 'POST /v2/user-households': () => { + rows = [familyRow]; + return itemResponse(familyRow, 201); + }, + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + save: useSaveHousehold(), + })); + await waitFor(() => expect(result.current.list.isLoading).toBe(false)); + + await act(() => + result.current.save.mutateAsync({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }) + ); + + await waitFor(() => expect(result.current.list.savedHouseholds).toHaveLength(1)); + }); + + test('given a household saved through one hook then a list in another does not fetch it again', async () => { + const fetchMock = signedInSession(); + let rows: unknown[] = []; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + 'POST /v2/households': () => itemResponse(familyHousehold, 201), + 'POST /v2/user-households': () => { + rows = [familyRow]; + return itemResponse(familyRow, 201); + }, + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + save: useSaveHousehold(), + })); + await waitFor(() => expect(result.current.list.isLoading).toBe(false)); + + await act(() => + result.current.save.mutateAsync({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }) + ); + + await waitFor(() => expect(result.current.list.savedHouseholds).toHaveLength(1)); + expect(result.current.list.savedHouseholds[0].unavailable).toBeFalsy(); + expect( + requests.filter((request) => request.path === `/v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`) + ).toEqual([]); + }); + + test('given a rename or a delete then the list is read again', async () => { + const fetchMock = signedInSession(); + let rows: unknown[] = [familyRow]; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + rows = [{ ...familyRow, name: RETIRED_LABEL }]; + return itemResponse(rows[0]); + }, + [`DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + rows = []; + return noContentResponse(); + }, + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + rename: useRenameSavedHousehold(), + remove: useDeleteSavedHousehold(), + })); + await waitFor(() => expect(result.current.list.savedHouseholds).toHaveLength(1)); + + await act(() => + result.current.rename.mutateAsync({ + savedHouseholdId: SAVED_HOUSEHOLD_IDS.FAMILY, + label: RETIRED_LABEL, + }) + ); + await waitFor(() => expect(result.current.list.savedHouseholds[0]?.label).toBe(RETIRED_LABEL)); + + await act(() => result.current.remove.mutateAsync(SAVED_HOUSEHOLD_IDS.FAMILY)); + await waitFor(() => expect(result.current.list.savedHouseholds).toEqual([])); + expect(requests.filter((request) => request.path === '/v2/user-households')).toHaveLength(3); + }); + + test('given changes to a saved household then the list and the opened household are read again', async () => { + const fetchMock = signedInSession(); + const editedHousehold = v2HouseholdItem( + EDITED_FAMILY_HOUSEHOLD, + V2_HOUSEHOLD_IDS.EDITED_FAMILY + ); + let row = familyRow; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([row]), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(row), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.EDITED_FAMILY}`]: () => itemResponse(editedHousehold), + 'POST /v2/households': () => itemResponse(editedHousehold, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + row = { ...familyRow, household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }; + return itemResponse(row); + }, + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + opened: useSavedHousehold(SAVED_HOUSEHOLD_IDS.FAMILY), + saveChanges: useSaveHouseholdChanges(), + })); + await waitFor(() => expect(result.current.opened.data).toBeTruthy()); + + await act(() => + result.current.saveChanges.mutateAsync({ + saved: result.current.opened.data!, + changes: { household: EDITED_FAMILY_HOUSEHOLD }, + }) + ); + + await waitFor(() => + expect(result.current.opened.data?.householdId).toBe(V2_HOUSEHOLD_IDS.EDITED_FAMILY) + ); + await waitFor(() => + expect(result.current.list.savedHouseholds[0]).toMatchObject({ + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }) + ); + expect( + requests.filter( + (request) => request.method === 'GET' && request.path === '/v2/user-households' + ) + ).toHaveLength(2); + }); + + test('given no accounts then saving fails plainly rather than saving anywhere else', async () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + const { result } = renderWithQueries(() => useSaveHousehold()); + + await expect( + result.current.mutateAsync({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }) + ).rejects.toThrow('Saved households need an account.'); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/householdReport.test.ts b/app/src/tests/unit/libs/flagship/householdReport.test.ts new file mode 100644 index 000000000..456194d0f --- /dev/null +++ b/app/src/tests/unit/libs/flagship/householdReport.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'vitest'; +import { householdAmountInputs } from '@/libs/flagship/householdReport'; +import { HOUSEHOLD_WITH_TIPS } from '@/tests/fixtures/libs/flagship/householdReportMocks'; + +describe('householdAmountInputs', () => { + test('given a household with tip income then its amounts are listed, but not the earnings the chart varies, age, or amounts left at zero', () => { + expect(householdAmountInputs(HOUSEHOLD_WITH_TIPS)).toEqual(['tip_income']); + }); +}); diff --git a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts new file mode 100644 index 000000000..5d7d58593 --- /dev/null +++ b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'vitest'; +import { + haveSameStoredContent, + householdFromV2Item, + householdToV2CreateBody, + V2HouseholdDocumentError, +} from '@/models/household/v2DocumentCodec'; +import { + STORED_V2_HOUSEHOLD_ID, + storedV2Household, + UK_DOCUMENT_WITH_TWO_YEARS, + UK_DOCUMENT_WITHOUT_SOURCE_NAMES, + UK_DOCUMENT_WITHOUT_YEARS, + UK_SINGLE_PARENT, + UK_SINGLE_PARENT_V2_BODY, + US_COUPLE_WITH_TWO_CHILDREN, + US_PERSON_WITHOUT_MARITAL_UNIT, + US_SINGLE_PARENT, + US_SINGLE_PARENT_V2_BODY, + V2_CODEC_YEAR, +} from '@/tests/fixtures/models/household/v2DocumentCodecMocks'; + +describe('householdToV2CreateBody', () => { + test('given a US household then it is the document the Public API translates its v1 form into', () => { + expect(householdToV2CreateBody(US_SINGLE_PARENT)).toEqual(US_SINGLE_PARENT_V2_BODY); + }); + + test('given a UK household then it has people, households and benefit units only', () => { + expect(householdToV2CreateBody(UK_SINGLE_PARENT)).toEqual(UK_SINGLE_PARENT_V2_BODY); + }); + + test('given a labelled household then the label is not sent: v2 households have no presentation fields', () => { + const body = householdToV2CreateBody(US_SINGLE_PARENT.withLabel('Our family')); + + expect(JSON.stringify(body)).not.toContain('Our family'); + }); + + test('given a person outside one of the collections then it fails, naming the collection', () => { + expect(() => householdToV2CreateBody(US_PERSON_WITHOUT_MARITAL_UNIT)).toThrow( + V2HouseholdDocumentError + ); + expect(() => householdToV2CreateBody(US_PERSON_WITHOUT_MARITAL_UNIT)).toThrow( + 'child is not in any marital unit.' + ); + }); +}); + +describe('householdFromV2Item', () => { + test('given a stored US household then it reads back as the household that was saved', () => { + const stored = storedV2Household('us', US_SINGLE_PARENT_V2_BODY.household_data); + + const household = householdFromV2Item(stored); + + expect(household.toV1CreationPayload()).toEqual(US_SINGLE_PARENT.toV1CreationPayload()); + expect(household.spm).toEqual({ geography_kind: 'national' }); + }); + + test('given a household saved and read back then nothing is lost', () => { + for (const original of [US_COUPLE_WITH_TWO_CHILDREN, UK_SINGLE_PARENT]) { + const body = householdToV2CreateBody(original); + + const household = householdFromV2Item( + storedV2Household(body.country_id, body.household_data) + ); + + expect(household.toV1CreationPayload()).toEqual(original.toV1CreationPayload()); + } + }); + + test.each([ + ['inputs in two years', UK_DOCUMENT_WITH_TWO_YEARS], + ['values that name no year', UK_DOCUMENT_WITHOUT_YEARS], + ])( + 'given a stored household with %s then it reads back in its stored default year', + (_case, document) => { + const household = householdFromV2Item(storedV2Household('uk', document)); + + expect(household.year).toBe(2026); + expect(householdToV2CreateBody(household).default_year).toBe(2026); + } + ); + + test('given a stored household without a default year then it reads back in the year its values name', () => { + const household = householdFromV2Item({ + ...storedV2Household('uk', UK_SINGLE_PARENT_V2_BODY.household_data), + default_year: null, + }); + + expect(household.year).toBe(Number(V2_CODEC_YEAR)); + }); + + test('given a stored household then it carries the stored id and year', () => { + const household = householdFromV2Item( + storedV2Household('uk', UK_SINGLE_PARENT_V2_BODY.household_data) + ); + + expect(household.id).toBe(STORED_V2_HOUSEHOLD_ID); + expect(household.countryId).toBe('uk'); + expect(household.year).toBe(2026); + }); + + test('given a household stored without v1 names then its ids name its people and groups', () => { + const household = householdFromV2Item( + storedV2Household('uk', UK_DOCUMENT_WITHOUT_SOURCE_NAMES) + ); + + const data = household.toV1CreationPayload().data; + expect(Object.keys(data.people)).toEqual(['adult']); + expect(data.households).toEqual({ home: { members: ['adult'] } }); + expect(data.benunits).toEqual({ family: { members: ['adult'] } }); + }); +}); + +describe('haveSameStoredContent', () => { + test('given a household and the same household read back from the Public API then they are the same', () => { + const body = householdToV2CreateBody(US_COUPLE_WITH_TWO_CHILDREN); + const readBack = householdFromV2Item(storedV2Household(body.country_id, body.household_data)); + + expect(haveSameStoredContent(US_COUPLE_WITH_TWO_CHILDREN, readBack)).toBe(true); + }); + + test('given a different label or id then they are still the same stored household', () => { + expect( + haveSameStoredContent( + US_SINGLE_PARENT, + US_SINGLE_PARENT.withLabel('Our family').withId(STORED_V2_HOUSEHOLD_ID) + ) + ).toBe(true); + }); + + test('given a changed input then they differ', () => { + const edited = US_SINGLE_PARENT.setPersonVariableAtYear( + 'you', + 'employment_income', + V2_CODEC_YEAR, + 40000 + ); + + expect(haveSameStoredContent(US_SINGLE_PARENT, edited)).toBe(false); + }); + + test('given a household that cannot be stored then it is compared as changed', () => { + expect( + haveSameStoredContent(US_PERSON_WITHOUT_MARITAL_UNIT, US_PERSON_WITHOUT_MARITAL_UNIT) + ).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx new file mode 100644 index 000000000..171e581ef --- /dev/null +++ b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx @@ -0,0 +1,628 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Household } from '@/models/Household'; +import HouseholdReportPage from '@/pages/flagship/HouseholdReport.page'; +import metadataReducer from '@/reducers/metadataReducer'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_HOUSEHOLD, + RETIRED_LABEL, + routeRequests, + SAVED_FAMILY, + SAVED_HOUSEHOLD_IDS, + storedV2HouseholdItem, + TWO_YEAR_FAMILY_HOUSEHOLD, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + BASELINE_RUN_BUNDLE, + calculationResponse, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + REFORM_POLICY_ID, + REFORM_RESULT, + REFORM_RUN_BUNDLE, + UK_BASELINE_RESULT, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; +import { + LATER_MODEL_YEAR, + MODEL_YEARS, + NOT_READY_TO_RUN, + policyMetadata, + READY_TO_RUN, + REFORM_PARAMETER, + REFORM_PARAMETER_METADATA, + REFORM_POLICY_JSON, +} from '@/tests/fixtures/pages/flagship/householdReportPageMocks'; + +/** + * v3's standalone household report: one household under a baseline and, + * optionally, a reform (issues #498, #1095, #1096). No route renders it + * before v3 moves to the Public API's v2 routes. + */ + +const mockCreateHousehold = vi.fn(); +const mockFetchHouseholdCalculation = vi.fn(); +const mockFetchPolicyById = vi.fn(); +const mockSavedWork = vi.fn(); + +vi.mock('@/api/household', () => ({ + createHousehold: (...args: unknown[]) => mockCreateHousehold(...args), +})); +vi.mock('@/api/householdCalculation', () => ({ + fetchHouseholdCalculationWithBundle: async (...args: unknown[]) => { + const [, , policyId] = args as [string, string, string]; + return calculationResponse(await mockFetchHouseholdCalculation(...args), { + policyengine_bundle: + policyId === BASELINE_POLICY_ID ? BASELINE_RUN_BUNDLE : REFORM_RUN_BUNDLE, + }); + }, +})); +vi.mock('@/api/policy', () => ({ + fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), +})); + +vi.mock('@/hooks/useParameterMetadata', () => ({ + useParameterMetadata: () => ({ parameters: REFORM_PARAMETER_METADATA, isReady: true }), +})); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); +// The household builder, tested on its own: a stand-in shows the household +// and makes the builder's own changes. +vi.mock('@/components/household/HouseholdBuilderForm', () => ({ + default: ({ + household, + year, + onChange, + disabled, + }: { + household: Household; + year: string; + onChange: (household: Household) => void; + disabled?: boolean; + }) => { + const { maritalStatus, numChildren } = household.deriveBuilderComposition(year); + return ( +
+ {`${maritalStatus} · ${numChildren}`} + + +
+ ); + }, +})); +// The v2 breakdowns, tested on their own: a stand-in shows what each is given. +vi.mock('@/components/flagship/report/household/HouseholdBreakdowns', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/components/flagship/report/household/HouseholdBreakdowns') + >()), + default: ({ view, source }: any) => ( +
+ {[view, source.baselinePolicyId, source.reformPolicyId].join(' · ')} +
+ ), +})); +const mockHouseholdValidation = vi.fn(); +vi.mock('@/utils/HouseholdValidation', () => ({ + HouseholdValidation: { isReadyForSimulation: () => mockHouseholdValidation() }, +})); + +function renderReport(props: Partial> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const initialMetadata = metadataReducer(undefined, { type: 'test/init' }); + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...initialMetadata, + economyOptions: { + ...initialMetadata.economyOptions, + time_period: MODEL_YEARS.map((year) => ({ name: year, label: String(year) })), + }, + }, + }, + }); + render( + + + + + , + props.countryId ?? 'us' + ); +} + +async function calculate() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + return user; +} + +/** The household the report sent to the Public API's v1 household route. */ +const createdPayload = (callIndex = 0) => mockCreateHousehold.mock.calls[callIndex][0]; + +describe('HouseholdReportPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHouseholdValidation.mockReturnValue(READY_TO_RUN); + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => + policyMetadata(countryId, policyId) + ); + mockCreateHousehold.mockResolvedValue({ result: { household_id: CREATED_HOUSEHOLD_ID } }); + mockFetchHouseholdCalculation.mockImplementation( + async (_country: string, _household: string, policyId: string) => + policyId === BASELINE_POLICY_ID ? BASELINE_RESULT : REFORM_RESULT + ); + }); + + test('given a household and a reform then the change in its net income is reported', async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household impact' }); + expect( + within(result).getByRole('group', { name: 'Change in annual net income' }) + ).toHaveTextContent('+$1,000'); + expect(mockFetchHouseholdCalculation.mock.calls.map((call) => call[2])).toEqual([ + BASELINE_POLICY_ID, + REFORM_POLICY_ID, + ]); + }); + + test("given no reform then it reports the household's net income under the baseline alone", async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '$72,000' + ); + expect(screen.queryByText('Change in annual net income')).not.toBeInTheDocument(); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(1); + }); + + test('given the reform is the baseline then the report is baseline-only', async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: BASELINE_POLICY_ID }); + + await calculate(); + + expect(await screen.findByRole('region', { name: 'Household net income' })).toBeVisible(); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(1); + }); + + test('given a baseline-only result then its tabs open the breakdowns under the baseline alone', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + const result = await screen.findByRole('region', { name: 'Household net income' }); + + await user.click(within(result).getByRole('tab', { name: 'Marginal tax rates' })); + + expect(within(result).getByLabelText('Breakdown')).toHaveTextContent( + `marginal-tax-rates · ${BASELINE_POLICY_ID} · ${BASELINE_POLICY_ID}` + ); + }); + + test("given a result then the household's inputs, the policy and Reproduce in Python follow it", async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const inputs = await screen.findByRole('region', { name: 'Household inputs' }); + expect(inputs).toHaveTextContent('Population information'); + expect(await screen.findByRole('region', { name: 'Policy' })).toHaveTextContent( + 'Policy information' + ); + const reproduce = await screen.findByRole('region', { name: 'Reproduce in Python' }); + expect(within(reproduce).getByRole('heading', { name: 'Baseline simulation' })).toBeVisible(); + expect(within(reproduce).getByRole('heading', { name: 'Reform simulation' })).toBeVisible(); + // The DataFrame code of issue #1095 is offered here. + expect( + within(reproduce).getByRole('switch', { name: 'Include a pandas DataFrame' }) + ).toBeInTheDocument(); + // Each block is pinned to the .py bundle its own calculation reported. + expect( + within(reproduce).getByRole('region', { name: 'Baseline simulation' }) + ).toHaveTextContent(`policyengine-us ${BASELINE_RUN_BUNDLE.model_version}`); + expect(within(reproduce).getByRole('region', { name: 'Reform simulation' })).toHaveTextContent( + `policyengine-us ${REFORM_RUN_BUNDLE.model_version}` + ); + }); + + test('given both policies then each Python block reproduces its own policy', async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => + policyMetadata(countryId, policyId, policyId === REFORM_POLICY_ID ? REFORM_POLICY_JSON : {}) + ); + renderReport({ countryId: 'uk', reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const reformBlock = await screen.findByRole('region', { name: 'Reform simulation' }); + await waitFor(() => expect(reformBlock).toHaveTextContent(REFORM_PARAMETER)); + expect(screen.getByRole('region', { name: 'Baseline simulation' })).not.toHaveTextContent( + REFORM_PARAMETER + ); + }); + + test("given the baseline's policy failing to load then no Python block is given the reform's policy", async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => { + if (policyId === BASELINE_POLICY_ID) { + throw new Error('Policy could not be loaded'); + } + return policyMetadata(countryId, policyId, REFORM_POLICY_JSON); + }); + renderReport({ countryId: 'uk', reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + expect(await screen.findByText("The report's policies could not be loaded")).toBeVisible(); + const baselineBlock = screen.getByRole('region', { name: 'Baseline simulation' }); + expect(baselineBlock).toHaveTextContent( + "Load this simulation's household and policy to generate its code." + ); + expect(baselineBlock).not.toHaveTextContent(REFORM_PARAMETER); + }); + + test('given a failed calculation then trying again calculates the same household again', async () => { + mockFetchHouseholdCalculation.mockRejectedValueOnce(new Error('Household calculation failed')); + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + + await user.click(await screen.findByRole('button', { name: 'Try again' })); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '$72,000' + ); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(2); + }); + + test('given a failed calculation and the household then edited so it cannot run, trying again asks for the household that failed', async () => { + mockFetchHouseholdCalculation.mockRejectedValueOnce(new Error('Household calculation failed')); + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('button', { name: 'Try again' }); + + mockHouseholdValidation.mockReturnValue(NOT_READY_TO_RUN); + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + await screen.findByRole('region', { name: 'Household net income' }); + expect(mockCreateHousehold).toHaveBeenCalledTimes(2); + expect( + mockCreateHousehold.mock.calls.map((call) => call[0].data.people.you.tip_income) + ).toEqual([undefined, undefined]); + }); + + test('given the household calculated and unchanged then there is nothing new to calculate', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('region', { name: 'Household net income' }); + + expect(screen.getByRole('button', { name: 'Calculate' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + + expect(screen.getByRole('button', { name: 'Calculate' })).toBeEnabled(); + }); + + test.each([ + ['a household', { household: FAMILY_HOUSEHOLD }], + ['a saved household', { savedHousehold: SAVED_FAMILY }], + ])( + "given %s from another year then the report opens it in the report's year", + async (_opening, openingProps) => { + renderReport({ ...openingProps, year: LATER_MODEL_YEAR }); + + await calculate(); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().data.people.you.age).toEqual({ [LATER_MODEL_YEAR]: 30 }); + expect(screen.getByRole('combobox', { name: 'Year' })).toHaveValue(LATER_MODEL_YEAR); + } + ); + + test('given the household edited after its result then the result still describes the household calculated', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + const result = await screen.findByRole('region', { name: 'Household net income' }); + + await user.click(screen.getByRole('button', { name: 'Make single' })); + + expect(result).toHaveTextContent('Married · 2 children'); + expect(result).not.toHaveTextContent('Single'); + }); + + test("given another year then the household's inputs move to it before it runs (issue #498)", async () => { + const user = userEvent.setup(); + renderReport({ household: FAMILY_HOUSEHOLD }); + + expect( + within(screen.getByRole('combobox', { name: 'Year' })) + .getAllByRole('option') + .map((option) => option.textContent) + ).toEqual(['2026', '2027']); + await user.selectOptions(screen.getByRole('combobox', { name: 'Year' }), LATER_MODEL_YEAR); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().data.people.you.age).toEqual({ [LATER_MODEL_YEAR]: 30 }); + }); + + test('given another year after a result then the result clears until the next calculation', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('region', { name: 'Household net income' }); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Year' }), LATER_MODEL_YEAR); + + expect(screen.queryByRole('region', { name: 'Household net income' })).not.toBeInTheDocument(); + }); + + test('given a household with tip income then the earnings variation offers it (issue #1096)', async () => { + const user = userEvent.setup(); + renderReport({ household: FAMILY_HOUSEHOLD }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + const result = await screen.findByRole('region', { name: 'Household net income' }); + await user.click(within(result).getByRole('tab', { name: 'Varying earnings' })); + + expect(within(result).getByLabelText('Breakdown')).toHaveAttribute('data-inputs', 'tip_income'); + }); + + test('given a new US household then it starts on national poverty thresholds', async () => { + renderReport(); + + await calculate(); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().spm).toEqual({ geography_kind: 'national' }); + }); + + test('given a UK household and no reform then it runs in the UK and reads in pounds', async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + renderReport({ countryId: 'uk' }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '£24,940' + ); + expect(mockFetchHouseholdCalculation.mock.calls[0][0]).toBe('uk'); + expect(createdPayload().spm).toBeUndefined(); + }); + + describe('saved households', () => { + /** Signed in, with the family saved; `moreRoutes` answers the saves a test makes. */ + function signedInWithFamily(moreRoutes: Parameters[1] = {}) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + return routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([userHouseholdItem()]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + ...moreRoutes, + }); + } + + test('given a new household saved then the report says it is saved, with nothing left to save', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + renderReport({ household: FAMILY_HOUSEHOLD }); + + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + expect(await screen.findByText('Saved')).toHaveAttribute('role', 'status'); + // The stored household reads back with the same content. + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given a saved household chosen while a save is still running then the finished save does not take its place', async () => { + const user = userEvent.setup(); + let finishStoring: (response: Response) => void = () => {}; + const requests = signedInWithFamily({ + 'POST /v2/households': () => + new Promise((resolve) => { + finishStoring = resolve; + }), + 'POST /v2/user-households': () => + itemResponse( + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + name: RETIRED_LABEL, + }), + 201 + ), + }); + renderReport({ household: RETIRED_HOUSEHOLD }); + await user.type(screen.getByRole('textbox', { name: 'Household label' }), RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + finishStoring( + itemResponse(v2HouseholdItem(RETIRED_HOUSEHOLD, V2_HOUSEHOLD_IDS.RETIRED), 201) + ); + + // The finished save's request for its saved row has gone out and been answered. + await waitFor(() => + expect( + requests.some( + (request) => request.method === 'POST' && request.path === '/v2/user-households' + ) + ).toBe(true) + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.getByRole('button', { name: new RegExp(FAMILY_LABEL) })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given an edited saved household saved then it points at the edit and keeps its id', async () => { + const user = userEvent.setup(); + const requests = signedInWithFamily({ + 'POST /v2/households': (request) => + itemResponse(storedV2HouseholdItem(request.body, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY })), + }); + renderReport({ savedHousehold: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Saved')).toHaveAttribute('role', 'status'); + expect(requests.find((request) => request.method === 'PATCH')?.path).toBe( + `/v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}` + ); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given the edit cannot be saved then the report says the changes were not saved', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'POST /v2/households': () => + itemResponse( + v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), + 201 + ), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + renderReport({ savedHousehold: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Your changes were not saved'); + }); + + test('given a saved household chosen then the builder holds it, and saving edits it', async () => { + const user = userEvent.setup(); + signedInWithFamily(); + renderReport(); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('single · 0'); + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given a saved household with inputs in two years then the picker opens it in its own year, saying why', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'GET /v2/user-households': () => + pageResponse([userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY })]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY}`]: () => + itemResponse( + v2HouseholdItem(TWO_YEAR_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY) + ), + }); + renderReport({ year: LATER_MODEL_YEAR }); + + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('combobox', { name: 'Year' })).toHaveValue(HOUSEHOLD_YEAR); + expect(screen.getByRole('alert')).toHaveTextContent('inputs for multiple years'); + }); + + test('given a calculation running then no saved household can be opened, as the builder and year cannot change', async () => { + const user = userEvent.setup(); + signedInWithFamily(); + mockFetchHouseholdCalculation.mockReturnValue(new Promise(() => {})); + renderReport({ household: RETIRED_HOUSEHOLD }); + const familyOption = await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) }); + + await user.click(screen.getByRole('button', { name: 'Calculate' })); + + await waitFor(() => expect(familyOption).toBeDisabled()); + expect(screen.getByRole('combobox', { name: 'Year' })).toBeDisabled(); + }); + + test('given a saved household opened in another year then moving it is not an edit, and a new label only renames it', async () => { + const user = userEvent.setup(); + const requests = signedInWithFamily({ + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: (request) => + itemResponse(userHouseholdItem({ name: request.body.name })), + }); + renderReport({ savedHousehold: SAVED_FAMILY, year: LATER_MODEL_YEAR }); + + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + const label = screen.getByRole('textbox', { name: 'Household label' }); + await user.clear(label); + await user.type(label, RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Saved')).toHaveAttribute('role', 'status'); + const writes = requests.filter((request) => request.method !== 'GET'); + expect(writes.map((request) => `${request.method} ${request.path}`)).toEqual([ + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(writes[0].body).toEqual({ name: RETIRED_LABEL }); + }); + + test('given the report opened from a saved household then it is the one the save form edits', () => { + signedInWithFamily(); + renderReport({ savedHousehold: SAVED_FAMILY }); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + }); + + test('given no accounts then saved households are not offered', () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + + expect(screen.getAllByText('Saved households are not available here.')).toHaveLength(2); + expect(screen.queryByRole('textbox', { name: 'Household label' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx b/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx new file mode 100644 index 000000000..ed48fe91c --- /dev/null +++ b/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx @@ -0,0 +1,203 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SavedHouseholdsPage from '@/pages/flagship/SavedHouseholds.page'; +import { + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V1_ONLY_LABEL, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +const SAVED_WORK_UNAVAILABLE = 'Saved work is temporarily unavailable.'; + +const v1OnlyRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + name: V1_ONLY_LABEL, +}); + +/** + * The account holds these saved rows and the family's household; a rename + * or delete changes what the list reads next, unless `failing` says that + * request fails. + */ +function signedInWith( + initialRows: ReturnType[], + failing: { rename?: boolean; remove?: boolean } = {} +) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + let rows = initialRows; + const rowRoutes = Object.fromEntries( + initialRows.flatMap((row) => [ + [ + `PATCH /v2/user-households/${row.id}`, + (request: { body: { name: string } }) => { + if (failing.rename) { + return errorResponse(503, SAVED_WORK_UNAVAILABLE); + } + rows = rows.map((other) => + other.id === row.id ? { ...other, name: request.body.name } : other + ); + return itemResponse(rows.find((other) => other.id === row.id)); + }, + ], + [ + `DELETE /v2/user-households/${row.id}`, + () => { + if (failing.remove) { + return errorResponse(503, SAVED_WORK_UNAVAILABLE); + } + rows = rows.filter((other) => other.id !== row.id); + return noContentResponse(); + }, + ], + ]) + ); + return routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + ...rowRoutes, + }); +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); +} + +async function renameTo(row: HTMLElement, label: string) { + const user = userEvent.setup(); + await user.click(within(row).getByRole('button', { name: 'Rename' })); + const nameField = await screen.findByRole('textbox'); + await user.clear(nameField); + await user.type(nameField, label); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Rename' })); +} + +describe('SavedHouseholdsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given saved households then each is listed with who it describes', async () => { + signedInWith([userHouseholdItem()]); + renderPage(); + + expect(screen.getByRole('heading', { name: 'Your households' })).toBeInTheDocument(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + expect(row).toHaveTextContent('Married · 2 children'); + }); + + test('given Rename then the saved household takes the new label', async () => { + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + + await renameTo(await screen.findByRole('listitem', { name: FAMILY_LABEL }), RETIRED_LABEL); + + expect(await screen.findByRole('listitem', { name: RETIRED_LABEL })).toBeInTheDocument(); + expect(requests.find((request) => request.method === 'PATCH')?.body).toEqual({ + name: RETIRED_LABEL, + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + test('given a rename that fails then the dialog stays open and says why', async () => { + signedInWith([userHouseholdItem()], { rename: true }); + renderPage(); + + await renameTo(await screen.findByRole('listitem', { name: FAMILY_LABEL }), RETIRED_LABEL); + + expect( + await within(screen.getByRole('dialog')).findByText(SAVED_WORK_UNAVAILABLE) + ).toBeInTheDocument(); + // The open dialog hides the page behind it from assistive technology. + expect(screen.getByRole('listitem', { name: FAMILY_LABEL, hidden: true })).toBeInTheDocument(); + }); + + test('given Delete, once confirmed, then the saved household leaves the list', async () => { + const user = userEvent.setup(); + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + expect(requests.some((request) => request.method === 'DELETE')).toBe(false); + await user.click(await screen.findByRole('button', { name: 'Delete household' })); + + await waitFor(() => + expect(screen.queryByRole('listitem', { name: FAMILY_LABEL })).not.toBeInTheDocument() + ); + expect(screen.getByText('No saved households yet.')).toBeInTheDocument(); + }); + + test('given Delete, then Cancel, then nothing is deleted', async () => { + const user = userEvent.setup(); + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(requests.some((request) => request.method === 'DELETE')).toBe(false); + expect(screen.getByRole('listitem', { name: FAMILY_LABEL })).toBeInTheDocument(); + }); + + test('given a delete that fails then it says which household and why, and keeps it', async () => { + const user = userEvent.setup(); + signedInWith([userHouseholdItem()], { remove: true }); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + await user.click(await screen.findByRole('button', { name: 'Delete household' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + `“${FAMILY_LABEL}” could not be deleted. ${SAVED_WORK_UNAVAILABLE}` + ); + expect(screen.getByRole('listitem', { name: FAMILY_LABEL })).toBeInTheDocument(); + }); + + test('given a saved household that cannot open then it is listed, and can be renamed or deleted', async () => { + signedInWith([userHouseholdItem(), v1OnlyRow]); + renderPage(); + + const row = await screen.findByRole('listitem', { name: V1_ONLY_LABEL }); + expect(row).toHaveTextContent('Cannot be opened here'); + expect(within(row).getByRole('button', { name: 'Rename' })).toBeEnabled(); + expect(within(row).getByRole('button', { name: 'Delete' })).toBeEnabled(); + }); + + test('given no accounts then it says saved households are not available', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderPage(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx new file mode 100644 index 000000000..9cd31d4f5 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx @@ -0,0 +1,123 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import PopulationPicker from '@/components/flagship/reform/PopulationPicker'; +import * as householdEntryPoints from '@/pages/flagship/householdEntryPoints'; + +/** + * v3's standalone household report and saved households work only on the + * Public API's v2 routes, so until v3 moves to them nothing a user can + * reach leads there. These checks keep it so: the change that moves v3 + * changes them on purpose. (A report whose simulations are households + * still shows "This report cannot open here": see ReportLoadStates.test.tsx.) + */ + +/** app/src, whether the tests run from the app's directory or the repository's. */ +const APP_SOURCE = existsSync(resolve(process.cwd(), 'src/pages/flagship')) + ? resolve(process.cwd(), 'src') + : resolve(process.cwd(), 'app/src'); +const CALCULATOR_SOURCE = resolve(APP_SOURCE, '../../calculator-app/src'); + +/** The modules only the household features use, by the specifier the app imports them with. */ +const UNROUTED_MODULES = [ + '@/pages/flagship/householdEntryPoints', + '@/pages/flagship/HouseholdReport.page', + '@/pages/flagship/SavedHouseholds.page', + '@/components/flagship/household/SaveHouseholdForm', + '@/components/flagship/household/SavedHouseholdPicker', + '@/components/flagship/household/SavedHouseholdsList', + '@/components/flagship/household/savedHouseholdDisplay', + '@/components/flagship/household/HouseholdNetIncomeResult', + '@/hooks/useSavedHouseholds', + '@/api/savedHouseholdStore', + '@/models/household/v2DocumentCodec', +]; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === 'tests' || entry.name === 'node_modules' ? [] : sourceFiles(path); + } + return /\.(ts|tsx)$/.test(entry.name) ? [path] : []; + }); +} + +/** The app specifier a file under app/src is imported by: "@/api/savedHouseholdStore". */ +function specifierOf(path: string): string | null { + if (!path.startsWith(APP_SOURCE)) { + return null; + } + return `@/${relative(APP_SOURCE, path).replace(/\.(ts|tsx)$/, '')}`; +} + +/** Every module a source file names: imports (static, dynamic, side effect), re-exports, requires. */ +function importedSpecifiers(source: string): string[] { + const specifierPattern = + /(?:\bfrom\s+|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"])([^'"]+)\1/g; + return [...source.matchAll(specifierPattern)].map((match) => match[2]); +} + +/** + * The app specifier an import names, however it is written: "@/…" as is, + * and a relative path ("./HouseholdReport.page") resolved from the + * importing file. Package imports name no app module. + */ +function appSpecifierOf(importer: string, specifier: string): string | null { + if (specifier.startsWith('@/')) { + return specifier.replace(/\.(ts|tsx)$/, ''); + } + if (specifier.startsWith('.')) { + return specifierOf(resolve(dirname(importer), specifier)); + } + return null; +} + +/** Whether a source file names one of the household modules, by either specifier form. */ +function importsAHouseholdModule(importer: string, source: string): boolean { + return importedSpecifiers(source).some((specifier) => + UNROUTED_MODULES.includes(appSpecifierOf(importer, specifier) ?? '') + ); +} + +describe('household entry points before v3 moves to the v2 routes', () => { + test('given Build\'s population picker then "A household" is listed, but cannot be chosen', async () => { + const user = userEvent.setup(); + render( {}} regions={[]} countryId="us" />); + + await user.click(screen.getByRole('button', { name: /Population/ })); + + expect(screen.getByRole('option', { name: /A household/ })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + test('given the app and calculator sources then only the household modules import one another', () => { + const importers = [...sourceFiles(APP_SOURCE), ...sourceFiles(CALCULATOR_SOURCE)] + .filter((path) => !UNROUTED_MODULES.includes(specifierOf(path) ?? '')) + .filter((path) => importsAHouseholdModule(path, readFileSync(path, 'utf8'))) + .map((path) => relative(resolve(APP_SOURCE, '../..'), path)); + + expect(importers).toEqual([]); + }); + + test.each([ + ['an @/ specifier', "import HouseholdReportPage from '@/pages/flagship/HouseholdReport.page';"], + ['a relative path', "import HouseholdReportPage from './HouseholdReport.page';"], + ])('given a routed page importing the report by %s then the check finds it', (_form, source) => { + const routedPage = resolve(APP_SOURCE, 'pages/flagship/Report.page.tsx'); + + expect(importsAHouseholdModule(routedPage, source)).toBe(true); + }); + + test('given the entry points then the report, the saved households page, the picker and the save form are gathered there', () => { + expect(Object.keys(householdEntryPoints).sort()).toEqual([ + 'HouseholdReportPage', + 'SaveHouseholdForm', + 'SavedHouseholdPicker', + 'SavedHouseholdsPage', + ]); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx b/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx new file mode 100644 index 000000000..ec419a730 --- /dev/null +++ b/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx @@ -0,0 +1,75 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { Provider } from 'react-redux'; +import { describe, expect, test, vi } from 'vitest'; +import EarningsVariationSubPage from '@/pages/report-output/earnings-variation/EarningsVariationSubPage'; +import metadataReducer from '@/reducers/metadataReducer'; +import { + REPORT_YEAR, + TIPS_POINT_OUTPUT, + TIPS_VARIATION, + VARIATION_METADATA_VARIABLES, +} from '@/tests/fixtures/libs/flagship/householdReportMocks'; + +vi.mock('@/hooks/useReportYear', () => ({ useReportYear: () => REPORT_YEAR })); +// The chart is tested on its own; here only the variable choice matters. +vi.mock('@/pages/report-output/earnings-variation/BaselineOnlyChart', () => ({ + default: () =>

Earnings chart

, +})); + +function renderSubPage(alsoOfferInputs?: string[]) { + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...metadataReducer(undefined, { type: 'test/init' }), + currentCountry: 'us', + version: 'test-model', + variables: VARIATION_METADATA_VARIABLES as any, + entities: { person: { plural: 'people' }, household: { plural: 'households' } } as any, + }, + }, + }); + render( + + + + + + ); +} + +/** The variables the chart's variable menu offers. */ +async function offeredVariables() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Net income' })); + return screen.getAllByRole('button').map((button) => button.textContent ?? ''); +} + +describe('EarningsVariationSubPage variables (issue #1096)', () => { + test('given no inputs to offer then only calculated variables are offered, as before', async () => { + renderSubPage(); + + const offered = await offeredVariables(); + + expect(offered.some((text) => text.includes('Net income'))).toBe(true); + expect(offered.some((text) => text.includes('Tip income'))).toBe(false); + }); + + test('given inputs the household sets then they are offered beside the calculated variables', async () => { + renderSubPage(['tip_income']); + + const offered = await offeredVariables(); + + expect(offered.some((text) => text.includes('Tip income'))).toBe(true); + // An input the household was not asked to offer is still left out. + expect(offered.some((text) => text.startsWith('Age'))).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx b/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx index 9e15fb637..44f9e23e2 100644 --- a/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx +++ b/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx @@ -171,4 +171,26 @@ describe('HouseholdReproducibility', () => { expect(screen.getByText(new RegExp(MOCK_REPORT_YEAR))).toBeInTheDocument(); }); }); + + describe('given the pandas DataFrame option (issue #1095)', () => { + test('then a switch adds DataFrame code to each block', async () => { + const user = userEvent.setup(); + render( + + ); + + expect(screen.queryByText(/pd\.DataFrame/)).not.toBeInTheDocument(); + await user.click(screen.getByRole('switch', { name: 'Include a pandas DataFrame' })); + + expect(screen.getByText(/pd\.DataFrame/)).toBeInTheDocument(); + }); + + test('then without it there is no DataFrame switch, as before', () => { + render(); + + expect( + screen.queryByRole('switch', { name: 'Include a pandas DataFrame' }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/app/src/tests/unit/utils/reproducibilityCode.test.ts b/app/src/tests/unit/utils/reproducibilityCode.test.ts index 4ef1e17b7..234daef27 100644 --- a/app/src/tests/unit/utils/reproducibilityCode.test.ts +++ b/app/src/tests/unit/utils/reproducibilityCode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest'; import { POPULACE_US_DEFAULT_DATASET_URI } from '@/constants'; import { TEST_COUNTRIES } from '@/tests/fixtures/constants'; +import { DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS } from '@/tests/fixtures/pages/report-output/reproduce-in-python/reproducibilityMocks'; import { BASELINE_AND_REFORM_POLICY, COMPLEX_HOUSEHOLD, @@ -25,6 +26,7 @@ import { import { convertPoliciesToV1Format, getColabLink, + getHouseholdReproducibilityCode, getReproducibilityCodeBlock, sanitizeStringToPython, } from '@/utils/reproducibilityCode'; @@ -895,3 +897,47 @@ describe('reproducibilityCode', () => { }); }); }); + +describe('getHouseholdReproducibilityCode with a pandas DataFrame (issue #1095)', () => { + const reproduction = DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS.simulations[0]; + const year = Number(DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS.year); + + test('given a point calculation then a DataFrame of the household results follows the code', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + includeDataFrame: true, + }).join('\n'); + + expect(code).toContain('import pandas as pd'); + expect(code).toContain( + 'variables = ["household_net_income", "household_market_income", "household_tax", "household_benefits"]' + ); + expect(code).toContain( + `"value": [float(simulation.calculate(variable, ${year}).sum()) for variable in variables],` + ); + expect(code.trimEnd().endsWith('print(df)')).toBe(true); + }); + + test('given an earnings variation then the DataFrame has one row per earnings level, ready to chart', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + earningVariation: true, + includeDataFrame: true, + }).join('\n'); + + expect(code).toContain('axes'); + expect(code).toContain( + `"employment_income": simulation.calculate("employment_income", ${year}, map_to="household"),` + ); + expect(code).toContain( + `"household_net_income": simulation.calculate("household_net_income", ${year}),` + ); + }); + + test('given no DataFrame asked for then the code is as before', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + earningVariation: true, + }).join('\n'); + + expect(code).toContain('# Reform earning variation'); + expect(code).not.toContain('pandas'); + }); +}); diff --git a/app/src/types/v2Households.ts b/app/src/types/v2Households.ts new file mode 100644 index 000000000..a5fc3ee8f --- /dev/null +++ b/app/src/types/v2Households.ts @@ -0,0 +1,98 @@ +import type { SPMSelection } from '@/types/spm'; +import type { AssociationCountryId } from '@/types/v2User'; + +/** + * Wire types for the Public API's v2 household routes: immutable, + * deduplicated household content (`/v2/households`) and the user's saved + * households that point at it (`/v2/user-households`). Field names are + * snake_case, as on the wire. + */ + +/** One group in a household (a tax unit, a benefit unit…): its id and its values. */ +export interface V2HouseholdEntityRecord { + /** Unique within its collection; people's memberships refer to it. */ + id: string; + /** The group's name in the v1 household it came from ("your tax unit"). */ + source_name?: string | null; + /** Variable name → value, each keyed by period as in a v1 household. */ + values: Record; +} + +/** One person: their values, and the group they belong to in every collection. */ +export interface V2HouseholdPersonRecord extends V2HouseholdEntityRecord { + /** Collection ("tax_unit") → the id of the person's group in it; names every collection. */ + memberships: Record; +} + +export interface V2USHouseholdDocument { + people: V2HouseholdPersonRecord[]; + household: V2HouseholdEntityRecord[]; + family: V2HouseholdEntityRecord[]; + tax_unit: V2HouseholdEntityRecord[]; + spm_unit: V2HouseholdEntityRecord[]; + marital_unit: V2HouseholdEntityRecord[]; + /** The SPM settings; left out (never null) when none are chosen. */ + spm?: SPMSelection; +} + +export interface V2UKHouseholdDocument { + people: V2HouseholdPersonRecord[]; + household: V2HouseholdEntityRecord[]; + benunit: V2HouseholdEntityRecord[]; +} + +export type V2HouseholdDocument = V2USHouseholdDocument | V2UKHouseholdDocument; + +/** `POST /v2/households`: strict on the server, with no label or other presentation field. */ +export interface V2HouseholdCreateBody { + country_id: AssociationCountryId; + /** The year that values without an explicit period are read in (1900–2200). */ + default_year: number; + household_data: V2HouseholdDocument; +} + +/** A stored household. Creating identical content returns the existing one, with its id. */ +export interface V2HouseholdItem { + id: string; + country_id: AssociationCountryId; + default_year: number | null; + household_data: V2HouseholdDocument; + created_at: string; + updated_at: string; +} + +/** A household the user saved: their name for it, and the household it points at. */ +export interface UserHouseholdItem { + id: string; + country_id: AssociationCountryId; + user_id: string; + /** The v2 household; null for a row that only names a v1 household. */ + household_id: string | null; + legacy_household_id: number | null; + name: string | null; + description: string | null; + created_at: string; + updated_at: string; +} + +/** `POST /v2/user-households`: a v2 household_id, a v1 legacy_household_id, or both. */ +export interface UserHouseholdCreateBody { + country_id: AssociationCountryId; + household_id?: string | null; + legacy_household_id?: number | null; + name?: string | null; + description?: string | null; +} + +/** + * `PATCH /v2/user-households/{id}`: only the supplied fields change. + * `household_id` points the saved household at another stored household, + * keeping its id, name and creation date; the Public API checks that the new + * household is in the saved household's country. + */ +export interface UserHouseholdPatchBody { + household_id?: string; + legacy_household_id?: number; + name?: string | null; + description?: string | null; +} diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index 0dd923f3a..f19acc742 100644 --- a/app/src/utils/reproducibilityCode.ts +++ b/app/src/utils/reproducibilityCode.ts @@ -420,12 +420,69 @@ export function getHouseholdReproductionUnavailableReason( return null; } +/** The household results a reproduction's DataFrame holds: each is in the US and UK models. */ +const DATAFRAME_HOUSEHOLD_VARIABLES = [ + 'household_net_income', + 'household_market_income', + 'household_tax', + 'household_benefits', +] as const; + +/** + * Code that puts the simulation's results in a pandas DataFrame, for + * charts and further analysis (issue #1095). A point calculation gives one + * row per result; an earnings variation gives one row per earnings level, + * with the household's earnings beside its results. + */ +function getDataFrameCode(year: number, earningVariation: boolean): string[] { + if (!earningVariation) { + const variables = DATAFRAME_HOUSEHOLD_VARIABLES.map((name) => `"${name}"`).join(', '); + return [ + '', + '# The household results as a pandas DataFrame, for charts and analysis.', + 'import pandas as pd', + '', + `variables = [${variables}]`, + 'df = pd.DataFrame(', + ' {', + ' "variable": variables,', + ` "value": [float(simulation.calculate(variable, ${year}).sum()) for variable in variables],`, + ' }', + ')', + 'print(df)', + ]; + } + return [ + '', + '# The results at each earnings level as a pandas DataFrame, ready to chart.', + 'import pandas as pd', + '', + 'df = pd.DataFrame(', + ' {', + ` "employment_income": simulation.calculate("employment_income", ${year}, map_to="household"),`, + ...DATAFRAME_HOUSEHOLD_VARIABLES.map( + (name) => ` "${name}": simulation.calculate("${name}", ${year}),` + ), + ' }', + ')', + 'print(df)', + ]; +} + +/** What a household reproduction block covers beyond the household at its own earnings. */ +export interface HouseholdReproductionOptions { + /** Calculates the household across a range of the first adult's earnings. */ + earningVariation?: boolean; + /** Ends the block with code that puts its results in a pandas DataFrame. */ + includeDataFrame?: boolean; +} + /** Each block runs in its own notebook so different resolved versions stay isolated. */ export function getHouseholdReproducibilityCode( countryId: string, reproduction: HouseholdReproduction, year: number | null, - earningVariation: boolean = false + { earningVariation = false, includeDataFrame = false }: HouseholdReproductionOptions = {} ): string[] { const { role, household, policy, policyengineVersion, modelVersion, spmConfig } = reproduction; if (getHouseholdReproductionUnavailableReason(countryId, reproduction, year)) { @@ -474,6 +531,7 @@ export function getHouseholdReproducibilityCode( role, spmConfig ), + ...(includeDataFrame ? getDataFrameCode(year!, earningVariation) : []), ]; }