From 0e65cb0f35c545825a0876e29ec38a6bd0c3723c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Sat, 15 Nov 2025 00:07:42 +0100 Subject: [PATCH 01/49] feat: Types for pathway structure --- app/src/types/pathway.ts | 59 +++++++++++++++++++ app/src/types/pathwayModes/PolicyViewMode.ts | 13 ++++ .../types/pathwayModes/PopulationViewMode.ts | 15 +++++ app/src/types/pathwayModes/ReportViewMode.ts | 48 +++++++++++++++ .../types/pathwayModes/SimulationViewMode.ts | 36 +++++++++++ .../types/pathwayState/PolicyStateProps.ts | 15 +++++ .../pathwayState/PopulationStateProps.ts | 20 +++++++ .../types/pathwayState/ReportStateProps.ts | 27 +++++++++ .../pathwayState/SimulationStateProps.ts | 25 ++++++++ app/src/types/pathwayState/index.ts | 11 ++++ .../pathwayState/initializePolicyState.ts | 16 +++++ .../pathwayState/initializePopulationState.ts | 17 ++++++ .../pathwayState/initializeReportState.ts | 26 ++++++++ .../pathwayState/initializeSimulationState.ts | 25 ++++++++ 14 files changed, 353 insertions(+) create mode 100644 app/src/types/pathway.ts create mode 100644 app/src/types/pathwayModes/PolicyViewMode.ts create mode 100644 app/src/types/pathwayModes/PopulationViewMode.ts create mode 100644 app/src/types/pathwayModes/ReportViewMode.ts create mode 100644 app/src/types/pathwayModes/SimulationViewMode.ts create mode 100644 app/src/types/pathwayState/PolicyStateProps.ts create mode 100644 app/src/types/pathwayState/PopulationStateProps.ts create mode 100644 app/src/types/pathwayState/ReportStateProps.ts create mode 100644 app/src/types/pathwayState/SimulationStateProps.ts create mode 100644 app/src/types/pathwayState/index.ts create mode 100644 app/src/utils/pathwayState/initializePolicyState.ts create mode 100644 app/src/utils/pathwayState/initializePopulationState.ts create mode 100644 app/src/utils/pathwayState/initializeReportState.ts create mode 100644 app/src/utils/pathwayState/initializeSimulationState.ts diff --git a/app/src/types/pathway.ts b/app/src/types/pathway.ts new file mode 100644 index 00000000..a13a6da7 --- /dev/null +++ b/app/src/types/pathway.ts @@ -0,0 +1,59 @@ +/** + * Pathway Types - Base interfaces for PathwayWrapper components + * + * These interfaces define the common props and patterns used across all + * PathwayWrapper implementations (Report, Simulation, Policy, Population). + */ + +/** + * PathwayWrapperProps - Base props for all PathwayWrapper components + * + * All PathwayWrappers accept: + * - countryId: Determines which API and metadata to use + * - onComplete: Optional callback when pathway successfully completes + * - onCancel: Optional callback when user cancels pathway + */ +export interface PathwayWrapperProps { + countryId: string; + onComplete?: () => void; + onCancel?: () => void; +} + +/** + * ViewProps - Generic interface for view components within a pathway + * + * Views are stateless display components that receive: + * - state: Current state of the pathway (type varies by pathway) + * - currentMode: Current view mode enum value + * - onNavigate: Function to navigate to a different mode + * - onUpdateState: Function to update pathway state + * - onComplete: Optional callback to complete the pathway + * - onCancel: Optional callback to cancel the pathway + * + * @template TState - The state props type (e.g., ReportStateProps) + * @template TMode - The view mode enum type (e.g., ReportViewMode) + */ +export interface ViewProps { + state: TState; + currentMode: TMode; + onNavigate: (mode: TMode) => void; + onUpdateState: (updates: Partial) => void; + onComplete?: () => void; + onCancel?: () => void; +} + +/** + * PathwayWrapperState - Generic internal state structure for PathwayWrappers + * + * While PathwayWrappers use useState/useReducer internally, this interface + * documents the common pattern of tracking both the ingredient state and + * the current view mode. + * + * @template TState - The state props type (e.g., ReportStateProps) + * @template TMode - The view mode enum type (e.g., ReportViewMode) + */ +export interface PathwayWrapperState { + ingredientState: TState; + currentMode: TMode; + modeHistory?: TMode[]; // Optional: for back button navigation +} diff --git a/app/src/types/pathwayModes/PolicyViewMode.ts b/app/src/types/pathwayModes/PolicyViewMode.ts new file mode 100644 index 00000000..c206cba7 --- /dev/null +++ b/app/src/types/pathwayModes/PolicyViewMode.ts @@ -0,0 +1,13 @@ +/** + * PolicyViewMode - Enum for policy creation pathway view states + * + * Maps to the frames in PolicyCreationFlow: + * - LABEL: PolicyCreationFrame (enter policy name) + * - PARAMETER_SELECTOR: PolicyParameterSelectorFrame (select and configure parameters) + * - SUBMIT: PolicySubmitFrame (review and submit policy) + */ +export enum PolicyViewMode { + LABEL = 'LABEL', + PARAMETER_SELECTOR = 'PARAMETER_SELECTOR', + SUBMIT = 'SUBMIT', +} diff --git a/app/src/types/pathwayModes/PopulationViewMode.ts b/app/src/types/pathwayModes/PopulationViewMode.ts new file mode 100644 index 00000000..8d0de7c5 --- /dev/null +++ b/app/src/types/pathwayModes/PopulationViewMode.ts @@ -0,0 +1,15 @@ +/** + * PopulationViewMode - Enum for population creation pathway view states + * + * Maps to the frames in PopulationCreationFlow: + * - SCOPE: SelectGeographicScopeFrame (choose household vs geographic scope) + * - LABEL: SetPopulationLabelFrame (enter population name) + * - HOUSEHOLD_BUILDER: HouseholdBuilderFrame (configure household members) + * - GEOGRAPHIC_CONFIRM: GeographicConfirmationFrame (confirm geographic population) + */ +export enum PopulationViewMode { + SCOPE = 'SCOPE', + LABEL = 'LABEL', + HOUSEHOLD_BUILDER = 'HOUSEHOLD_BUILDER', + GEOGRAPHIC_CONFIRM = 'GEOGRAPHIC_CONFIRM', +} diff --git a/app/src/types/pathwayModes/ReportViewMode.ts b/app/src/types/pathwayModes/ReportViewMode.ts new file mode 100644 index 00000000..ae35d1d8 --- /dev/null +++ b/app/src/types/pathwayModes/ReportViewMode.ts @@ -0,0 +1,48 @@ +/** + * ReportViewMode - Enum for report creation pathway view states + * + * Maps to the frames in ReportCreationFlow, PLUS inline simulation/policy/population setup. + * Following Option A (inline sub-pathways), this enum includes ALL views for the + * complete report creation experience. + * + * Grouped by pathway level: + * - Report-level views (LABEL, SETUP, SUBMIT) + * - Simulation setup views (inline, replaces SimulationCreationFlow subflow) + * - Policy setup views (inline, nested within simulation setup) + * - Population setup views (inline, nested within simulation setup) + * - Selection views (for loading existing ingredients) + * + * Note: This enum is large (~20+ modes) which is acceptable per the plan's Option A. + */ +export enum ReportViewMode { + // ========== Report-level views ========== + LABEL = 'LABEL', // ReportCreationFrame + SETUP = 'SETUP', // ReportSetupFrame (shows two simulation cards) + SUBMIT = 'SUBMIT', // ReportSubmitFrame + + // ========== Simulation selection/creation views ========== + SELECT_SIMULATION = 'SELECT_SIMULATION', // ReportSelectSimulationFrame (create new vs load existing) + SELECT_EXISTING_SIMULATION = 'SELECT_EXISTING_SIMULATION', // ReportSelectExistingSimulationFrame + + // ========== Simulation setup views (inline) ========== + SIMULATION_LABEL = 'SIMULATION_LABEL', // SimulationCreationFrame + SIMULATION_SETUP = 'SIMULATION_SETUP', // SimulationSetupFrame (choose policy/population) + SIMULATION_SUBMIT = 'SIMULATION_SUBMIT', // SimulationSubmitFrame + + // ========== Policy setup views (inline, nested in simulation) ========== + POLICY_LABEL = 'POLICY_LABEL', // PolicyCreationFrame + POLICY_PARAMETER_SELECTOR = 'POLICY_PARAMETER_SELECTOR', // PolicyParameterSelectorFrame + POLICY_SUBMIT = 'POLICY_SUBMIT', // PolicySubmitFrame + SELECT_EXISTING_POLICY = 'SELECT_EXISTING_POLICY', // SimulationSelectExistingPolicyFrame + + // ========== Population setup views (inline, nested in simulation) ========== + POPULATION_SCOPE = 'POPULATION_SCOPE', // SelectGeographicScopeFrame + POPULATION_LABEL = 'POPULATION_LABEL', // SetPopulationLabelFrame + POPULATION_HOUSEHOLD_BUILDER = 'POPULATION_HOUSEHOLD_BUILDER', // HouseholdBuilderFrame + POPULATION_GEOGRAPHIC_CONFIRM = 'POPULATION_GEOGRAPHIC_CONFIRM', // GeographicConfirmationFrame + SELECT_EXISTING_POPULATION = 'SELECT_EXISTING_POPULATION', // SimulationSelectExistingPopulationFrame + + // ========== Setup coordination views (nested in simulation) ========== + SETUP_POLICY = 'SETUP_POLICY', // SimulationSetupPolicyFrame (create new vs load existing choice) + SETUP_POPULATION = 'SETUP_POPULATION', // SimulationSetupPopulationFrame (create new vs load existing choice) +} diff --git a/app/src/types/pathwayModes/SimulationViewMode.ts b/app/src/types/pathwayModes/SimulationViewMode.ts new file mode 100644 index 00000000..f40447f5 --- /dev/null +++ b/app/src/types/pathwayModes/SimulationViewMode.ts @@ -0,0 +1,36 @@ +/** + * SimulationViewMode - Enum for simulation creation pathway view states + * + * Maps to the frames in SimulationCreationFlow, PLUS inline policy and population setup. + * Following Option A (inline sub-pathways), this enum includes ALL views for the + * complete simulation creation experience. + * + * Grouped by pathway level: + * - Simulation-level views (LABEL, SETUP, SUBMIT) + * - Policy setup views (inline, replaces PolicyCreationFlow subflow) + * - Population setup views (inline, replaces PopulationCreationFlow subflow) + * - Selection views (for loading existing ingredients) + */ +export enum SimulationViewMode { + // ========== Simulation-level views ========== + LABEL = 'LABEL', // SimulationCreationFrame + SETUP = 'SETUP', // SimulationSetupFrame (choose policy/population) + SUBMIT = 'SUBMIT', // SimulationSubmitFrame + + // ========== Policy setup views (inline) ========== + POLICY_LABEL = 'POLICY_LABEL', // PolicyCreationFrame + POLICY_PARAMETER_SELECTOR = 'POLICY_PARAMETER_SELECTOR', // PolicyParameterSelectorFrame + POLICY_SUBMIT = 'POLICY_SUBMIT', // PolicySubmitFrame + SELECT_EXISTING_POLICY = 'SELECT_EXISTING_POLICY', // SimulationSelectExistingPolicyFrame + + // ========== Population setup views (inline) ========== + POPULATION_SCOPE = 'POPULATION_SCOPE', // SelectGeographicScopeFrame + POPULATION_LABEL = 'POPULATION_LABEL', // SetPopulationLabelFrame + POPULATION_HOUSEHOLD_BUILDER = 'POPULATION_HOUSEHOLD_BUILDER', // HouseholdBuilderFrame + POPULATION_GEOGRAPHIC_CONFIRM = 'POPULATION_GEOGRAPHIC_CONFIRM', // GeographicConfirmationFrame + SELECT_EXISTING_POPULATION = 'SELECT_EXISTING_POPULATION', // SimulationSelectExistingPopulationFrame + + // ========== Setup coordination views ========== + SETUP_POLICY = 'SETUP_POLICY', // SimulationSetupPolicyFrame (create new vs load existing choice) + SETUP_POPULATION = 'SETUP_POPULATION', // SimulationSetupPopulationFrame (create new vs load existing choice) +} diff --git a/app/src/types/pathwayState/PolicyStateProps.ts b/app/src/types/pathwayState/PolicyStateProps.ts new file mode 100644 index 00000000..d0b6046c --- /dev/null +++ b/app/src/types/pathwayState/PolicyStateProps.ts @@ -0,0 +1,15 @@ +import { Parameter } from '@/types/subIngredients/parameter'; + +/** + * PolicyStateProps - Local state interface for policy within PathwayWrapper + * + * Replaces Redux-based Policy interface for component-local state management. + * Mirrors the structure from types/ingredients/Policy.ts but with required fields + * for better type safety within PathwayWrappers. + */ +export interface PolicyStateProps { + id?: string; // Populated after API creation + label: string | null; // Required field, can be null + parameters: Parameter[]; // Always present, empty array if no params + isCreated: boolean; // Tracks whether policy has been successfully created +} diff --git a/app/src/types/pathwayState/PopulationStateProps.ts b/app/src/types/pathwayState/PopulationStateProps.ts new file mode 100644 index 00000000..eff16f72 --- /dev/null +++ b/app/src/types/pathwayState/PopulationStateProps.ts @@ -0,0 +1,20 @@ +import { Geography } from '@/types/ingredients/Geography'; +import { Household } from '@/types/ingredients/Household'; + +/** + * PopulationStateProps - Local state interface for population within PathwayWrapper + * + * Replaces Redux-based Population interface for component-local state management. + * Mirrors the structure from types/ingredients/Population.ts but with required fields + * for better type safety within PathwayWrappers. + * + * Can contain either a Household or Geography, but not both. + * The `type` field helps track which population type is being managed. + */ +export interface PopulationStateProps { + label: string | null; // Required field, can be null + isCreated: boolean; // Tracks whether population has been successfully created + type: 'household' | 'geography' | null; // Tracks population type for easier management + household: Household | null; // Mutually exclusive with geography + geography: Geography | null; // Mutually exclusive with household +} diff --git a/app/src/types/pathwayState/ReportStateProps.ts b/app/src/types/pathwayState/ReportStateProps.ts new file mode 100644 index 00000000..672a1cdb --- /dev/null +++ b/app/src/types/pathwayState/ReportStateProps.ts @@ -0,0 +1,27 @@ +import { countryIds } from '@/libs/countries'; +import type { ReportOutput } from '@/types/ingredients/Report'; +import { SimulationStateProps } from './SimulationStateProps'; + +/** + * ReportStateProps - Local state interface for report within PathwayWrapper + * + * Replaces Redux-based Report interface for component-local state management. + * Contains nested simulation state (which itself contains nested policy/population state). + * + * Key difference from Redux: Instead of storing simulationIds and managing + * simulations in separate Redux slice, we store the full simulation objects + * as an array within the report state. + */ +export interface ReportStateProps { + id?: string; // Populated after API creation + label: string | null; // Required field, can be null + countryId: (typeof countryIds)[number]; // Required - determines which API to use + apiVersion: string | null; // API version for calculations + status: 'pending' | 'complete' | 'error'; // Report generation status + outputType?: 'household' | 'economy'; // Discriminator for output type + output?: ReportOutput | null; // Generated report output + + // Nested ingredient state - REPLACES separate Redux slices + // Array of exactly 2 simulations (baseline and reform) + simulations: [SimulationStateProps, SimulationStateProps]; +} diff --git a/app/src/types/pathwayState/SimulationStateProps.ts b/app/src/types/pathwayState/SimulationStateProps.ts new file mode 100644 index 00000000..55869906 --- /dev/null +++ b/app/src/types/pathwayState/SimulationStateProps.ts @@ -0,0 +1,25 @@ +import { PolicyStateProps } from './PolicyStateProps'; +import { PopulationStateProps } from './PopulationStateProps'; + +/** + * SimulationStateProps - Local state interface for simulation within PathwayWrapper + * + * Replaces Redux-based Simulation interface for component-local state management. + * Contains nested policy and population state for composition. + * + * This structure allows a SimulationPathwayWrapper OR a parent ReportPathwayWrapper + * to manage complete simulation state including its dependencies. + */ +export interface SimulationStateProps { + id?: string; // Populated after API creation + label: string | null; // Required field, can be null + countryId?: string; // Optional - may be inherited from parent + apiVersion?: string; // Optional - may be inherited from parent + isCreated: boolean; // Tracks whether simulation has been successfully created + status?: 'pending' | 'complete' | 'error'; // Calculation status + output?: unknown | null; // Calculation result (for household simulations) + + // Nested ingredient state - REPLACES separate Redux slices + policy: PolicyStateProps; // Owned policy state + population: PopulationStateProps; // Owned population state +} diff --git a/app/src/types/pathwayState/index.ts b/app/src/types/pathwayState/index.ts new file mode 100644 index 00000000..0ab41964 --- /dev/null +++ b/app/src/types/pathwayState/index.ts @@ -0,0 +1,11 @@ +/** + * PathwayState Types - Barrel Export + * + * Local state interfaces for PathwayWrapper components. + * These replace Redux-based ingredient types for component-local state management. + */ + +export type { PolicyStateProps } from './PolicyStateProps'; +export type { PopulationStateProps } from './PopulationStateProps'; +export type { SimulationStateProps } from './SimulationStateProps'; +export type { ReportStateProps } from './ReportStateProps'; diff --git a/app/src/utils/pathwayState/initializePolicyState.ts b/app/src/utils/pathwayState/initializePolicyState.ts new file mode 100644 index 00000000..c80fa679 --- /dev/null +++ b/app/src/utils/pathwayState/initializePolicyState.ts @@ -0,0 +1,16 @@ +import { PolicyStateProps } from '@/types/pathwayState'; + +/** + * Creates an empty PolicyStateProps object with default values + * + * Used to initialize policy state in PathwayWrappers. + * Matches the default state from policyReducer.ts but as a plain object. + */ +export function initializePolicyState(): PolicyStateProps { + return { + id: undefined, + label: null, + parameters: [], + isCreated: false, + }; +} diff --git a/app/src/utils/pathwayState/initializePopulationState.ts b/app/src/utils/pathwayState/initializePopulationState.ts new file mode 100644 index 00000000..f2c5b144 --- /dev/null +++ b/app/src/utils/pathwayState/initializePopulationState.ts @@ -0,0 +1,17 @@ +import { PopulationStateProps } from '@/types/pathwayState'; + +/** + * Creates an empty PopulationStateProps object with default values + * + * Used to initialize population state in PathwayWrappers. + * Matches the default state from populationReducer.ts but as a plain object. + */ +export function initializePopulationState(): PopulationStateProps { + return { + label: null, + isCreated: false, + type: null, + household: null, + geography: null, + }; +} diff --git a/app/src/utils/pathwayState/initializeReportState.ts b/app/src/utils/pathwayState/initializeReportState.ts new file mode 100644 index 00000000..12028b0d --- /dev/null +++ b/app/src/utils/pathwayState/initializeReportState.ts @@ -0,0 +1,26 @@ +import { ReportStateProps } from '@/types/pathwayState'; +import { initializeSimulationState } from './initializeSimulationState'; + +/** + * Creates an empty ReportStateProps object with default values + * + * Used to initialize report state in ReportPathwayWrapper. + * Includes nested simulation state (which itself contains nested policy/population). + * Matches the default state from reportReducer.ts but as a plain object + * with nested ingredient state. + * + * @param countryId - Required country ID for the report + * @returns Initialized report state with two empty simulations + */ +export function initializeReportState(countryId: string): ReportStateProps { + return { + id: undefined, + label: null, + countryId: countryId as any, // Type assertion for countryIds type + apiVersion: null, + status: 'pending', + outputType: undefined, + output: null, + simulations: [initializeSimulationState(), initializeSimulationState()], + }; +} diff --git a/app/src/utils/pathwayState/initializeSimulationState.ts b/app/src/utils/pathwayState/initializeSimulationState.ts new file mode 100644 index 00000000..ac28865c --- /dev/null +++ b/app/src/utils/pathwayState/initializeSimulationState.ts @@ -0,0 +1,25 @@ +import { SimulationStateProps } from '@/types/pathwayState'; +import { initializePolicyState } from './initializePolicyState'; +import { initializePopulationState } from './initializePopulationState'; + +/** + * Creates an empty SimulationStateProps object with default values + * + * Used to initialize simulation state in PathwayWrappers. + * Includes nested policy and population state. + * Matches the default state from simulationsReducer.ts but as a plain object + * with nested ingredient state. + */ +export function initializeSimulationState(): SimulationStateProps { + return { + id: undefined, + label: null, + countryId: undefined, + apiVersion: undefined, + isCreated: false, + status: undefined, + output: null, + policy: initializePolicyState(), + population: initializePopulationState(), + }; +} From 7704f590d6bd35053a9bc2d65b575f926699aceb Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Sat, 15 Nov 2025 00:37:07 +0100 Subject: [PATCH 02/49] feat: First implementation of one portion of report creation flow --- app/src/Router.tsx | 5 + app/src/pages/Reports.page.tsx | 52 ++-- .../pathways/report/ReportPathwayWrapper.tsx | 206 +++++++++++++++ .../report/ReportPathwayWrapperRoute.tsx | 36 +++ .../pathways/report/views/ReportLabelView.tsx | 54 ++++ .../pathways/report/views/ReportSetupView.tsx | 235 ++++++++++++++++++ .../views/ReportSimulationExistingView.tsx | 185 ++++++++++++++ .../views/ReportSimulationSelectionView.tsx | 62 +++++ .../report/views/ReportSubmitView.tsx | 53 ++++ 9 files changed, 872 insertions(+), 16 deletions(-) create mode 100644 app/src/pathways/report/ReportPathwayWrapper.tsx create mode 100644 app/src/pathways/report/ReportPathwayWrapperRoute.tsx create mode 100644 app/src/pathways/report/views/ReportLabelView.tsx create mode 100644 app/src/pathways/report/views/ReportSetupView.tsx create mode 100644 app/src/pathways/report/views/ReportSimulationExistingView.tsx create mode 100644 app/src/pathways/report/views/ReportSimulationSelectionView.tsx create mode 100644 app/src/pathways/report/views/ReportSubmitView.tsx diff --git a/app/src/Router.tsx b/app/src/Router.tsx index 890c792b..7fbcfb42 100644 --- a/app/src/Router.tsx +++ b/app/src/Router.tsx @@ -26,6 +26,7 @@ import { CountryGuard } from './routing/guards/CountryGuard'; import { MetadataGuard } from './routing/guards/MetadataGuard'; import { MetadataLazyLoader } from './routing/guards/MetadataLazyLoader'; import { RedirectToCountry } from './routing/RedirectToCountry'; +import ReportPathwayWrapperRoute from './pathways/report/ReportPathwayWrapperRoute'; const router = createBrowserRouter( [ @@ -73,6 +74,10 @@ const router = createBrowserRouter( path: 'reports/create', element: , }, + { + path: 'reports/create-v2', + element: , + }, { path: 'simulations', element: , diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index 3898d8c7..c88f39f0 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Badge, Button, Group, Stack } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { BulletsValue, @@ -50,6 +51,13 @@ export default function ReportsPage() { navigate(targetPath); }; + const handleBuildReportV2 = () => { + console.log('[ReportsPage] ========== NEW REPORT V2 CLICKED =========='); + const targetPath = `/${countryId}/reports/create-v2`; + console.log('[ReportsPage] Navigating to:', targetPath); + navigate(targetPath); + }; + const handleSelectionChange = (recordId: string, selected: boolean) => { setSelectedIds((prev) => selected ? [...prev, recordId] : prev.filter((id) => id !== recordId) @@ -199,23 +207,35 @@ export default function ReportsPage() { ); return ( - <> + + {/* TEMPORARY: Test button for new PathwayWrapper system */} + + + NEW + + + + + <> + ingredient="report" + title="Your saved reports" + subtitle="Generate comprehensive impact analyses comparing tax policy scenarios. Reports show distributional effects, budget impacts, and poverty outcomes across demographics" + onBuild={handleBuildReport} + isLoading={isLoading} + isError={isError} + error={error} + data={transformedData} + columns={reportColumns} + searchValue={searchValue} + onSearchChange={setSearchValue} + enableSelection + isSelected={isSelected} + onSelectionChange={handleSelectionChange} + /> + void; + onCancel?: () => void; +} + +export default function ReportPathwayWrapper({ + countryId, + onComplete, + onCancel, +}: ReportPathwayWrapperProps) { + console.log('[ReportPathwayWrapper] ========== RENDER =========='); + console.log('[ReportPathwayWrapper] countryId:', countryId); + + // Initialize report state + const [reportState, setReportState] = useState(() => + initializeReportState(countryId) + ); + const [currentMode, setCurrentMode] = useState(ReportViewMode.LABEL); + const [activeSimulationIndex, setActiveSimulationIndex] = useState<0 | 1>(0); + + const navigate = useNavigate(); + const { createReport, isPending: isSubmitting } = useCreateReport(reportState.label || undefined); + + // Navigation helpers + const navigateToMode = useCallback((mode: ReportViewMode) => { + console.log('[ReportPathwayWrapper] Navigating to mode:', mode); + setCurrentMode(mode); + }, []); + + // State update helpers + const updateReportLabel = useCallback((label: string) => { + console.log('[ReportPathwayWrapper] Updating label:', label); + setReportState((prev) => ({ ...prev, label })); + }, []); + + const handleNavigateToSimulationSelection = useCallback((simulationIndex: 0 | 1) => { + console.log('[ReportPathwayWrapper] Setting active simulation index:', simulationIndex); + setActiveSimulationIndex(simulationIndex); + setCurrentMode(ReportViewMode.SELECT_SIMULATION); + }, []); + + const handlePrefillPopulation2 = useCallback(() => { + console.log('[ReportPathwayWrapper] Pre-filling population 2 from simulation 1'); + // TODO: Implement population prefill logic + // For now, just copy the population from simulation 1 to simulation 2 + setReportState((prev) => { + const sim1Population = prev.simulations[0].population; + const newSimulations = [...prev.simulations] as [typeof prev.simulations[0], typeof prev.simulations[1]]; + newSimulations[1] = { + ...newSimulations[1], + population: { ...sim1Population }, + }; + return { + ...prev, + simulations: newSimulations, + }; + }); + }, []); + + const handleSelectExistingSimulation = useCallback((enhancedSimulation: EnhancedUserSimulation) => { + console.log('[ReportPathwayWrapper] Selecting existing simulation:', enhancedSimulation); + // TODO: Map EnhancedUserSimulation to SimulationStateProps + // For now, placeholder implementation + alert('Selecting existing simulations not yet implemented in Phase 2'); + }, []); + + const handleSubmitReport = useCallback(() => { + console.log('[ReportPathwayWrapper] ========== SUBMIT REPORT =========='); + console.log('[ReportPathwayWrapper] Report state:', reportState); + + const sim1Id = reportState.simulations[0]?.id; + const sim2Id = reportState.simulations[1]?.id; + + // Validation + if (!sim1Id) { + console.error('[ReportPathwayWrapper] Cannot submit: no baseline simulation'); + return; + } + + // Prepare report data + const reportData: Partial = { + countryId: reportState.countryId, + simulationIds: [sim1Id, sim2Id].filter(Boolean) as string[], + apiVersion: reportState.apiVersion, + }; + + const serializedPayload: ReportCreationPayload = ReportAdapter.toCreationPayload( + reportData as Report + ); + + // Submit report + createReport( + { + countryId: reportState.countryId, + payload: serializedPayload, + simulations: { + simulation1: reportState.simulations[0] as any, // TODO: Convert SimulationStateProps to Simulation + simulation2: reportState.simulations[1] as any, + }, + populations: { + household1: reportState.simulations[0].population.household, + household2: reportState.simulations[1].population.household, + geography1: reportState.simulations[0].population.geography, + geography2: reportState.simulations[1].population.geography, + }, + }, + { + onSuccess: (data) => { + console.log('[ReportPathwayWrapper] Report created:', data.userReport); + const outputPath = getReportOutputPath(reportState.countryId, data.userReport.id); + navigate(outputPath); + onComplete?.(); + }, + onError: (error) => { + console.error('[ReportPathwayWrapper] Report creation failed:', error); + }, + } + ); + }, [reportState, createReport, navigate, onComplete]); + + // Render current view + console.log('[ReportPathwayWrapper] Current mode:', currentMode); + + switch (currentMode) { + case ReportViewMode.LABEL: + return ( + navigateToMode(ReportViewMode.SETUP)} + /> + ); + + case ReportViewMode.SETUP: + return ( + navigateToMode(ReportViewMode.SUBMIT)} + onPrefillPopulation2={handlePrefillPopulation2} + /> + ); + + case ReportViewMode.SELECT_SIMULATION: + return ( + { + // TODO: Navigate to simulation creation flow (Phase 3+) + alert('Creating new simulations not yet implemented in Phase 2'); + }} + onLoadExisting={() => navigateToMode(ReportViewMode.SELECT_EXISTING_SIMULATION)} + /> + ); + + case ReportViewMode.SELECT_EXISTING_SIMULATION: + const otherIndex = activeSimulationIndex === 0 ? 1 : 0; + return ( + navigateToMode(ReportViewMode.SETUP)} + /> + ); + + case ReportViewMode.SUBMIT: + return ( + + ); + + default: + return
Unknown view mode: {currentMode}
; + } +} diff --git a/app/src/pathways/report/ReportPathwayWrapperRoute.tsx b/app/src/pathways/report/ReportPathwayWrapperRoute.tsx new file mode 100644 index 00000000..5e6912f8 --- /dev/null +++ b/app/src/pathways/report/ReportPathwayWrapperRoute.tsx @@ -0,0 +1,36 @@ +/** + * ReportPathwayWrapperRoute - Route wrapper for ReportPathwayWrapper + * + * Extracts countryId from URL params and passes to PathwayWrapper. + * Provides completion/cancellation handlers for navigation. + */ + +import { useNavigate, useParams } from 'react-router-dom'; +import ReportPathwayWrapper from './ReportPathwayWrapper'; + +export default function ReportPathwayWrapperRoute() { + const { countryId } = useParams<{ countryId: string}>(); + const navigate = useNavigate(); + + if (!countryId) { + return
Error: Country ID not found
; + } + + const handleComplete = () => { + console.log('[ReportPathwayWrapperRoute] Pathway completed'); + navigate(`/${countryId}/reports`); + }; + + const handleCancel = () => { + console.log('[ReportPathwayWrapperRoute] Pathway cancelled'); + navigate(`/${countryId}/reports`); + }; + + return ( + + ); +} diff --git a/app/src/pathways/report/views/ReportLabelView.tsx b/app/src/pathways/report/views/ReportLabelView.tsx new file mode 100644 index 00000000..cb5bc91f --- /dev/null +++ b/app/src/pathways/report/views/ReportLabelView.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { Select, TextInput } from '@mantine/core'; +import FlowView from '@/components/common/FlowView'; + +interface ReportLabelViewProps { + label: string | null; + onUpdateLabel: (label: string) => void; + onNext: () => void; +} + +export default function ReportLabelView({ label, onUpdateLabel, onNext }: ReportLabelViewProps) { + console.log('[ReportLabelView] ========== COMPONENT RENDER =========='); + const [localLabel, setLocalLabel] = useState(label || ''); + // NOTE: Temporary hardcoded year dropdown - does nothing functionally, placeholder for future feature + const [year, setYear] = useState('2025'); + + function handleLocalLabelChange(value: string) { + setLocalLabel(value); + } + + function submissionHandler() { + console.log('[ReportLabelView] Submit clicked - label:', localLabel); + onUpdateLabel(localLabel); + console.log('[ReportLabelView] Navigating to next'); + onNext(); + } + + const formInputs = ( + <> + handleLocalLabelChange(e.currentTarget.value)} + /> + {/* NOTE: Temporary hardcoded year dropdown - does nothing functionally */} + handleHouseholdFieldChange(field, val)} + data={options} + placeholder={`Select ${fieldLabel}`} + searchable + /> + ); + } + + return ( + handleHouseholdFieldChange(field, e.currentTarget.value)} + placeholder={`Enter ${fieldLabel}`} + /> + ); + })} + + ); + }; + + // Render adults section + const renderAdults = () => { + // Get formatting for age and employment_income + const ageVariable = variables?.age; + const employmentIncomeVariable = variables?.employment_income; + const ageFormatting = ageVariable + ? getInputFormattingProps(ageVariable) + : { thousandSeparator: ',' }; + const incomeFormatting = employmentIncomeVariable + ? getInputFormattingProps(employmentIncomeVariable) + : { thousandSeparator: ',' }; + + return ( + + + Adults + + + {/* Primary adult */} + + + You + + handleAdultChange('you', 'age', val || 0)} + min={18} + max={120} + placeholder="Age" + style={{ flex: 1 }} + {...ageFormatting} + /> + handleAdultChange('you', 'employment_income', val || 0)} + min={0} + placeholder="Employment Income" + style={{ flex: 2 }} + {...incomeFormatting} + /> + + + {/* Spouse (if married) */} + {maritalStatus === 'married' && ( + + + Your Partner + + handleAdultChange('your partner', 'age', val || 0)} + min={18} + max={120} + placeholder="Age" + style={{ flex: 1 }} + {...ageFormatting} + /> + handleAdultChange('your partner', 'employment_income', val || 0)} + min={0} + placeholder="Employment Income" + style={{ flex: 2 }} + {...incomeFormatting} + /> + + )} + + ); + }; + + // Render children section + const renderChildren = () => { + if (numChildren === 0) { + return null; + } + + // Get formatting for age and employment_income + const ageVariable = variables?.age; + const employmentIncomeVariable = variables?.employment_income; + const ageFormatting = ageVariable + ? getInputFormattingProps(ageVariable) + : { thousandSeparator: ',' }; + const incomeFormatting = employmentIncomeVariable + ? getInputFormattingProps(employmentIncomeVariable) + : { thousandSeparator: ',' }; + + const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; + + return ( + + + Children + + + {Array.from({ length: numChildren }, (_, index) => { + const childKey = `your ${ordinals[index] || `${index + 1}th`} dependent`; + return ( + + + Child {index + 1} + + handleChildChange(childKey, 'age', val || 0)} + min={0} + max={17} + placeholder="Age" + style={{ flex: 1 }} + {...ageFormatting} + /> + handleChildChange(childKey, 'employment_income', val || 0)} + min={0} + placeholder="Employment Income" + style={{ flex: 2 }} + {...incomeFormatting} + /> + + ); + })} + + ); + }; + + const validation = HouseholdValidation.isReadyForSimulation(household); + const canProceed = validation.isValid; + + const primaryAction = { + label: 'Create household', + onClick: handleSubmit, + isLoading: isPending, + isDisabled: !canProceed, + }; const content = ( - - - ⚠️ Household Builder View - Phase 4 Implementation - - - This view will contain the full household building interface. For now, this is an - architectural placeholder. Full implementation will duplicate HouseholdBuilderFrame's 650+ - lines of logic using props-based state management. - - - Props received: population, countryId={countryId} - + + + + {/* Tax Year Selection */} + setMaritalStatus((val || 'single') as 'single' | 'married')} + data={[ + { value: 'single', label: 'Single' }, + { value: 'married', label: 'Married' }, + ]} + /> + + setYear(val || '2025')} - disabled + data={availableYears} + value={localYear} + onChange={handleYearChange} + searchable /> ); diff --git a/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx b/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx index e4af951b..958d7298 100644 --- a/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx +++ b/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx @@ -9,8 +9,8 @@ import { useSelector } from 'react-redux'; import { AppShell, Box, Button, Group, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconChevronRight } from '@tabler/icons-react'; -import MainEmpty from '@/components/policyParameterSelectorFrame/MainEmpty'; -import Menu from '@/components/policyParameterSelectorFrame/Menu'; +import MainEmpty from '../../components/policyParameterSelector/MainEmpty'; +import Menu from '../../components/policyParameterSelector/Menu'; import HeaderNavigation from '@/components/shared/HomeHeader'; import LegacyBanner from '@/components/shared/LegacyBanner'; import { colors } from '@/designTokens/colors'; diff --git a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx index b46fa7b6..d67f282b 100644 --- a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx +++ b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx @@ -18,13 +18,12 @@ import { } from '@mantine/core'; import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; import FlowView from '@/components/common/FlowView'; -import { CURRENT_YEAR } from '@/constants'; import { useCreateHousehold } from '@/hooks/useCreateHousehold'; +import { useReportYear } from '@/hooks/useReportYear'; import { getBasicInputFields, getFieldLabel, getFieldOptions, - getTaxYears, isDropdownField, } from '@/libs/metadataUtils'; import { RootState } from '@/store'; @@ -49,22 +48,46 @@ export default function HouseholdBuilderView({ onBack, }: HouseholdBuilderViewProps) { const { createHousehold, isPending } = useCreateHousehold(population?.label || ''); + const reportYear = useReportYear(); + + // Get metadata-driven options + const basicInputFields = useSelector(getBasicInputFields); + const variables = useSelector((state: RootState) => state.metadata.variables); + const { loading, error } = useSelector((state: RootState) => state.metadata); + + // Error boundary: Show error if no report year available + if (!reportYear) { + return ( + + + Configuration Error + + + No report year available. Please return to the report creation page and select a year + before creating a household. + + + } + buttonPreset="cancel-only" + cancelAction={{ + onClick: onBack, + }} + /> + ); + } // Initialize with empty household if none exists const [household, setLocalHousehold] = useState(() => { if (population?.household) { return population.household; } - const builder = new HouseholdBuilder(countryId as any, CURRENT_YEAR); + const builder = new HouseholdBuilder(countryId as any, reportYear); return builder.build(); }); - // Get metadata-driven options - const taxYears = useSelector(getTaxYears); - const basicInputFields = useSelector(getBasicInputFields); - const variables = useSelector((state: RootState) => state.metadata.variables); - const { loading, error } = useSelector((state: RootState) => state.metadata); - // Helper to get default value for a variable from metadata const getVariableDefault = (variableName: string): any => { const snakeCaseName = variableName.replace(/([A-Z])/g, '_$1').toLowerCase(); @@ -73,13 +96,12 @@ export default function HouseholdBuilderView({ }; // State for form controls - const [taxYear, setTaxYear] = useState(CURRENT_YEAR); const [maritalStatus, setMaritalStatus] = useState<'single' | 'married'>('single'); const [numChildren, setNumChildren] = useState(0); // Build household based on form values useEffect(() => { - const builder = new HouseholdBuilder(countryId as any, taxYear); + const builder = new HouseholdBuilder(countryId as any, reportYear); // Get current people to preserve their data const currentPeople = Object.keys(household.householdData.people); @@ -122,10 +144,10 @@ export default function HouseholdBuilderView({ } // Handle children - const currentChildCount = HouseholdQueries.getChildCount(household, taxYear); + const currentChildCount = HouseholdQueries.getChildCount(household, reportYear); if (numChildren !== currentChildCount) { // Remove all existing children - const children = HouseholdQueries.getChildren(household, taxYear); + const children = HouseholdQueries.getChildren(household, reportYear); children.forEach((child) => builder.removePerson(child.name)); // Add new children with defaults (age 10, other variables from metadata) @@ -194,7 +216,7 @@ export default function HouseholdBuilderView({ } setLocalHousehold(builder.build()); - }, [maritalStatus, numChildren, taxYear, countryId]); + }, [maritalStatus, numChildren, reportYear, countryId]); // Handle adult field changes const handleAdultChange = (person: string, field: string, value: number | string) => { @@ -209,7 +231,7 @@ export default function HouseholdBuilderView({ updatedHousehold.householdData.people[person][field] = {}; } - updatedHousehold.householdData.people[person][field][taxYear] = numValue; + updatedHousehold.householdData.people[person][field][reportYear] = numValue; setLocalHousehold(updatedHousehold); }; @@ -226,7 +248,7 @@ export default function HouseholdBuilderView({ updatedHousehold.householdData.people[childKey][field] = {}; } - updatedHousehold.householdData.people[childKey][field][taxYear] = numValue; + updatedHousehold.householdData.people[childKey][field][reportYear] = numValue; setLocalHousehold(updatedHousehold); }; @@ -251,7 +273,7 @@ export default function HouseholdBuilderView({ entities[groupKey] = { members: [] }; } - entities[groupKey][field] = { [taxYear]: value || '' }; + entities[groupKey][field] = { [reportYear]: value || '' }; setLocalHousehold(updatedHousehold); }; @@ -293,7 +315,7 @@ export default function HouseholdBuilderView({ const handleSubmit = async () => { // Validate household - const validation = HouseholdValidation.isReadyForSimulation(household); + const validation = HouseholdValidation.isReadyForSimulation(household, reportYear); if (!validation.isValid) { console.error('Household validation failed:', validation.errors); return; @@ -335,7 +357,7 @@ export default function HouseholdBuilderView({ ); const fieldLabel = getFieldLabel(field); const fieldValue = - household.householdData.households?.['your household']?.[field]?.[taxYear] || ''; + household.householdData.households?.['your household']?.[field]?.[reportYear] || ''; if (isDropdown) { const options = fieldOptionsMap[field] || []; @@ -391,7 +413,7 @@ export default function HouseholdBuilderView({ handleAdultChange('you', 'age', val || 0)} @@ -403,7 +425,7 @@ export default function HouseholdBuilderView({ /> handleAdultChange('you', 'employment_income', val || 0)} @@ -422,7 +444,7 @@ export default function HouseholdBuilderView({ handleAdultChange('your partner', 'age', val || 0)} @@ -438,7 +460,7 @@ export default function HouseholdBuilderView({ household, 'your partner', 'employment_income', - taxYear + reportYear ) || 0 } onChange={(val) => handleAdultChange('your partner', 'employment_income', val || 0)} @@ -486,7 +508,7 @@ export default function HouseholdBuilderView({ handleChildChange(childKey, 'age', val || 0)} min={0} @@ -501,7 +523,7 @@ export default function HouseholdBuilderView({ household, childKey, 'employment_income', - taxYear + reportYear ) || 0 } onChange={(val) => handleChildChange(childKey, 'employment_income', val || 0)} @@ -517,7 +539,7 @@ export default function HouseholdBuilderView({ ); }; - const validation = HouseholdValidation.isReadyForSimulation(household); + const validation = HouseholdValidation.isReadyForSimulation(household, reportYear); const canProceed = validation.isValid; const primaryAction = { @@ -531,16 +553,6 @@ export default function HouseholdBuilderView({ - {/* Tax Year Selection */} - { - const position = state.report.mode === 'report' ? state.report.activeSimulationPosition : 0; - return state.simulations.simulations[position]; -}; - -/** - * Select the currently active policy based on mode and position - * In report mode: uses activeSimulationPosition from report - * In standalone mode: defaults to position 0 - */ -export const selectActivePolicy = (state: RootState) => { - const position = state.report.mode === 'report' ? state.report.activeSimulationPosition : 0; - const policy = state.policy.policies[position]; - - console.log('[SELECTOR] selectActivePolicy - position:', position); - console.log('[SELECTOR] selectActivePolicy - policy:', policy); - console.log('[SELECTOR] policy is Proxy?', isProxy(policy)); - - if (policy?.parameters) { - console.log('[SELECTOR] policy.parameters:', policy.parameters); - console.log('[SELECTOR] policy.parameters is Proxy?', isProxy(policy.parameters)); - - if (policy.parameters.length > 0) { - const firstParam = policy.parameters[0]; - console.log('[SELECTOR] first parameter:', firstParam); - console.log('[SELECTOR] first parameter is Proxy?', isProxy(firstParam)); - - if (firstParam?.values && firstParam.values.length > 0) { - console.log('[SELECTOR] first parameter values:', firstParam.values); - console.log('[SELECTOR] values is Proxy?', isProxy(firstParam.values)); - console.log('[SELECTOR] first value:', firstParam.values[0]); - console.log('[SELECTOR] first value is Proxy?', isProxy(firstParam.values[0])); - } - } - } - - return policy; -}; - -/** - * Select the currently active population based on mode and position - * In report mode: uses activeSimulationPosition from report - * In standalone mode: defaults to position 0 - */ -export const selectActivePopulation = (state: RootState) => { - const position = state.report.mode === 'report' ? state.report.activeSimulationPosition : 0; - return state.population.populations[position]; -}; - -/** - * Get the current position based on mode - * In report mode: returns activeSimulationPosition from report - * In standalone mode: returns 0 - */ -export const selectCurrentPosition = (state: RootState): 0 | 1 => { - return state.report.mode === 'report' ? state.report.activeSimulationPosition : 0; -}; diff --git a/app/src/reducers/flowReducer.ts b/app/src/reducers/flowReducer.ts deleted file mode 100644 index e080dafd..00000000 --- a/app/src/reducers/flowReducer.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ComponentKey } from '../flows/registry'; -import { Flow } from '../types/flow'; - -interface FlowState { - currentFlow: Flow | null; - currentFrame: ComponentKey | null; - // Stack to track nested flows - when we enter a subflow, we push the current state - flowStack: Array<{ - flow: Flow; - frame: ComponentKey; - }>; - // Path to navigate to when top-level flow completes - returnPath: string | null; -} - -const initialState: FlowState = { - currentFlow: null, - currentFrame: null, - flowStack: [], - returnPath: null, -}; - -export const flowSlice = createSlice({ - name: 'flow', - initialState, - reducers: { - clearFlow: (state) => { - console.log('[FLOW REDUCER] ========== clearFlow CALLED =========='); - console.log('[FLOW REDUCER] Before clear - currentFlow:', state.currentFlow); - console.log('[FLOW REDUCER] Before clear - currentFrame:', state.currentFrame); - console.log('[FLOW REDUCER] Before clear - flowStack length:', state.flowStack.length); - state.currentFlow = null; - state.currentFrame = null; - state.flowStack = []; - state.returnPath = null; - console.log('[FLOW REDUCER] After clear - all state nulled'); - console.log('[FLOW REDUCER] ========== clearFlow COMPLETE =========='); - }, - setFlow: (state, action: PayloadAction<{ flow: Flow; returnPath?: string }>) => { - console.log('[FLOW REDUCER] ========== setFlow START =========='); - console.log('[FLOW REDUCER] New flow initialFrame:', action.payload.flow.initialFrame); - console.log('[FLOW REDUCER] returnPath:', action.payload.returnPath); - console.log('[FLOW REDUCER] Current frame before:', state.currentFrame); - - state.currentFlow = action.payload.flow; - state.returnPath = action.payload.returnPath || null; - // Set initial frame - if it's a component, use it; if it's a flow, handle separately - if ( - action.payload.flow.initialFrame && - typeof action.payload.flow.initialFrame === 'string' - ) { - state.currentFrame = action.payload.flow.initialFrame as ComponentKey; - } - state.flowStack = []; - - console.log('[FLOW REDUCER] Current frame after:', state.currentFrame); - console.log('[FLOW REDUCER] ========== setFlow END =========='); - }, - navigateToFrame: (state, action: PayloadAction) => { - console.log('[FLOW REDUCER] ========== navigateToFrame START =========='); - console.log('[FLOW REDUCER] Current frame:', state.currentFrame); - console.log('[FLOW REDUCER] New frame:', action.payload); - state.currentFrame = action.payload; - console.log('[FLOW REDUCER] Frame updated to:', state.currentFrame); - console.log('[FLOW REDUCER] ========== navigateToFrame END =========='); - }, - // Navigate to a subflow - pushes current state onto stack - navigateToFlow: (state, action: PayloadAction<{ flow: Flow; returnFrame?: ComponentKey }>) => { - if (state.currentFlow && state.currentFrame) { - // Push current state onto stack - state.flowStack.push({ - flow: state.currentFlow, - frame: action.payload.returnFrame || state.currentFrame, - }); - } - - // Set new flow as current - state.currentFlow = action.payload.flow; - if ( - action.payload.flow.initialFrame && - typeof action.payload.flow.initialFrame === 'string' - ) { - state.currentFrame = action.payload.flow.initialFrame as ComponentKey; - } - }, - // Return from a subflow - pops from stack - returnFromFlow: (state) => { - if (state.flowStack.length > 0) { - // In a subflow: pop back to parent flow - const previousState = state.flowStack.pop()!; - state.currentFlow = previousState.flow; - state.currentFrame = previousState.frame; - } else { - // At top level: clear the flow entirely - state.currentFlow = null; - state.currentFrame = null; - state.flowStack = []; - } - }, - }, -}); - -export const { clearFlow, setFlow, navigateToFrame, navigateToFlow, returnFromFlow } = - flowSlice.actions; - -export default flowSlice.reducer; diff --git a/app/src/reducers/policyReducer.ts b/app/src/reducers/policyReducer.ts deleted file mode 100644 index 0b4d1ebe..00000000 --- a/app/src/reducers/policyReducer.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Policy } from '@/types/ingredients/Policy'; -import { getParameterByName } from '@/types/subIngredients/parameter'; -import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; - -// Helper to detect Immer Proxy objects -function isProxy(obj: any): boolean { - return obj != null && typeof obj === 'object' && obj.constructor?.name === 'DraftObject'; -} - -export interface PolicyParamAdditionPayload { - position: 0 | 1; - name: string; - valueInterval: ValueInterval; -} - -// Position-based storage for exactly 2 policies -interface PolicyState { - policies: [Policy | null, Policy | null]; -} - -const initialState: PolicyState = { - policies: [null, null], -}; - -export const policySlice = createSlice({ - name: 'policy', - initialState, - reducers: { - /** - * Creates a policy at the specified position if one doesn't already exist. - * If a policy already exists at that position, this action does nothing, - * preserving the existing policy data. - * @param position - The position (0 or 1) where the policy should be created - * @param policy - Optional partial policy data to initialize with - */ - createPolicyAtPosition: ( - state, - action: PayloadAction<{ - position: 0 | 1; - policy?: Partial; - }> - ) => { - const { position, policy } = action.payload; - const currentPolicy = state.policies[position]; - - console.log('[POLICY REDUCER] ========== createPolicyAtPosition START =========='); - console.log('[POLICY REDUCER] Action payload:', { position, policy }); - console.log('[POLICY REDUCER] Full state.policies:', state.policies); - console.log('[POLICY REDUCER] Current policy at position:', currentPolicy); - console.log('[POLICY REDUCER] typeof currentPolicy:', typeof currentPolicy); - console.log('[POLICY REDUCER] currentPolicy === null:', currentPolicy === null); - console.log('[POLICY REDUCER] currentPolicy === undefined:', currentPolicy === undefined); - console.log('[POLICY REDUCER] isProxy(currentPolicy):', isProxy(currentPolicy)); - console.log('[POLICY REDUCER] !currentPolicy:', !currentPolicy); - - if (currentPolicy) { - console.log('[POLICY REDUCER] Policy contents:', { - id: currentPolicy.id, - label: currentPolicy.label, - parametersLength: currentPolicy.parameters?.length, - isCreated: currentPolicy.isCreated, - }); - } - - // Only create if no policy exists at this position - if (!state.policies[position]) { - const newPolicy: Policy = { - id: undefined, - label: null, - parameters: [], - isCreated: false, - ...policy, - }; - console.log('[POLICY REDUCER] Creating new policy:', newPolicy); - state.policies[position] = newPolicy; - console.log( - '[POLICY REDUCER] After assignment, state.policies[position]:', - state.policies[position] - ); - console.log( - '[POLICY REDUCER] After assignment, isProxy?:', - isProxy(state.policies[position]) - ); - } else { - console.log('[POLICY REDUCER] Policy already exists, preserving existing data'); - } - console.log('[POLICY REDUCER] ========== createPolicyAtPosition END =========='); - }, - - // Update policy at position - updatePolicyAtPosition: ( - state, - action: PayloadAction<{ - position: 0 | 1; - updates: Partial; - }> - ) => { - console.log('[POLICY REDUCER] ========== updatePolicyAtPosition START =========='); - console.log('[POLICY REDUCER] Position:', action.payload.position); - console.log('[POLICY REDUCER] Updates:', action.payload.updates); - - const policy = state.policies[action.payload.position]; - console.log('[POLICY REDUCER] Current policy:', policy); - console.log('[POLICY REDUCER] isProxy(policy)?:', isProxy(policy)); - - if (!policy) { - throw new Error( - `Cannot update policy at position ${action.payload.position}: no policy exists at that position` - ); - } - state.policies[action.payload.position] = { - ...policy, - ...action.payload.updates, - }; - console.log('[POLICY REDUCER] Updated policy:', state.policies[action.payload.position]); - console.log('[POLICY REDUCER] ========== updatePolicyAtPosition END =========='); - }, - - // Add parameter to policy at position - addPolicyParamAtPosition: (state, action: PayloadAction) => { - const { position, name, valueInterval } = action.payload; - const policy = state.policies[position]; - - console.log('[POLICY REDUCER] addPolicyParamAtPosition - START'); - console.log('[POLICY REDUCER] policy:', policy); - console.log('[POLICY REDUCER] policy is Proxy?', isProxy(policy)); - - if (!policy) { - throw new Error( - `Cannot add parameter to policy at position ${position}: no policy exists at that position` - ); - } - - if (!policy.parameters) { - policy.parameters = []; - } - - let param = getParameterByName(policy, name); - console.log('[POLICY REDUCER] param:', param); - console.log('[POLICY REDUCER] param is Proxy?', isProxy(param)); - - if (!param) { - param = { name, values: [] }; - policy.parameters.push(param); - } - - console.log('[POLICY REDUCER] param.values before collection:', param.values); - console.log('[POLICY REDUCER] param.values is Proxy?', isProxy(param.values)); - - const paramCollection = new ValueIntervalCollection(param.values); - paramCollection.addInterval(valueInterval); - const newValues = paramCollection.getIntervals(); - - console.log('[POLICY REDUCER] newValues from getIntervals():', newValues); - console.log('[POLICY REDUCER] newValues is Proxy?', isProxy(newValues)); - console.log( - '[POLICY REDUCER] newValues[0] is Proxy?', - newValues.length > 0 && isProxy(newValues[0]) - ); - - param.values = newValues; - console.log('[POLICY REDUCER] addPolicyParamAtPosition - END'); - }, - - // Clear policy at position - clearPolicyAtPosition: (state, action: PayloadAction<0 | 1>) => { - console.log('[POLICY REDUCER] ========== clearPolicyAtPosition START =========='); - console.log('[POLICY REDUCER] Clearing position:', action.payload); - console.log('[POLICY REDUCER] Policy before clear:', state.policies[action.payload]); - state.policies[action.payload] = null; - console.log('[POLICY REDUCER] Policy after clear:', state.policies[action.payload]); - console.log('[POLICY REDUCER] ========== clearPolicyAtPosition END =========='); - }, - - // Clear all policies - clearAllPolicies: (state) => { - console.log('[POLICY REDUCER] ========== clearAllPolicies START =========='); - console.log('[POLICY REDUCER] Policies before clear:', state.policies); - state.policies = [null, null]; - console.log('[POLICY REDUCER] Policies after clear:', state.policies); - console.log('[POLICY REDUCER] ========== clearAllPolicies END =========='); - }, - }, -}); - -// Action creators are generated for each case reducer function -export const { - createPolicyAtPosition, - updatePolicyAtPosition, - addPolicyParamAtPosition, - clearPolicyAtPosition, - clearAllPolicies, -} = policySlice.actions; - -// Selectors -export const selectPolicyAtPosition = ( - state: { policy: PolicyState }, - position: 0 | 1 -): Policy | null => { - return state.policy?.policies[position] || null; -}; - -export const selectAllPolicies = (state: { policy: PolicyState }): Policy[] => { - const policies: Policy[] = []; - const [policy1, policy2] = state.policy?.policies || [null, null]; - if (policy1) { - policies.push(policy1); - } - if (policy2) { - policies.push(policy2); - } - return policies; -}; - -export const selectHasPolicyAtPosition = ( - state: { policy: PolicyState }, - position: 0 | 1 -): boolean => { - return state.policy?.policies[position] !== null; -}; - -export default policySlice.reducer; diff --git a/app/src/reducers/simulationReducer.ts b/app/src/reducers/simulationReducer.ts deleted file mode 100644 index e6840078..00000000 --- a/app/src/reducers/simulationReducer.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Simulation } from '@/types/ingredients/Simulation'; - -const initialState: Simulation = { - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: null, - id: undefined, - isCreated: false, -}; - -export const simulationSlice = createSlice({ - name: 'simulation', - initialState, - reducers: { - updateSimulationPopulationId: ( - state, - action: PayloadAction<{ id: string; type: 'household' | 'geography' }> - ) => { - state.populationId = action.payload.id; - state.populationType = action.payload.type; - }, - updateSimulationPolicyId: (state, action: PayloadAction) => { - state.policyId = action.payload; - }, - updateSimulationLabel: (state, action: PayloadAction) => { - state.label = action.payload; - }, - updateSimulationId: (state, action: PayloadAction) => { - state.id = action.payload; - }, - markSimulationAsCreated: (state) => { - state.isCreated = true; - }, - clearSimulation: (state) => { - state.populationId = undefined; - state.policyId = undefined; - state.populationType = undefined; - state.label = null; - state.id = undefined; - state.isCreated = false; - }, - }, -}); - -export const { - updateSimulationPopulationId, - updateSimulationPolicyId, - updateSimulationLabel, - updateSimulationId, - markSimulationAsCreated, - clearSimulation, -} = simulationSlice.actions; - -export default simulationSlice.reducer; diff --git a/app/src/reducers/simulationsReducer.ts b/app/src/reducers/simulationsReducer.ts deleted file mode 100644 index 9045eab3..00000000 --- a/app/src/reducers/simulationsReducer.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Simulation } from '@/types/ingredients/Simulation'; - -// Position-based storage for exactly 2 simulations -interface SimulationsState { - simulations: [Simulation | null, Simulation | null]; // Store directly by position -} - -const initialState: SimulationsState = { - simulations: [null, null], -}; - -export const simulationsSlice = createSlice({ - name: 'simulations', - initialState, - reducers: { - /** - * Creates a simulation at the specified position if one doesn't already exist. - * If a simulation already exists at that position, this action does nothing, - * preserving the existing simulation data. - * @param position - The position (0 or 1) where the simulation should be created - * @param simulation - Optional partial simulation data to initialize with - */ - createSimulationAtPosition: ( - state, - action: PayloadAction<{ - position: 0 | 1; - simulation?: Partial; - }> - ) => { - const { position, simulation } = action.payload; - - // Only create if no simulation exists at this position - if (!state.simulations[position]) { - const newSimulation: Simulation = { - id: undefined, // No ID until API creates it - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: null, - isCreated: false, - ...simulation, - }; - state.simulations[position] = newSimulation; - } - // If a simulation already exists, do nothing - preserve existing data - }, - - // Update fields at position - updateSimulationAtPosition: ( - state, - action: PayloadAction<{ - position: 0 | 1; - updates: Partial; - }> - ) => { - const sim = state.simulations[action.payload.position]; - if (!sim) { - throw new Error( - `Cannot update simulation at position ${action.payload.position}: no simulation exists at that position` - ); - } - state.simulations[action.payload.position] = { - ...sim, - ...action.payload.updates, - }; - }, - - // Clear position - clearSimulationAtPosition: (state, action: PayloadAction<0 | 1>) => { - state.simulations[action.payload] = null; - }, - - // Swap positions - swapSimulations: (state) => { - [state.simulations[0], state.simulations[1]] = [state.simulations[1], state.simulations[0]]; - }, - - // Clear all - clearAllSimulations: (state) => { - console.log('[SIMULATIONS REDUCER] ========== clearAllSimulations =========='); - console.log('[SIMULATIONS REDUCER] Before clear:', state.simulations); - state.simulations = [null, null]; - console.log('[SIMULATIONS REDUCER] After clear:', state.simulations); - console.log('[SIMULATIONS REDUCER] ========== COMPLETE =========='); - }, - }, -}); - -export const { - createSimulationAtPosition, - updateSimulationAtPosition, - clearSimulationAtPosition, - swapSimulations, - clearAllSimulations, -} = simulationsSlice.actions; - -// Selectors -export const selectSimulationAtPosition = ( - state: { simulations: SimulationsState }, - position: 0 | 1 -): Simulation | null => { - return state.simulations?.simulations[position] || null; -}; - -export const selectBothSimulations = (state: { - simulations: SimulationsState; -}): [Simulation | null, Simulation | null] => { - return state.simulations?.simulations || [null, null]; -}; - -export const selectHasEmptySlot = (state: { simulations: SimulationsState }): boolean => { - const [sim1, sim2] = selectBothSimulations(state); - return sim1 === null || sim2 === null; -}; - -export const selectIsSlotEmpty = ( - state: { simulations: SimulationsState }, - position: 0 | 1 -): boolean => { - return selectSimulationAtPosition(state, position) === null; -}; - -export const selectSimulationById = ( - state: { simulations: SimulationsState }, - id: string | undefined -): Simulation | null => { - if (!id) { - return null; - } - const [sim1, sim2] = selectBothSimulations(state); - if (sim1?.id === id) { - return sim1; - } - if (sim2?.id === id) { - return sim2; - } - return null; -}; - -export const selectAllSimulations = (state: { simulations: SimulationsState }): Simulation[] => { - const [sim1, sim2] = selectBothSimulations(state); - const simulations: Simulation[] = []; - if (sim1) { - simulations.push(sim1); - } - if (sim2) { - simulations.push(sim2); - } - return simulations; -}; - -export default simulationsSlice.reducer; diff --git a/app/src/routing/guards/CountryGuard.tsx b/app/src/routing/guards/CountryGuard.tsx index d1d67c9e..3a3f6ab4 100644 --- a/app/src/routing/guards/CountryGuard.tsx +++ b/app/src/routing/guards/CountryGuard.tsx @@ -3,10 +3,6 @@ import { useDispatch } from 'react-redux'; import { Navigate, Outlet, useParams } from 'react-router-dom'; import { countryIds } from '@/libs/countries'; import { setCurrentCountry } from '@/reducers/metadataReducer'; -import { clearAllPolicies } from '@/reducers/policyReducer'; -import { clearAllPopulations } from '@/reducers/populationReducer'; -import { clearReport } from '@/reducers/reportReducer'; -import { clearAllSimulations } from '@/reducers/simulationsReducer'; import { AppDispatch } from '@/store'; /** @@ -16,15 +12,12 @@ import { AppDispatch } from '@/store'; * - URL parameter is the single source of truth for country * - This guard validates the country parameter * - Components read country directly from URL via useCurrentCountry() hook - * - Syncs to Redux state for metadata loading and session-scoped state management + * - Syncs to Redux state for metadata loading * * Flow: * 1. Validates countryId from URL parameter * 2. If valid, syncs to Redux metadata state - * 3. Clears all ingredient state for new country (session-scoped behavior) - * - Policies, simulations, populations, and reports are all cleared - * - This prevents cross-country data contamination - * 4. If invalid, redirects to root path + * 3. If invalid, redirects to root path * * Acts as a layout component that either redirects or renders child routes. */ @@ -35,18 +28,10 @@ export function CountryGuard() { // Validation logic const isValid = countryId && countryIds.includes(countryId as any); - // Sync country to Redux and clear all ingredient state (session-scoped) - // This ensures all state is tied to the current country session and prevents - // cross-country data contamination (e.g., US policy used with UK simulation) + // Sync country to Redux for metadata loading useEffect(() => { if (isValid && countryId) { dispatch(setCurrentCountry(countryId)); - // Clear all ingredient state when country changes - // Pass countryId directly from URL to avoid race conditions - dispatch(clearReport(countryId as (typeof countryIds)[number])); - dispatch(clearAllPolicies()); - dispatch(clearAllSimulations()); - dispatch(clearAllPopulations()); } }, [countryId, isValid, dispatch]); diff --git a/app/src/store.ts b/app/src/store.ts index 673fa083..d25f7835 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -1,20 +1,9 @@ import { configureStore } from '@reduxjs/toolkit'; -import flowReducer from './reducers/flowReducer'; import metadataReducer from './reducers/metadataReducer'; -import policyReducer from './reducers/policyReducer'; -import populationReducer from './reducers/populationReducer'; -import reportReducer from './reducers/reportReducer'; -import simulationsReducer from './reducers/simulationsReducer'; export const store = configureStore({ reducer: { - policy: policyReducer, - flow: flowReducer, - household: populationReducer, - simulations: simulationsReducer, - population: populationReducer, metadata: metadataReducer, - report: reportReducer, }, }); diff --git a/app/src/tests/fixtures/components/FlowRouterMocks.tsx b/app/src/tests/fixtures/components/FlowRouterMocks.tsx deleted file mode 100644 index e0d3e157..00000000 --- a/app/src/tests/fixtures/components/FlowRouterMocks.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { vi } from 'vitest'; -import { Flow } from '@/types/flow'; - -export const TEST_COUNTRY_ID = 'us'; -export const TEST_RETURN_PATH = 'reports'; -export const ABSOLUTE_RETURN_PATH = `/us/reports`; - -export const TEST_FLOW: Flow = { - initialFrame: 'TestFrame' as any, - frames: { - TestFrame: { - component: 'TestFrame' as any, - on: { - next: 'NextFrame', - }, - }, - NextFrame: { - component: 'NextFrame' as any, - on: { - back: 'TestFrame', - }, - }, - }, -}; - -export const mockDispatch = vi.fn(); -export const mockSetFlow = vi.fn((payload) => ({ type: 'flow/setFlow', payload })); - -export const createMockFlowState = (overrides?: Partial<{ currentFlow: Flow | null }>) => ({ - flow: { - currentFlow: overrides?.currentFlow ?? null, - currentFrame: null, - flowStack: [], - returnPath: null, - }, -}); - -export const mockUseParams = vi.fn(() => ({ countryId: TEST_COUNTRY_ID })); -export const mockUseSelector = vi.fn(); diff --git a/app/src/tests/fixtures/frames/ReportCreationFrame.ts b/app/src/tests/fixtures/frames/ReportCreationFrame.ts deleted file mode 100644 index 7be8fa4b..00000000 --- a/app/src/tests/fixtures/frames/ReportCreationFrame.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Test constants for ReportCreationFrame -export const TEST_REPORT_LABEL = 'My Test Report'; -export const TEST_REPORT_LABEL_UPDATED = 'Updated Test Report'; -export const EMPTY_REPORT_LABEL = ''; - -// Button and input labels -export const REPORT_NAME_INPUT_LABEL = 'Report name'; -export const YEAR_INPUT_LABEL = 'Year'; -export const CREATE_REPORT_BUTTON_LABEL = 'Create report'; -export const REPORT_NAME_PLACEHOLDER = 'Enter report name'; -export const YEAR_PLACEHOLDER = 'Select year'; - -// Report frame titles -export const REPORT_CREATION_FRAME_TITLE = 'Create report'; - -// Year dropdown values -export const DEFAULT_YEAR = '2025'; -export const AVAILABLE_YEARS = ['2025'] as const; diff --git a/app/src/tests/fixtures/frames/ReportSelectExistingSimulationFrame.ts b/app/src/tests/fixtures/frames/ReportSelectExistingSimulationFrame.ts deleted file mode 100644 index 42639720..00000000 --- a/app/src/tests/fixtures/frames/ReportSelectExistingSimulationFrame.ts +++ /dev/null @@ -1,239 +0,0 @@ -// Test constants for ReportSelectExistingSimulationFrame -export const SELECT_EXISTING_SIMULATION_FRAME_TITLE = 'Select an Existing Simulation'; - -// UI labels -export const NEXT_BUTTON_LABEL = 'Next'; -export const NO_SIMULATIONS_MESSAGE = 'No simulations available. Please create a new simulation.'; -export const SEARCH_LABEL = 'Search'; -export const SEARCH_TODO = 'TODO: Search'; -export const YOUR_SIMULATIONS_LABEL = 'Your Simulations'; -export const SHOWING_SIMULATIONS_PREFIX = 'Showing'; -export const SIMULATIONS_SUFFIX = 'simulations'; - -// Mock simulation data -export const MOCK_CONFIGURED_SIMULATION_1 = { - id: '1', - label: 'Test Simulation 1', - policyId: '1', - populationId: '1', - populationType: 'household' as const, - isCreated: true, -}; - -export const MOCK_CONFIGURED_SIMULATION_2 = { - id: '2', - label: 'Test Simulation 2', - policyId: '2', - populationId: 'pop-2', - populationType: 'geography' as const, - isCreated: true, -}; - -export const MOCK_CONFIGURED_SIMULATION_WITHOUT_LABEL = { - id: '3', - label: null, - policyId: '3', - populationId: '3', - populationType: 'household' as const, - isCreated: true, -}; - -export const MOCK_UNCONFIGURED_SIMULATION = { - id: '4', - label: 'Incomplete Simulation', - policyId: undefined, - populationId: undefined, - populationType: undefined, - isCreated: false, -}; - -// Console log messages -export const SELECTED_SIMULATION_LOG_PREFIX = 'Submitting Simulation in handleSubmit:'; -export const AFTER_SORTING_LOG = - '[ReportSelectExistingSimulationFrame] ========== AFTER SORTING =========='; - -// Incompatibility messages -export const INCOMPATIBLE_POPULATION_MESSAGE = - 'Incompatible: different population than configured simulation'; - -// Population IDs for sorting tests -export const SHARED_POPULATION_ID = 'pop-123'; -export const DIFFERENT_POPULATION_ID = 'pop-different'; -export const BASE_POPULATION_ID = 'pop-base'; -export const SHARED_POPULATION_ID_2 = 'pop-shared'; - -// Simulations for sorting tests -export const OTHER_SIMULATION_CONFIG = { - id: 'other-sim', - label: 'Other Simulation', - policyId: 'policy-1', - populationId: SHARED_POPULATION_ID, - populationType: 'household' as const, - isCreated: true, -}; - -export const INCOMPATIBLE_SIMULATION_CONFIG = { - id: '1', - label: 'Incompatible Sim', - policyId: '1', - populationId: DIFFERENT_POPULATION_ID, - populationType: 'household' as const, - isCreated: true, -}; - -export const COMPATIBLE_SIMULATION_CONFIG = { - id: '2', - label: 'Compatible Sim', - policyId: '2', - populationId: SHARED_POPULATION_ID, - populationType: 'household' as const, - isCreated: true, -}; - -// Compatible simulations for "all compatible" test -export const COMPATIBLE_SIMULATIONS = [ - { - id: '1', - label: 'Sim A', - policyId: '1', - populationId: SHARED_POPULATION_ID_2, - populationType: 'household' as const, - isCreated: true, - }, - { - id: '2', - label: 'Sim B', - policyId: '2', - populationId: SHARED_POPULATION_ID_2, - populationType: 'household' as const, - isCreated: true, - }, - { - id: '3', - label: 'Sim C', - policyId: '3', - populationId: SHARED_POPULATION_ID_2, - populationType: 'household' as const, - isCreated: true, - }, -]; - -// Incompatible simulations for "all incompatible" test -export const INCOMPATIBLE_SIMULATIONS = [ - { - id: '1', - label: 'Sim X', - policyId: '1', - populationId: 'pop-different-1', - populationType: 'household' as const, - isCreated: true, - }, - { - id: '2', - label: 'Sim Y', - policyId: '2', - populationId: 'pop-different-2', - populationType: 'household' as const, - isCreated: true, - }, - { - id: '3', - label: 'Sim Z', - policyId: '3', - populationId: 'pop-different-3', - populationType: 'household' as const, - isCreated: true, - }, -]; - -// Simulations for "no other simulation" test -export const VARIOUS_POPULATION_SIMULATIONS = [ - { - id: '1', - label: 'Sim 1', - policyId: '1', - populationId: 'pop-A', - populationType: 'household' as const, - isCreated: true, - }, - { - id: '2', - label: 'Sim 2', - policyId: '2', - populationId: 'pop-B', - populationType: 'household' as const, - isCreated: true, - }, -]; - -// Simulation for log message test -export const TEST_SIMULATION_CONFIG = { - id: '1', - label: 'Test Sim', - policyId: '1', - populationId: 'pop-1', - populationType: 'household' as const, - isCreated: true, -}; - -// Helper function to create other simulation with custom populationId -export function createOtherSimulation(populationId: string) { - return { - id: 'other-sim', - label: 'Other Simulation', - policyId: 'policy-1', - populationId, - populationType: 'household' as const, - isCreated: true, - }; -} - -// Helper function to create EnhancedUserSimulation from a basic simulation -export function createEnhancedUserSimulation( - simulation: - | typeof MOCK_CONFIGURED_SIMULATION_1 - | typeof MOCK_CONFIGURED_SIMULATION_2 - | typeof MOCK_CONFIGURED_SIMULATION_WITHOUT_LABEL - | typeof MOCK_UNCONFIGURED_SIMULATION -) { - return { - userSimulation: { - id: `user-sim-${simulation.id}`, - userId: '1', - simulationId: simulation.id, - label: simulation.label, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - isCreated: simulation.isCreated, - }, - simulation, - policy: simulation.policyId - ? { - id: simulation.policyId, - label: `Policy ${simulation.policyId}`, - countryId: 'us', - data: {}, - } - : undefined, - household: - simulation.populationType && simulation.populationId - ? { - id: simulation.populationId, - label: `Household ${simulation.populationId}`, - countryId: 'us', - data: {}, - } - : undefined, - geography: - simulation.populationType && simulation.populationId - ? { - id: simulation.populationId, - name: `Geography ${simulation.populationId}`, - countryId: 'us', - type: 'state' as const, - } - : undefined, - isLoading: false, - error: null, - }; -} diff --git a/app/src/tests/fixtures/frames/ReportSelectSimulationFrame.ts b/app/src/tests/fixtures/frames/ReportSelectSimulationFrame.ts deleted file mode 100644 index 453781ec..00000000 --- a/app/src/tests/fixtures/frames/ReportSelectSimulationFrame.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Test constants for ReportSelectSimulationFrame -export const SELECT_SIMULATION_FRAME_TITLE = 'Select Simulation'; - -// Button labels -export const LOAD_EXISTING_SIMULATION_TITLE = 'Load Existing Simulation'; -export const LOAD_EXISTING_SIMULATION_DESCRIPTION = 'Use a simulation you have already created'; -export const CREATE_NEW_SIMULATION_TITLE = 'Create New Simulation'; -export const CREATE_NEW_SIMULATION_DESCRIPTION = 'Build a new simulation'; - -export const NEXT_BUTTON_LABEL = 'Next'; - -// Navigation actions -export const CREATE_NEW_ACTION = 'createNew'; -export const LOAD_EXISTING_ACTION = 'loadExisting'; diff --git a/app/src/tests/fixtures/frames/ReportSetupFrame.ts b/app/src/tests/fixtures/frames/ReportSetupFrame.ts deleted file mode 100644 index 7ba611cb..00000000 --- a/app/src/tests/fixtures/frames/ReportSetupFrame.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Test constants for ReportSetupFrame -export const REPORT_SETUP_FRAME_TITLE = 'Setup Report'; - -// Card titles and descriptions -export const BASELINE_SIMULATION_TITLE = 'Baseline simulation'; -export const BASELINE_SIMULATION_DESCRIPTION = 'Select your baseline simulation'; -export const COMPARISON_SIMULATION_WAITING_TITLE = 'Comparison simulation · Waiting for baseline'; -export const COMPARISON_SIMULATION_WAITING_DESCRIPTION = 'Set up your baseline simulation first'; -export const COMPARISON_SIMULATION_OPTIONAL_TITLE = 'Comparison simulation (optional)'; -export const COMPARISON_SIMULATION_OPTIONAL_DESCRIPTION = - 'Optional: add a second simulation to compare'; -export const COMPARISON_SIMULATION_REQUIRED_TITLE = 'Comparison simulation'; -export const COMPARISON_SIMULATION_REQUIRED_DESCRIPTION = - 'Required: add a second simulation to compare'; - -export const BASELINE_CONFIGURED_TITLE_PREFIX = 'Baseline:'; -export const COMPARISON_CONFIGURED_TITLE_PREFIX = 'Comparison:'; - -// Button labels -export const SETUP_BASELINE_SIMULATION_LABEL = 'Setup baseline simulation'; -export const SETUP_COMPARISON_SIMULATION_LABEL = 'Setup comparison simulation'; -export const REVIEW_REPORT_LABEL = 'Review report'; - -// Console log messages -export const ADDING_SIMULATION_1_MESSAGE = 'Adding simulation 1'; -export const ADDING_SIMULATION_2_MESSAGE = 'Adding simulation 2'; -export const SETTING_UP_SIMULATION_1_MESSAGE = 'Setting up simulation 1'; -export const SETTING_UP_SIMULATION_2_MESSAGE = 'Setting up simulation 2'; -export const BOTH_SIMULATIONS_CONFIGURED_MESSAGE = - 'Both simulations configured, proceeding to next step'; - -// Mock simulation data -export const MOCK_HOUSEHOLD_SIMULATION = { - id: '1', - label: 'Test Household Sim', - policyId: '1', - populationId: 'household-123', // Matches TEST_HOUSEHOLD_ID_1 from useUserHouseholdMocks - populationType: 'household' as const, - isCreated: true, -}; - -export const MOCK_GEOGRAPHY_SIMULATION = { - id: '2', - label: 'Test Geography Sim', - policyId: '2', - populationId: 'geography-789', // Matches TEST_GEOGRAPHY_ID_1 from useUserHouseholdMocks - populationType: 'geography' as const, - isCreated: true, -}; - -export const MOCK_COMPARISON_SIMULATION = { - id: '3', - label: 'Test Comparison Sim', - policyId: '3', - populationId: 'household_2', - populationType: 'household' as const, - isCreated: true, -}; - -export const MOCK_UNCONFIGURED_SIMULATION = { - id: undefined, - label: null, - policyId: undefined, - populationId: undefined, - populationType: undefined, - isCreated: false, -}; - -export const MOCK_PARTIALLY_CONFIGURED_SIMULATION = { - id: undefined, - label: 'In Progress', - policyId: '1', - populationId: undefined, // Missing population - populationType: 'household' as const, - isCreated: false, -}; - -// Population data for prefill tests -export const MOCK_POPULATION_1 = { - label: 'Test Population 1', - isCreated: true, - household: { - id: 'household-123', - countryId: 'us' as any, - householdData: { - people: { you: { age: { '2025': 30 } } }, - families: {}, - spm_units: {}, - households: { 'your household': { members: ['you'] } }, - marital_units: {}, - tax_units: { 'your tax unit': { members: ['you'] } }, - }, - }, - geography: null, -}; - -export const MOCK_POPULATION_2 = { - label: 'Test Population 2', - isCreated: true, - household: { - id: 'household-456', - countryId: 'us' as any, - householdData: { - people: { you: { age: { '2025': 35 } } }, - families: {}, - spm_units: {}, - households: { 'your household': { members: ['you'] } }, - marital_units: {}, - tax_units: { 'your tax unit': { members: ['you'] } }, - }, - }, - geography: null, -}; - -export const MOCK_GEOGRAPHY_POPULATION = { - label: 'Geographic Population', - isCreated: true, - household: null, - geography: { - id: 'geography-789', - countryId: 'us' as any, - scope: 'national', - geographyId: 'us', - }, -}; - -// Console messages for prefill -export const PREFILL_CONSOLE_MESSAGES = { - PRE_FILLING_START: '[ReportSetupFrame] ===== PRE-FILLING POPULATION 2 =====', - PRE_FILLING_HOUSEHOLD: '[ReportSetupFrame] Pre-filling household population', - PRE_FILLING_GEOGRAPHY: '[ReportSetupFrame] Pre-filling geographic population', - HOUSEHOLD_SUCCESS: '[ReportSetupFrame] Household population pre-filled successfully', - GEOGRAPHY_SUCCESS: '[ReportSetupFrame] Geographic population pre-filled successfully', - ALREADY_EXISTS: '[ReportSetupFrame] Population 2 already exists, skipping prefill', - NO_POPULATION: '[ReportSetupFrame] Cannot prefill: simulation1 has no population', -}; - -// Loading messages -export const LOADING_POPULATION_DATA_MESSAGE = 'Loading household data...'; diff --git a/app/src/tests/fixtures/frames/SimulationCreationFrame.ts b/app/src/tests/fixtures/frames/SimulationCreationFrame.ts deleted file mode 100644 index 1725081d..00000000 --- a/app/src/tests/fixtures/frames/SimulationCreationFrame.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Test constants for SimulationCreationFrame -export const TEST_SIMULATION_LABEL = 'My Test Simulation'; -export const TEST_SIMULATION_LABEL_UPDATED = 'Updated Test Simulation'; -export const TEST_TEMP_SIMULATION_ID = 'temp-1'; - -// Auto-naming test data -export const TEST_REPORT_NAME = '2025 Tax Analysis'; -export const EXPECTED_BASELINE_SIMULATION_LABEL = 'Baseline simulation'; -export const EXPECTED_REFORM_SIMULATION_LABEL = 'Reform simulation'; -export const EXPECTED_BASELINE_WITH_REPORT_LABEL = '2025 Tax Analysis baseline simulation'; -export const EXPECTED_REFORM_WITH_REPORT_LABEL = '2025 Tax Analysis reform simulation'; - -// Button and input labels -export const SIMULATION_NAME_INPUT_LABEL = 'Simulation name'; -export const CREATE_SIMULATION_BUTTON_LABEL = 'Create simulation'; -export const SIMULATION_NAME_PLACEHOLDER = 'Enter simulation name'; diff --git a/app/src/tests/fixtures/frames/SimulationSetupFrameMocks.ts b/app/src/tests/fixtures/frames/SimulationSetupFrameMocks.ts deleted file mode 100644 index 30d5883f..00000000 --- a/app/src/tests/fixtures/frames/SimulationSetupFrameMocks.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Fixtures for SimulationSetupFrame tests - -// UI text constants -export const UI_TEXT = { - ADD_POPULATION: 'Add household(s)', - ADD_POLICY: 'Add Policy', - FROM_BASELINE_SUFFIX: '(from baseline)', - INHERITED_HOUSEHOLD_PREFIX: 'Household #', - INHERITED_GEOGRAPHY_PREFIX: 'Household collection #', - INHERITED_SUFFIX: '• Inherited from baseline simulation', - SELECT_GEOGRAPHIC_OR_HOUSEHOLD: 'Select a household collection or custom household', -}; - -// Mock populations -export const MOCK_HOUSEHOLD_POPULATION = { - label: 'My Household', - isCreated: true, - household: { - id: 'household-123', - countryId: 'us' as any, - householdData: { - people: { you: { age: { '2025': 30 } } }, - families: {}, - spm_units: {}, - households: { 'your household': { members: ['you'] } }, - marital_units: {}, - tax_units: { 'your tax unit': { members: ['you'] } }, - }, - }, - geography: null, -}; - -export const MOCK_GEOGRAPHY_POPULATION = { - label: 'United States', - isCreated: true, - household: null, - geography: { - id: 'geography-456', - countryId: 'us' as any, - scope: 'national', - geographyId: 'us', - }, -}; - -export const MOCK_UNFILLED_POPULATION = { - label: null, - isCreated: false, - household: null, - geography: null, -}; - -// Mock policies -export const MOCK_POLICY = { - id: 'policy-789', - label: 'Test Policy', - isCreated: true, -}; - -export const MOCK_UNFILLED_POLICY = { - id: null, - label: null, - isCreated: false, -}; - -// Mock simulations -export const MOCK_SIMULATION = { - id: 'sim-123', - label: 'Test Simulation', - policyId: 'policy-789', - populationId: 'household-123', - populationType: 'household' as const, - isCreated: true, -}; - -export const MOCK_SIMULATION_NO_POPULATION = { - id: 'sim-456', - label: 'Sim Without Population', - policyId: 'policy-789', - populationId: undefined, - populationType: undefined, - isCreated: false, -}; - -// Test positions -export const POSITION_0 = 0; -export const POSITION_1 = 1; - -// Mode constants -export const MODE_REPORT = 'report'; -export const MODE_STANDALONE = 'standalone'; - -// Helper function to create mockUseSelector implementation for standalone mode (position 0) -export function createStandaloneMockSelector( - population: - | typeof MOCK_HOUSEHOLD_POPULATION - | typeof MOCK_GEOGRAPHY_POPULATION - | typeof MOCK_UNFILLED_POPULATION, - policy = MOCK_POLICY, - simulation = MOCK_SIMULATION -) { - let callCount = 0; - return () => { - callCount++; - // Call 1: selectCurrentPosition - if (callCount === 1) { - return POSITION_0; - } - // Call 2: selectSimulationAtPosition - if (callCount === 2) { - return simulation; - } - // Call 3: selectActivePolicy - if (callCount === 3) { - return policy; - } - // Call 4: selectActivePopulation - if (callCount === 4) { - return population; - } - // Call 5: state.report.mode - if (callCount === 5) { - return MODE_STANDALONE; - } - return null; - }; -} - -// Helper function to create mockUseSelector implementation for report mode (position 1) -export function createReportModeMockSelector( - population: - | typeof MOCK_HOUSEHOLD_POPULATION - | typeof MOCK_GEOGRAPHY_POPULATION - | typeof MOCK_UNFILLED_POPULATION, - policy = MOCK_POLICY, - simulation = MOCK_SIMULATION -) { - let callCount = 0; - return () => { - callCount++; - // Call 1: selectCurrentPosition - if (callCount === 1) { - return POSITION_1; - } - // Call 2: selectSimulationAtPosition - if (callCount === 2) { - return simulation; - } - // Call 3: selectActivePolicy - if (callCount === 3) { - return policy; - } - // Call 4: selectActivePopulation - if (callCount === 4) { - return population; - } - // Call 5: state.report.mode - if (callCount === 5) { - return MODE_REPORT; - } - return null; - }; -} diff --git a/app/src/tests/fixtures/frames/SimulationSubmitFrame.ts b/app/src/tests/fixtures/frames/SimulationSubmitFrame.ts deleted file mode 100644 index 4daedbf7..00000000 --- a/app/src/tests/fixtures/frames/SimulationSubmitFrame.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Simulation } from '@/types/ingredients/Simulation'; - -// Test IDs -export const TEST_SIMULATION_ID = '123'; -export const TEST_SIMULATION_ID_MISSING = '999'; -export const TEST_HOUSEHOLD_ID = '456'; -export const TEST_POLICY_ID = '789'; - -// Test labels -export const TEST_SIMULATION_LABEL = 'Test Simulation Submit'; -export const TEST_POPULATION_LABEL = 'Test Population'; -export const TEST_POLICY_LABEL = 'Test Policy Reform'; - -// UI text constants -export const SUBMIT_BUTTON_TEXT = 'Save Simulation'; -export const SUBMIT_VIEW_TITLE = 'Summary of Selections'; -export const POPULATION_ADDED_TITLE = 'Population Added'; -export const POLICY_REFORM_ADDED_TITLE = 'Policy Reform Added'; - -// Mock simulations for different test scenarios -export const mockSimulationComplete: Simulation = { - id: TEST_SIMULATION_ID, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID, - label: TEST_SIMULATION_LABEL, - isCreated: false, -}; - -export const mockSimulationPartial: Simulation = { - id: TEST_SIMULATION_ID, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: undefined, - label: TEST_SIMULATION_LABEL, - isCreated: false, -}; - -export const mockSimulationEmpty: Simulation = { - id: undefined, - populationId: undefined, - populationType: undefined, - policyId: undefined, - label: null, - isCreated: false, -}; - -// Mock state configurations for testing -export const mockStateWithOldSimulation = { - simulation: mockSimulationComplete, - policy: { - policies: [ - { - id: TEST_POLICY_ID, - label: TEST_POLICY_LABEL, - parameters: [], - isCreated: true, - }, - null, - ] as any, - }, - population: { - populations: [ - { - household: { - id: TEST_HOUSEHOLD_ID, - }, - label: TEST_POPULATION_LABEL, - isCreated: true, - geography: null, - }, - null, - ] as any, - }, -}; - -export const mockStateWithNewSimulation = { - simulations: { - simulations: [mockSimulationComplete, null] as [Simulation | null, Simulation | null], - }, - policy: { - policies: [ - { - id: TEST_POLICY_ID, - label: TEST_POLICY_LABEL, - parameters: [], - isCreated: true, - }, - null, - ] as any, - }, - population: { - populations: [ - { - household: { - id: TEST_HOUSEHOLD_ID, - }, - label: TEST_POPULATION_LABEL, - isCreated: true, - geography: null, - }, - null, - ] as any, - }, -}; - -export const mockStateWithBothSimulations = { - // Old state - simulation: mockSimulationPartial, - // New state - simulations: { - simulations: [mockSimulationComplete, null] as [Simulation | null, Simulation | null], - }, - policy: { - policies: [ - { - id: TEST_POLICY_ID, - label: TEST_POLICY_LABEL, - parameters: [], - isCreated: true, - }, - null, - ] as any, - }, - population: { - populations: [ - { - household: { - id: TEST_HOUSEHOLD_ID, - }, - label: TEST_POPULATION_LABEL, - isCreated: true, - geography: null, - }, - null, - ] as any, - }, -}; diff --git a/app/src/tests/fixtures/frames/policyFrameMocks.ts b/app/src/tests/fixtures/frames/policyFrameMocks.ts deleted file mode 100644 index fe0b2822..00000000 --- a/app/src/tests/fixtures/frames/policyFrameMocks.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import { Policy } from '@/types/ingredients/Policy'; -import { Parameter } from '@/types/subIngredients/parameter'; -import { ValueInterval } from '@/types/subIngredients/valueInterval'; - -// Mock selector functions -export const mockSelectCurrentPosition = vi.fn(); -export const mockSelectActivePolicy = vi.fn(); - -// Mock dispatch -export const mockDispatch = vi.fn(); - -// Mock navigation functions -export const mockOnNavigate = vi.fn(); -export const mockOnReturn = vi.fn(); - -// Mock flow props -export const createMockFlowProps = (overrides?: Partial) => ({ - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: { - component: 'PolicyFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - ...overrides, -}); - -// Mock policy data -export const MOCK_VALUE_INTERVAL: ValueInterval = { - startDate: `${CURRENT_YEAR}-01-01`, - endDate: `${CURRENT_YEAR}-12-31`, - value: 0.25, -}; - -export const MOCK_PARAMETER: Parameter = { - name: 'income_tax_rate', - values: [MOCK_VALUE_INTERVAL], -}; - -export const MOCK_POLICY_WITH_PARAMS: Policy = { - id: '123', - countryId: 'us', - label: 'Test Tax Policy', - isCreated: false, - parameters: [MOCK_PARAMETER], -}; - -export const MOCK_EMPTY_POLICY: Policy = { - countryId: 'us', - label: 'New Policy', - isCreated: false, - parameters: [], -}; - -// Mock API responses -export const mockCreatePolicySuccessResponse = { - result: { - policy_id: '123', - status: 'ok', - }, -}; - -// Mock hooks -export const mockUseCreatePolicy = { - createPolicy: vi.fn(), - isPending: false, - isError: false, - error: null, -}; - -// Mock metadata for parameters -export const mockParameterMetadata = { - name: 'income_tax_rate', - parameter: 'income_tax_rate', - label: 'Income tax rate', - type: 'parameter', - unit: '%', - period: 'year', - defaultValue: 0.2, - minValue: 0, - maxValue: 1, - scale: 100, -}; - -// Mock report states for auto-naming tests -export const mockReportStateStandalone = { - id: '', - label: null, - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'standalone' as const, - activeSimulationPosition: 0 as const, -}; - -export const mockReportStateReportWithName = { - id: '456', - label: '2025 Tax Analysis', - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'report' as const, - activeSimulationPosition: 0 as const, -}; - -export const mockReportStateReportWithoutName = { - id: '789', - label: null, - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'report' as const, - activeSimulationPosition: 0 as const, -}; - -// Auto-naming test constants -export const TEST_REPORT_NAME = '2025 Tax Analysis'; -export const EXPECTED_BASELINE_POLICY_LABEL = 'Baseline policy'; -export const EXPECTED_REFORM_POLICY_LABEL = 'Reform policy'; -export const EXPECTED_BASELINE_WITH_REPORT_LABEL = '2025 Tax Analysis baseline policy'; -export const EXPECTED_REFORM_WITH_REPORT_LABEL = '2025 Tax Analysis reform policy'; diff --git a/app/src/tests/fixtures/frames/populationMocks.ts b/app/src/tests/fixtures/frames/populationMocks.ts deleted file mode 100644 index 920b1c04..00000000 --- a/app/src/tests/fixtures/frames/populationMocks.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import { RootState } from '@/store'; -import { FlowComponentProps } from '@/types/flow'; -import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; -import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; - -// Test IDs and labels -export const TEST_USER_ID = 'test-user-123'; -export const TEST_HOUSEHOLD_ID = '456'; -export const TEST_POPULATION_LABEL = `Test Population ${CURRENT_YEAR}`; -export const EMPTY_LABEL = ''; -export const LONG_LABEL = 'A'.repeat(101); // Over 100 char limit - -// Test text constants for assertions -export const UI_TEXT = { - // Common - CONTINUE_BUTTON: /Continue/i, - BACK_BUTTON: /Back/i, - - // GeographicConfirmationFrame - CONFIRM_GEOGRAPHIC_TITLE: 'Confirm Geographic Selection', - CREATE_ASSOCIATION_BUTTON: /Create Geographic Association/i, - SCOPE_NATIONAL: 'National', - SCOPE_STATE: 'State', - SCOPE_CONSTITUENCY: 'Constituency', - COUNTRY_US: 'United States', - COUNTRY_UK: 'United Kingdom', - STATE_CALIFORNIA: 'California', - CONSTITUENCY_LONDON: 'London', - - // HouseholdBuilderFrame - BUILD_HOUSEHOLD_TITLE: 'Build Your Household', - CREATE_HOUSEHOLD_BUTTON: /Create household/i, - TAX_YEAR_LABEL: 'Tax Year', - MARITAL_STATUS_LABEL: 'Marital Status', - NUM_CHILDREN_LABEL: 'Number of Children', - YOU_LABEL: 'You', - YOUR_PARTNER_LABEL: 'Your Partner', - CHILD_LABEL: (n: number) => `Child ${n}`, - MARITAL_SINGLE: 'Single', - MARITAL_MARRIED: 'Married', - ERROR_LOAD_DATA: 'Failed to Load Required Data', - ERROR_LOAD_MESSAGE: /Unable to load household configuration data/, - - // SelectGeographicScopeFrame - CHOOSE_SCOPE_TITLE: 'Choose Geographic Scope', - SCOPE_NATIONAL_LABEL: 'National', - SCOPE_STATE_LABEL: 'State', - SCOPE_HOUSEHOLD_LABEL: 'Custom Household', - SELECT_STATE_PLACEHOLDER: 'Select a state', - SELECT_UK_COUNTRY_PLACEHOLDER: 'Select a UK country', - SELECT_CONSTITUENCY_PLACEHOLDER: 'Select a constituency', - COUNTRY_ENGLAND: 'England', - COUNTRY_SCOTLAND: 'Scotland', - STATE_NEW_YORK: 'New York', - STATE_TEXAS: 'Texas', - CONSTITUENCY_MANCHESTER: 'Manchester', - - // SetPopulationLabelFrame - NAME_POPULATION_TITLE: 'Name Your Household(s)', - POPULATION_LABEL: 'Household Label', - LABEL_PLACEHOLDER: `e.g., My Family ${CURRENT_YEAR}, All California Households, UK National Households`, - LABEL_DESCRIPTION: 'Give your household(s) a descriptive name.', - LABEL_HELP_TEXT: 'This label will help you identify this household(s) when creating simulations.', - ERROR_EMPTY_LABEL: 'Please enter a label for your household(s)', - ERROR_LONG_LABEL: 'Label must be less than 100 characters', - DEFAULT_NATIONAL_LABEL: 'National Households', - DEFAULT_HOUSEHOLD_LABEL: 'Custom Household', - DEFAULT_STATE_LABEL: (state: string) => `${state} Households`, -} as const; - -// Error messages -export const ERROR_MESSAGES = { - FAILED_CREATE_ASSOCIATION: 'Failed to create geographic association:', - FAILED_CREATE_HOUSEHOLD: 'Failed to create household:', - STATE_NO_REGION: 'State selected but no region chosen', - VALIDATION_FAILED: 'Household validation failed:', - MISSING_REQUIRED_FIELDS: 'Missing required fields', -} as const; - -// Input field placeholders -export const PLACEHOLDERS = { - AGE: 'Age', - EMPLOYMENT_INCOME: 'Employment Income', - STATE_CODE: 'State', -} as const; - -// Numeric test values -export const TEST_VALUES = { - DEFAULT_AGE: 30, - DEFAULT_INCOME: 50000, - UPDATED_AGE: 35, - UPDATED_INCOME: 75000, - PARTNER_AGE: 28, - PARTNER_INCOME: 45000, - CHILD_DEFAULT_AGE: 10, - MIN_ADULT_AGE: 18, - MAX_ADULT_AGE: 120, - MIN_CHILD_AGE: 0, - MAX_CHILD_AGE: 17, - LABEL_MAX_LENGTH: 100, - TEST_LABEL: 'Test Label', -} as const; - -// Geographic constants -export const GEOGRAPHIC_SCOPES = { - NATIONAL: 'national', - STATE: 'state', - HOUSEHOLD: 'household', -} as const; - -export const TEST_COUNTRIES = { - US: 'us', - UK: 'uk', -} as const; - -export const TEST_REGIONS = { - US_CALIFORNIA: 'state/ca', - US_NEW_YORK: 'state/ny', - UK_LONDON: 'constituency/london', - UK_MANCHESTER: 'constituency/manchester', -} as const; - -// Mock geography objects -export const mockNationalGeography: Geography = { - id: TEST_COUNTRIES.US, - countryId: TEST_COUNTRIES.US as any, - scope: 'national', - geographyId: TEST_COUNTRIES.US, -}; - -export const mockStateGeography: Geography = { - id: `${TEST_COUNTRIES.US}-ca`, - countryId: TEST_COUNTRIES.US as any, - scope: 'subnational', - geographyId: 'ca', -}; - -// Mock household - using a function to return a fresh mutable object each time -export const getMockHousehold = (): Household => ({ - id: TEST_HOUSEHOLD_ID, - countryId: TEST_COUNTRIES.US as any, - householdData: { - people: { - you: { - age: { [CURRENT_YEAR]: 30 }, - employment_income: { [CURRENT_YEAR]: 50000 }, - }, - }, - families: {}, - spm_units: {}, - households: { - 'your household': { - members: ['you'], - }, - }, - marital_units: {}, - tax_units: { - 'your tax unit': { - members: ['you'], - }, - }, - }, -}); - -// Keep a static version for backward compatibility but note it should not be mutated -export const mockHousehold: Household = getMockHousehold(); - -// Mock Redux state -export const mockPopulationState = { - populations: [null, null] as [any, any], - type: 'geographic' as const, - id: null, - label: TEST_POPULATION_LABEL, - geography: mockNationalGeography, - household: null, - isCreated: false, -}; - -export const getMockHouseholdPopulationState = () => ({ - type: 'household' as const, - id: TEST_HOUSEHOLD_ID, - label: TEST_POPULATION_LABEL, - geography: null, - household: getMockHousehold(), - isCreated: false, -}); - -export const mockHouseholdPopulationState = getMockHouseholdPopulationState(); - -export const mockMetadataState = { - currentCountry: TEST_COUNTRIES.US, - entities: { - person: { plural: 'people', label: 'Person' }, - tax_unit: { plural: 'tax_units', label: 'Tax unit' }, - household: { plural: 'households', label: 'Household' }, - }, - variables: { - age: { defaultValue: 30 }, - employment_income: { defaultValue: 0 }, - }, - basic_inputs: { - person: ['age', 'employment_income'], - household: ['state_code'], - }, - variable_metadata: { - state_code: { - possibleValues: [ - { value: 'CA', label: 'California' }, - { value: 'NY', label: 'New York' }, - ], - }, - }, - loading: false, - error: null, -}; - -export const mockRootState: Partial = { - population: mockPopulationState, - metadata: mockMetadataState as any, -}; - -// Mock geographic association -export const mockGeographicAssociation: UserGeographyPopulation = { - type: 'geography', - id: `${TEST_USER_ID}-${Date.now()}`, - userId: TEST_USER_ID, - countryId: TEST_COUNTRIES.US, - scope: 'national', - geographyId: TEST_COUNTRIES.US, - label: 'United States', - createdAt: new Date().toISOString(), -}; - -// Mock region data -export const mockUSRegions = { - result: { - economy_options: { - region: [ - { name: 'us', label: 'United States' }, - { name: 'state/ca', label: 'California' }, - { name: 'state/ny', label: 'New York' }, - { name: 'state/tx', label: 'Texas' }, - ], - }, - }, -}; - -export const mockUKRegions = { - result: { - economy_options: { - region: [ - { name: 'uk', label: 'United Kingdom' }, - { name: 'country/england', label: 'England' }, - { name: 'country/scotland', label: 'Scotland' }, - { name: 'constituency/london', label: 'London' }, - { name: 'constituency/manchester', label: 'Manchester' }, - ], - }, - }, -}; - -// Mock flow props -export const mockFlowProps: FlowComponentProps = { - onNavigate: vi.fn(), - onReturn: vi.fn(), - isInSubflow: false, - flowConfig: {} as any, - flowDepth: 0, -}; - -// ============= MOCKS FOR MODULES ============= - -// Mock regions data -export const mockRegions = { - us_regions: mockUSRegions, - uk_regions: mockUKRegions, -}; - -// Mock constants module -export const mockConstants = { - MOCK_USER_ID: TEST_USER_ID, -}; - -// Mock hooks -export const mockCreateGeographicAssociation = vi.fn(); -export const mockResetIngredient = vi.fn(); -export const mockCreateHousehold = vi.fn(); - -export const mockUseCreateGeographicAssociation = () => ({ - mutateAsync: mockCreateGeographicAssociation, - isPending: false, -}); - -export const mockUseIngredientReset = () => ({ - resetIngredient: mockResetIngredient, -}); - -export const mockUseCreateHousehold = () => ({ - createHousehold: mockCreateHousehold, - isPending: false, -}); - -// Mock household utilities -export const mockHouseholdBuilder = vi.fn().mockImplementation((_countryId, _taxYear) => ({ - build: vi.fn(() => getMockHousehold()), - loadHousehold: vi.fn(), - addAdult: vi.fn(), - addChild: vi.fn(), - removePerson: vi.fn(), - setMaritalStatus: vi.fn(), - assignToGroupEntity: vi.fn(), -})); - -export const mockHouseholdQueries = { - getChildCount: vi.fn(() => 0), - getChildren: vi.fn(() => []), - getPersonVariable: vi.fn((_household, _person, variable, _year) => { - if (variable === 'age') { - return TEST_VALUES.DEFAULT_AGE; - } - if (variable === 'employment_income') { - return TEST_VALUES.DEFAULT_INCOME; - } - return 0; - }), -}; - -export const mockHouseholdValidation = { - isReadyForSimulation: vi.fn(() => ({ isValid: true, errors: [] })), -}; - -export const mockHouseholdAdapter = { - toCreationPayload: vi.fn(() => ({ - country_id: TEST_COUNTRIES.US, - data: getMockHousehold().householdData, - })), -}; - -// Mock metadata utilities -export const mockGetTaxYears = () => mockTaxYears; -export const mockGetBasicInputFields = () => ({ - person: ['age', 'employment_income'], - household: ['state_code'], -}); -export const mockGetFieldLabel = (field: string) => { - const labels: Record = { - state_code: PLACEHOLDERS.STATE_CODE, - age: PLACEHOLDERS.AGE, - employment_income: PLACEHOLDERS.EMPLOYMENT_INCOME, - }; - return labels[field] || field; -}; -export const mockIsDropdownField = (field: string) => field === 'state_code'; -export const mockGetFieldOptions = () => [ - { value: 'CA', label: UI_TEXT.STATE_CALIFORNIA }, - { value: 'NY', label: UI_TEXT.STATE_NEW_YORK }, -]; - -// Mock household creation response -export const mockCreateHouseholdResponse = { - result: { - household_id: TEST_HOUSEHOLD_ID, - }, -}; - -// Helper functions for tests -export const createMockStore = (overrides?: Partial) => ({ - getState: vi.fn(() => ({ - ...mockRootState, - ...overrides, - })), - dispatch: vi.fn(), - subscribe: vi.fn(), - replaceReducer: vi.fn(), -}); - -export const mockTaxYears = [ - { value: '2025', label: '2025' }, - { value: '2024', label: '2024' }, - { value: '2023', label: '2023' }, - { value: '2022', label: '2022' }, -]; diff --git a/app/src/tests/fixtures/frames/reportFrameMocks.ts b/app/src/tests/fixtures/frames/reportFrameMocks.ts deleted file mode 100644 index fd77364a..00000000 --- a/app/src/tests/fixtures/frames/reportFrameMocks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { vi } from 'vitest'; -import { Simulation } from '@/types/ingredients/Simulation'; - -// Mock simulations for testing -export const mockSimulation1: Simulation = { - id: '123', - countryId: 'us', - apiVersion: '1.0.0', - policyId: '123', - populationId: '123', - populationType: 'household', - label: 'Baseline Simulation', - isCreated: true, -}; - -export const mockSimulation2: Simulation = { - id: '456', - countryId: 'uk', - apiVersion: '1.0.0', - policyId: '456', - populationId: 'test-value', - populationType: 'geography', - label: 'Reform Simulation', - isCreated: true, -}; - -export const mockSimulationUnconfigured: Simulation = { - countryId: 'us', - label: null, - isCreated: false, -}; - -// Mock navigation function -export const mockOnNavigate = vi.fn(); -export const mockOnReturn = vi.fn(); - -// Mock flow props for report frames -export const mockReportFlowProps = { - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: { - component: 'ReportSetupFrame' as const, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, -}; - -// Helper to create mock Redux state for report frames -export const createMockReportState = (overrides?: { - mode?: 'standalone' | 'report'; - activeSimulationPosition?: 0 | 1; - simulations?: [Simulation | null, Simulation | null]; -}) => { - const { - mode = 'standalone', - activeSimulationPosition = 0, - simulations = [null, null], - } = overrides || {}; - - return { - report: { - reportId: undefined, - label: null, - countryId: 'us', - apiVersion: null, - simulationIds: [], - status: 'pending', - output: null, - createdAt: null, - updatedAt: null, - activeSimulationPosition, - mode, - }, - simulations: { - simulations, - }, - policy: { - policies: [null, null], - }, - population: { - populations: [null, null], - }, - household: { - populations: [null, null], - }, - flow: {}, - metadata: {}, - }; -}; diff --git a/app/src/tests/fixtures/frames/simulationFrameMocks.ts b/app/src/tests/fixtures/frames/simulationFrameMocks.ts deleted file mode 100644 index 645a812a..00000000 --- a/app/src/tests/fixtures/frames/simulationFrameMocks.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { vi } from 'vitest'; -import { Simulation } from '@/types/ingredients/Simulation'; - -// Mock simulations -export const mockSimulation: Simulation = { - id: '123', - countryId: 'us', - apiVersion: '1.0.0', - policyId: '123', - populationId: '123', - populationType: 'household', - label: 'Test Simulation', - isCreated: true, -}; - -export const mockSimulationWithoutPolicy: Simulation = { - countryId: 'us', - populationId: '123', - populationType: 'household', - label: 'Partial Simulation', - isCreated: false, -}; - -export const mockSimulationEmpty: Simulation = { - countryId: 'us', - label: null, - isCreated: false, -}; - -// Mock policy (old structure - to be migrated) -export const mockPolicyOld = { - id: '123', - label: 'Test Policy', - isCreated: true, - parameters: [], -}; - -// Mock population (old structure - to be migrated) -export const mockPopulationOld = { - label: 'Test Population', - isCreated: true, - household: { - id: '123', - countryId: 'us', - householdData: { - people: {}, - }, - }, - geography: null, -}; - -// Mock navigation function -export const mockOnNavigate = vi.fn(); - -// Mock dispatch -export const mockDispatch = vi.fn(); - -// Helper to create mock state for simulation frames -export const createMockSimulationState = (overrides?: { - mode?: 'standalone' | 'report'; - activeSimulationPosition?: 0 | 1; - reportLabel?: string | null; - simulation?: Simulation | null; - policy?: any; // Old structure - population?: any; // Old structure -}) => { - const { - mode = 'standalone', - activeSimulationPosition = 0, - reportLabel = null, - simulation = null, - policy = {}, - population = {}, - } = overrides || {}; - - return { - report: { - reportId: undefined, - label: reportLabel, - countryId: 'us', - apiVersion: null, - simulationIds: [], - status: 'pending', - output: null, - createdAt: null, - updatedAt: null, - activeSimulationPosition, - mode, - }, - simulations: { - simulations: [simulation, null], - }, - policy, - population, - }; -}; - -// Mock report states for auto-naming tests -export const mockReportStateStandalone = { - id: '', - label: null, - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'standalone' as const, - activeSimulationPosition: 0 as const, -}; - -export const mockReportStateReportWithName = { - id: '456', - label: '2025 Tax Analysis', - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'report' as const, - activeSimulationPosition: 0 as const, -}; - -export const mockReportStateReportWithoutName = { - id: '789', - label: null, - countryId: 'us' as const, - apiVersion: null, - simulationIds: [], - status: 'pending' as const, - output: null, - mode: 'report' as const, - activeSimulationPosition: 0 as const, -}; diff --git a/app/src/tests/fixtures/frames/simulationSelectExistingMocks.ts b/app/src/tests/fixtures/frames/simulationSelectExistingMocks.ts deleted file mode 100644 index 0ec7ca41..00000000 --- a/app/src/tests/fixtures/frames/simulationSelectExistingMocks.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; - -// Mock selector functions -export const mockSelectCurrentPosition = vi.fn(); - -// Mock Redux hooks -export const mockUseSelector = (selector: any) => { - if (selector.toString().includes('metadata')) { - return { regions: {} }; // Mock metadata state - } - return selector({}); -}; - -// Mock policy data for tests -export const MOCK_POLICY_WITH_PARAMS = { - policy: { - id: 123, - country_id: 'us', - policy_json: { - income_tax_rate: [ - { startDate: `${CURRENT_YEAR}-01-01`, endDate: `${CURRENT_YEAR}-12-31`, value: 0.25 }, - ], - }, - }, - association: { label: 'My Tax Reform' }, -}; - -export const MOCK_EMPTY_POLICY = { - policy: { - id: 456, - country_id: 'us', - policy_json: {}, - }, - association: { label: 'Empty Policy' }, -}; - -export const mockPolicyData = [MOCK_POLICY_WITH_PARAMS, MOCK_EMPTY_POLICY]; - -// Mock household data for tests -export const MOCK_HOUSEHOLD_FAMILY = { - household: { - id: '123', - country_id: 'us', - household_json: { people: {} }, - }, - association: { - label: 'My Family', - countryId: 'us', - isCreated: true, - }, -}; - -export const mockHouseholdData = [MOCK_HOUSEHOLD_FAMILY]; - -// Mock geographic data for tests -export const MOCK_GEOGRAPHIC_US = { - geography: { - id: 'mock-geography', - countryId: 'us', - scope: 'national', - geographyId: 'us', - name: 'United States', - }, - association: { - label: 'US National', - countryId: 'us', - isCreated: true, - }, -}; - -export const mockGeographicData = [MOCK_GEOGRAPHIC_US]; - -// Mock hook responses -export const mockLoadingResponse = { - data: null, - isLoading: true, - isError: false, - error: null, -}; - -export const mockErrorResponse = (errorMessage: string) => ({ - data: null, - isLoading: false, - isError: true, - error: new Error(errorMessage), -}); - -export const mockSuccessResponse = (data: any) => ({ - data, - isLoading: false, - isError: false, - error: null, -}); - -// Mock adapters -export const mockHouseholdAdapter = { - fromAPI: (household: any) => ({ - id: household.id, - countryId: household.country_id, - householdData: household.household_json || {}, - }), -}; diff --git a/app/src/tests/fixtures/hooks/useCreateSimulationMocks.ts b/app/src/tests/fixtures/hooks/useCreateSimulationMocks.ts index 6ccbe0b5..3fc9b2a6 100644 --- a/app/src/tests/fixtures/hooks/useCreateSimulationMocks.ts +++ b/app/src/tests/fixtures/hooks/useCreateSimulationMocks.ts @@ -1,12 +1,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; -import flowReducer from '@/reducers/flowReducer'; import metadataReducer from '@/reducers/metadataReducer'; -import policyReducer from '@/reducers/policyReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import simulationsReducer from '@/reducers/simulationsReducer'; import { SIMULATION_IDS, TEST_COUNTRIES } from '../api/simulationMocks'; // ============= TEST CONSTANTS ============= @@ -74,18 +69,12 @@ export const mockResponseWithoutId = { /** * Creates a test Redux store with custom initial state - * Uses the same structure as the main store + * Uses only metadataReducer since other reducers were removed */ export function createTestStore(initialState?: any) { const storeConfig = { reducer: { - policy: policyReducer, - flow: flowReducer, - household: populationReducer, - simulations: simulationsReducer, - population: populationReducer, metadata: metadataReducer, - report: reportReducer, }, }; diff --git a/app/src/tests/fixtures/reducers/activeSelectorsMocks.ts b/app/src/tests/fixtures/reducers/activeSelectorsMocks.ts deleted file mode 100644 index 937d6f02..00000000 --- a/app/src/tests/fixtures/reducers/activeSelectorsMocks.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { RootState } from '@/store'; -import { Policy } from '@/types/ingredients/Policy'; -import { Population } from '@/types/ingredients/Population'; -import { Simulation } from '@/types/ingredients/Simulation'; - -// Mock simulations -export const mockSimulation1: Simulation = { - id: '123', - countryId: 'us', - apiVersion: '1.0.0', - policyId: '123', - populationId: '123', - populationType: 'household', - label: 'Baseline Simulation', - isCreated: true, -}; - -export const mockSimulation2: Simulation = { - id: '456', - countryId: 'uk', - apiVersion: '1.0.0', - policyId: 'test-geography', - populationId: '456', - populationType: 'geography', - label: 'Reform Simulation', - isCreated: false, -}; - -// Mock policies -export const mockPolicy1: Policy = { - id: '123', - label: 'Baseline Policy', - parameters: [], - isCreated: true, -}; - -export const mockPolicy2: Policy = { - id: '456', - label: 'Reform Policy', - parameters: [], - isCreated: false, -}; - -// Mock populations -export const mockPopulation1: Population = { - label: 'Baseline Population', - isCreated: true, - household: { - id: '123', - countryId: 'us', - householdData: { - people: {}, - }, - }, - geography: null, -}; - -export const mockPopulation2: Population = { - label: 'Reform Population', - isCreated: false, - household: null, - geography: { - id: 'test-geography', - countryId: 'uk', - scope: 'national', - geographyId: 'uk', - }, -}; - -// Helper function to create a mock RootState -export const createMockRootState = (overrides?: { - reportMode?: 'standalone' | 'report'; - activePosition?: 0 | 1; - simulations?: [Simulation | null, Simulation | null]; - policies?: [Policy | null, Policy | null]; - populations?: [Population | null, Population | null]; -}): RootState => { - const { - reportMode = 'standalone', - activePosition = 0, - simulations = [mockSimulation1, mockSimulation2], - policies = [mockPolicy1, mockPolicy2], - populations = [mockPopulation1, mockPopulation2], - } = overrides || {}; - - return { - report: { - reportId: undefined, - label: null, - countryId: 'us', - apiVersion: null, - simulationIds: [], - status: 'pending', - output: null, - createdAt: null, - updatedAt: null, - activeSimulationPosition: activePosition, - mode: reportMode, - }, - simulations: { - simulations, - }, - policy: { - policies, - }, - population: { - populations, - }, - } as unknown as RootState; -}; - -// Common test scenarios -export const STANDALONE_MODE_STATE = createMockRootState({ - reportMode: 'standalone', - activePosition: 1, // Should be ignored in standalone mode -}); - -export const REPORT_MODE_POSITION_0_STATE = createMockRootState({ - reportMode: 'report', - activePosition: 0, -}); - -export const REPORT_MODE_POSITION_1_STATE = createMockRootState({ - reportMode: 'report', - activePosition: 1, -}); - -export const STATE_WITH_NULL_AT_POSITION = createMockRootState({ - reportMode: 'report', - activePosition: 0, - simulations: [null, mockSimulation2], - policies: [null, mockPolicy2], - populations: [null, mockPopulation2], -}); - -export const STATE_WITH_ALL_NULL = createMockRootState({ - simulations: [null, null], - policies: [null, null], - populations: [null, null], -}); diff --git a/app/src/tests/fixtures/reducers/flowReducerMocks.ts b/app/src/tests/fixtures/reducers/flowReducerMocks.ts deleted file mode 100644 index d1484300..00000000 --- a/app/src/tests/fixtures/reducers/flowReducerMocks.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { ComponentKey } from '@/flows/registry'; -import { Flow } from '@/types/flow'; - -// Define FlowState interface to match the reducer -interface FlowState { - currentFlow: Flow | null; - currentFrame: ComponentKey | null; - flowStack: Array<{ - flow: Flow; - frame: ComponentKey; - }>; - returnPath: string | null; -} - -// Test constants for flow names -export const FLOW_NAMES = { - MAIN_FLOW: 'mainFlow', - SUB_FLOW: 'subFlow', - NESTED_FLOW: 'nestedFlow', - EMPTY_FLOW: 'emptyFlow', -} as const; - -// Test constants for frame/component names -export const FRAME_NAMES = { - INITIAL_FRAME: 'initialFrame' as ComponentKey, - SECOND_FRAME: 'secondFrame' as ComponentKey, - THIRD_FRAME: 'thirdFrame' as ComponentKey, - RETURN_FRAME: 'returnFrame' as ComponentKey, - SUB_INITIAL_FRAME: 'subInitialFrame' as ComponentKey, - SUB_SECOND_FRAME: 'subSecondFrame' as ComponentKey, - NESTED_INITIAL_FRAME: 'nestedInitialFrame' as ComponentKey, - NULL_FRAME: null, -} as const; - -// Test constants for action types -export const ACTION_TYPES = { - CLEAR_FLOW: 'flow/clearFlow', - SET_FLOW: 'flow/setFlow', - NAVIGATE_TO_FRAME: 'flow/navigateToFrame', - NAVIGATE_TO_FLOW: 'flow/navigateToFlow', - RETURN_FROM_FLOW: 'flow/returnFromFlow', -} as const; - -// Mock flows -export const mockMainFlow: Flow = { - initialFrame: FRAME_NAMES.INITIAL_FRAME, - frames: { - [FRAME_NAMES.INITIAL_FRAME]: { - component: FRAME_NAMES.INITIAL_FRAME, - on: { - next: FRAME_NAMES.SECOND_FRAME, - submit: '__return__', - }, - }, - [FRAME_NAMES.SECOND_FRAME]: { - component: FRAME_NAMES.SECOND_FRAME, - on: { - next: FRAME_NAMES.THIRD_FRAME, - back: FRAME_NAMES.INITIAL_FRAME, - }, - }, - [FRAME_NAMES.THIRD_FRAME]: { - component: FRAME_NAMES.THIRD_FRAME, - on: { - back: FRAME_NAMES.SECOND_FRAME, - submit: '__return__', - }, - }, - }, -}; - -export const mockSubFlow: Flow = { - initialFrame: FRAME_NAMES.SUB_INITIAL_FRAME, - frames: { - [FRAME_NAMES.SUB_INITIAL_FRAME]: { - component: FRAME_NAMES.SUB_INITIAL_FRAME, - on: { - next: FRAME_NAMES.SUB_SECOND_FRAME, - cancel: '__return__', - }, - }, - [FRAME_NAMES.SUB_SECOND_FRAME]: { - component: FRAME_NAMES.SUB_SECOND_FRAME, - on: { - back: FRAME_NAMES.SUB_INITIAL_FRAME, - submit: '__return__', - }, - }, - }, -}; - -export const mockNestedFlow: Flow = { - initialFrame: FRAME_NAMES.NESTED_INITIAL_FRAME, - frames: { - [FRAME_NAMES.NESTED_INITIAL_FRAME]: { - component: FRAME_NAMES.NESTED_INITIAL_FRAME, - on: { - done: '__return__', - }, - }, - }, -}; - -export const mockFlowWithoutInitialFrame: Flow = { - initialFrame: null, - frames: {}, -}; - -export const mockFlowWithNonStringInitialFrame: Flow = { - initialFrame: { someObject: 'value' } as any, - frames: { - [FRAME_NAMES.INITIAL_FRAME]: { - component: FRAME_NAMES.INITIAL_FRAME, - on: {}, - }, - }, -}; - -// Initial state constant -export const INITIAL_STATE: FlowState = { - currentFlow: null, - currentFrame: null, - flowStack: [], - returnPath: null, -}; - -// Helper function to create a flow state -export const createFlowState = (overrides: Partial = {}): FlowState => ({ - ...INITIAL_STATE, - ...overrides, -}); - -// Helper function to create a flow stack entry -export const createFlowStackEntry = (flow: Flow, frame: ComponentKey) => ({ - flow, - frame, -}); - -// Mock flow stack scenarios -export const mockEmptyStack: Array<{ flow: Flow; frame: ComponentKey }> = []; - -export const mockSingleLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [ - createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME), -]; - -export const mockTwoLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [ - createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME), - createFlowStackEntry(mockSubFlow, FRAME_NAMES.SUB_INITIAL_FRAME), // This is the frame we'll return to -]; - -export const mockThreeLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [ - createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME), - createFlowStackEntry(mockSubFlow, FRAME_NAMES.SUB_SECOND_FRAME), - createFlowStackEntry(mockNestedFlow, FRAME_NAMES.NESTED_INITIAL_FRAME), -]; - -// State scenarios for testing -export const mockStateWithMainFlow = createFlowState({ - currentFlow: mockMainFlow, - currentFrame: FRAME_NAMES.INITIAL_FRAME, - flowStack: mockEmptyStack, -}); - -export const mockStateWithSubFlow = createFlowState({ - currentFlow: mockSubFlow, - currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME, - flowStack: mockSingleLevelStack, -}); - -export const mockStateWithNestedFlow = createFlowState({ - currentFlow: mockNestedFlow, - currentFrame: FRAME_NAMES.NESTED_INITIAL_FRAME, - flowStack: mockTwoLevelStack, -}); - -export const mockStateInMiddleFrame = createFlowState({ - currentFlow: mockMainFlow, - currentFrame: FRAME_NAMES.SECOND_FRAME, - flowStack: mockEmptyStack, -}); - -export const mockStateWithoutCurrentFlow = createFlowState({ - currentFlow: null, - currentFrame: FRAME_NAMES.INITIAL_FRAME, - flowStack: mockEmptyStack, -}); - -export const mockStateWithoutCurrentFrame = createFlowState({ - currentFlow: mockMainFlow, - currentFrame: null, - flowStack: mockEmptyStack, -}); - -// Action payloads -export const mockSetFlowPayload = mockMainFlow; - -export const mockNavigateToFramePayload = FRAME_NAMES.SECOND_FRAME; - -export const mockNavigateToFlowPayload = { - flow: mockSubFlow, - returnFrame: undefined, -}; - -export const mockNavigateToFlowWithReturnPayload = { - flow: mockSubFlow, - returnFrame: FRAME_NAMES.RETURN_FRAME, -}; - -// Expected states after actions -export const expectedStateAfterSetFlow: FlowState = { - currentFlow: mockMainFlow, - currentFrame: FRAME_NAMES.INITIAL_FRAME, - flowStack: [], - returnPath: null, -}; - -export const expectedStateAfterNavigateToFrame: FlowState = { - ...mockStateWithMainFlow, - currentFrame: FRAME_NAMES.SECOND_FRAME, -}; - -export const expectedStateAfterNavigateToFlow: FlowState = { - currentFlow: mockSubFlow, - currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME, - flowStack: [createFlowStackEntry(mockMainFlow, FRAME_NAMES.INITIAL_FRAME)], - returnPath: null, -}; - -export const expectedStateAfterNavigateToFlowWithReturn: FlowState = { - currentFlow: mockSubFlow, - currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME, - flowStack: [createFlowStackEntry(mockMainFlow, FRAME_NAMES.RETURN_FRAME)], - returnPath: null, -}; - -export const expectedStateAfterReturnFromFlow: FlowState = { - currentFlow: mockMainFlow, - currentFrame: FRAME_NAMES.SECOND_FRAME, - flowStack: [], - returnPath: null, -}; - -export const expectedStateAfterClearFlow: FlowState = INITIAL_STATE; diff --git a/app/src/tests/fixtures/reducers/populationMocks.ts b/app/src/tests/fixtures/reducers/populationMocks.ts deleted file mode 100644 index 404cc47d..00000000 --- a/app/src/tests/fixtures/reducers/populationMocks.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; -import { Population } from '@/types/ingredients/Population'; - -// Test labels -export const TEST_LABEL = 'Test Population'; -export const TEST_LABEL_UPDATED = 'Updated Population'; - -// Mock Households -export const mockHousehold1: Household = { - id: '123', - countryId: 'us', - householdData: { - people: {}, - }, -}; - -export const mockHousehold2: Household = { - id: '456', - countryId: 'uk', - householdData: { - people: {}, - }, -}; - -// Mock Geographies -export const mockGeography1: Geography = { - id: 'test-geography', - countryId: 'us', - scope: 'national', - geographyId: 'us', -}; - -export const mockGeography2: Geography = { - id: 'test-geography-2', - countryId: 'uk', - scope: 'subnational', - geographyId: 'country/scotland', // NOW USING PREFIXED VALUE -}; - -// Mock Populations -export const mockPopulation1: Population = { - label: TEST_LABEL, - isCreated: true, - household: mockHousehold1, - geography: null, -}; - -export const mockPopulation2: Population = { - label: 'Second Population', - isCreated: false, - household: null, - geography: mockGeography1, -}; - -// Empty initial state for tests -export const emptyInitialState = { - populations: [null, null] as [Population | null, Population | null], -}; diff --git a/app/src/tests/fixtures/reducers/populationReducerMocks.ts b/app/src/tests/fixtures/reducers/populationReducerMocks.ts deleted file mode 100644 index 06f2d77b..00000000 --- a/app/src/tests/fixtures/reducers/populationReducerMocks.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; -import { Population } from '@/types/ingredients/Population'; - -// ============= TEST CONSTANTS ============= - -// IDs and identifiers -export const POPULATION_IDS = { - HOUSEHOLD_ID: '123', - HOUSEHOLD_ID_NEW: '456', - GEOGRAPHY_ID: 'test-geography', - GEOGRAPHY_ID_NEW: 'test-geography-2', - PERSON_ID_1: 'person-001', - PERSON_ID_2: 'person-002', - FAMILY_ID: 'family-001', - TAX_UNIT_ID: 'taxunit-001', - SPM_UNIT_ID: 'spmunit-001', - MARITAL_UNIT_ID: 'maritalunit-001', - BENEFIT_UNIT_ID: 'benunit-001', -} as const; - -// Labels -export const POPULATION_LABELS = { - DEFAULT: 'Test Population', - UPDATED: 'Updated Population Label', - HOUSEHOLD: 'My Household Configuration', - GEOGRAPHY: 'California State Population', -} as const; - -// Countries and regions -export const POPULATION_COUNTRIES = { - US: 'us', - UK: 'uk', - CA: 'ca', - NG: 'ng', - IL: 'il', -} as const; - -export const POPULATION_REGIONS = { - CALIFORNIA: 'ca', - NEW_YORK: 'ny', - ENGLAND: 'england', - SCOTLAND: 'scotland', -} as const; - -// Years -export const POPULATION_YEARS = { - DEFAULT: CURRENT_YEAR, - PAST: '2023', - FUTURE: CURRENT_YEAR, -} as const; - -// Action types (for testing action creators) -export const POPULATION_ACTION_TYPES = { - CLEAR: 'population/clearPopulation', - UPDATE_ID: 'population/updatePopulationId', - UPDATE_LABEL: 'population/updatePopulationLabel', - MARK_CREATED: 'population/markPopulationAsCreated', - SET_HOUSEHOLD: 'population/setHousehold', - INITIALIZE_HOUSEHOLD: 'population/initializeHousehold', - SET_GEOGRAPHY: 'population/setGeography', -} as const; - -// ============= MOCK DATA OBJECTS ============= - -// Mock household data -export const mockHousehold: Household = { - id: POPULATION_IDS.HOUSEHOLD_ID, - countryId: POPULATION_COUNTRIES.US as any, - householdData: { - people: { - [POPULATION_IDS.PERSON_ID_1]: { - age: { - [POPULATION_YEARS.DEFAULT]: 30, - }, - employment_income: { - [POPULATION_YEARS.DEFAULT]: 50000, - }, - }, - [POPULATION_IDS.PERSON_ID_2]: { - age: { - [POPULATION_YEARS.DEFAULT]: 28, - }, - employment_income: { - [POPULATION_YEARS.DEFAULT]: 45000, - }, - }, - }, - families: { - [POPULATION_IDS.FAMILY_ID]: { - members: [POPULATION_IDS.PERSON_ID_1, POPULATION_IDS.PERSON_ID_2], - }, - }, - households: { - 'your household': { - members: [POPULATION_IDS.PERSON_ID_1, POPULATION_IDS.PERSON_ID_2], - }, - }, - }, -}; - -export const mockHouseholdUK: Household = { - id: POPULATION_IDS.HOUSEHOLD_ID, - countryId: POPULATION_COUNTRIES.UK as any, - householdData: { - people: { - [POPULATION_IDS.PERSON_ID_1]: { - age: { - [POPULATION_YEARS.DEFAULT]: 35, - }, - }, - }, - benunits: { - [POPULATION_IDS.BENEFIT_UNIT_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }, - households: { - 'your household': { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }, - }, -}; - -// Mock geography data -export const mockGeography: Geography = { - id: POPULATION_IDS.GEOGRAPHY_ID, - countryId: POPULATION_COUNTRIES.US as any, - scope: 'subnational', - geographyId: `${POPULATION_COUNTRIES.US}-${POPULATION_REGIONS.CALIFORNIA}`, - name: 'California', -}; - -export const mockGeographyNational: Geography = { - id: POPULATION_IDS.GEOGRAPHY_ID, - countryId: POPULATION_COUNTRIES.UK as any, - scope: 'national', - geographyId: POPULATION_COUNTRIES.UK, - name: 'United Kingdom', -}; - -// Initial state variations -export const mockInitialState: Population = { - label: null, - isCreated: false, - household: null, - geography: null, -}; - -export const mockStateWithHousehold: Population = { - label: POPULATION_LABELS.HOUSEHOLD, - isCreated: false, - household: mockHousehold, - geography: null, -}; - -export const mockStateWithGeography: Population = { - label: POPULATION_LABELS.GEOGRAPHY, - isCreated: false, - household: null, - geography: mockGeography, -}; - -export const mockStateCreated: Population = { - label: POPULATION_LABELS.DEFAULT, - isCreated: true, - household: mockHousehold, - geography: null, -}; - -export const mockStateComplete: Population = { - label: POPULATION_LABELS.DEFAULT, - isCreated: true, - household: mockHousehold, - geography: null, -}; - -// ============= MOCK FUNCTIONS ============= - -// Mock HouseholdBuilder -export const mockHouseholdBuilderBuild = vi.fn(); -export const mockHouseholdBuilder = vi.fn(() => ({ - build: mockHouseholdBuilderBuild, -})); - -// Default mock implementation for HouseholdBuilder -export const setupMockHouseholdBuilder = (returnValue: Household = mockHousehold) => { - mockHouseholdBuilderBuild.mockReturnValue(returnValue); - return mockHouseholdBuilder; -}; - -// ============= TEST HELPERS ============= - -// Helper to create a new household for a specific country -export const createMockHouseholdForCountry = (countryId: string): Household => { - const baseHousehold: Household = { - id: `household-${countryId}`, - countryId: countryId as any, - householdData: { - people: { - [POPULATION_IDS.PERSON_ID_1]: { - age: { - [POPULATION_YEARS.DEFAULT]: 30, - }, - }, - }, - households: { - 'your household': { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }, - }, - }; - - // Add country-specific entities - switch (countryId) { - case POPULATION_COUNTRIES.US: - baseHousehold.householdData.families = { - [POPULATION_IDS.FAMILY_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }; - baseHousehold.householdData.taxUnits = { - [POPULATION_IDS.TAX_UNIT_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }; - baseHousehold.householdData.spmUnits = { - [POPULATION_IDS.SPM_UNIT_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }; - baseHousehold.householdData.maritalUnits = { - [POPULATION_IDS.MARITAL_UNIT_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }; - break; - case POPULATION_COUNTRIES.UK: - baseHousehold.householdData.benunits = { - [POPULATION_IDS.BENEFIT_UNIT_ID]: { - members: [POPULATION_IDS.PERSON_ID_1], - }, - }; - break; - // Other countries just have people and households - } - - return baseHousehold; -}; - -// Helper to create a geography for testing -export const createMockGeography = ( - countryCode: string, - scope: 'national' | 'subnational' = 'national', - regionCode?: string -): Geography => { - const geography: Geography = { - id: `geo-${countryCode}`, - countryId: countryCode as any, - scope, - geographyId: scope === 'national' ? countryCode : `${countryCode}-${regionCode}`, - name: `Test ${countryCode.toUpperCase()} Geography`, - }; - - return geography; -}; - -// Helper to verify state matches expected -export const expectStateToMatch = (actualState: Population, expectedState: Population): void => { - expect(actualState.label).toBe(expectedState.label); - expect(actualState.isCreated).toBe(expectedState.isCreated); - expect(actualState.household).toEqual(expectedState.household); - expect(actualState.geography).toEqual(expectedState.geography); -}; - -// Helper to create an action payload -export const createAction = (type: string, payload?: T) => ({ - type, - payload, -}); - -// Reset all mocks -export const resetAllMocks = () => { - mockHouseholdBuilderBuild.mockReset(); - mockHouseholdBuilder.mockClear(); -}; diff --git a/app/src/tests/fixtures/reducers/simulationsReducer.ts b/app/src/tests/fixtures/reducers/simulationsReducer.ts deleted file mode 100644 index 829ba178..00000000 --- a/app/src/tests/fixtures/reducers/simulationsReducer.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Simulation } from '@/types/ingredients/Simulation'; - -// Test IDs (only for simulations that have been created via API) -export const TEST_PERMANENT_ID_1 = '123'; -export const TEST_PERMANENT_ID_2 = '456'; - -// Test population and policy IDs -export const TEST_HOUSEHOLD_ID = '789'; -export const TEST_GEOGRAPHY_ID = 'test-geography'; -export const TEST_POLICY_ID_1 = '111'; -export const TEST_POLICY_ID_2 = '222'; - -// Test labels -export const TEST_LABEL_1 = 'Test Simulation 1'; -export const TEST_LABEL_2 = 'Test Simulation 2'; -export const TEST_LABEL_UPDATED = 'Updated Simulation'; - -// Mock simulations -export const mockSimulation1: Simulation = { - id: TEST_PERMANENT_ID_1, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_1, - label: TEST_LABEL_1, - isCreated: true, -}; - -export const mockSimulation2: Simulation = { - id: TEST_PERMANENT_ID_2, - populationId: TEST_GEOGRAPHY_ID, - populationType: 'geography', - policyId: TEST_POLICY_ID_2, - label: TEST_LABEL_2, - isCreated: false, -}; - -export const mockEmptySimulation: Simulation = { - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: null, - id: undefined, - isCreated: false, -}; - -// Mock simulations without IDs (not yet created via API) -export const mockSimulationWithoutId1: Simulation = { - id: undefined, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_1, - label: TEST_LABEL_1, - isCreated: false, -}; - -export const mockSimulationWithoutId2: Simulation = { - id: undefined, - populationId: TEST_GEOGRAPHY_ID, - populationType: 'geography', - policyId: TEST_POLICY_ID_2, - label: TEST_LABEL_2, - isCreated: false, -}; - -// Initial state configurations - position-based storage -export const emptyInitialState = { - simulations: [null, null] as [Simulation | null, Simulation | null], -}; - -export const singleSimulationState = { - simulations: [mockSimulationWithoutId1, null] as [Simulation | null, Simulation | null], -}; - -export const multipleSimulationsState = { - simulations: [mockSimulation1, mockSimulation2] as [Simulation | null, Simulation | null], -}; - -export const bothSimulationsWithoutIdState = { - simulations: [mockSimulationWithoutId1, mockSimulationWithoutId2] as [ - Simulation | null, - Simulation | null, - ], -}; diff --git a/app/src/tests/fixtures/types/flowMocks.ts b/app/src/tests/fixtures/types/flowMocks.ts deleted file mode 100644 index 310ff5fa..00000000 --- a/app/src/tests/fixtures/types/flowMocks.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { ComponentKey, FlowKey } from '@/flows/registry'; -import { NavigationTarget } from '@/types/flow'; - -// Test constants for flow keys (matching actual registry) -export const VALID_FLOW_KEYS = { - POLICY_CREATION: 'PolicyCreationFlow', - POLICY_VIEW: 'PolicyViewFlow', - POPULATION_CREATION: 'PopulationCreationFlow', - SIMULATION_CREATION: 'SimulationCreationFlow', - SIMULATION_VIEW: 'SimulationViewFlow', -} as const; - -// Test constants for component keys (matching actual registry) -export const VALID_COMPONENT_KEYS = { - POLICY_CREATION_FRAME: 'PolicyCreationFrame', - POLICY_PARAMETER_SELECTOR: 'PolicyParameterSelectorFrame', - POLICY_SUBMIT: 'PolicySubmitFrame', - POLICY_READ_VIEW: 'PolicyReadView', - SELECT_GEOGRAPHIC_SCOPE: 'SelectGeographicScopeFrame', - SET_POPULATION_LABEL: 'SetPopulationLabelFrame', - GEOGRAPHIC_CONFIRMATION: 'GeographicConfirmationFrame', - HOUSEHOLD_BUILDER: 'HouseholdBuilderFrame', - POPULATION_READ_VIEW: 'PopulationReadView', - SIMULATION_CREATION: 'SimulationCreationFrame', - SIMULATION_SETUP: 'SimulationSetupFrame', - SIMULATION_SUBMIT: 'SimulationSubmitFrame', - SIMULATION_SETUP_POLICY: 'SimulationSetupPolicyFrame', - SIMULATION_SELECT_EXISTING_POLICY: 'SimulationSelectExistingPolicyFrame', - SIMULATION_READ_VIEW: 'SimulationReadView', - SIMULATION_SETUP_POPULATION: 'SimulationSetupPopulationFrame', - SIMULATION_SELECT_EXISTING_POPULATION: 'SimulationSelectExistingPopulationFrame', -} as const; - -// Test constants for invalid keys -export const INVALID_KEYS = { - NON_EXISTENT_FLOW: 'NonExistentFlow', - NON_EXISTENT_COMPONENT: 'NonExistentComponent', - RANDOM_STRING: 'randomString123', - EMPTY_STRING: '', - SPECIAL_CHARS: '@#$%^&*()', - NUMBER_STRING: '12345', -} as const; - -// Test constants for special values -export const SPECIAL_VALUES = { - NULL: null, - UNDEFINED: undefined, - RETURN_KEYWORD: '__return__', -} as const; - -// Mock navigation objects -export const VALID_NAVIGATION_OBJECT = { - flow: VALID_FLOW_KEYS.POLICY_CREATION as FlowKey, - returnTo: VALID_COMPONENT_KEYS.POLICY_READ_VIEW as ComponentKey, -}; - -export const VALID_NAVIGATION_OBJECT_ALT = { - flow: VALID_FLOW_KEYS.SIMULATION_CREATION as FlowKey, - returnTo: VALID_COMPONENT_KEYS.SIMULATION_READ_VIEW as ComponentKey, -}; - -// Invalid navigation objects for testing -export const NAVIGATION_OBJECT_MISSING_FLOW = { - returnTo: VALID_COMPONENT_KEYS.POLICY_READ_VIEW as ComponentKey, -}; - -export const NAVIGATION_OBJECT_MISSING_RETURN = { - flow: VALID_FLOW_KEYS.POLICY_CREATION as FlowKey, -}; - -export const NAVIGATION_OBJECT_WITH_EXTRA_PROPS = { - flow: VALID_FLOW_KEYS.POLICY_CREATION as FlowKey, - returnTo: VALID_COMPONENT_KEYS.POLICY_READ_VIEW as ComponentKey, - extraProp: 'should not affect validation', -}; - -export const NAVIGATION_OBJECT_WITH_NULL_FLOW = { - flow: null, - returnTo: VALID_COMPONENT_KEYS.POLICY_READ_VIEW as ComponentKey, -}; - -export const NAVIGATION_OBJECT_WITH_NULL_RETURN = { - flow: VALID_FLOW_KEYS.POLICY_CREATION as FlowKey, - returnTo: null, -}; - -// Various target types for NavigationTarget testing -export const STRING_TARGET = VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME; -export const FLOW_KEY_TARGET = VALID_FLOW_KEYS.POLICY_CREATION as FlowKey; -export const COMPONENT_KEY_TARGET = VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME as ComponentKey; - -// Edge case objects -export const EMPTY_OBJECT = {}; -export const NULL_OBJECT = null; -export const ARRAY_OBJECT = []; -export const NUMBER_VALUE = 123; -export const BOOLEAN_VALUE = true; - -// Mock flow registry for testing (matches the actual registry keys) -export const mockFlowRegistry = { - [VALID_FLOW_KEYS.POLICY_CREATION]: {}, - [VALID_FLOW_KEYS.POLICY_VIEW]: {}, - [VALID_FLOW_KEYS.POPULATION_CREATION]: {}, - [VALID_FLOW_KEYS.SIMULATION_CREATION]: {}, - [VALID_FLOW_KEYS.SIMULATION_VIEW]: {}, -}; - -// String collections for batch testing -export const ALL_VALID_FLOW_KEYS = Object.values(VALID_FLOW_KEYS); -export const ALL_VALID_COMPONENT_KEYS = Object.values(VALID_COMPONENT_KEYS); -export const ALL_INVALID_KEYS = Object.values(INVALID_KEYS); - -// Navigation target test cases -export const VALID_STRING_TARGETS: NavigationTarget[] = [ - STRING_TARGET, - FLOW_KEY_TARGET, - COMPONENT_KEY_TARGET, - SPECIAL_VALUES.RETURN_KEYWORD, -]; - -export const VALID_OBJECT_TARGETS: NavigationTarget[] = [ - VALID_NAVIGATION_OBJECT, - VALID_NAVIGATION_OBJECT_ALT, - NAVIGATION_OBJECT_WITH_EXTRA_PROPS, -]; - -export const INVALID_NAVIGATION_OBJECTS = [ - NAVIGATION_OBJECT_MISSING_FLOW, - NAVIGATION_OBJECT_MISSING_RETURN, - NAVIGATION_OBJECT_WITH_NULL_FLOW, - NAVIGATION_OBJECT_WITH_NULL_RETURN, - EMPTY_OBJECT, - NULL_OBJECT, - ARRAY_OBJECT, -]; - -// Type guard test collections -export const TRUTHY_NAVIGATION_OBJECTS = [ - VALID_NAVIGATION_OBJECT, - VALID_NAVIGATION_OBJECT_ALT, - NAVIGATION_OBJECT_WITH_EXTRA_PROPS, - // These have the required properties even though values are null - NAVIGATION_OBJECT_WITH_NULL_FLOW, - NAVIGATION_OBJECT_WITH_NULL_RETURN, -]; - -export const FALSY_NAVIGATION_OBJECTS = [ - NAVIGATION_OBJECT_MISSING_FLOW, - NAVIGATION_OBJECT_MISSING_RETURN, - EMPTY_OBJECT, - NULL_OBJECT, - ARRAY_OBJECT, - STRING_TARGET, - NUMBER_VALUE, - BOOLEAN_VALUE, - SPECIAL_VALUES.NULL, - SPECIAL_VALUES.UNDEFINED, -]; - -export const TRUTHY_FLOW_KEYS = ALL_VALID_FLOW_KEYS; - -export const FALSY_FLOW_KEYS = [...ALL_VALID_COMPONENT_KEYS, ...ALL_INVALID_KEYS]; - -export const TRUTHY_COMPONENT_KEYS = [ - ...ALL_VALID_COMPONENT_KEYS, - ...ALL_INVALID_KEYS, // These are considered component keys since they're not flow keys -]; - -export const FALSY_COMPONENT_KEYS = ALL_VALID_FLOW_KEYS; diff --git a/app/src/tests/integration/currentLawSimulationFlow.test.tsx b/app/src/tests/integration/currentLawSimulationFlow.test.tsx deleted file mode 100644 index d35bd6bd..00000000 --- a/app/src/tests/integration/currentLawSimulationFlow.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { screen, userEvent } from '@test-utils'; -import { render, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { MantineProvider } from '@mantine/core'; -import SimulationSetupPolicyFrame from '@/frames/simulation/SimulationSetupPolicyFrame'; -import flowReducer from '@/reducers/flowReducer'; -import metadataReducer, { fetchMetadataThunk } from '@/reducers/metadataReducer'; -import policyReducer from '@/reducers/policyReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import simulationsReducer from '@/reducers/simulationsReducer'; -import { CountryGuard } from '@/routing/guards/CountryGuard'; -import { - createMetadataFetchMock, - INTEGRATION_TEST_COUNTRIES, - INTEGRATION_TEST_CURRENT_LAW_IDS, -} from '@/tests/fixtures/integration/currentLawFlowMocks'; -import { policyEngineTheme } from '@/theme'; - -/** - * Integration tests for Current Law selection in simulation creation flow. - * - * These tests verify that: - * 1. Current Law button appears in SimulationSetupPolicyFrame - * 2. Selecting Current Law creates the correct policy - * 3. Policy uses country-specific current law ID from metadata - * 4. Policy structure is correct (empty parameters, correct ID, label) - */ - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock fetch for metadata -const mockFetch = vi.fn(); -global.fetch = mockFetch as any; - -describe('Current Law Simulation Flow Integration', () => { - let store: any; - - beforeEach(() => { - vi.clearAllMocks(); - mockFetch.mockClear(); - - // Create a fresh store for each test - store = configureStore({ - reducer: { - report: reportReducer, - simulations: simulationsReducer, - policy: policyReducer, - population: populationReducer, - flow: flowReducer, - metadata: metadataReducer, - }, - }); - }); - - const renderPolicyFrameWithRouter = (country: string = 'us') => { - // Setup metadata fetch mock - mockFetch.mockImplementation(createMetadataFetchMock(country)); - - // Dispatch metadata fetch - store.dispatch(fetchMetadataThunk(country)); - - const mockFlowProps = { - onNavigate: vi.fn(), - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSetupPolicyFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - return render( - - - - - }> - } - /> - - - - - - ); - }; - - describe('Current Law button in policy selection', () => { - test('given policy frame loads then Current Law option is visible', async () => { - // Given/When - renderPolicyFrameWithRouter('us'); - - // Wait for metadata to load - await waitFor(() => { - const state = store.getState(); - expect(state.metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.US); - }); - - // Then - expect(screen.getByText('Current Law')).toBeInTheDocument(); - expect( - screen.getByText('Use the baseline tax-benefit system with no reforms') - ).toBeInTheDocument(); - }); - - test('given US context then metadata loads with US current law ID', async () => { - // Given/When - renderPolicyFrameWithRouter('us'); - - // Then - await waitFor(() => { - const state = store.getState(); - expect(state.metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.US); - expect(state.metadata.currentCountry).toBe(INTEGRATION_TEST_COUNTRIES.US); - }); - }); - - test('given UK context then metadata loads with UK current law ID', async () => { - // Given/When - renderPolicyFrameWithRouter('uk'); - - // Then - await waitFor(() => { - const state = store.getState(); - expect(state.metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.UK); - expect(state.metadata.currentCountry).toBe(INTEGRATION_TEST_COUNTRIES.UK); - }); - }); - }); - - describe('Current Law policy creation', () => { - test('given user selects Current Law then policy is created with correct ID', async () => { - // Given - const user = userEvent.setup(); - renderPolicyFrameWithRouter('us'); - - // Wait for metadata to load - await waitFor(() => { - const state = store.getState(); - expect(state.metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.US); - }); - - // When - const currentLawButton = screen.getByText('Current Law'); - await user.click(currentLawButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - const state = store.getState(); - const policy = state.policy.policies[0]; - - expect(policy).not.toBeNull(); - expect(policy?.id).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.US.toString()); - expect(policy?.label).toBe('Current law'); - expect(policy?.parameters).toEqual([]); - expect(policy?.isCreated).toBe(true); - expect(policy?.countryId).toBe(INTEGRATION_TEST_COUNTRIES.US); - }); - - test('given user selects Current Law in UK then policy uses UK current law ID', async () => { - // Given - const user = userEvent.setup(); - renderPolicyFrameWithRouter('uk'); - - // Wait for metadata to load - await waitFor(() => { - const state = store.getState(); - expect(state.metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.UK); - }); - - // When - await user.click(screen.getByText('Current Law')); - await user.click(screen.getByRole('button', { name: /Next/i })); - - // Then - const state = store.getState(); - const policy = state.policy.policies[0]; - - expect(policy).not.toBeNull(); - expect(policy?.id).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.UK.toString()); - expect(policy?.countryId).toBe(INTEGRATION_TEST_COUNTRIES.UK); - }); - - test('given Current Law created then policy has empty parameters array', async () => { - // Given - const user = userEvent.setup(); - renderPolicyFrameWithRouter('us'); - - await waitFor(() => { - expect(store.getState().metadata.currentLawId).toBe(INTEGRATION_TEST_CURRENT_LAW_IDS.US); - }); - - // When - await user.click(screen.getByText('Current Law')); - await user.click(screen.getByRole('button', { name: /Next/i })); - - // Then - const state = store.getState(); - const policy = state.policy.policies[0]; - - expect(policy?.parameters).toEqual([]); - expect(Array.isArray(policy?.parameters)).toBe(true); - expect(policy?.parameters?.length).toBe(0); - }); - }); -}); diff --git a/app/src/tests/unit/components/FlowContainer.test.tsx b/app/src/tests/unit/components/FlowContainer.test.tsx deleted file mode 100644 index 5adad352..00000000 --- a/app/src/tests/unit/components/FlowContainer.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { useSelector } from 'react-redux'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import FlowContainer from '@/components/FlowContainer'; -import { navigateToFlow, navigateToFrame, returnFromFlow } from '@/reducers/flowReducer'; -import { - addEventToMockFlow, - cleanupDynamicEvents, - createMockState, - mockFlow, - mockFlowRegistry, - mockSubflowStack, - TEST_EVENTS, - TEST_FLOW_NAMES, - TEST_FRAME_NAMES, - TEST_STRINGS, - TestComponent, -} from '@/tests/fixtures/components/FlowContainerMocks'; - -const mockDispatch = vi.fn(); - -vi.mock('@/flows/registry', async () => { - const mocks = await import('@/tests/fixtures/components/FlowContainerMocks'); - return { - componentRegistry: mocks.mockComponentRegistry, - flowRegistry: mocks.mockFlowRegistry, - }; -}); - -vi.mock('@/reducers/flowReducer', () => ({ - default: vi.fn((state = {}) => state), - navigateToFlow: vi.fn((payload) => ({ type: 'flow/navigateToFlow', payload })), - navigateToFrame: vi.fn((payload) => ({ type: 'flow/navigateToFrame', payload })), - returnFromFlow: vi.fn(() => ({ type: 'flow/returnFromFlow' })), -})); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: vi.fn(), - }; -}); - -vi.mock('@/types/flow', async () => { - const actual = await vi.importActual('@/types/flow'); - const mocks = await import('@/tests/fixtures/components/FlowContainerMocks'); - return { - ...actual, - isFlowKey: vi.fn((target: string) => { - return ( - target === mocks.TEST_FLOW_NAMES.ANOTHER_FLOW || target === mocks.TEST_FLOW_NAMES.TEST_FLOW - ); - }), - isComponentKey: vi.fn((target: string) => { - return ( - target === mocks.TEST_FRAME_NAMES.TEST_FRAME || - target === mocks.TEST_FRAME_NAMES.NEXT_FRAME || - target === mocks.TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT - ); - }), - }; -}); - -describe('FlowContainer', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'error').mockImplementation(() => {}); - cleanupDynamicEvents(); - }); - - describe('Error States', () => { - test('given no current flow then displays no flow message', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ - flow: { - currentFlow: null, - currentFrame: null, - flowStack: [], - }, - }) - ); - - render(); - - expect(screen.getByText(TEST_STRINGS.NO_FLOW_MESSAGE)).toBeInTheDocument(); - }); - - test('given no current frame then displays no flow message', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ - flow: { - currentFlow: mockFlow, - currentFrame: null, - flowStack: [], - }, - }) - ); - - render(); - - expect(screen.getByText(TEST_STRINGS.NO_FLOW_MESSAGE)).toBeInTheDocument(); - }); - - test('given component not in registry then displays error message', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ - flow: { - currentFlow: mockFlow, - currentFrame: TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT, - flowStack: [], - }, - }) - ); - - render(); - - expect( - screen.getByText( - `${TEST_STRINGS.COMPONENT_NOT_FOUND_PREFIX} ${TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT}` - ) - ).toBeInTheDocument(); - expect( - screen.getByText(new RegExp(TEST_STRINGS.AVAILABLE_COMPONENTS_PREFIX)) - ).toBeInTheDocument(); - }); - }); - - describe('Component Rendering', () => { - test('given valid flow and frame then renders correct component', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - expect(screen.getByText(TEST_STRINGS.TEST_COMPONENT_TEXT)).toBeInTheDocument(); - expect(TestComponent).toHaveBeenCalledWith( - expect.objectContaining({ - onNavigate: expect.any(Function), - onReturn: expect.any(Function), - flowConfig: mockFlow, - isInSubflow: false, - flowDepth: 0, - parentFlowContext: undefined, - }), - undefined - ); - }); - - test('given subflow context then passes correct props to component', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState({ flowStack: mockSubflowStack }).flow }) - ); - - render(); - - expect(screen.getByText(TEST_STRINGS.IN_SUBFLOW_TEXT)).toBeInTheDocument(); - expect(screen.getByText(`${TEST_STRINGS.FLOW_DEPTH_PREFIX} 1`)).toBeInTheDocument(); - expect( - screen.getByText(`${TEST_STRINGS.PARENT_PREFIX} ${TEST_FRAME_NAMES.PARENT_FRAME}`) - ).toBeInTheDocument(); - - expect(TestComponent).toHaveBeenCalledWith( - expect.objectContaining({ - isInSubflow: true, - flowDepth: 1, - parentFlowContext: { - parentFrame: TEST_FRAME_NAMES.PARENT_FRAME, - }, - }), - undefined - ); - }); - }); - - describe('Navigation Event Handling', () => { - test('given user navigates to frame then dispatches navigateToFrame action', async () => { - const user = userEvent.setup(); - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - await user.click(screen.getByRole('button', { name: /navigate next/i })); - - expect(mockDispatch).toHaveBeenCalledWith( - navigateToFrame(TEST_FRAME_NAMES.NEXT_FRAME as any) - ); - }); - - test('given user navigates with return keyword then dispatches returnFromFlow action', async () => { - const user = userEvent.setup(); - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - await user.click(screen.getByRole('button', { name: /submit/i })); - - expect(mockDispatch).toHaveBeenCalledWith(returnFromFlow()); - }); - - test('given user navigates to flow with navigation object then dispatches navigateToFlow with returnFrame', async () => { - const user = userEvent.setup(); - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - await user.click(screen.getByRole('button', { name: /go to flow/i })); - - expect(mockDispatch).toHaveBeenCalledWith( - navigateToFlow({ - flow: mockFlowRegistry.anotherFlow, - returnFrame: TEST_FRAME_NAMES.RETURN_FRAME as any, - }) - ); - }); - - test('given navigation event with no target defined then logs error', async () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - const component = TestComponent.mock.calls[0][0]; - component.onNavigate(TEST_EVENTS.NON_EXISTENT_EVENT); - - expect(vi.mocked(console.error)).toHaveBeenCalledWith( - expect.stringContaining(`No target defined for event ${TEST_EVENTS.NON_EXISTENT_EVENT}`) - ); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('given navigation to flow key then dispatches navigateToFlow action', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - const component = TestComponent.mock.calls[0][0]; - - addEventToMockFlow(TEST_EVENTS.DIRECT_FLOW, TEST_FLOW_NAMES.ANOTHER_FLOW); - component.onNavigate(TEST_EVENTS.DIRECT_FLOW); - - expect(mockDispatch).toHaveBeenCalledWith( - navigateToFlow({ flow: mockFlowRegistry[TEST_FLOW_NAMES.ANOTHER_FLOW] }) - ); - }); - - test('given navigation to unknown target type then logs error', () => { - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState().flow }) - ); - - render(); - - const component = TestComponent.mock.calls[0][0]; - - addEventToMockFlow(TEST_EVENTS.UNKNOWN_TARGET, TEST_FRAME_NAMES.UNKNOWN_TARGET); - component.onNavigate(TEST_EVENTS.UNKNOWN_TARGET); - - expect(vi.mocked(console.error)).toHaveBeenCalledWith( - expect.stringContaining(`Unknown target type: ${TEST_FRAME_NAMES.UNKNOWN_TARGET}`) - ); - }); - }); - - describe('Return From Subflow', () => { - test('given user returns from subflow then dispatches returnFromFlow action', async () => { - const user = userEvent.setup(); - vi.mocked(useSelector).mockImplementation((selector: any) => - selector({ flow: createMockState({ flowStack: mockSubflowStack }).flow }) - ); - - render(); - - await user.click(screen.getByRole('button', { name: /return/i })); - - expect(mockDispatch).toHaveBeenCalledWith(returnFromFlow()); - }); - }); -}); diff --git a/app/src/tests/unit/components/FlowRouter.test.tsx b/app/src/tests/unit/components/FlowRouter.test.tsx deleted file mode 100644 index 56f88c66..00000000 --- a/app/src/tests/unit/components/FlowRouter.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { render } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -// Import after mocks are set up -import FlowRouter from '@/components/FlowRouter'; -import { setFlow } from '@/reducers/flowReducer'; -import { - ABSOLUTE_RETURN_PATH, - createMockFlowState, - mockDispatch, - mockUseParams, - mockUseSelector, - TEST_COUNTRY_ID, - TEST_FLOW, - TEST_RETURN_PATH, -} from '@/tests/fixtures/components/FlowRouterMocks'; - -// Mock dependencies - must be hoisted before imports -vi.mock('@/components/FlowContainer', () => ({ - default: vi.fn(() => null), -})); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - const mocks = await import('@/tests/fixtures/components/FlowRouterMocks'); - return { - ...actual, - useParams: () => mocks.mockUseParams(), - }; -}); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - const mocks = await import('@/tests/fixtures/components/FlowRouterMocks'); - return { - ...actual, - useDispatch: () => mocks.mockDispatch, - useSelector: (selector: any) => mocks.mockUseSelector(selector), - }; -}); - -describe('FlowRouter', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseParams.mockReturnValue({ countryId: TEST_COUNTRY_ID }); - }); - - test('given no current flow then initializes flow with setFlow', () => { - // Given - mockUseSelector.mockImplementation((selector: any) => selector(createMockFlowState())); - - // When - render(); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - setFlow({ flow: TEST_FLOW, returnPath: ABSOLUTE_RETURN_PATH }) - ); - }); - - test('given existing current flow then does not call setFlow', () => { - // Given - mockUseSelector.mockImplementation((selector: any) => - selector(createMockFlowState({ currentFlow: TEST_FLOW })) - ); - - // When - render(); - - // Then - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('given countryId and returnPath then constructs correct absolute path', () => { - // Given - mockUseParams.mockReturnValue({ countryId: 'uk' }); - mockUseSelector.mockImplementation((selector: any) => selector(createMockFlowState())); - - // When - render(); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - setFlow({ flow: TEST_FLOW, returnPath: '/uk/policies' }) - ); - }); - - test('given component remounts with existing flow then does not reset flow', () => { - // Given - First mount with no flow - mockUseSelector.mockImplementation((selector: any) => selector(createMockFlowState())); - const { unmount } = render(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - vi.clearAllMocks(); - - // Given - Flow now exists (simulating mid-flow remount) - mockUseSelector.mockImplementation((selector: any) => - selector(createMockFlowState({ currentFlow: TEST_FLOW })) - ); - - // When - Component remounts - unmount(); - render(); - - // Then - setFlow should not be called again - expect(mockDispatch).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/tests/unit/components/policyParameterSelectorFrame/HistoricalValues.test.tsx b/app/src/tests/unit/components/policyParameterSelectorFrame/HistoricalValues.test.tsx deleted file mode 100644 index f8949a9f..00000000 --- a/app/src/tests/unit/components/policyParameterSelectorFrame/HistoricalValues.test.tsx +++ /dev/null @@ -1,1450 +0,0 @@ -import { render, screen } from '@test-utils'; -import { describe, expect, it, vi } from 'vitest'; -import PolicyParameterSelectorHistoricalValues, { - ParameterOverTimeChart, -} from '@/components/policyParameterSelectorFrame/HistoricalValues'; -import { CHART_COLORS } from '@/constants/chartColors'; -import { CHART_DISPLAY_EXTENSION_DATE } from '@/constants/chartConstants'; -import { - BOOLEAN_PARAMETER, - CURRENCY_USD_PARAMETER, - EMPTY_VALUES_COLLECTION, - EXPECTED_BASE_TRACE, - EXPECTED_EXTENDED_BASE_DATES, - EXPECTED_INFINITY_WARNING_MESSAGE, - EXPECTED_NO_DATA_MESSAGE, - EXPECTED_REFORM_NAME_DEFAULT, - EXPECTED_REFORM_NAME_WITH_ID, - EXPECTED_REFORM_NAME_WITH_LABEL, - EXPECTED_REFORM_NAME_WITH_SHORT_LABEL, - EXPECTED_REFORM_NAME_WITH_SMALL_ID, - EXPECTED_REFORM_TRACE, - INTEGER_PARAMETER, - MockErrorThrowingCollection, - MockMismatchedValueCollection, - PERCENTAGE_PARAMETER, - SAMPLE_BASE_VALUES_ALL_INFINITE, - SAMPLE_BASE_VALUES_COMPLEX, - SAMPLE_BASE_VALUES_SIMPLE, - SAMPLE_BASE_VALUES_WITH_INFINITY, - SAMPLE_BASE_VALUES_WITH_INVALID_DATES, - SAMPLE_POLICY_ID_NUMERIC, - SAMPLE_POLICY_ID_SMALL, - SAMPLE_POLICY_LABEL_CUSTOM, - SAMPLE_POLICY_LABEL_SHORT, - SAMPLE_REFORM_VALUES_COMPLEX, - SAMPLE_REFORM_VALUES_SIMPLE, - SAMPLE_REFORM_VALUES_WITH_INFINITY, -} from '@/tests/fixtures/components/HistoricalValuesMocks'; - -// Mock Plotly to avoid rendering issues in tests -vi.mock('react-plotly.js', () => ({ - default: vi.fn((props: any) => { - return
; - }), -})); - -describe('HistoricalValues', () => { - describe('PolicyParameterSelectorHistoricalValues wrapper', () => { - it('given component renders then displays historical values section', () => { - // Given - const { getByText } = render( - - ); - - // Then - expect(getByText('Historical values')).toBeInTheDocument(); - }); - - it('given parameter label then displays in component', () => { - // Given - const { getByText } = render( - - ); - - // Then - expect(getByText('Standard Deduction over time')).toBeInTheDocument(); - }); - - it('given base values only then renders chart without reform', () => { - // Given - const { getByTestId } = render( - - ); - - // Then - const chart = getByTestId('plotly-chart'); - expect(chart).toBeInTheDocument(); - }); - - it('given reform values then passes to chart component', () => { - // Given - const { getByTestId } = render( - - ); - - // Then - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - expect(props.data.length).toBeGreaterThan(1); // Should have both base and reform traces - }); - }); - - describe('ParameterOverTimeChart data processing', () => { - it('given base values then extends data to display extension date', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.x).toContain(CHART_DISPLAY_EXTENSION_DATE); - expect(baseTrace.x.length).toBe(EXPECTED_EXTENDED_BASE_DATES.length); - }); - - it('given base values then repeats last value at extension date', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - const lastValue = baseTrace.y[baseTrace.y.length - 1]; - const secondLastValue = baseTrace.y[baseTrace.y.length - 2]; - expect(lastValue).toBe(secondLastValue); // Last value should be repeated - }); - - it('given values with invalid dates then still includes all data points', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - // Note: Invalid dates are filtered from axis calculations (xaxisValues), - // but are kept in trace data to show all historical values - expect(baseTrace.x).toContain('0000-01-01'); - expect(baseTrace.x).toContain('2020-01-01'); - expect(baseTrace.x).toContain('2024-01-01'); - }); - - it('given reform values then extends reform data to display extension date', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; // Reform trace is first when it exists - - // Then - expect(reformTrace.x).toContain(CHART_DISPLAY_EXTENSION_DATE); - }); - }); - - describe('ParameterOverTimeChart styling', () => { - it('given base only then uses dark gray color', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.line.color).toBe(CHART_COLORS.BASE_LINE_ALONE); - expect(baseTrace.marker.color).toBe(CHART_COLORS.BASE_LINE_ALONE); - }); - - it('given base with reform then uses light gray color for base', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[1]; // Base is second when reform exists - - // Then - expect(baseTrace.line.color).toBe(CHART_COLORS.BASE_LINE_WITH_REFORM); - expect(baseTrace.marker.color).toBe(CHART_COLORS.BASE_LINE_WITH_REFORM); - }); - - it('given reform values then uses blue color for reform', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; // Reform is first when it exists - - // Then - expect(reformTrace.line.color).toBe(CHART_COLORS.REFORM_LINE); - expect(reformTrace.marker.color).toBe(CHART_COLORS.REFORM_LINE); - }); - - it('given reform trace then uses dotted line style', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.line.dash).toBe(EXPECTED_REFORM_TRACE.lineDash); - }); - - it('given traces then uses step line shape', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.line.shape).toBe(EXPECTED_BASE_TRACE.lineShape); - }); - - it('given traces then uses correct marker size', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.marker.size).toBe(CHART_COLORS.MARKER_SIZE); - }); - }); - - describe('ParameterOverTimeChart trace names', () => { - it('given base only then names trace "Current law"', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.name).toBe(EXPECTED_BASE_TRACE.name); - }); - - it('given reform then uses reform label from utility', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - // Reform label uses getReformPolicyLabel() which defaults to "Reform" - expect(reformTrace.name).toBe('Reform'); - expect(reformTrace.name).toBeDefined(); - }); - }); - - describe('ParameterOverTimeChart different parameter types', () => { - it('given currency parameter then renders chart', () => { - // Given - const { getByTestId } = render( - - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - - it('given percentage parameter then renders chart', () => { - // Given - const { getByTestId } = render( - - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - - it('given boolean parameter then renders chart', () => { - // Given - const { getByTestId } = render( - - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - }); - - describe('ParameterOverTimeChart complex data scenarios', () => { - it('given multiple value changes then includes all dates', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.x.length).toBeGreaterThan(2); // Should have multiple dates plus extension - }); - - it('given base and reform with overlapping dates then combines correctly', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.data.length).toBe(2); // Should have both traces - expect(props.data[0].name).toBe('Reform'); - expect(props.data[1].name).toBe('Current law'); - }); - }); - - describe('ParameterOverTimeChart layout configuration', () => { - it('given chart then configures horizontal legend above plot', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.legend.orientation).toBe('h'); - expect(props.layout.legend.y).toBe(1.2); - }); - - it('given chart then hides mode bar', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.config.displayModeBar).toBe(false); - }); - - it('given chart then enables responsive mode', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.config.responsive).toBe(true); - }); - }); - - describe('ParameterOverTimeChart axis formatting', () => { - it('given chart then includes x-axis format', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.xaxis).toBeDefined(); - expect(props.layout.xaxis.type).toBe('date'); - }); - - it('given chart then includes y-axis format', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.yaxis).toBeDefined(); - }); - - it('given currency parameter then y-axis has dollar prefix', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.yaxis.tickprefix).toBe('$'); - }); - - it('given percentage parameter then y-axis has percentage format', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.yaxis.tickformat).toBe('.1%'); - }); - - it('given boolean parameter then y-axis has True/False labels', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.yaxis.tickvals).toEqual([0, 1]); - expect(props.layout.yaxis.ticktext).toEqual(['False', 'True']); - }); - }); - - describe('ParameterOverTimeChart hover tooltips', () => { - it('given base trace then includes custom data for hover', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.customdata).toBeDefined(); - expect(Array.isArray(baseTrace.customdata)).toBe(true); - }); - - it('given reform trace then includes custom data for hover', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.customdata).toBeDefined(); - expect(Array.isArray(reformTrace.customdata)).toBe(true); - }); - - it('given currency values then formats hover data with dollar sign', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.customdata[0]).toContain('$'); - }); - - it('given trace then uses date and value hover template', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - expect(baseTrace.hovertemplate).toContain('%{x|%b, %Y}'); - expect(baseTrace.hovertemplate).toContain('%{customdata}'); - }); - }); - - describe('ParameterOverTimeChart responsive behavior', () => { - it('given chart renders then includes container ref', () => { - // Given/When - const { container } = render( - - ); - - // Then - const chartContainer = container.querySelector('div'); - expect(chartContainer).toBeInTheDocument(); - }); - - it('given chart then has margin configuration', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.margin).toBeDefined(); - expect(props.layout.margin.t).toBeDefined(); - expect(props.layout.margin.r).toBeDefined(); - expect(props.layout.margin.l).toBeDefined(); - expect(props.layout.margin.b).toBeDefined(); - }); - - it('given chart then has dragmode configuration', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.dragmode).toBeDefined(); - }); - - it('given chart then has style with height', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.style).toBeDefined(); - expect(props.style.height).toBeDefined(); - expect(typeof props.style.height).toBe('number'); - }); - }); - - describe('ParameterOverTimeChart policy label integration', () => { - it('given no policy data then reform trace uses default label', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_DEFAULT); - }); - - it('given custom policy label then reform trace uses label', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_LABEL); - }); - - it('given short policy label then reform trace uses short label', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_SHORT_LABEL); - }); - - it('given policy ID without label then reform trace formats ID', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_ID); - }); - - it('given small policy ID then formats correctly', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_SMALL_ID); - }); - - it('given policy label and ID then prioritizes label', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_LABEL); - expect(reformTrace.name).not.toBe(EXPECTED_REFORM_NAME_WITH_ID); - }); - - it('given empty string label then falls back to policy ID', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_ID); - }); - - it('given null label then falls back to policy ID', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_ID); - }); - - it('given empty string label and null ID then uses default', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_DEFAULT); - }); - - it('given wrapper component with policy label then passes to chart', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_LABEL); - }); - - it('given wrapper component with policy ID then passes to chart', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; - - // Then - expect(reformTrace.name).toBe(EXPECTED_REFORM_NAME_WITH_ID); - }); - }); - - describe('ParameterOverTimeChart error handling', () => { - it('given empty values collection then displays no data message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.getByText(EXPECTED_NO_DATA_MESSAGE)).toBeInTheDocument(); - }); - - it('given empty values then does not render chart', () => { - // Given/When - const { queryByTestId } = render( - - ); - - // Then - expect(queryByTestId('plotly-chart')).not.toBeInTheDocument(); - }); - - it('given mismatched dates and values then handles gracefully', () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const mismatchedCollection = new MockMismatchedValueCollection([]); - - // When - render( - - ); - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'ParameterOverTimeChart: Mismatched dates and values length' - ); - expect(screen.getByText(EXPECTED_NO_DATA_MESSAGE)).toBeInTheDocument(); - - // Cleanup - consoleWarnSpy.mockRestore(); - }); - - it('given error in data processing then handles gracefully', () => { - // Given - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const errorCollection = new MockErrorThrowingCollection([]); - - // When - render( - - ); - - // Then - expect(consoleErrorSpy).toHaveBeenCalled(); - expect(screen.getByText(EXPECTED_NO_DATA_MESSAGE)).toBeInTheDocument(); - - // Cleanup - consoleErrorSpy.mockRestore(); - }); - - it('given error in reform data processing then still renders base data', () => { - // Given - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const errorCollection = new MockErrorThrowingCollection([]); - - // When - const { getByTestId } = render( - - ); - - // Then - expect(consoleErrorSpy).toHaveBeenCalled(); - const chart = getByTestId('plotly-chart'); - expect(chart).toBeInTheDocument(); - - // Should only have base trace, no reform trace - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - expect(props.data.length).toBe(1); - expect(props.data[0].name).toBe('Current law'); - - // Cleanup - consoleErrorSpy.mockRestore(); - }); - - it('given empty reform values then only renders base trace', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.data.length).toBe(1); - expect(props.data[0].name).toBe('Current law'); - }); - - it('given mismatched reform data then shows warning and skips reform', () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const mismatchedCollection = new MockMismatchedValueCollection([]); - - // When - const { getByTestId } = render( - - ); - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'ParameterOverTimeChart: Mismatched reform dates and values length' - ); - - // Should still render base trace - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - expect(props.data.length).toBe(1); - expect(props.data[0].name).toBe('Current law'); - - // Cleanup - consoleWarnSpy.mockRestore(); - }); - }); - - describe('ParameterOverTimeChart memoization', () => { - it('given component re-renders with same props then uses memoized values', () => { - // Given - const { rerender, getByTestId } = render( - - ); - - const firstChart = getByTestId('plotly-chart'); - const firstProps = JSON.parse(firstChart.getAttribute('data-plotly-props') || '{}'); - - // When - Re-render with same props - rerender( - - ); - - // Then - Should have same data (memoization working) - const secondChart = getByTestId('plotly-chart'); - const secondProps = JSON.parse(secondChart.getAttribute('data-plotly-props') || '{}'); - - expect(firstProps.data[0].name).toBe(secondProps.data[0].name); - expect(firstProps.data[0].x).toEqual(secondProps.data[0].x); - expect(firstProps.data[0].y).toEqual(secondProps.data[0].y); - }); - }); - - describe('ParameterOverTimeChart axis buffer space', () => { - it('given chart then x-axis range starts at 2013', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - expect(props.layout.xaxis.range).toBeDefined(); - expect(props.layout.xaxis.range).toHaveLength(2); - - const rangeStart = new Date(props.layout.xaxis.range[0]); - - // X-axis starts at 2013-01-01 (earliest display date with 2-year buffer) - expect(rangeStart.getFullYear()).toBe(2013); - }); - - it('given chart then y-axis range includes 10% buffer above max value', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Buffer is calculated after extending data, so includes extended points - expect(props.layout.yaxis.range).toBeDefined(); - expect(props.layout.yaxis.range).toHaveLength(2); - - // The actual range includes the extension point at 2099, creating a range - const actualMax = props.layout.yaxis.range[1]; - const actualMin = props.layout.yaxis.range[0]; - - // Verify there is buffer space (range is larger than data) - expect(actualMax).toBeGreaterThan(12500); // Max data value - expect(actualMin).toBeLessThan(12000); // Min data value - }); - - it('given chart then y-axis range includes 10% buffer below min value', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Verify buffer exists below minimum - const actualMin = props.layout.yaxis.range[0]; - expect(actualMin).toBeLessThan(12000); // Min data value from SAMPLE_BASE_VALUES_SIMPLE - }); - - it('given chart with multiple values then buffer calculated from range', () => { - // Given - SAMPLE_BASE_VALUES_COMPLEX uses percentage values (0.15, 0.20, 0.22) - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Verify buffer exists on both sides - expect(props.layout.yaxis.range).toBeDefined(); - - const actualMin = props.layout.yaxis.range[0]; - const actualMax = props.layout.yaxis.range[1]; - - // Min/max from SAMPLE_BASE_VALUES_COMPLEX (percentages: 0.15 min, 0.22 max) - expect(actualMin).toBeLessThan(0.15); // Buffer below minimum - expect(actualMax).toBeGreaterThan(0.22); // Buffer above maximum - }); - - it('given chart with base and reform then buffer includes both datasets', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Buffer should account for both datasets - expect(props.layout.yaxis.range).toBeDefined(); - - const actualMin = props.layout.yaxis.range[0]; - const actualMax = props.layout.yaxis.range[1]; - - // Combined data: base has 12000-12500, reform has 15000 - expect(actualMin).toBeLessThan(12000); // Buffer below base minimum - expect(actualMax).toBeGreaterThan(15000); // Buffer above reform maximum - }); - }); - - describe('ParameterOverTimeChart infinite value filtering', () => { - it('given base values with infinity then filters out infinite values', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const baseTrace = props.data[0]; - - // Then - Should only have finite value (2), not Infinity - expect(baseTrace.y).toEqual([2, 2]); // Extended with duplicate - expect(baseTrace.y).not.toContain(Infinity); - }); - - it('given base values with infinity then displays warning message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given reform values with infinity then filters out infinite values', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - const reformTrace = props.data[0]; // Reform is first - - // Then - Should only have finite value (5), not -Infinity - expect(reformTrace.y).not.toContain(-Infinity); - expect(reformTrace.y).toContain(5); - }); - - it('given reform values with infinity then displays warning message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given both base and reform with infinity then displays warning message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given all infinite base values then displays no data message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.getByText(EXPECTED_NO_DATA_MESSAGE)).toBeInTheDocument(); - expect(screen.queryByText(EXPECTED_INFINITY_WARNING_MESSAGE)).not.toBeInTheDocument(); - }); - - it('given no infinite values then does not display warning message', () => { - // Given/When - render( - - ); - - // Then - expect(screen.queryByText(EXPECTED_INFINITY_WARNING_MESSAGE)).not.toBeInTheDocument(); - }); - - it('given base with infinity then y-axis includes 0', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Y-axis range should include 0 even though value is 2 - const [minY] = props.layout.yaxis.range; - expect(minY).toBeLessThanOrEqual(0); - }); - }); - - describe('ParameterOverTimeChart y-axis bounds', () => { - it('given numeric parameter then y-axis includes 0', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Y-axis range should include 0 - const [minY] = props.layout.yaxis.range; - expect(minY).toBeLessThanOrEqual(0); - }); - - it('given percentage parameter then y-axis includes 0% and 100%', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Y-axis range should include 0 (0%) and 1 (100%) - const [minY, maxY] = props.layout.yaxis.range; - expect(minY).toBeLessThanOrEqual(0); - expect(maxY).toBeGreaterThanOrEqual(1); - }); - - it('given integer parameter then y-axis includes 0', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - Y-axis range should include 0 - const [minY] = props.layout.yaxis.range; - expect(minY).toBeLessThanOrEqual(0); - }); - }); - - describe('ParameterOverTimeChart x-axis starting date', () => { - it('given chart then x-axis starts at 2013', () => { - // Given - const { getByTestId } = render( - - ); - - // When - const chart = getByTestId('plotly-chart'); - const props = JSON.parse(chart.getAttribute('data-plotly-props') || '{}'); - - // Then - X-axis range should start at 2013-01-01 - const [minDate] = props.layout.xaxis.range; - expect(minDate).toBe('2013-01-01'); - }); - }); -}); diff --git a/app/src/tests/unit/components/policyParameterSelectorFrame/Main.test.tsx b/app/src/tests/unit/components/policyParameterSelectorFrame/Main.test.tsx deleted file mode 100644 index d5320ae4..00000000 --- a/app/src/tests/unit/components/policyParameterSelectorFrame/Main.test.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { render, screen } from '@test-utils'; -import { Provider } from 'react-redux'; -import { describe, expect, it, vi } from 'vitest'; -import PolicyParameterSelectorMain from '@/components/policyParameterSelectorFrame/Main'; -import { policySlice } from '@/reducers/policyReducer'; -import { reportSlice } from '@/reducers/reportReducer'; - -// Mock the child components -vi.mock('@/components/policyParameterSelectorFrame/ValueSetter', () => ({ - default: () =>
ValueSetter
, -})); - -vi.mock('@/components/policyParameterSelectorFrame/HistoricalValues', () => ({ - default: (props: any) => ( -
-
{JSON.stringify(props.baseValues?.getIntervals())}
-
{JSON.stringify(props.reformValues?.getIntervals())}
-
{props.policyLabel || 'null'}
-
{props.policyId || 'null'}
-
- ), -})); - -// Sample parameter metadata -const SAMPLE_PARAM = { - parameter: 'gov.test.parameter', - label: 'Test Parameter', - type: 'parameter' as const, - unit: 'currency-USD', - description: 'A test parameter', - values: { - '2020-01-01': 1000, - '2024-01-01': 1500, - }, - economy: true, - household: true, -}; - -function createTestStore(initialState?: any) { - const config: any = { - reducer: { - policy: policySlice.reducer, - report: reportSlice.reducer, - }, - preloadedState: initialState, - }; - return configureStore(config); -} - -function renderWithStore(component: React.ReactElement, store: any) { - return render({component}); -} - -describe('PolicyParameterSelectorMain', () => { - describe('reform value initialization', () => { - it('given no active policy then reform values equal base values', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - const baseIntervals = screen.getByTestId('base-intervals').textContent; - const reformIntervals = screen.getByTestId('reform-intervals').textContent; - - expect(baseIntervals).toBeTruthy(); - expect(reformIntervals).toBeTruthy(); - expect(baseIntervals).toBe(reformIntervals); // Reform should match base initially - }); - - it('given active policy with no parameters then reform values equal base values', () => { - // Given - const store = createTestStore({ - policy: { - policies: [{ id: '123', label: 'Test Policy', parameters: [], isCreated: true }, null], - }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - const baseIntervals = screen.getByTestId('base-intervals').textContent; - const reformIntervals = screen.getByTestId('reform-intervals').textContent; - - expect(baseIntervals).toBe(reformIntervals); // Reform should still match base - }); - }); - - describe('reform value merging with user modifications', () => { - it('given user adds value then reform merges with base values', () => { - // Given - policy with user-defined value starting 2023 - const store = createTestStore({ - policy: { - policies: [ - { - id: '456', - label: 'My Reform', - parameters: [ - { - name: 'gov.test.parameter', - values: [{ startDate: '2023-01-01', endDate: '2100-12-31', value: 2000 }], - }, - ], - isCreated: true, - }, - null, - ], - }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - const reformIntervals = JSON.parse( - screen.getByTestId('reform-intervals').textContent || '[]' - ); - - // Should have base values (2020, 2024) plus user value (2023) - // After merging, the 2023 value should override the 2024 base value - expect(reformIntervals.length).toBeGreaterThan(1); - - // Check that reform contains intervals - const hasPre2023Interval = reformIntervals.some( - (interval: any) => interval.startDate < '2023-01-01' - ); - const has2023Interval = reformIntervals.some( - (interval: any) => interval.startDate === '2023-01-01' && interval.value === 2000 - ); - - expect(hasPre2023Interval).toBe(true); // Base value before 2023 - expect(has2023Interval).toBe(true); // User value from 2023 - }); - - it('given user adds multiple values then reform merges all with base', () => { - // Given - policy with multiple user-defined values - const store = createTestStore({ - policy: { - policies: [ - { - id: '789', - label: 'Complex Reform', - parameters: [ - { - name: 'gov.test.parameter', - values: [ - { startDate: '2022-01-01', endDate: '2023-12-31', value: 1800 }, - { startDate: '2025-01-01', endDate: '2100-12-31', value: 2500 }, - ], - }, - ], - isCreated: true, - }, - null, - ], - }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - const reformIntervals = JSON.parse( - screen.getByTestId('reform-intervals').textContent || '[]' - ); - - // Should have merged intervals - expect(reformIntervals.length).toBeGreaterThan(0); - - // Verify user values are present - const has1800Value = reformIntervals.some((i: any) => i.value === 1800); - const has2500Value = reformIntervals.some((i: any) => i.value === 2500); - - expect(has1800Value).toBe(true); - expect(has2500Value).toBe(true); - }); - }); - - describe('policy metadata propagation', () => { - it('given policy with label then passes label to chart', () => { - // Given - const store = createTestStore({ - policy: { - policies: [ - { - id: '999', - label: 'Universal Basic Income', - parameters: [], - isCreated: true, - }, - null, - ], - }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByTestId('policy-label')).toHaveTextContent('Universal Basic Income'); - }); - - it('given policy with ID then passes ID to chart', () => { - // Given - const store = createTestStore({ - policy: { - policies: [ - { - id: '12345', - label: null, - parameters: [], - isCreated: true, - }, - null, - ], - }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByTestId('policy-id')).toHaveTextContent('12345'); - }); - - it('given no policy then passes null label and ID', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByTestId('policy-label')).toHaveTextContent('null'); - expect(screen.getByTestId('policy-id')).toHaveTextContent('null'); - }); - }); - - describe('component rendering', () => { - it('given component renders then displays parameter label', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Test Parameter'); - }); - - it('given parameter has description then displays description', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByText('A test parameter')).toBeInTheDocument(); - }); - - it('given component renders then includes ValueSetter', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByTestId('value-setter')).toBeInTheDocument(); - }); - - it('given component renders then includes HistoricalValues', () => { - // Given - const store = createTestStore({ - policy: { policies: [null, null] }, - report: { mode: 'standalone', activeSimulationPosition: 0 }, - }); - - // When - renderWithStore(, store); - - // Then - expect(screen.getByTestId('historical-values')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/flows/reportViewFlow.test.ts b/app/src/tests/unit/flows/reportViewFlow.test.ts deleted file mode 100644 index 2de5a252..00000000 --- a/app/src/tests/unit/flows/reportViewFlow.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { ReportViewFlow } from '@/flows/reportViewFlow'; -import { SimulationViewFlow } from '@/flows/simulationViewFlow'; - -describe('ReportViewFlow', () => { - test('given ReportViewFlow then has correct structure', () => { - // Then - expect(ReportViewFlow).toBeDefined(); - expect(ReportViewFlow.initialFrame).toBe('ReportReadView'); - expect(ReportViewFlow.frames).toBeDefined(); - }); - - test('given ReportViewFlow then has ReportReadView frame', () => { - // Then - expect(ReportViewFlow.frames.ReportReadView).toBeDefined(); - expect(ReportViewFlow.frames.ReportReadView.component).toBe('ReportReadView'); - expect(ReportViewFlow.frames.ReportReadView.on).toBeDefined(); - expect(ReportViewFlow.frames.ReportReadView.on.next).toBe('__return__'); - }); - - test('given ReportViewFlow then is valid flow structure', () => { - // Then - expect(ReportViewFlow).toHaveProperty('initialFrame'); - expect(ReportViewFlow).toHaveProperty('frames'); - expect(typeof ReportViewFlow.initialFrame).toBe('string'); - expect(typeof ReportViewFlow.frames).toBe('object'); - }); - - test('given ReportViewFlow then follows same pattern as SimulationViewFlow', () => { - // Then - expect(ReportViewFlow.initialFrame).toBeDefined(); - expect(SimulationViewFlow.initialFrame).toBeDefined(); - - if (ReportViewFlow.initialFrame && SimulationViewFlow.initialFrame) { - expect(ReportViewFlow.initialFrame.replace('Report', 'Simulation')).toBe( - SimulationViewFlow.initialFrame - ); - } - - expect(Object.keys(ReportViewFlow.frames).length).toBe( - Object.keys(SimulationViewFlow.frames).length - ); - - // Both should have single frame with __return__ action - const reportFrame = ReportViewFlow.frames.ReportReadView; - const simulationFrame = SimulationViewFlow.frames.SimulationReadView; - expect(reportFrame.on.next).toBe(simulationFrame.on.next); - expect(reportFrame.on.next).toBe('__return__'); - }); -}); diff --git a/app/src/tests/unit/frames/policy/PolicyCreationFrame.test.tsx b/app/src/tests/unit/frames/policy/PolicyCreationFrame.test.tsx deleted file mode 100644 index 98776f66..00000000 --- a/app/src/tests/unit/frames/policy/PolicyCreationFrame.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import PolicyCreationFrame from '@/frames/policy/PolicyCreationFrame'; -import * as policyReducer from '@/reducers/policyReducer'; -import * as reportReducer from '@/reducers/reportReducer'; -import { - createMockFlowProps, - EXPECTED_BASELINE_POLICY_LABEL, - EXPECTED_BASELINE_WITH_REPORT_LABEL, - EXPECTED_REFORM_POLICY_LABEL, - EXPECTED_REFORM_WITH_REPORT_LABEL, - mockDispatch, - mockOnNavigate, - mockReportStateReportWithName, - mockReportStateReportWithoutName, - mockReportStateStandalone, - mockSelectCurrentPosition, -} from '@/tests/fixtures/frames/policyFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock selectors -let mockReportState: any = mockReportStateStandalone; - -vi.mock('@/reducers/activeSelectors', () => ({ - selectCurrentPosition: () => mockSelectCurrentPosition(), -})); - -const mockSelectPolicyAtPosition = vi.fn(); - -vi.mock('@/reducers/policyReducer', async () => { - const actual = await vi.importActual('@/reducers/policyReducer'); - return { - ...actual, - selectPolicyAtPosition: (_state: any, position: number) => mockSelectPolicyAtPosition(position), - }; -}); - -// Mock Redux -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => selector({ report: mockReportState }), - }; -}); - -describe('PolicyCreationFrame', () => { - const mockFlowProps = createMockFlowProps(); - - beforeEach(() => { - vi.clearAllMocks(); - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectPolicyAtPosition.mockReturnValue(null); - }); - - test('given component mounts in standalone mode with no existing policy then sets mode and creates policy', () => { - // Given - const flowProps = createMockFlowProps({ isInSubflow: false }); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - expect(mockDispatch).toHaveBeenCalledWith(reportReducer.setMode('standalone')); - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.createPolicyAtPosition({ position: 0 }) - ); - }); - - test('given component mounts in subflow with no existing policy then does not set mode but creates policy', () => { - // Given - const flowProps = createMockFlowProps({ isInSubflow: true }); - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - expect(mockDispatch).not.toHaveBeenCalledWith(reportReducer.setMode('standalone')); - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.createPolicyAtPosition({ position: 1 }) - ); - }); - - test('given component mounts with existing policy then does not create policy', () => { - // Given - const existingPolicy = { id: '123', label: 'Existing Policy', parameters: [] }; - mockSelectPolicyAtPosition.mockReturnValue(existingPolicy); - - // When - render(); - - // Then - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ type: expect.stringContaining('createPolicyAtPosition') }) - ); - }); - - test('given user enters policy label and submits then updates policy at position and navigates', async () => { - // Given - const user = userEvent.setup(); - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(0); - render(); - - // When - Clear prefilled label and type new one - const input = screen.getByLabelText('Policy title'); - await user.clear(input); - await user.type(input, 'My New Tax Policy'); - - const submitButton = screen.getByRole('button', { name: /Create a policy/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.updatePolicyAtPosition({ - position: 0, - updates: { label: 'My New Tax Policy' }, - }) - ); - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given user clears label and submits then updates with empty label and navigates', async () => { - // Given - const user = userEvent.setup(); - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(0); - render(); - - // When - Clear the prefilled label - const input = screen.getByLabelText('Policy title'); - await user.clear(input); - - const submitButton = screen.getByRole('button', { name: /Create a policy/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.updatePolicyAtPosition({ - position: 0, - updates: { label: '' }, - }) - ); - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given report mode position 1 then creates policy at position 1', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(1); - - // When - render(); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.createPolicyAtPosition({ position: 1 }) - ); - }); - - describe('Auto-naming behavior', () => { - test('given standalone mode at position 0 then prefills with baseline policy label', () => { - // Given - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_POLICY_LABEL); - }); - - test('given standalone mode at position 1 then prefills with reform policy label', () => { - // Given - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_POLICY_LABEL); - }); - - test('given report mode with report name at position 0 then prefills with report name and baseline', () => { - // Given - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_WITH_REPORT_LABEL); - }); - - test('given report mode with report name at position 1 then prefills with report name and reform', () => { - // Given - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_WITH_REPORT_LABEL); - }); - - test('given report mode without report name at position 0 then prefills with baseline policy', () => { - // Given - mockReportState = mockReportStateReportWithoutName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_POLICY_LABEL); - }); - - test('given report mode without report name at position 1 then prefills with reform policy', () => { - // Given - mockReportState = mockReportStateReportWithoutName; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectPolicyAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - const input = screen.getByLabelText('Policy title') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_POLICY_LABEL); - }); - - test('given user edits prefilled label then custom label is used', async () => { - // Given - const user = userEvent.setup(); - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectPolicyAtPosition.mockReturnValue(null); - - render(); - - // When - Clear and type new label - const input = screen.getByLabelText('Policy title'); - await user.clear(input); - await user.type(input, 'Custom Tax Reform'); - - const submitButton = screen.getByRole('button', { name: /Create a policy/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.updatePolicyAtPosition({ - position: 0, - updates: { label: 'Custom Tax Reform' }, - }) - ); - }); - }); -}); diff --git a/app/src/tests/unit/frames/policy/PolicySubmitFrame.test.tsx b/app/src/tests/unit/frames/policy/PolicySubmitFrame.test.tsx deleted file mode 100644 index a5fda40b..00000000 --- a/app/src/tests/unit/frames/policy/PolicySubmitFrame.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import PolicySubmitFrame from '@/frames/policy/PolicySubmitFrame'; -import * as policyReducer from '@/reducers/policyReducer'; -import { - createMockFlowProps, - MOCK_EMPTY_POLICY, - MOCK_POLICY_WITH_PARAMS, - mockCreatePolicySuccessResponse, - mockDispatch, - mockOnReturn, - mockSelectActivePolicy, - mockSelectCurrentPosition, - mockUseCreatePolicy, -} from '@/tests/fixtures/frames/policyFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock useCurrentCountry hook -vi.mock('@/hooks/useCurrentCountry', () => ({ - useCurrentCountry: vi.fn(() => 'us'), -})); - -// Mock selectors -vi.mock('@/reducers/activeSelectors', () => ({ - selectCurrentPosition: () => mockSelectCurrentPosition(), - selectActivePolicy: () => mockSelectActivePolicy(), -})); - -// Mock Redux -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => selector({}), - }; -}); - -// Mock useCreatePolicy hook -vi.mock('@/hooks/useCreatePolicy', () => ({ - useCreatePolicy: () => mockUseCreatePolicy, -})); - -describe('PolicySubmitFrame', () => { - const mockFlowProps = createMockFlowProps(); - - beforeEach(() => { - vi.clearAllMocks(); - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectActivePolicy.mockReturnValue(MOCK_POLICY_WITH_PARAMS); - mockUseCreatePolicy.createPolicy.mockClear(); - mockUseCreatePolicy.isPending = false; - }); - - test('given policy with parameters then displays all parameters in submission view', () => { - // Given - mockSelectActivePolicy.mockReturnValue(MOCK_POLICY_WITH_PARAMS); - - // When - render(); - - // Then - expect(screen.getByText('Review Policy')).toBeInTheDocument(); - expect(screen.getByText('income_tax_rate')).toBeInTheDocument(); - expect(screen.getByText(/0.25/)).toBeInTheDocument(); - }); - - test('given empty policy then displays empty provisions list', () => { - // Given - mockSelectActivePolicy.mockReturnValue(MOCK_EMPTY_POLICY); - - // When - render(); - - // Then - expect(screen.getByText('Review Policy')).toBeInTheDocument(); - expect(screen.getByText('Provision')).toBeInTheDocument(); - }); - - test('given user submits policy then creates policy and updates at position on success', async () => { - // Given - const user = userEvent.setup(); - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectActivePolicy.mockReturnValue(MOCK_POLICY_WITH_PARAMS); - - // Mock successful API call - mockUseCreatePolicy.createPolicy.mockImplementation((_payload, options) => { - options.onSuccess(mockCreatePolicySuccessResponse); - }); - - render(); - - // When - const submitButton = screen.getByRole('button', { name: /Submit Policy/i }); - await user.click(submitButton); - - // Then - expect(mockUseCreatePolicy.createPolicy).toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.updatePolicyAtPosition({ - position: 1, - updates: { - id: '123', - isCreated: true, - }, - }) - ); - expect(mockOnReturn).toHaveBeenCalled(); - }); - - test('given standalone mode when policy submitted then clears policy at position after success', async () => { - // Given - const user = userEvent.setup(); - const flowProps = createMockFlowProps({ isInSubflow: false }); - mockSelectActivePolicy.mockReturnValue(MOCK_POLICY_WITH_PARAMS); - - // Mock successful API call - mockUseCreatePolicy.createPolicy.mockImplementation((_payload, options) => { - options.onSuccess(mockCreatePolicySuccessResponse); - }); - - render(); - - // When - const submitButton = screen.getByRole('button', { name: /Submit Policy/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith(policyReducer.clearPolicyAtPosition(0)); - }); - - test('given subflow mode when policy submitted then does not clear policy', async () => { - // Given - const user = userEvent.setup(); - const flowProps = createMockFlowProps({ isInSubflow: true }); - mockSelectActivePolicy.mockReturnValue(MOCK_POLICY_WITH_PARAMS); - - // Mock successful API call - mockUseCreatePolicy.createPolicy.mockImplementation((_payload, options) => { - options.onSuccess(mockCreatePolicySuccessResponse); - }); - - render(); - - // When - const submitButton = screen.getByRole('button', { name: /Submit Policy/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).not.toHaveBeenCalledWith(policyReducer.clearPolicyAtPosition(0)); - }); - - test('given no policy at current position then does not submit', async () => { - // Given - const user = userEvent.setup(); - mockSelectActivePolicy.mockReturnValue(null); - - render(); - - // When - const submitButton = screen.getByRole('button', { name: /Submit Policy/i }); - await user.click(submitButton); - - // Then - expect(mockUseCreatePolicy.createPolicy).not.toHaveBeenCalled(); - }); - - test('given policy submission is pending then shows loading state', () => { - // Given - mockUseCreatePolicy.isPending = true; - - // When - render(); - - // Then - const submitButton = screen.getByRole('button', { name: /Submit Policy/i }); - expect(submitButton).toHaveAttribute('data-loading', 'true'); - }); -}); diff --git a/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx b/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx deleted file mode 100644 index 8a297146..00000000 --- a/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { screen, waitFor } from '@test-utils'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Provider } from 'react-redux'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { MantineProvider } from '@mantine/core'; -import GeographicConfirmationFrame from '@/frames/population/GeographicConfirmationFrame'; -import metadataReducer from '@/reducers/metadataReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import { - mockFlowProps, - mockGeographicAssociation, - mockNationalGeography, - mockStateGeography, - TEST_COUNTRIES, -} from '@/tests/fixtures/frames/populationMocks'; - -// Mock the regions data -vi.mock('@/mocks/regions', () => ({ - us_regions: { - result: { - economy_options: { - region: [ - { name: 'us', label: 'United States' }, - { name: 'state/ca', label: 'California' }, - { name: 'state/ny', label: 'New York' }, - ], - }, - }, - }, - uk_regions: { - result: { - economy_options: { - region: [ - { name: 'uk', label: 'United Kingdom' }, - { name: 'constituency/london', label: 'London' }, - ], - }, - }, - }, -})); - -// Mock constants -vi.mock('@/constants', () => ({ - MOCK_USER_ID: 'test-user-123', - CURRENT_YEAR: '2025', -})); - -// Mock hooks -const mockCreateGeographicAssociation = vi.fn(); -const mockResetIngredient = vi.fn(); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useCreateGeographicAssociation: () => ({ - mutateAsync: mockCreateGeographicAssociation, - isPending: false, - }), -})); - -vi.mock('@/hooks/useIngredientReset', () => ({ - useIngredientReset: () => ({ - resetIngredient: mockResetIngredient, - }), -})); - -describe('GeographicConfirmationFrame', () => { - let store: any; - - beforeEach(() => { - vi.clearAllMocks(); - mockCreateGeographicAssociation.mockResolvedValue(mockGeographicAssociation); - }); - - const renderComponent = ( - populationState: any = {}, - metadataState: Partial = { currentCountry: TEST_COUNTRIES.US }, - props = mockFlowProps - ) => { - // Use position-based structure for populations - const basePopulationState = { - populations: [populationState, null], // Population at position 0 - }; - const fullMetadataState = { - loading: false, - error: null, - currentCountry: TEST_COUNTRIES.US as string, - variables: {}, - parameters: {}, - entities: {}, - variableModules: {}, - economyOptions: { - region: [ - { name: 'us', label: 'United States' }, - { name: 'state/ca', label: 'California' }, - { name: 'state/ny', label: 'New York' }, - { name: 'uk', label: 'United Kingdom' }, - { name: 'constituency/london', label: 'London' }, - ], - time_period: [], - datasets: [], - }, - currentLawId: 0, - basicInputs: [], - modelledPolicies: { core: {}, filtered: {} }, - version: null, - parameterTree: null, - ...metadataState, - }; - - // Report reducer for position management - const reportState = { - mode: 'standalone' as const, - activeSimulationPosition: 0 as 0 | 1, - countryId: 'us', - apiVersion: 'v1', - simulationIds: [], - status: 'idle' as const, - output: null, - }; - - store = configureStore({ - reducer: { - population: populationReducer, - report: reportReducer, - metadata: metadataReducer, - }, - preloadedState: { - population: basePopulationState as any, - metadata: fullMetadataState, - report: reportState as any, - }, - }); - - return render( - - - - - - ); - }; - - describe('National geography', () => { - test('given national geography then displays correct country information', async () => { - // Given - const populationState = { - geography: mockNationalGeography, - }; - - // When - renderComponent(populationState); - - // Then - expect( - screen.getByRole('heading', { name: 'Confirm household collection' }) - ).toBeInTheDocument(); - expect(screen.getByText('National')).toBeInTheDocument(); - expect(screen.getByText('United States')).toBeInTheDocument(); - }); - - test('given UK national geography then displays United Kingdom', () => { - // Given - const populationState = { - geography: { - ...mockNationalGeography, - id: TEST_COUNTRIES.UK, - countryId: TEST_COUNTRIES.UK, - geographyId: TEST_COUNTRIES.UK, - }, - }; - - // When - renderComponent(populationState, { currentCountry: TEST_COUNTRIES.UK }); - - // Then - expect(screen.getByText('United Kingdom')).toBeInTheDocument(); - }); - }); - - describe('Subnational geography', () => { - test('given state geography then displays state information', () => { - // Given - const populationState = { - geography: mockStateGeography, - }; - - // When - renderComponent(populationState); - - // Then - expect( - screen.getByRole('heading', { name: 'Confirm household collection' }) - ).toBeInTheDocument(); - expect(screen.getByText('State')).toBeInTheDocument(); - expect(screen.getByText('California')).toBeInTheDocument(); - }); - - test('given UK constituency then displays constituency information', () => { - // Given - const populationState = { - geography: { - id: `${TEST_COUNTRIES.UK}-london`, - countryId: TEST_COUNTRIES.UK, - scope: 'subnational', - geographyId: 'london', - }, - }; - - // When - renderComponent(populationState, { currentCountry: TEST_COUNTRIES.UK }); - - // Then - expect(screen.getByText('Constituency')).toBeInTheDocument(); - expect(screen.getByText('London')).toBeInTheDocument(); - }); - }); - - describe('Submission handling', () => { - test('given valid national geography when submitted then creates association and updates state', async () => { - // Given - const user = userEvent.setup(); - const populationState = { - geography: mockNationalGeography, - label: 'Test National Population', - }; - renderComponent(populationState); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(mockCreateGeographicAssociation).toHaveBeenCalledWith( - expect.objectContaining({ - countryId: TEST_COUNTRIES.US, - scope: 'national', - label: 'Test National Population', - }) - ); - }); - }); - - test('given subnational geography when submitted then creates correct association', async () => { - // Given - const user = userEvent.setup(); - const populationState = { - geography: mockStateGeography, - label: 'California Population', - }; - renderComponent(populationState); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(mockCreateGeographicAssociation).toHaveBeenCalledWith( - expect.objectContaining({ - countryId: TEST_COUNTRIES.US, - scope: 'subnational', - geographyId: 'ca', - label: 'California Population', - }) - ); - }); - }); - - test('given standalone flow when submitted then resets ingredient', async () => { - // Given - const user = userEvent.setup(); - const populationState = { - geography: mockNationalGeography, - }; - renderComponent(populationState, {}, { ...mockFlowProps, isInSubflow: false }); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(mockResetIngredient).toHaveBeenCalledWith('population'); - }); - }); - - test('given subflow when submitted then does not reset ingredient', async () => { - // Given - const user = userEvent.setup(); - const populationState = { - geography: mockNationalGeography, - }; - renderComponent(populationState, {}, { ...mockFlowProps, isInSubflow: true }); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(mockCreateGeographicAssociation).toHaveBeenCalled(); - expect(mockResetIngredient).not.toHaveBeenCalled(); - }); - }); - - test('given API error when submitted then logs error', async () => { - // Given - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockCreateGeographicAssociation.mockRejectedValueOnce(new Error('API Error')); - - const user = userEvent.setup(); - const populationState = { - geography: mockNationalGeography, - }; - renderComponent(populationState); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to create geographic association:', - expect.any(Error) - ); - }); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('Edge cases', () => { - test('given unknown region code then displays raw code', () => { - // Given - const populationState = { - geography: { - ...mockStateGeography, - geographyId: 'unknown-region', - }, - }; - - // When - renderComponent(populationState); - - // Then - expect(screen.getByText('unknown-region')).toBeInTheDocument(); - }); - - test('given onReturn prop when submitted then calls onReturn', async () => { - // Given - const user = userEvent.setup(); - const mockOnNavigate = vi.fn(); - const mockOnReturn = vi.fn(); - const populationState = { - geography: mockNationalGeography, - }; - renderComponent( - populationState, - {}, - { - ...mockFlowProps, - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - } - ); - - // When - const submitButton = screen.getByRole('button', { name: /Create household collection/i }); - await user.click(submitButton); - - // Then - await waitFor(() => { - expect(mockOnReturn).toHaveBeenCalled(); - expect(mockOnNavigate).not.toHaveBeenCalledWith('__return__'); - }); - }); - - test('given missing metadata country then defaults to us', () => { - // Given - const populationState = { - geography: { - ...mockNationalGeography, - countryId: 'us', // Keep countryId - }, - }; - - // When - render without country in metadata - renderComponent(populationState, { currentCountry: undefined }); - - // Then - should still display United States (from geography) - expect(screen.getByText('United States')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx b/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx deleted file mode 100644 index 4d8eff8f..00000000 --- a/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { screen, waitFor } from '@test-utils'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Provider } from 'react-redux'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { MantineProvider } from '@mantine/core'; -import SelectGeographicScopeFrame from '@/frames/population/SelectGeographicScopeFrame'; -import metadataReducer from '@/reducers/metadataReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import { - GEOGRAPHIC_SCOPES, - mockFlowProps, - TEST_COUNTRIES, -} from '@/tests/fixtures/frames/populationMocks'; - -// Mock region data for tests -const mockUSRegions = [ - { name: 'us', label: 'United States' }, - { name: 'ca', label: 'California' }, - { name: 'ny', label: 'New York' }, - { name: 'tx', label: 'Texas' }, -]; - -const mockUKRegions = [ - { name: 'uk', label: 'United Kingdom' }, - { name: 'country/england', label: 'England' }, - { name: 'country/scotland', label: 'Scotland' }, - { name: 'constituency/E14000639', label: 'Cities of London and Westminster' }, - { name: 'constituency/E14000973', label: 'Uxbridge and South Ruislip' }, -]; - -describe('SelectGeographicScopeFrame', () => { - let store: any; - const user = userEvent.setup(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - const renderComponent = ( - metadataState: Partial = { currentCountry: TEST_COUNTRIES.US as string }, - props = mockFlowProps - ) => { - const countryId = metadataState.currentCountry || TEST_COUNTRIES.US; - const regionData = countryId === TEST_COUNTRIES.UK ? mockUKRegions : mockUSRegions; - - const fullMetadataState = { - loading: false, - error: null, - currentCountry: countryId as string, - variables: {}, - parameters: {}, - entities: {}, - variableModules: {}, - economyOptions: { region: regionData, time_period: [], datasets: [] }, - currentLawId: 0, - basicInputs: [], - modelledPolicies: { core: {}, filtered: {} }, - version: null, - parameterTree: null, - ...metadataState, - }; - - store = configureStore({ - reducer: { - population: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - preloadedState: { - population: { - populations: [null, null] as [any, any], - }, - metadata: fullMetadataState, - }, - }); - - // Use the country from metadata state for the router path - const countryForRouter = fullMetadataState.currentCountry || TEST_COUNTRIES.US; - - return render( - - - - - } /> - - - - - ); - }; - - describe('Component rendering', () => { - test('given component loads then displays all scope options', () => { - // When - renderComponent(); - - // Then - expect(screen.getByRole('heading', { name: 'Select Household Scope' })).toBeInTheDocument(); - expect(screen.getByLabelText('All households nationally')).toBeInTheDocument(); - expect(screen.getByLabelText('All households in a state')).toBeInTheDocument(); - expect(screen.getByLabelText('Custom household')).toBeInTheDocument(); - }); - - test('given initial state then national is selected by default', () => { - // When - renderComponent(); - - // Then - const nationalRadio = screen.getByLabelText('All households nationally') as HTMLInputElement; - expect(nationalRadio.checked).toBe(true); - }); - }); - - describe('Scope selection', () => { - test('given state scope selected then shows state dropdown for US', async () => { - // Given - renderComponent(); - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - await user.click(stateRadio); - - // Then - await waitFor(() => { - expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); - }); - - // And the dropdown should have US states - const dropdown = screen.getByPlaceholderText('Pick a state'); - await user.click(dropdown); - - await waitFor(() => { - expect(screen.getByText('California')).toBeInTheDocument(); - expect(screen.getByText('New York')).toBeInTheDocument(); - expect(screen.getByText('Texas')).toBeInTheDocument(); - }); - }); - - test('given UK then shows UK-wide, Country, Parliamentary Constituency, Household options', async () => { - // Given - renderComponent({ currentCountry: TEST_COUNTRIES.UK }); - - // Then - Shows 4 UK options - expect(screen.getByLabelText('All households UK-wide')).toBeInTheDocument(); - expect(screen.getByLabelText('All households in a country')).toBeInTheDocument(); - expect(screen.getByLabelText('All households in a constituency')).toBeInTheDocument(); - expect(screen.getByLabelText('Custom household')).toBeInTheDocument(); - }); - - test('given UK Country selected then shows country dropdown', async () => { - // Given - renderComponent({ currentCountry: TEST_COUNTRIES.UK }); - - // When - const countryRadio = screen.getByLabelText('All households in a country'); - await user.click(countryRadio); - - // Then - Shows country selector - await waitFor(() => { - expect(screen.getByPlaceholderText('Pick a country')).toBeInTheDocument(); - }); - - const countryDropdown = screen.getByPlaceholderText('Pick a country'); - await user.click(countryDropdown); - await waitFor(() => { - expect(screen.getByText('England')).toBeInTheDocument(); - expect(screen.getByText('Scotland')).toBeInTheDocument(); - }); - }); - - test('given UK Constituency selected then shows constituency dropdown', async () => { - // Given - renderComponent({ currentCountry: TEST_COUNTRIES.UK }); - - // When - const constituencyRadio = screen.getByLabelText('All households in a constituency'); - await user.click(constituencyRadio); - - // Then - Shows constituency selector - await waitFor(() => { - expect(screen.getByPlaceholderText('Pick a constituency')).toBeInTheDocument(); - }); - - const constituencyDropdown = screen.getByPlaceholderText('Pick a constituency'); - await user.click(constituencyDropdown); - - await waitFor(() => { - expect(screen.getByText('Cities of London and Westminster')).toBeInTheDocument(); - expect(screen.getByText('Uxbridge and South Ruislip')).toBeInTheDocument(); - }); - }); - - test('given household scope selected then hides region selectors', async () => { - // Given - renderComponent(); - - // First select state to show dropdown - const stateRadio = screen.getByLabelText('All households in a state'); - await user.click(stateRadio); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); - }); - - // When - Switch to household - const householdRadio = screen.getByLabelText('Custom household'); - await user.click(householdRadio); - - // Then - Dropdown should be hidden - await waitFor(() => { - expect(screen.queryByPlaceholderText('Pick a state')).not.toBeInTheDocument(); - }); - }); - }); - - describe('Form submission', () => { - test('given national scope when submitted then creates national geography', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent(undefined, props); - - // When - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.NATIONAL); - - // Verify Redux action was dispatched - const state = store.getState(); - expect(state.population.populations[0]?.geography).toEqual( - expect.objectContaining({ - id: TEST_COUNTRIES.US, - countryId: TEST_COUNTRIES.US, - scope: 'national', - geographyId: TEST_COUNTRIES.US, - }) - ); - }); - - test('given state scope with selected region when submitted then creates subnational geography', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent(undefined, props); - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - await user.click(stateRadio); - - const dropdown = await screen.findByPlaceholderText('Pick a state'); - await user.click(dropdown); - - const california = await screen.findByText('California'); - await user.click(california); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.STATE); - - const state = store.getState(); - expect(state.population.populations[0]?.geography).toEqual( - expect.objectContaining({ - id: `${TEST_COUNTRIES.US}-ca`, - countryId: TEST_COUNTRIES.US, - scope: 'subnational', - geographyId: 'ca', - }) - ); - }); - - test('given state scope without region selected when submitted then does not navigate', async () => { - // Given - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const props = { ...mockFlowProps }; - renderComponent(undefined, props); - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - await user.click(stateRadio); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - expect(props.onNavigate).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith('state selected but no region chosen'); - - consoleSpy.mockRestore(); - }); - - test('given household scope when submitted then navigates without creating geography', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent(undefined, props); - - // When - const householdRadio = screen.getByLabelText('Custom household'); - await user.click(householdRadio); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.HOUSEHOLD); - - // Geography should not be set for household scope - const state = store.getState(); - expect(state.population.populations[0]?.geography).toBeNull(); - }); - }); - - describe('Region value storage', () => { - test('given US state when submitted then stores value without prefix', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent(undefined, props); - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - await user.click(stateRadio); - - const dropdown = await screen.findByPlaceholderText('Pick a state'); - await user.click(dropdown); - - const california = await screen.findByText('California'); - await user.click(california); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - US values stored without prefix - const state = store.getState(); - expect(state.population.populations[0]?.geography?.geographyId).toBe('ca'); - }); - - test('given UK constituency when submitted then stores full prefixed value', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent({ currentCountry: TEST_COUNTRIES.UK }, props); - - // When - const constituencyRadio = screen.getByLabelText('All households in a constituency'); - await user.click(constituencyRadio); - - // Select constituency - const constituencyDropdown = await screen.findByPlaceholderText('Pick a constituency'); - await user.click(constituencyDropdown); - const constituency = await screen.findByText('Cities of London and Westminster'); - await user.click(constituency); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - Should store FULL prefixed value - const state = store.getState(); - expect(state.population.populations[0]?.geography?.geographyId).toBe( - 'constituency/E14000639' - ); - }); - - test('given UK country when submitted then stores full prefixed value', async () => { - // Given - const props = { ...mockFlowProps }; - renderComponent({ currentCountry: TEST_COUNTRIES.UK }, props); - - // When - const countryRadio = screen.getByLabelText('All households in a country'); - await user.click(countryRadio); - - // Select country - const countryDropdown = await screen.findByPlaceholderText('Pick a country'); - await user.click(countryDropdown); - const england = await screen.findByText('England'); - await user.click(england); - - const submitButton = screen.getByRole('button', { name: /Select Scope/i }); - await user.click(submitButton); - - // Then - Should store FULL prefixed value - const state = store.getState(); - expect(state.population.populations[0]?.geography?.geographyId).toBe('country/england'); - }); - }); - - describe('Country-specific behavior', () => { - test('given no metadata country then defaults to US', () => { - // Given - renderComponent({ currentCountry: null }); - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - stateRadio.click(); - - // Then - Should show US states - waitFor(() => { - expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); - }); - }); - - test('given unknown country then defaults to US behavior', () => { - // Given - renderComponent({ currentCountry: 'ca' }); // Canada not implemented - - // When - const stateRadio = screen.getByLabelText('All households in a state'); - stateRadio.click(); - - // Then - Should show US states as fallback - waitFor(() => { - expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx b/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx deleted file mode 100644 index c1525a7a..00000000 --- a/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx +++ /dev/null @@ -1,266 +0,0 @@ -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 { MantineProvider } from '@mantine/core'; -import { CURRENT_YEAR } from '@/constants'; -import SetPopulationLabelFrame from '@/frames/population/SetPopulationLabelFrame'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import { - getMockHousehold, - LONG_LABEL, - mockFlowProps, - mockNationalGeography, - mockStateGeography, - TEST_POPULATION_LABEL, - TEST_VALUES, - UI_TEXT, -} from '@/tests/fixtures/frames/populationMocks'; - -describe('SetPopulationLabelFrame', () => { - let store: any; - const user = userEvent.setup(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - const renderComponent = (populationState: any = null, props = mockFlowProps) => { - // Use position-based structure - population at position 0 - const basePopulationState = { - populations: [populationState, null] as [any, any], - }; - - // Report reducer for position management - const reportState = { - mode: 'standalone' as const, - activeSimulationPosition: 0 as 0 | 1, - countryId: 'us', - apiVersion: 'v1', - simulationIds: [], - status: 'idle' as const, - output: null, - }; - - store = configureStore({ - reducer: { - population: populationReducer, - report: reportReducer, - metadata: () => ({}), - }, - preloadedState: { - population: basePopulationState, - report: reportState as any, - metadata: {}, - }, - }); - - return render( - - - - - - ); - }; - - describe('Component rendering', () => { - test('given component loads then displays label input form', () => { - // When - renderComponent(); - - // Then - expect(screen.getByText(UI_TEXT.NAME_POPULATION_TITLE)).toBeInTheDocument(); - expect(screen.getByText(UI_TEXT.POPULATION_LABEL)).toBeInTheDocument(); - expect(screen.getByText(UI_TEXT.LABEL_DESCRIPTION)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER)).toBeInTheDocument(); - }); - - test('given existing label then pre-fills input', () => { - // Given - const populationState = { - label: TEST_POPULATION_LABEL, - }; - - // When - renderComponent(populationState); - - // Then - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; - expect(input.value).toBe(TEST_POPULATION_LABEL); - }); - }); - - describe('Default labels', () => { - test('given national geography then suggests National Population', () => { - // Given - const populationState = { - geography: mockNationalGeography, - }; - - // When - renderComponent(populationState); - - // Then - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; - expect(input.value).toBe(UI_TEXT.DEFAULT_NATIONAL_LABEL); - }); - - test('given state geography then suggests state name population', () => { - // Given - const populationState = { - geography: mockStateGeography, - }; - - // When - renderComponent(populationState); - - // Then - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; - expect(input.value).toBe(UI_TEXT.DEFAULT_STATE_LABEL('ca')); - }); - - test('given household then suggests Custom Household', () => { - // Given - const populationState = { - household: getMockHousehold(), - }; - - // When - renderComponent(populationState); - - // Then - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; - expect(input.value).toBe(UI_TEXT.DEFAULT_HOUSEHOLD_LABEL); - }); - - test('given no population type then defaults to Custom Household', () => { - // When - renderComponent(); - - // Then - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; - expect(input.value).toBe(UI_TEXT.DEFAULT_HOUSEHOLD_LABEL); - }); - }); - - describe('Label validation', () => { - test('given empty label when submitted then shows error', async () => { - // Given - renderComponent(); - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - - // When - await user.clear(input); - const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); - await user.click(submitButton); - - // Then - expect(screen.getByText(UI_TEXT.ERROR_EMPTY_LABEL)).toBeInTheDocument(); - }); - - test('given valid label when submitted then updates state', async () => { - // Given - renderComponent(); - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - - // When - await user.clear(input); - await user.type(input, TEST_VALUES.TEST_LABEL); - const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); - await user.click(submitButton); - - // Then - verify state was updated - const state = store.getState(); - expect(state.population.populations[0]?.label).toBe(TEST_VALUES.TEST_LABEL); - }); - - test('given label over 150 characters then truncates', async () => { - // Given - renderComponent(); - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - - // When - await user.clear(input); - await user.type(input, LONG_LABEL); - - // Then - const inputElement = input as HTMLInputElement; - expect(inputElement.value.length).toBeLessThanOrEqual(150); - }); - }); - - describe('Form submission', () => { - test('given valid label with geography when submitted then navigates to geographic', async () => { - // Given - const mockOnNavigate = vi.fn(); - const populationState = { - geography: mockNationalGeography, - }; - renderComponent(populationState, { ...mockFlowProps, onNavigate: mockOnNavigate }); - - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - await user.clear(input); - await user.type(input, 'My Population'); - - // When - const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); - await user.click(submitButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('geographic'); - }); - - test('given valid label with household when submitted then navigates to household', async () => { - // Given - const mockOnNavigate = vi.fn(); - const populationState = { - household: getMockHousehold(), - }; - renderComponent(populationState, { ...mockFlowProps, onNavigate: mockOnNavigate }); - - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - await user.clear(input); - await user.type(input, `My Family ${CURRENT_YEAR}`); - - // When - const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); - await user.click(submitButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('household'); - const state = store.getState(); - expect(state.population.populations[0]?.label).toBe(`My Family ${CURRENT_YEAR}`); - }); - - test('given label with leading/trailing spaces when submitted then trims label', async () => { - // Given - renderComponent(); - const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); - - // When - await user.clear(input); - await user.type(input, ' Trimmed Label '); - const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); - await user.click(submitButton); - - // Then - const state = store.getState(); - expect(state.population.populations[0]?.label).toBe('Trimmed Label'); - }); - }); - - describe('Navigation', () => { - test('given back button then renders as disabled', () => { - // Given - renderComponent(null, mockFlowProps); - - // When - const backButton = screen.getByRole('button', { name: /Back/i }); - - // Then - expect(backButton).toBeDisabled(); - }); - }); -}); diff --git a/app/src/tests/unit/frames/report/ReportSelectExistingSimulationFrame.test.tsx b/app/src/tests/unit/frames/report/ReportSelectExistingSimulationFrame.test.tsx deleted file mode 100644 index b3a1a725..00000000 --- a/app/src/tests/unit/frames/report/ReportSelectExistingSimulationFrame.test.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import { QueryNormalizerProvider } from '@normy/react-query'; -import { configureStore } from '@reduxjs/toolkit'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { screen, userEvent } from '@test-utils'; -import { render } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { MantineProvider } from '@mantine/core'; -import ReportSelectExistingSimulationFrame from '@/frames/report/ReportSelectExistingSimulationFrame'; -import flowReducer from '@/reducers/flowReducer'; -import metadataReducer from '@/reducers/metadataReducer'; -import policyReducer from '@/reducers/policyReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import simulationsReducer, * as simulationsActions from '@/reducers/simulationsReducer'; -import { - AFTER_SORTING_LOG, - BASE_POPULATION_ID, - COMPATIBLE_SIMULATION_CONFIG, - COMPATIBLE_SIMULATIONS, - createEnhancedUserSimulation, - createOtherSimulation, - INCOMPATIBLE_SIMULATION_CONFIG, - INCOMPATIBLE_SIMULATIONS, - MOCK_CONFIGURED_SIMULATION_1, - MOCK_CONFIGURED_SIMULATION_2, - MOCK_CONFIGURED_SIMULATION_WITHOUT_LABEL, - MOCK_UNCONFIGURED_SIMULATION, - NEXT_BUTTON_LABEL, - NO_SIMULATIONS_MESSAGE, - OTHER_SIMULATION_CONFIG, - SELECT_EXISTING_SIMULATION_FRAME_TITLE, - SELECTED_SIMULATION_LOG_PREFIX, - SHARED_POPULATION_ID_2, - TEST_SIMULATION_CONFIG, - VARIOUS_POPULATION_SIMULATIONS, -} from '@/tests/fixtures/frames/ReportSelectExistingSimulationFrame'; - -// Mock useUserSimulations hook -const mockUseUserSimulations = vi.fn(); -vi.mock('@/hooks/useUserSimulations', () => ({ - useUserSimulations: (userId: string) => mockUseUserSimulations(userId), -})); - -describe('ReportSelectExistingSimulationFrame', () => { - let store: any; - let queryClient: QueryClient; - let mockOnNavigate: ReturnType; - let mockOnReturn: ReturnType; - let defaultFlowProps: any; - let consoleLogSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create QueryClient - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - }, - }); - - // Create a fresh store for each test - store = configureStore({ - reducer: { - report: reportReducer, - simulations: simulationsReducer, - flow: flowReducer, - policy: policyReducer, - population: populationReducer, - household: populationReducer, - metadata: metadataReducer, - }, - }); - - mockOnNavigate = vi.fn(); - mockOnReturn = vi.fn(); - - // Default flow props to satisfy FlowComponentProps interface - defaultFlowProps = { - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: { - component: 'ReportSelectExistingSimulationFrame', - on: { - next: 'ReportSetupFrame', - }, - }, - isInSubflow: false, - flowDepth: 0, - }; - - // Spy on console.log for testing console messages - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // Default mock for useUserSimulations - returns empty array (no simulations) - mockUseUserSimulations.mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - }); - }); - - // Helper function to render with router context - const renderFrame = (component: React.ReactElement) => { - return render( - - - - - - - - - - - - - - ); - }; - - test('given no simulations exist then displays empty state', () => { - // Given - mock hook returns empty array (default from beforeEach) - - // When - renderFrame(); - - // Then - expect( - screen.getByRole('heading', { name: SELECT_EXISTING_SIMULATION_FRAME_TITLE }) - ).toBeInTheDocument(); - expect(screen.getByText(NO_SIMULATIONS_MESSAGE)).toBeInTheDocument(); - }); - - test('given unconfigured simulations exist then filters them out', () => { - // Given - mock hook returns unconfigured simulation (simulation without id) - // The component filters based on enhancedSim.simulation?.id, so we need to ensure id is falsy - mockUseUserSimulations.mockReturnValue({ - data: [ - { - userSimulation: { - id: 'user-sim-unconfigured', - userId: '1', - simulationId: MOCK_UNCONFIGURED_SIMULATION.id, - label: MOCK_UNCONFIGURED_SIMULATION.label, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - isCreated: false, - }, - simulation: { - id: null, // Explicitly null to be filtered out - label: MOCK_UNCONFIGURED_SIMULATION.label, - policyId: null, - populationId: null, - populationType: null, - isCreated: false, - }, - isLoading: false, - error: null, - }, - ], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - component shows title but no simulation cards (filtered out) - // Note: Component currently doesn't show "No simulations" message after filtering, - // it just renders an empty card list - expect( - screen.getByRole('heading', { name: SELECT_EXISTING_SIMULATION_FRAME_TITLE }) - ).toBeInTheDocument(); - // Verify no simulation cards are present by checking that simulation label is NOT in document - expect(screen.queryByText(MOCK_UNCONFIGURED_SIMULATION.label!)).not.toBeInTheDocument(); - // Next button should still be present but disabled - const nextButton = screen.queryByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).toBeInTheDocument(); - }); - - test('given configured simulations exist then displays simulation list', () => { - // Given - mock hook returns configured simulations - const enhanced1 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - const enhanced2 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_2); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced1, enhanced2], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - expect( - screen.getByRole('heading', { name: SELECT_EXISTING_SIMULATION_FRAME_TITLE }) - ).toBeInTheDocument(); - // Check that the simulation cards are shown - expect(screen.getByText(MOCK_CONFIGURED_SIMULATION_1.label!)).toBeInTheDocument(); - expect(screen.getByText(MOCK_CONFIGURED_SIMULATION_2.label!)).toBeInTheDocument(); - }); - - test('given simulations with labels then displays titles correctly', () => { - // Given - mock hook returns simulations with labels - const enhanced1 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - const enhanced2 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_2); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced1, enhanced2], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - check that simulation titles are displayed - expect(screen.getByText(MOCK_CONFIGURED_SIMULATION_1.label!)).toBeInTheDocument(); - expect(screen.getByText(MOCK_CONFIGURED_SIMULATION_2.label!)).toBeInTheDocument(); - // Check that policy and population info appears somewhere in the document - // (exact format may vary based on subtitle construction) - expect(screen.getByText(/Policy 1/)).toBeInTheDocument(); - expect(screen.getByText(/Policy 2/)).toBeInTheDocument(); - }); - - test('given simulation without label then displays ID as title', () => { - // Given - mock hook returns simulation without label - const enhanced = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_WITHOUT_LABEL); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - expect( - screen.getByText(`Simulation #${MOCK_CONFIGURED_SIMULATION_WITHOUT_LABEL.id}`) - ).toBeInTheDocument(); - }); - - test('given no selection then Next button is disabled', () => { - // Given - mock hook returns simulation - const enhanced = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).toBeDisabled(); - }); - - test('given user selects simulation then Next button is enabled', async () => { - // Given - const user = userEvent.setup(); - const enhanced = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced], - isLoading: false, - isError: false, - error: null, - }); - - renderFrame(); - - // When - const simCard = screen.getByText(MOCK_CONFIGURED_SIMULATION_1.label!).closest('button'); - await user.click(simCard!); - - // Then - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).not.toBeDisabled(); - }); - - test('given user selects simulation and clicks Next then logs selection and navigates', async () => { - // Given - set up a placeholder simulation in Redux at position 0 so update can succeed - store.dispatch( - simulationsActions.createSimulationAtPosition({ - position: 0, - simulation: { - id: undefined, - label: undefined, - policyId: undefined, - populationId: undefined, - populationType: undefined, - isCreated: false, - }, - }) - ); - - const user = userEvent.setup(); - const enhanced = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced], - isLoading: false, - isError: false, - error: null, - }); - - renderFrame(); - - // When - const simCard = screen.getByText(MOCK_CONFIGURED_SIMULATION_1.label!).closest('button'); - await user.click(simCard!); - - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - await user.click(nextButton); - - // Then - check that console.log was called with the prefix and an object - expect(consoleLogSpy).toHaveBeenCalledWith(SELECTED_SIMULATION_LOG_PREFIX, expect.any(Object)); - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given user switches selection then updates selected simulation', async () => { - // Given - set up a placeholder simulation in Redux at position 0 so update can succeed - store.dispatch( - simulationsActions.createSimulationAtPosition({ - position: 0, - simulation: { - id: undefined, - label: undefined, - policyId: undefined, - populationId: undefined, - populationType: undefined, - isCreated: false, - }, - }) - ); - - const user = userEvent.setup(); - const enhanced1 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_1); - const enhanced2 = createEnhancedUserSimulation(MOCK_CONFIGURED_SIMULATION_2); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced1, enhanced2], - isLoading: false, - isError: false, - error: null, - }); - - renderFrame(); - - // When - first select simulation 1 - const sim1Card = screen.getByText(MOCK_CONFIGURED_SIMULATION_1.label!).closest('button'); - await user.click(sim1Card!); - - // Then switch to simulation 2 - const sim2Card = screen.getByText(MOCK_CONFIGURED_SIMULATION_2.label!).closest('button'); - await user.click(sim2Card!); - - // When clicking Next - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - await user.click(nextButton); - - // Then - should log the last selected simulation - expect(consoleLogSpy).toHaveBeenCalledWith(SELECTED_SIMULATION_LOG_PREFIX, expect.any(Object)); - }); - - test('given 2 simulations (max capacity) then displays both', () => { - // Given - mock hook returns 2 simulations - const sim1 = { - id: `1`, - label: `Simulation 1`, - policyId: `1`, - populationId: `1`, - populationType: 'household' as const, - isCreated: true, - }; - const sim2 = { - id: `2`, - label: `Simulation 2`, - policyId: `2`, - populationId: `2`, - populationType: 'household' as const, - isCreated: true, - }; - const enhanced1 = createEnhancedUserSimulation(sim1); - const enhanced2 = createEnhancedUserSimulation(sim2); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced1, enhanced2], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - Both should be visible - expect(screen.getByText('Simulation 1')).toBeInTheDocument(); - expect(screen.getByText('Simulation 2')).toBeInTheDocument(); - }); - - describe('Simulation Sorting by Compatibility', () => { - test('given mixed compatible and incompatible sims then compatible appear first', () => { - // Given - set up otherSimulation at position 1 with shared population - store.dispatch( - simulationsActions.createSimulationAtPosition({ - position: 1, - simulation: OTHER_SIMULATION_CONFIG, - }) - ); - - // Set active position to 0 (so otherSimulation is at position 1) - store.dispatch({ type: 'report/setActiveSimulationPosition', payload: 0 }); - - // Create simulations: incompatible first, compatible second (reversed order) - const enhanced1 = createEnhancedUserSimulation(INCOMPATIBLE_SIMULATION_CONFIG); - const enhanced2 = createEnhancedUserSimulation(COMPATIBLE_SIMULATION_CONFIG); - mockUseUserSimulations.mockReturnValue({ - data: [enhanced1, enhanced2], // Incompatible first in data - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - Get all simulation cards (buttons with both title and subtitle) - const cards = screen.getAllByRole('button').filter((button) => { - return button.textContent?.includes('Sim'); - }); - - // First card should be compatible (not disabled) - expect(cards[0]).not.toHaveAttribute('disabled'); - expect(cards[0]).toHaveTextContent('Compatible Sim'); - - // Second card should be incompatible (disabled) - expect(cards[1]).toHaveAttribute('disabled'); - expect(cards[1]).toHaveTextContent('Incompatible Sim'); - }); - - test('given all compatible sims then all appear enabled in original order', () => { - // Given - set up otherSimulation at position 1 with shared population - store.dispatch( - simulationsActions.createSimulationAtPosition({ - position: 1, - simulation: createOtherSimulation(SHARED_POPULATION_ID_2), - }) - ); - - store.dispatch({ type: 'report/setActiveSimulationPosition', payload: 0 }); - - // Create 3 compatible sims with same populationId - const enhancedSims = COMPATIBLE_SIMULATIONS.map(createEnhancedUserSimulation); - mockUseUserSimulations.mockReturnValue({ - data: enhancedSims, - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - const cards = screen.getAllByRole('button').filter((button) => { - return button.textContent?.includes('Sim'); - }); - - expect(cards).toHaveLength(3); - - // All should be enabled - cards.forEach((card) => { - expect(card).not.toHaveAttribute('disabled'); - }); - - // Check order preserved: A, B, C - expect(cards[0]).toHaveTextContent('Sim A'); - expect(cards[1]).toHaveTextContent('Sim B'); - expect(cards[2]).toHaveTextContent('Sim C'); - }); - - test('given all incompatible sims then all appear disabled', () => { - // Given - set up otherSimulation at position 1 with base population - store.dispatch( - simulationsActions.createSimulationAtPosition({ - position: 1, - simulation: createOtherSimulation(BASE_POPULATION_ID), - }) - ); - - store.dispatch({ type: 'report/setActiveSimulationPosition', payload: 0 }); - - // Create 3 incompatible sims with different populationIds - const enhancedSims = INCOMPATIBLE_SIMULATIONS.map(createEnhancedUserSimulation); - mockUseUserSimulations.mockReturnValue({ - data: enhancedSims, - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - const cards = screen.getAllByRole('button').filter((button) => { - return button.textContent?.includes('Sim'); - }); - - expect(cards).toHaveLength(3); - - // All should be disabled - cards.forEach((card) => { - expect(card).toHaveAttribute('disabled'); - expect(card).toHaveTextContent(/Incompatible/); - }); - }); - - test('given no other simulation then all appear compatible', () => { - // Given - no otherSimulation configured (default store state) - // Active position is 0, but position 1 is null/undefined - - // Create sims with various populationIds - const enhancedSims = VARIOUS_POPULATION_SIMULATIONS.map(createEnhancedUserSimulation); - mockUseUserSimulations.mockReturnValue({ - data: enhancedSims, - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - const cards = screen.getAllByRole('button').filter((button) => { - return button.textContent?.includes('Sim'); - }); - - // All should be enabled (compatible when no other simulation) - cards.forEach((card) => { - expect(card).not.toHaveAttribute('disabled'); - expect(card).not.toHaveTextContent(/Incompatible/); - }); - }); - - test('given sorting occurs then log message is present', () => { - // Given - set up some simulations - mockUseUserSimulations.mockReturnValue({ - data: [createEnhancedUserSimulation(TEST_SIMULATION_CONFIG)], - isLoading: false, - isError: false, - error: null, - }); - - // When - renderFrame(); - - // Then - expect(consoleLogSpy).toHaveBeenCalledWith(AFTER_SORTING_LOG); - }); - }); -}); diff --git a/app/src/tests/unit/frames/report/ReportSelectSimulationFrame.test.tsx b/app/src/tests/unit/frames/report/ReportSelectSimulationFrame.test.tsx deleted file mode 100644 index afcc42f5..00000000 --- a/app/src/tests/unit/frames/report/ReportSelectSimulationFrame.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import ReportSelectSimulationFrame from '@/frames/report/ReportSelectSimulationFrame'; -import { - CREATE_NEW_ACTION, - CREATE_NEW_SIMULATION_DESCRIPTION, - CREATE_NEW_SIMULATION_TITLE, - LOAD_EXISTING_ACTION, - LOAD_EXISTING_SIMULATION_DESCRIPTION, - LOAD_EXISTING_SIMULATION_TITLE, - NEXT_BUTTON_LABEL, - SELECT_SIMULATION_FRAME_TITLE, -} from '@/tests/fixtures/frames/ReportSelectSimulationFrame'; - -describe('ReportSelectSimulationFrame', () => { - let mockOnNavigate: ReturnType; - let mockOnReturn: ReturnType; - let defaultFlowProps: any; - - beforeEach(() => { - vi.clearAllMocks(); - - mockOnNavigate = vi.fn(); - mockOnReturn = vi.fn(); - - // Default flow props to satisfy FlowComponentProps interface - defaultFlowProps = { - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: { - component: 'ReportSelectSimulationFrame', - on: { - createNew: { - flow: 'SimulationCreationFlow', - returnTo: 'ReportSetupFrame', - }, - loadExisting: 'ReportSelectExistingSimulationFrame', - }, - }, - isInSubflow: false, - flowDepth: 0, - }; - }); - - test('given component renders then displays title and both options', () => { - // Given/When - render(); - - // Then - expect( - screen.getByRole('heading', { name: SELECT_SIMULATION_FRAME_TITLE }) - ).toBeInTheDocument(); - expect(screen.getByText(LOAD_EXISTING_SIMULATION_TITLE)).toBeInTheDocument(); - expect(screen.getByText(LOAD_EXISTING_SIMULATION_DESCRIPTION)).toBeInTheDocument(); - expect(screen.getByText(CREATE_NEW_SIMULATION_TITLE)).toBeInTheDocument(); - expect(screen.getByText(CREATE_NEW_SIMULATION_DESCRIPTION)).toBeInTheDocument(); - }); - - test('given no selection then Next button is disabled', () => { - // Given/When - render(); - - // Then - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).toBeDisabled(); - }); - - test('given user clicks load existing option then option is selected', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const loadExistingCard = screen.getByText(LOAD_EXISTING_SIMULATION_TITLE).closest('button'); - await user.click(loadExistingCard!); - - // Then - Next button should be enabled - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).not.toBeDisabled(); - }); - - test('given user clicks create new option then option is selected', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const createNewCard = screen.getByText(CREATE_NEW_SIMULATION_TITLE).closest('button'); - await user.click(createNewCard!); - - // Then - Next button should be enabled - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - expect(nextButton).not.toBeDisabled(); - }); - - test('given load existing selected and Next clicked then navigates to loadExisting', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const loadExistingCard = screen.getByText(LOAD_EXISTING_SIMULATION_TITLE).closest('button'); - await user.click(loadExistingCard!); - - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith(LOAD_EXISTING_ACTION); - }); - - test('given create new selected and Next clicked then navigates to createNew', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const createNewCard = screen.getByText(CREATE_NEW_SIMULATION_TITLE).closest('button'); - await user.click(createNewCard!); - - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith(CREATE_NEW_ACTION); - }); - - test('given user switches selection then updates selected option', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - first select load existing - const loadExistingCard = screen.getByText(LOAD_EXISTING_SIMULATION_TITLE).closest('button'); - await user.click(loadExistingCard!); - - // Then switch to create new - const createNewCard = screen.getByText(CREATE_NEW_SIMULATION_TITLE).closest('button'); - await user.click(createNewCard!); - - // When clicking Next - const nextButton = screen.getByRole('button', { name: NEXT_BUTTON_LABEL }); - await user.click(nextButton); - - // Then - should navigate to the last selected option - expect(mockOnNavigate).toHaveBeenCalledWith(CREATE_NEW_ACTION); - expect(mockOnNavigate).not.toHaveBeenCalledWith(LOAD_EXISTING_ACTION); - }); -}); diff --git a/app/src/tests/unit/frames/report/ReportSetupFrame.test.tsx b/app/src/tests/unit/frames/report/ReportSetupFrame.test.tsx deleted file mode 100644 index 850de313..00000000 --- a/app/src/tests/unit/frames/report/ReportSetupFrame.test.tsx +++ /dev/null @@ -1,615 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import ReportSetupFrame from '@/frames/report/ReportSetupFrame'; -import { setActiveSimulationPosition } from '@/reducers/reportReducer'; -import { createSimulationAtPosition } from '@/reducers/simulationsReducer'; -import { - BASELINE_CONFIGURED_TITLE_PREFIX, - BASELINE_SIMULATION_DESCRIPTION, - BASELINE_SIMULATION_TITLE, - COMPARISON_CONFIGURED_TITLE_PREFIX, - COMPARISON_SIMULATION_OPTIONAL_DESCRIPTION, - COMPARISON_SIMULATION_OPTIONAL_TITLE, - COMPARISON_SIMULATION_REQUIRED_DESCRIPTION, - COMPARISON_SIMULATION_REQUIRED_TITLE, - COMPARISON_SIMULATION_WAITING_DESCRIPTION, - COMPARISON_SIMULATION_WAITING_TITLE, - MOCK_COMPARISON_SIMULATION, - MOCK_GEOGRAPHY_SIMULATION, - MOCK_HOUSEHOLD_SIMULATION, - PREFILL_CONSOLE_MESSAGES, - REVIEW_REPORT_LABEL, - SETUP_BASELINE_SIMULATION_LABEL, - SETUP_COMPARISON_SIMULATION_LABEL, -} from '@/tests/fixtures/frames/ReportSetupFrame'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock Redux -const mockDispatch = vi.fn(); -const mockUseSelector = vi.fn(); -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => mockUseSelector(selector), - }; -}); - -// Mock population hooks (needed for Phase 1 prefill functionality) -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: vi.fn(() => ({ - data: [], - isLoading: false, - isError: false, - })), - isHouseholdMetadataWithAssociation: vi.fn(() => false), -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useUserGeographics: vi.fn(() => ({ - data: [], - isLoading: false, - isError: false, - })), - isGeographicMetadataWithAssociation: vi.fn(() => false), -})); - -// Mock populationMatching utility -vi.mock('@/utils/populationMatching', () => ({ - findMatchingPopulation: vi.fn(() => null), -})); - -describe('ReportSetupFrame', () => { - const mockOnNavigate = vi.fn(); - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'ReportSetupFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('Initial state (no simulations)', () => { - test('given no simulations configured then baseline card is enabled', () => { - // Given - mockUseSelector.mockReturnValue(null); - - // When - render(); - - // Then - expect(screen.getByText(BASELINE_SIMULATION_TITLE)).toBeInTheDocument(); - expect(screen.getByText(BASELINE_SIMULATION_DESCRIPTION)).toBeInTheDocument(); - }); - - test('given no simulations configured then comparison card shows waiting message', () => { - // Given - mockUseSelector.mockReturnValue(null); - - // When - render(); - - // Then - expect(screen.getByText(COMPARISON_SIMULATION_WAITING_TITLE)).toBeInTheDocument(); - expect(screen.getByText(COMPARISON_SIMULATION_WAITING_DESCRIPTION)).toBeInTheDocument(); - }); - - test('given no simulations configured then Review report button is disabled', () => { - // Given - mockUseSelector.mockReturnValue(null); - - // When - render(); - - // Then - const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL }); - expect(reviewButton).toBeDisabled(); - }); - }); - - describe('Household report (simulation1 configured)', () => { - test('given household simulation configured then baseline card shows configured state', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : null; - }); - - // When - render(); - - // Then - expect( - screen.getByText(`${BASELINE_CONFIGURED_TITLE_PREFIX} ${MOCK_HOUSEHOLD_SIMULATION.label}`) - ).toBeInTheDocument(); - expect( - screen.getByText( - `Policy #${MOCK_HOUSEHOLD_SIMULATION.policyId} • Household(s) #${MOCK_HOUSEHOLD_SIMULATION.populationId}` - ) - ).toBeInTheDocument(); - }); - - test('given household simulation configured then comparison card shows optional', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : null; - }); - - // When - render(); - - // Then - expect(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)).toBeInTheDocument(); - expect(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_DESCRIPTION)).toBeInTheDocument(); - }); - - test('given household simulation configured then Review report button is enabled', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : null; - }); - - // When - render(); - - // Then - const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL }); - expect(reviewButton).toBeEnabled(); - }); - }); - - describe('Geography report (simulation1 configured)', () => { - test('given geography simulation configured then comparison card shows required', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_GEOGRAPHY_SIMULATION : null; - }); - - // When - render(); - - // Then - expect(screen.getByText(COMPARISON_SIMULATION_REQUIRED_TITLE)).toBeInTheDocument(); - expect(screen.getByText(COMPARISON_SIMULATION_REQUIRED_DESCRIPTION)).toBeInTheDocument(); - }); - - test('given geography simulation configured but no comparison then Review report button is disabled', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_GEOGRAPHY_SIMULATION : null; - }); - - // When - render(); - - // Then - const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL }); - expect(reviewButton).toBeDisabled(); - }); - }); - - describe('User interactions', () => { - test('given user clicks baseline card when not configured then setup button appears', async () => { - // Given - const user = userEvent.setup(); - mockUseSelector.mockReturnValue(null); - render(); - - // When - await user.click(screen.getByText(BASELINE_SIMULATION_TITLE)); - - // Then - expect( - screen.getByRole('button', { name: SETUP_BASELINE_SIMULATION_LABEL }) - ).toBeInTheDocument(); - }); - - test('given user clicks Setup baseline simulation then creates simulation and navigates', async () => { - // Given - const user = userEvent.setup(); - mockUseSelector.mockReturnValue(null); - render(); - await user.click(screen.getByText(BASELINE_SIMULATION_TITLE)); - - // When - await user.click(screen.getByRole('button', { name: SETUP_BASELINE_SIMULATION_LABEL })); - - // Then - expect(mockDispatch).toHaveBeenCalledWith(createSimulationAtPosition({ position: 0 })); - expect(mockDispatch).toHaveBeenCalledWith(setActiveSimulationPosition(0)); - expect(mockOnNavigate).toHaveBeenCalledWith('setupSimulation1'); - }); - - test('given user clicks comparison card when baseline configured then setup button appears', async () => { - // Given - const user = userEvent.setup(); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : null; - }); - render(); - - // When - await user.click(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)); - - // Then - expect( - screen.getByRole('button', { name: SETUP_COMPARISON_SIMULATION_LABEL }) - ).toBeInTheDocument(); - }); - - test('given user clicks Review report when household report ready then navigates to next', async () => { - // Given - const user = userEvent.setup(); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : null; - }); - render(); - - // When - await user.click(screen.getByRole('button', { name: REVIEW_REPORT_LABEL })); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - }); - - describe('Both simulations configured', () => { - test('given both simulations configured then comparison card shows configured state', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - return callCount === 1 ? MOCK_HOUSEHOLD_SIMULATION : MOCK_COMPARISON_SIMULATION; - }); - - // When - render(); - - // Then - expect( - screen.getByText( - `${COMPARISON_CONFIGURED_TITLE_PREFIX} ${MOCK_COMPARISON_SIMULATION.label}` - ) - ).toBeInTheDocument(); - }); - - test('given both geography simulations configured then Review report button is enabled', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - if (callCount === 1) { - return MOCK_GEOGRAPHY_SIMULATION; - } - if (callCount === 2) { - return { ...MOCK_GEOGRAPHY_SIMULATION, id: '3', populationId: 'geography_2' }; - } - return null; - }); - - // When - render(); - - // Then - const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL }); - expect(reviewButton).toBeEnabled(); - }); - }); - - describe('Population Pre-filling', () => { - let consoleLogSpy: ReturnType; - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - // Spy on console methods but allow them to pass through so we can see what's happening - consoleLogSpy = vi.spyOn(console, 'log'); - consoleErrorSpy = vi.spyOn(console, 'error'); - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - }); - - test('given user sets up simulation 2 with household report then population is pre-filled', async () => { - // Given - const user = userEvent.setup(); - - // Import fixtures and mocked modules - const { - mockHouseholdMetadata, - mockUseUserHouseholdsSuccess, - mockUseUserGeographicsEmpty, - TEST_HOUSEHOLD_LABEL, - } = await import('@/tests/fixtures/hooks/useUserHouseholdMocks'); - const { createPopulationAtPosition, setHouseholdAtPosition, updatePopulationAtPosition } = - await import('@/reducers/populationReducer'); - - // Get the mocked functions - const { useUserHouseholds, isHouseholdMetadataWithAssociation } = await import( - '@/hooks/useUserHousehold' - ); - const { useUserGeographics, isGeographicMetadataWithAssociation } = await import( - '@/hooks/useUserGeographic' - ); - const { findMatchingPopulation } = await import('@/utils/populationMatching'); - - // Mock useSelector to handle different selectors - mockUseSelector.mockImplementation((selector: any) => { - // Create a mock state - const mockState = { - simulations: { - simulations: [MOCK_HOUSEHOLD_SIMULATION, null], // position 0 and 1 - }, - population: { - populations: [null, null], // position 0 and 1 - population2 not yet created - }, - }; - return selector(mockState); - }); - - // Mock hooks using vi.mocked - vi.mocked(useUserHouseholds).mockImplementation(() => mockUseUserHouseholdsSuccess); - vi.mocked(useUserGeographics).mockImplementation(() => mockUseUserGeographicsEmpty); - vi.mocked(findMatchingPopulation).mockImplementation(() => mockHouseholdMetadata); - vi.mocked(isHouseholdMetadataWithAssociation).mockImplementation(() => true); - vi.mocked(isGeographicMetadataWithAssociation).mockImplementation(() => false); - - render(); - await user.click(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)); - - // When - await user.click(screen.getByRole('button', { name: SETUP_COMPARISON_SIMULATION_LABEL })); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: createPopulationAtPosition.type, - payload: expect.objectContaining({ - position: 1, - population: expect.objectContaining({ - label: TEST_HOUSEHOLD_LABEL, - isCreated: true, - }), - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: setHouseholdAtPosition.type, - payload: expect.objectContaining({ - position: 1, - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: updatePopulationAtPosition.type, - payload: expect.objectContaining({ - position: 1, - updates: expect.objectContaining({ - isCreated: true, - }), - }), - }) - ); - }); - - test('given user sets up simulation 2 with geography report then geography is pre-filled', async () => { - // Given - const user = userEvent.setup(); - - const { - mockGeographicMetadata, - mockUseUserHouseholdsEmpty, - mockUseUserGeographicsSuccess, - TEST_GEOGRAPHY_LABEL, - } = await import('@/tests/fixtures/hooks/useUserHouseholdMocks'); - const { createPopulationAtPosition, setGeographyAtPosition, updatePopulationAtPosition } = - await import('@/reducers/populationReducer'); - - // Get the mocked functions - const { useUserHouseholds, isHouseholdMetadataWithAssociation } = await import( - '@/hooks/useUserHousehold' - ); - const { useUserGeographics, isGeographicMetadataWithAssociation } = await import( - '@/hooks/useUserGeographic' - ); - const { findMatchingPopulation } = await import('@/utils/populationMatching'); - - // Mock useSelector to handle different selectors - mockUseSelector.mockImplementation((selector: any) => { - const mockState = { - simulations: { - simulations: [MOCK_GEOGRAPHY_SIMULATION, null], - }, - population: { - populations: [null, null], - }, - }; - return selector(mockState); - }); - - vi.mocked(useUserHouseholds).mockImplementation(() => mockUseUserHouseholdsEmpty); - vi.mocked(useUserGeographics).mockImplementation(() => mockUseUserGeographicsSuccess); - vi.mocked(findMatchingPopulation).mockImplementation(() => mockGeographicMetadata); - vi.mocked(isHouseholdMetadataWithAssociation).mockImplementation(() => false); - vi.mocked(isGeographicMetadataWithAssociation).mockImplementation(() => true); - - render(); - await user.click(screen.getByText(COMPARISON_SIMULATION_REQUIRED_TITLE)); - - // When - await user.click(screen.getByRole('button', { name: SETUP_COMPARISON_SIMULATION_LABEL })); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: createPopulationAtPosition.type, - payload: expect.objectContaining({ - position: 1, - population: expect.objectContaining({ - label: TEST_GEOGRAPHY_LABEL, - isCreated: true, - }), - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: setGeographyAtPosition.type, - payload: expect.objectContaining({ - position: 1, - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: updatePopulationAtPosition.type, - payload: expect.objectContaining({ - position: 1, - updates: expect.objectContaining({ - isCreated: true, - }), - }), - }) - ); - }); - - test('given population data is loading then button is disabled', async () => { - // Given - const user = userEvent.setup(); - - const { mockUseUserHouseholdsLoading, mockUseUserGeographicsLoading } = await import( - '@/tests/fixtures/hooks/useUserHouseholdMocks' - ); - - // Get the mocked functions - const { useUserHouseholds } = await import('@/hooks/useUserHousehold'); - const { useUserGeographics } = await import('@/hooks/useUserGeographic'); - - mockUseSelector.mockImplementation((selector: any) => { - const mockState = { - simulations: { - simulations: [MOCK_HOUSEHOLD_SIMULATION, null], - }, - population: { - populations: [null, null], - }, - }; - return selector(mockState); - }); - - vi.mocked(useUserHouseholds).mockImplementation(() => mockUseUserHouseholdsLoading); - vi.mocked(useUserGeographics).mockImplementation(() => mockUseUserGeographicsLoading); - - render(); - await user.click(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)); - - // Then - Button should be disabled while loading - const button = screen.getByRole('button', { name: SETUP_COMPARISON_SIMULATION_LABEL }); - expect(button).toBeDisabled(); - }); - - test('given population 2 already exists then prefill is skipped', async () => { - // Given - const user = userEvent.setup(); - - const { mockUseUserHouseholdsSuccess, mockUseUserGeographicsEmpty } = await import( - '@/tests/fixtures/hooks/useUserHouseholdMocks' - ); - - // Get the mocked functions - const { useUserHouseholds } = await import('@/hooks/useUserHousehold'); - const { useUserGeographics } = await import('@/hooks/useUserGeographic'); - - mockUseSelector.mockImplementation((selector: any) => { - const mockState = { - simulations: { - simulations: [MOCK_HOUSEHOLD_SIMULATION, null], - }, - population: { - populations: [null, { isCreated: true }], // population2 already exists - }, - }; - return selector(mockState); - }); - - vi.mocked(useUserHouseholds).mockImplementation(() => mockUseUserHouseholdsSuccess); - vi.mocked(useUserGeographics).mockImplementation(() => mockUseUserGeographicsEmpty); - - render(); - await user.click(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)); - - // When - await user.click(screen.getByRole('button', { name: SETUP_COMPARISON_SIMULATION_LABEL })); - - // Then - Should log that prefill was skipped - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining(PREFILL_CONSOLE_MESSAGES.ALREADY_EXISTS.slice(19)) // Remove "[ReportSetupFrame]" prefix - ); - }); - - test('given simulation1 has no population then prefill shows error', async () => { - // Given - const { mockUseUserHouseholdsSuccess, mockUseUserGeographicsEmpty } = await import( - '@/tests/fixtures/hooks/useUserHouseholdMocks' - ); - - // Get the mocked functions - const { useUserHouseholds } = await import('@/hooks/useUserHousehold'); - const { useUserGeographics } = await import('@/hooks/useUserGeographic'); - - mockUseSelector.mockImplementation((selector: any) => { - const mockState = { - simulations: { - simulations: [{ ...MOCK_HOUSEHOLD_SIMULATION, populationId: undefined }, null], // simulation1 without population - }, - population: { - populations: [null, null], - }, - }; - return selector(mockState); - }); - - vi.mocked(useUserHouseholds).mockImplementation(() => mockUseUserHouseholdsSuccess); - vi.mocked(useUserGeographics).mockImplementation(() => mockUseUserGeographicsEmpty); - - render(); - - // Need to wait for simulation1 to be "configured" which requires both policyId and populationId - // Since populationId is undefined, simulation1Configured will be false - // So comparison card will show "Waiting" state, not "Optional" - // Let's check the actual text that appears - const waitingTitle = screen.getByText(COMPARISON_SIMULATION_WAITING_TITLE); - expect(waitingTitle).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/frames/report/ReportSubmitFrame.test.tsx b/app/src/tests/unit/frames/report/ReportSubmitFrame.test.tsx deleted file mode 100644 index 2eb88861..00000000 --- a/app/src/tests/unit/frames/report/ReportSubmitFrame.test.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import ReportSubmitFrame from '@/frames/report/ReportSubmitFrame'; -import { MOCK_HOUSEHOLD_SIMULATION } from '@/tests/fixtures/frames/ReportSetupFrame'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock useCreateReport hook -const mockCreateReport = vi.fn(); -const mockIsPending = false; -vi.mock('@/hooks/useCreateReport', () => ({ - useCreateReport: () => ({ - createReport: mockCreateReport, - isPending: mockIsPending, - }), -})); - -// Mock useIngredientReset hook -vi.mock('@/hooks/useIngredientReset', () => ({ - useIngredientReset: () => ({ - resetIngredient: vi.fn(), - }), -})); - -// Mock react-router-dom -const mockNavigate = vi.fn(); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); - -// Mock Redux -const mockUseSelector = vi.fn(); -const mockDispatch = vi.fn(); -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useSelector: (selector: any) => mockUseSelector(selector), - useDispatch: () => mockDispatch, - }; -}); - -describe('ReportSubmitFrame', () => { - const mockFlowProps = { - onNavigate: vi.fn(), - onReturn: vi.fn(), - flowConfig: { - component: 'ReportSubmitFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockUseSelector.mockReturnValue(null); - mockDispatch.mockClear(); - }); - - describe('Validation', () => { - test('given no simulation1 then does not submit report', async () => { - // Given - const user = userEvent.setup(); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // Call 1: reportState - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - // Call 2: selectBothSimulations - returns array - if (callCount === 2) { - return [null, null]; - } - // Calls 3-6: household/geography selectors - return null; - }); - - render(); - - // When - await user.click(screen.getByRole('button', { name: /generate report/i })); - - // Then - expect(consoleSpy).toHaveBeenCalledWith( - '[ReportSubmitFrame] Cannot submit report: no simulations configured' - ); - expect(mockCreateReport).not.toHaveBeenCalled(); - - consoleSpy.mockRestore(); - }); - - test('given simulation1 exists then allows submission', async () => { - // Given - const user = userEvent.setup(); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // Call 1: reportState - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - // Call 2: selectBothSimulations - returns array with simulation1 - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, null]; - } - // Calls 3-6: household/geography selectors - return null; - }); - - render(); - - // When - await user.click(screen.getByRole('button', { name: /generate report/i })); - - // Then - expect(mockCreateReport).toHaveBeenCalled(); - }); - }); - - describe('Display', () => { - test('given one simulation then shows first simulation summary', () => { - // Given - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // Call 1: reportState - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - // Call 2: selectBothSimulations - returns array with simulation1 - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, null]; - } - // Calls 3-6: household/geography selectors - return null; - }); - - // When - render(); - - // Then - expect(screen.getByText('Baseline simulation')).toBeInTheDocument(); - expect(screen.getByText(MOCK_HOUSEHOLD_SIMULATION.label!)).toBeInTheDocument(); - }); - - test('given two simulations then shows both simulation summaries', () => { - // Given - const mockSim2 = { ...MOCK_HOUSEHOLD_SIMULATION, id: '2', label: 'Second Sim' }; - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // Call 1: reportState - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - // Call 2: selectBothSimulations - returns array with both simulations - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, mockSim2]; - } - // Calls 3-6: household/geography selectors - return null; - }); - - // When - render(); - - // Then - expect(screen.getByText(MOCK_HOUSEHOLD_SIMULATION.label!)).toBeInTheDocument(); - expect(screen.getByText(mockSim2.label!)).toBeInTheDocument(); - }); - }); - - describe('Flow and state cleanup', () => { - test('given report created successfully and not in subflow then clears flow and ingredients', async () => { - // Given - const user = userEvent.setup(); - const mockResetIngredient = vi.fn(); - - // Re-mock useIngredientReset for this test to capture the function - vi.doMock('@/hooks/useIngredientReset', () => ({ - useIngredientReset: () => ({ - resetIngredient: mockResetIngredient, - }), - })); - - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, null]; - } - return null; - }); - - // Mock successful report creation - mockCreateReport.mockImplementation((_params, options) => { - // Simulate successful creation - const mockData = { - userReport: { id: 'test-report-123' }, - }; - if (options?.onSuccess) { - options.onSuccess(mockData); - } - return Promise.resolve(mockData); - }); - - render(); - - // When - await user.click(screen.getByRole('button', { name: /generate report/i })); - - // Then - clearFlow should be dispatched - expect(mockDispatch).toHaveBeenCalled(); - // Note: We can't easily check the exact action without importing clearFlow - // but we verify dispatch was called - }); - - test('given report created successfully and in subflow then skips cleanup', async () => { - // Given - const user = userEvent.setup(); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, null]; - } - return null; - }); - - // Mock successful report creation - mockCreateReport.mockImplementation((_params, options) => { - const mockData = { - userReport: { id: 'test-report-123' }, - }; - if (options?.onSuccess) { - options.onSuccess(mockData); - } - return Promise.resolve(mockData); - }); - - render(); - - // When - await user.click(screen.getByRole('button', { name: /generate report/i })); - - // Then - clearFlow should NOT be dispatched when in subflow - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('given report created then navigates to report output', async () => { - // Given - const user = userEvent.setup(); - const mockReportId = 'test-report-xyz'; - - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - if (callCount === 1) { - return { countryId: 'us', apiVersion: 'v1', label: 'Test Report' }; - } - if (callCount === 2) { - return [MOCK_HOUSEHOLD_SIMULATION, null]; - } - return null; - }); - - // Mock successful report creation - mockCreateReport.mockImplementation((_params, options) => { - const mockData = { - userReport: { id: mockReportId }, - }; - if (options?.onSuccess) { - options.onSuccess(mockData); - } - return Promise.resolve(mockData); - }); - - render(); - - // When - await user.click(screen.getByRole('button', { name: /generate report/i })); - - // Then - expect(mockNavigate).toHaveBeenCalledWith(`/us/report-output/${mockReportId}`); - }); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationCreationFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationCreationFrame.test.tsx deleted file mode 100644 index 38216e98..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationCreationFrame.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import SimulationCreationFrame from '@/frames/simulation/SimulationCreationFrame'; -import * as simulationsReducer from '@/reducers/simulationsReducer'; -import { - EXPECTED_BASELINE_SIMULATION_LABEL, - EXPECTED_BASELINE_WITH_REPORT_LABEL, - EXPECTED_REFORM_SIMULATION_LABEL, - EXPECTED_REFORM_WITH_REPORT_LABEL, -} from '@/tests/fixtures/frames/SimulationCreationFrame'; -import { - mockDispatch, - mockOnNavigate, - mockReportStateReportWithName, - mockReportStateReportWithoutName, - mockReportStateStandalone, - mockSimulationEmpty, -} from '@/tests/fixtures/frames/simulationFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock selectors -const mockSelectCurrentPosition = vi.fn(); -const mockSelectSimulationAtPosition = vi.fn(); -let mockReportState: any = mockReportStateStandalone; - -vi.mock('@/reducers/activeSelectors', () => ({ - selectCurrentPosition: (_state: any) => mockSelectCurrentPosition(), -})); - -vi.mock('@/reducers/simulationsReducer', async () => { - const actual = (await vi.importActual('@/reducers/simulationsReducer')) as any; - return { - ...actual, - selectSimulationAtPosition: (_state: any, position: number) => - mockSelectSimulationAtPosition(position), - createSimulationAtPosition: actual.createSimulationAtPosition, - updateSimulationAtPosition: actual.updateSimulationAtPosition, - }; -}); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => { - return selector({ report: mockReportState }); - }, - }; -}); - -describe('SimulationCreationFrame', () => { - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationCreationFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockDispatch.mockClear(); - mockOnNavigate.mockClear(); - mockSelectCurrentPosition.mockClear(); - mockSelectSimulationAtPosition.mockClear(); - }); - - test('given no simulation exists then creates one at current position', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - should create simulation at position 0 - expect(mockDispatch).toHaveBeenCalledWith( - simulationsReducer.createSimulationAtPosition({ position: 0 }) - ); - }); - - test('given simulation exists then does not create new one', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - should NOT create simulation - expect(mockDispatch).not.toHaveBeenCalledWith( - simulationsReducer.createSimulationAtPosition({ position: 0 }) - ); - }); - - test('given user enters label and submits then updates simulation', async () => { - // Given - const user = userEvent.setup(); - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - render(); - - // When - Clear prefilled label and type new one - const input = screen.getByLabelText('Simulation name'); - await user.clear(input); - await user.type(input, 'My Custom Simulation'); - - const submitButton = screen.getByRole('button', { name: /Create simulation/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - simulationsReducer.updateSimulationAtPosition({ - position: 1, - updates: { label: 'My Custom Simulation' }, - }) - ); - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given report mode then uses activeSimulationPosition', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(1); // Report mode, position 1 - mockSelectSimulationAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - should create simulation at position 1 - expect(mockDispatch).toHaveBeenCalledWith( - simulationsReducer.createSimulationAtPosition({ position: 1 }) - ); - }); - - test('given standalone mode then uses position 0', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); // Standalone mode always returns 0 - mockSelectSimulationAtPosition.mockReturnValue(null); - - // When - render(); - - // Then - should create simulation at position 0 - expect(mockDispatch).toHaveBeenCalledWith( - simulationsReducer.createSimulationAtPosition({ position: 0 }) - ); - }); - - describe('Auto-naming behavior', () => { - test('given standalone mode at position 0 then prefills with baseline simulation label', () => { - // Given - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_SIMULATION_LABEL); - }); - - test('given standalone mode at position 1 then prefills with reform simulation label', () => { - // Given - mockReportState = mockReportStateStandalone; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_SIMULATION_LABEL); - }); - - test('given report mode with report name at position 0 then prefills with report name and baseline', () => { - // Given - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_WITH_REPORT_LABEL); - }); - - test('given report mode with report name at position 1 then prefills with report name and reform', () => { - // Given - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_WITH_REPORT_LABEL); - }); - - test('given report mode without report name at position 0 then prefills with baseline simulation', () => { - // Given - mockReportState = mockReportStateReportWithoutName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_BASELINE_SIMULATION_LABEL); - }); - - test('given report mode without report name at position 1 then prefills with reform simulation', () => { - // Given - mockReportState = mockReportStateReportWithoutName; - mockSelectCurrentPosition.mockReturnValue(1); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - // When - render(); - - // Then - const input = screen.getByLabelText('Simulation name') as HTMLInputElement; - expect(input.value).toBe(EXPECTED_REFORM_SIMULATION_LABEL); - }); - - test('given user edits prefilled label then custom label is used', async () => { - // Given - const user = userEvent.setup(); - mockReportState = mockReportStateReportWithName; - mockSelectCurrentPosition.mockReturnValue(0); - mockSelectSimulationAtPosition.mockReturnValue(mockSimulationEmpty); - - render(); - - // When - Clear and type new label - const input = screen.getByLabelText('Simulation name'); - await user.clear(input); - await user.type(input, 'Custom User Label'); - - const submitButton = screen.getByRole('button', { name: /Create simulation/i }); - await user.click(submitButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - simulationsReducer.updateSimulationAtPosition({ - position: 0, - updates: { label: 'Custom User Label' }, - }) - ); - }); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSelectExistingPolicyFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSelectExistingPolicyFrame.test.tsx deleted file mode 100644 index 26546f72..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSelectExistingPolicyFrame.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import SimulationSelectExistingPolicyFrame from '@/frames/simulation/SimulationSelectExistingPolicyFrame'; -import * as policyReducer from '@/reducers/policyReducer'; -import { mockDispatch, mockOnNavigate } from '@/tests/fixtures/frames/simulationFrameMocks'; -// Import mock data separately after mocks are set up -import { - mockErrorResponse, - mockLoadingResponse, - mockPolicyData, - mockSuccessResponse, -} from '@/tests/fixtures/frames/simulationSelectExistingMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock selector function -const mockSelectCurrentPosition = vi.fn(); - -// Mock selectors -vi.mock('@/reducers/activeSelectors', () => ({ - selectCurrentPosition: () => mockSelectCurrentPosition(), -})); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => selector({}), - }; -}); - -// Mock the useUserPolicies hook -const mockUserPolicies = vi.fn(); -vi.mock('@/hooks/useUserPolicy', () => ({ - useUserPolicies: () => mockUserPolicies(), - isPolicyMetadataWithAssociation: (policy: any) => policy && policy.policy && policy.association, -})); - -describe('SimulationSelectExistingPolicyFrame', () => { - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSelectExistingPolicyFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnNavigate.mockClear(); - mockDispatch.mockClear(); - mockSelectCurrentPosition.mockClear(); - mockUserPolicies.mockClear(); - }); - - test('given policies are loading then displays loading message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserPolicies.mockReturnValue(mockLoadingResponse); - - // When - render(); - - // Then - expect(screen.getByText('Loading policies...')).toBeInTheDocument(); - }); - - test('given error loading policies then displays error message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserPolicies.mockReturnValue(mockErrorResponse('Failed to fetch')); - - // When - render(); - - // Then - expect(screen.getByText(/Error: Failed to fetch/)).toBeInTheDocument(); - }); - - test('given no policies exist then displays empty state message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserPolicies.mockReturnValue(mockSuccessResponse([])); - - // When - render(); - - // Then - expect( - screen.getByText('No policies available. Please create a new policy.') - ).toBeInTheDocument(); - }); - - test('given user selects policy with parameters and submits then creates policy at position and adds parameters', async () => { - // Given - const user = userEvent.setup(); - mockSelectCurrentPosition.mockReturnValue(1); // Report mode, position 1 - mockUserPolicies.mockReturnValue(mockSuccessResponse(mockPolicyData)); - - render(); - - // When - const policyCard = screen.getByText('My Tax Reform'); - await user.click(policyCard); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.createPolicyAtPosition({ - position: 1, - policy: expect.objectContaining({ - id: '123', - label: 'My Tax Reform', - isCreated: true, - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.addPolicyParamAtPosition({ - position: 1, - name: 'income_tax_rate', - valueInterval: { - startDate: `${CURRENT_YEAR}-01-01`, - endDate: `${CURRENT_YEAR}-12-31`, - value: 0.25, - }, - }) - ); - - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given standalone mode when user selects policy then creates policy at position 0', async () => { - // Given - const user = userEvent.setup(); - mockSelectCurrentPosition.mockReturnValue(0); // Standalone mode - mockUserPolicies.mockReturnValue(mockSuccessResponse(mockPolicyData)); - - render(); - - // When - const policyCard = screen.getByText('Empty Policy'); - await user.click(policyCard); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - policyReducer.createPolicyAtPosition({ - position: 0, - policy: expect.objectContaining({ - id: '456', - }), - }) - ); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSelectExistingPopulationFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSelectExistingPopulationFrame.test.tsx deleted file mode 100644 index 0c073ae8..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSelectExistingPopulationFrame.test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import SimulationSelectExistingPopulationFrame from '@/frames/simulation/SimulationSelectExistingPopulationFrame'; -import * as populationReducer from '@/reducers/populationReducer'; -import { mockDispatch, mockOnNavigate } from '@/tests/fixtures/frames/simulationFrameMocks'; -// Import mock data separately after mocks are set up -import { - mockErrorResponse, - mockGeographicData, - mockHouseholdData, - mockLoadingResponse, - mockSuccessResponse, -} from '@/tests/fixtures/frames/simulationSelectExistingMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock selector function -const mockSelectCurrentPosition = vi.fn(); - -// Mock selectors -vi.mock('@/reducers/activeSelectors', () => ({ - selectCurrentPosition: () => mockSelectCurrentPosition(), -})); - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => { - if (selector.toString().includes('metadata')) { - return { regions: {} }; // Mock metadata state - } - return selector({}); - }, - }; -}); - -// Mock the useUserHouseholds hook -const mockUserHouseholds = vi.fn(); -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: () => mockUserHouseholds(), - isHouseholdMetadataWithAssociation: (pop: any) => pop && pop.household && pop.association, -})); - -// Mock the useUserGeographics hook -const mockUserGeographics = vi.fn(); -vi.mock('@/hooks/useUserGeographic', () => ({ - useUserGeographics: () => mockUserGeographics(), - isGeographicMetadataWithAssociation: (pop: any) => pop && pop.geography && pop.association, -})); - -// Mock HouseholdAdapter -vi.mock('@/adapters', () => ({ - HouseholdAdapter: { - fromAPI: (household: any) => ({ - id: household.id, - countryId: household.country_id, - householdData: household.household_json || {}, - }), - fromMetadata: (household: any) => ({ - id: household.id, - countryId: household.country_id, - householdData: household.household_json || {}, - }), - }, -})); - -describe('SimulationSelectExistingPopulationFrame', () => { - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSelectExistingPopulationFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnNavigate.mockClear(); - mockDispatch.mockClear(); - mockSelectCurrentPosition.mockClear(); - mockUserHouseholds.mockClear(); - mockUserGeographics.mockClear(); - }); - - test('given populations are loading then displays loading message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserHouseholds.mockReturnValue(mockLoadingResponse); - mockUserGeographics.mockReturnValue(mockLoadingResponse); - - // When - render(); - - // Then - expect(screen.getByText('Loading households...')).toBeInTheDocument(); - }); - - test('given error loading populations then displays error message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserHouseholds.mockReturnValue(mockErrorResponse('Failed to fetch households')); - mockUserGeographics.mockReturnValue(mockSuccessResponse([])); - - // When - render(); - - // Then - expect(screen.getByText(/Error: Failed to fetch households/)).toBeInTheDocument(); - }); - - test('given no populations exist then displays empty state message', () => { - // Given - mockSelectCurrentPosition.mockReturnValue(0); - mockUserHouseholds.mockReturnValue(mockSuccessResponse([])); - mockUserGeographics.mockReturnValue(mockSuccessResponse([])); - - // When - render(); - - // Then - expect( - screen.getByText('No households available. Please create new household(s).') - ).toBeInTheDocument(); - }); - - test('given user selects household and submits then creates population at position with household data', async () => { - // Given - const user = userEvent.setup(); - mockSelectCurrentPosition.mockReturnValue(1); // Report mode, position 1 - mockUserHouseholds.mockReturnValue(mockSuccessResponse(mockHouseholdData)); - mockUserGeographics.mockReturnValue(mockSuccessResponse([])); - - render(); - - // When - const householdCard = screen.getByText('My Family'); - await user.click(householdCard); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - populationReducer.createPopulationAtPosition({ - position: 1, - population: expect.objectContaining({ - label: 'My Family', - isCreated: true, - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - populationReducer.setHouseholdAtPosition({ - position: 1, - household: expect.objectContaining({ - id: '123', - countryId: 'us', - }), - }) - ); - - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); - - test('given user selects geography and submits then creates population at position with geography data', async () => { - // Given - const user = userEvent.setup(); - mockSelectCurrentPosition.mockReturnValue(0); // Standalone mode - mockUserHouseholds.mockReturnValue(mockSuccessResponse([])); - mockUserGeographics.mockReturnValue(mockSuccessResponse(mockGeographicData)); - - render(); - - // When - const geographyCard = screen.getByText('US National'); - await user.click(geographyCard); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - populationReducer.createPopulationAtPosition({ - position: 0, - population: expect.objectContaining({ - label: 'US National', - isCreated: true, - }), - }) - ); - - expect(mockDispatch).toHaveBeenCalledWith( - populationReducer.setGeographyAtPosition({ - position: 0, - geography: expect.objectContaining({ - id: 'mock-geography', - countryId: 'us', - }), - }) - ); - - expect(mockOnNavigate).toHaveBeenCalledWith('next'); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSetupFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSetupFrame.test.tsx deleted file mode 100644 index c2323e5c..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSetupFrame.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { render, screen } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import SimulationSetupFrame from '@/frames/simulation/SimulationSetupFrame'; -import { - createReportModeMockSelector, - createStandaloneMockSelector, - MOCK_GEOGRAPHY_POPULATION, - MOCK_HOUSEHOLD_POPULATION, - MOCK_UNFILLED_POPULATION, - UI_TEXT, -} from '@/tests/fixtures/frames/SimulationSetupFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock Redux -const mockDispatch = vi.fn(); -const mockUseSelector = vi.fn(); -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => mockUseSelector(selector), - }; -}); - -describe('SimulationSetupFrame', () => { - const mockOnNavigate = vi.fn(); - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSetupFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('Simulation 1 in standalone mode', () => { - test('given simulation 1 in standalone mode then population title is normal', () => { - // Given - mockUseSelector.mockImplementation(createStandaloneMockSelector(MOCK_HOUSEHOLD_POPULATION)); - - // When - render(); - - // Then - expect(screen.getByText(MOCK_HOUSEHOLD_POPULATION.label)).toBeInTheDocument(); - expect(screen.queryByText(new RegExp(UI_TEXT.FROM_BASELINE_SUFFIX))).not.toBeInTheDocument(); - }); - }); - - describe('Simulation 2 in report mode', () => { - test('given simulation 2 in report mode then population title includes from baseline', () => { - // Given - mockUseSelector.mockImplementation(createReportModeMockSelector(MOCK_HOUSEHOLD_POPULATION)); - - // When - render(); - - // Then - const expectedText = `${MOCK_HOUSEHOLD_POPULATION.label} ${UI_TEXT.FROM_BASELINE_SUFFIX}`; - expect(screen.getByText(expectedText)).toBeInTheDocument(); - }); - - test('given simulation 2 in report with household then description shows inherited household', () => { - // Given - mockUseSelector.mockImplementation(createReportModeMockSelector(MOCK_HOUSEHOLD_POPULATION)); - - // When - render(); - - // Then - expect( - screen.getByText( - new RegExp( - `${UI_TEXT.INHERITED_HOUSEHOLD_PREFIX}${MOCK_HOUSEHOLD_POPULATION.household.id}` - ) - ) - ).toBeInTheDocument(); - expect(screen.getByText(new RegExp(UI_TEXT.INHERITED_SUFFIX))).toBeInTheDocument(); - }); - - test('given simulation 2 in report with geography then description shows inherited geography', () => { - // Given - mockUseSelector.mockImplementation(createReportModeMockSelector(MOCK_GEOGRAPHY_POPULATION)); - - // When - render(); - - // Then - expect( - screen.getByText( - new RegExp( - `${UI_TEXT.INHERITED_GEOGRAPHY_PREFIX}${MOCK_GEOGRAPHY_POPULATION.geography!.id}` - ) - ) - ).toBeInTheDocument(); - expect(screen.getByText(new RegExp(UI_TEXT.INHERITED_SUFFIX))).toBeInTheDocument(); - }); - - test('given simulation 2 without population then shows add population prompt', () => { - // Given - mockUseSelector.mockImplementation(createReportModeMockSelector(MOCK_UNFILLED_POPULATION)); - - // When - render(); - - // Then - expect(screen.getByText(UI_TEXT.ADD_POPULATION)).toBeInTheDocument(); - expect(screen.getByText(UI_TEXT.SELECT_GEOGRAPHIC_OR_HOUSEHOLD)).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSetupPolicyFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSetupPolicyFrame.test.tsx deleted file mode 100644 index e28c8511..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSetupPolicyFrame.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import SimulationSetupPolicyFrame from '@/frames/simulation/SimulationSetupPolicyFrame'; -import { createPolicyAtPosition } from '@/reducers/policyReducer'; -import { - BUTTON_ORDER, - BUTTON_TEXT, - createMockSimulationSetupPolicyState, - expectedCurrentLawPolicyUK, - expectedCurrentLawPolicyUS, - mockDispatch, - mockOnNavigate, - TEST_COUNTRIES, - TEST_CURRENT_LAW_IDS, -} from '@/tests/fixtures/frames/SimulationSetupPolicyFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock useCurrentCountry hook -const mockUseCurrentCountry = vi.fn(); -vi.mock('@/hooks/useCurrentCountry', () => ({ - useCurrentCountry: () => mockUseCurrentCountry(), -})); - -// Mock Redux -const mockUseSelector = vi.fn(); -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux'); - return { - ...actual, - useDispatch: () => mockDispatch, - useSelector: (selector: any) => mockUseSelector(selector), - }; -}); - -describe('SimulationSetupPolicyFrame', () => { - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSetupPolicyFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnNavigate.mockClear(); - mockDispatch.mockClear(); - mockUseCurrentCountry.mockClear(); - mockUseSelector.mockClear(); - }); - - describe('Button rendering', () => { - test('given frame loads then all three policy options are visible', () => { - // Given - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - // When - render(); - - // Then - expect(screen.getByText('Load Existing Policy')).toBeInTheDocument(); - expect(screen.getByText('Create New Policy')).toBeInTheDocument(); - expect(screen.getByText('Current Law')).toBeInTheDocument(); - }); - - test('given frame loads then current law description is correct', () => { - // Given - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - // When - render(); - - // Then - expect( - screen.getByText('Use the baseline tax-benefit system with no reforms') - ).toBeInTheDocument(); - }); - - test('given frame loads then Current Law appears first in button order', () => { - // Given - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - // When - const { container } = render(); - - // Then - const buttons = container.querySelectorAll('button[class*="Card"]'); - expect(buttons.length).toBe(3); - - // First button should be Current Law - expect(buttons[BUTTON_ORDER.CURRENT_LAW]).toHaveTextContent(BUTTON_TEXT.CURRENT_LAW.title); - expect(buttons[BUTTON_ORDER.CURRENT_LAW]).toHaveTextContent( - BUTTON_TEXT.CURRENT_LAW.description - ); - - // Second button should be Load Existing Policy - expect(buttons[BUTTON_ORDER.LOAD_EXISTING]).toHaveTextContent( - BUTTON_TEXT.LOAD_EXISTING.title - ); - expect(buttons[BUTTON_ORDER.LOAD_EXISTING]).toHaveTextContent( - BUTTON_TEXT.LOAD_EXISTING.description - ); - - // Third button should be Create New Policy - expect(buttons[BUTTON_ORDER.CREATE_NEW]).toHaveTextContent(BUTTON_TEXT.CREATE_NEW.title); - expect(buttons[BUTTON_ORDER.CREATE_NEW]).toHaveTextContent( - BUTTON_TEXT.CREATE_NEW.description - ); - }); - }); - - describe('User interactions', () => { - test('given user selects load existing and clicks next then navigates to loadExisting', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - const loadExistingButton = screen.getByText('Load Existing Policy'); - await user.click(loadExistingButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('loadExisting'); - expect(mockDispatch).not.toHaveBeenCalled(); // No policy creation for existing - }); - - test('given user selects create new and clicks next then navigates to createNew', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - const createNewButton = screen.getByText('Create New Policy'); - await user.click(createNewButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('createNew'); - expect(mockDispatch).not.toHaveBeenCalled(); // No policy creation for new - }); - - test('given no selection made then next button is disabled', () => { - // Given - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - // When - render(); - - // Then - const nextButton = screen.getByRole('button', { name: /Next/i }); - expect(nextButton).toBeDisabled(); - }); - }); - - describe('Current Law selection - US', () => { - test('given US user selects current law and clicks next then creates US current law policy', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState({ - countryId: TEST_COUNTRIES.US, - currentLawId: TEST_CURRENT_LAW_IDS.US, - }); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - const currentLawButton = screen.getByText('Current Law'); - await user.click(currentLawButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - createPolicyAtPosition({ - position: 0, - policy: expectedCurrentLawPolicyUS, - }) - ); - expect(mockOnNavigate).toHaveBeenCalledWith('selectCurrentLaw'); - }); - - test('given US user in report mode at position 1 then creates policy at position 1', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState({ - countryId: TEST_COUNTRIES.US, - currentLawId: TEST_CURRENT_LAW_IDS.US, - mode: 'report', - activeSimulationPosition: 1, - }); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - const currentLawButton = screen.getByText('Current Law'); - await user.click(currentLawButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - createPolicyAtPosition({ - position: 1, - policy: expectedCurrentLawPolicyUS, - }) - ); - }); - }); - - describe('Current Law selection - UK', () => { - test('given UK user selects current law and clicks next then creates UK current law policy', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState({ - countryId: TEST_COUNTRIES.UK, - currentLawId: TEST_CURRENT_LAW_IDS.UK, - }); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.UK); - - render(); - - // When - const currentLawButton = screen.getByText('Current Law'); - await user.click(currentLawButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - createPolicyAtPosition({ - position: 0, - policy: expectedCurrentLawPolicyUK, - }) - ); - expect(mockOnNavigate).toHaveBeenCalledWith('selectCurrentLaw'); - }); - - test('given UK user in report mode at position 1 then creates policy at position 1', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState({ - countryId: TEST_COUNTRIES.UK, - currentLawId: TEST_CURRENT_LAW_IDS.UK, - mode: 'report', - activeSimulationPosition: 1, - }); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.UK); - - render(); - - // When - const currentLawButton = screen.getByText('Current Law'); - await user.click(currentLawButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockDispatch).toHaveBeenCalledWith( - createPolicyAtPosition({ - position: 1, - policy: expectedCurrentLawPolicyUK, - }) - ); - }); - }); - - describe('Current Law policy structure', () => { - test('given current law selected then policy has empty parameters array', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - await user.click(screen.getByText('Current Law')); - await user.click(screen.getByRole('button', { name: /Next/i })); - - // Then - const dispatchCall = mockDispatch.mock.calls[0][0]; - expect(dispatchCall.payload.policy.parameters).toEqual([]); - }); - - test('given current law selected then policy is marked as created', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - await user.click(screen.getByText('Current Law')); - await user.click(screen.getByRole('button', { name: /Next/i })); - - // Then - const dispatchCall = mockDispatch.mock.calls[0][0]; - expect(dispatchCall.payload.policy.isCreated).toBe(true); - }); - - test('given current law selected then policy has correct label', async () => { - // Given - const user = userEvent.setup(); - const mockState = createMockSimulationSetupPolicyState(); - mockUseSelector.mockImplementation((selector: any) => selector(mockState)); - mockUseCurrentCountry.mockReturnValue(TEST_COUNTRIES.US); - - render(); - - // When - await user.click(screen.getByText('Current Law')); - await user.click(screen.getByRole('button', { name: /Next/i })); - - // Then - const dispatchCall = mockDispatch.mock.calls[0][0]; - expect(dispatchCall.payload.policy.label).toBe('Current law'); - }); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSetupPopulationFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSetupPopulationFrame.test.tsx deleted file mode 100644 index 7a38956a..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSetupPopulationFrame.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { render, screen, userEvent } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import SimulationSetupPopulationFrame from '@/frames/simulation/SimulationSetupPopulationFrame'; -import { mockOnNavigate } from '@/tests/fixtures/frames/simulationFrameMocks'; - -// Mock Plotly -vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); - -// Mock the user hooks to avoid API calls -vi.mock('@/hooks/useUserHousehold', () => ({ - useUserHouseholds: () => ({ - data: [], - isLoading: false, - isError: false, - }), - isHouseholdMetadataWithAssociation: () => false, -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useUserGeographics: () => ({ - data: [], - isLoading: false, - isError: false, - }), - isGeographicMetadataWithAssociation: () => false, -})); - -describe('SimulationSetupPopulationFrame', () => { - const mockFlowProps = { - onNavigate: mockOnNavigate, - onReturn: vi.fn(), - flowConfig: { - component: 'SimulationSetupPopulationFrame' as any, - on: {}, - }, - isInSubflow: false, - flowDepth: 0, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnNavigate.mockClear(); - }); - - test('given user selects load existing and clicks next then navigates to loadExisting', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const loadExistingButton = screen.getByText('Load Existing Household(s)'); - await user.click(loadExistingButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('loadExisting'); - }); - - test('given user selects create new and clicks next then navigates to createNew', async () => { - // Given - const user = userEvent.setup(); - render(); - - // When - const createNewButton = screen.getByText('Create New Household(s)'); - await user.click(createNewButton); - - const nextButton = screen.getByRole('button', { name: /Next/i }); - await user.click(nextButton); - - // Then - expect(mockOnNavigate).toHaveBeenCalledWith('createNew'); - }); - - test('given no selection made then next button is disabled', () => { - // Given - render(); - - // When - const nextButton = screen.getByRole('button', { name: /Next/i }); - - // Then - expect(nextButton).toBeDisabled(); - }); -}); diff --git a/app/src/tests/unit/frames/simulation/SimulationSubmitFrame.test.tsx b/app/src/tests/unit/frames/simulation/SimulationSubmitFrame.test.tsx deleted file mode 100644 index a458fcd8..00000000 --- a/app/src/tests/unit/frames/simulation/SimulationSubmitFrame.test.tsx +++ /dev/null @@ -1,406 +0,0 @@ -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 SimulationSubmitFrame from '@/frames/simulation/SimulationSubmitFrame'; -import flowReducer from '@/reducers/flowReducer'; -import metadataReducer from '@/reducers/metadataReducer'; -import populationReducer from '@/reducers/populationReducer'; -import reportReducer from '@/reducers/reportReducer'; -import simulationsReducer, * as simulationsActions from '@/reducers/simulationsReducer'; -import { - mockSimulationComplete, - mockSimulationPartial, - mockStateWithBothSimulations, - mockStateWithNewSimulation, - mockStateWithOldSimulation, - POLICY_REFORM_ADDED_TITLE, - POPULATION_ADDED_TITLE, - SUBMIT_VIEW_TITLE, - TEST_POLICY_LABEL, - TEST_POPULATION_LABEL, -} from '@/tests/fixtures/frames/SimulationSubmitFrame'; - -// Mock the hooks - must be defined inline due to hoisting -vi.mock('@/hooks/useCreateSimulation', () => ({ - useCreateSimulation: vi.fn(() => ({ - createSimulation: vi.fn(), - isPending: false, - error: null, - })), -})); - -vi.mock('@/hooks/useIngredientReset', () => ({ - useIngredientReset: vi.fn(() => ({ - resetIngredient: vi.fn(), - resetIngredients: vi.fn(), - })), -})); - -describe('SimulationSubmitFrame - Compatibility Features', () => { - let mockOnNavigate: ReturnType; - let mockOnReturn: ReturnType; - let defaultFlowProps: any; - - beforeEach(() => { - vi.clearAllMocks(); - - mockOnNavigate = vi.fn(); - mockOnReturn = vi.fn(); - - defaultFlowProps = { - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: { - component: 'SimulationSubmitFrame', - on: { - submit: '__return__', - }, - }, - isInSubflow: false, - flowDepth: 0, - }; - }); - - describe('Specific Position', () => { - test('given position-based state structure then uses simulations at positions', () => { - // Given - store with both old and new state - const store = configureStore({ - reducer: { - simulation: () => mockSimulationPartial, // Missing policyId - simulations: () => mockStateWithBothSimulations.simulations, // Complete - flow: flowReducer, - policy: () => mockStateWithBothSimulations.policy, - population: () => mockStateWithBothSimulations.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - render( - - - - ); - - // Then - should use data from new state (which has policyId) - const policyBoxes = screen.getAllByText(/Policy/); - expect(policyBoxes.length).toBeGreaterThan(0); - }); - test('given specific position prop then uses simulation at that position', () => { - // Given - const store = configureStore({ - reducer: { - simulation: () => mockSimulationPartial, - simulations: () => ({ - simulations: [mockSimulationComplete, null], - activePosition: 0, - }), - flow: flowReducer, - policy: () => mockStateWithNewSimulation.policy, - population: () => mockStateWithNewSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - pass specific simulation ID - render( - - - - ); - - // Then - should display the specific simulation's data - expect(screen.getByText(SUBMIT_VIEW_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POPULATION_ADDED_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POLICY_REFORM_ADDED_TITLE)).toBeInTheDocument(); - }); - - test('given position with no simulation then handles gracefully', () => { - // Given - const store = configureStore({ - reducer: { - simulations: () => ({ - simulations: [mockSimulationComplete, null], - activePosition: 0, - }), - flow: flowReducer, - policy: () => mockStateWithNewSimulation.policy, - population: () => mockStateWithNewSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - pass position with no simulation - render( - - - - ); - - // Then - should still render without crashing - expect(screen.getByText(SUBMIT_VIEW_TITLE)).toBeInTheDocument(); - - // Population and policy cards should still appear, but without fulfilled state - expect(screen.getByText(POPULATION_ADDED_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POLICY_REFORM_ADDED_TITLE)).toBeInTheDocument(); - }); - }); - - describe('Summary Box Display', () => { - test('given complete simulation then shows all fulfilled badges', () => { - // Given - const store = configureStore({ - reducer: { - simulation: () => mockSimulationComplete, - simulations: simulationsReducer, - flow: flowReducer, - policy: () => mockStateWithOldSimulation.policy, - population: () => mockStateWithOldSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - render( - - - - ); - - // Then - both summary boxes should show populated data (may appear multiple times) - const populationElements = screen.getAllByText(TEST_POPULATION_LABEL); - expect(populationElements.length).toBeGreaterThan(0); - const policyElements = screen.getAllByText(TEST_POLICY_LABEL); - expect(policyElements.length).toBeGreaterThan(0); - }); - - test('given partial simulation then shows appropriate placeholders', () => { - // Given - simulation without policyId - const store = configureStore({ - reducer: { - simulation: () => mockSimulationPartial, - simulations: simulationsReducer, - flow: flowReducer, - policy: () => ({ ...mockStateWithOldSimulation.policy, label: null }), - population: () => mockStateWithOldSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - render( - - - - ); - - // Then - expect(screen.getByText(POPULATION_ADDED_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POLICY_REFORM_ADDED_TITLE)).toBeInTheDocument(); - - // Should show population label (may appear multiple times) - const populationElements = screen.getAllByText(TEST_POPULATION_LABEL); - expect(populationElements.length).toBeGreaterThan(0); - }); - - test('given no simulation data then shows empty state gracefully', () => { - // Given - completely empty position-based state - const store = configureStore({ - reducer: { - simulation: () => null, - simulations: () => ({ simulations: [null, null] }), - flow: flowReducer, - policy: () => ({ policies: [null, null] }), - population: () => ({ populations: [null, null] }), - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - // When - render( - - - - ); - - // Then - should render without crashing - expect(screen.getByText(SUBMIT_VIEW_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POPULATION_ADDED_TITLE)).toBeInTheDocument(); - expect(screen.getByText(POLICY_REFORM_ADDED_TITLE)).toBeInTheDocument(); - }); - }); - - describe('Position-Based Updates', () => { - test('given successful submission then updates simulation at position', async () => { - // Given - const mockCreateSimulation = vi.fn(); - vi.mocked(await import('@/hooks/useCreateSimulation')).useCreateSimulation.mockReturnValue({ - createSimulation: mockCreateSimulation, - isPending: false, - error: null, - }); - - // Set up store with simulation at position 0 - const store = configureStore({ - reducer: { - simulations: () => ({ - simulations: [mockSimulationComplete, null], - activePosition: 0, - }), - flow: flowReducer, - policy: () => mockStateWithOldSimulation.policy, - population: () => mockStateWithOldSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - const user = userEvent.setup(); - vi.spyOn(simulationsActions, 'updateSimulationAtPosition'); - vi.spyOn(simulationsActions, 'clearSimulationAtPosition'); - - // When - render( - - - - ); - - const submitButton = screen.getByRole('button', { name: /Save Simulation/i }); - await user.click(submitButton); - - // Simulate successful API response - const onSuccessCallback = mockCreateSimulation.mock.calls[0][1].onSuccess; - onSuccessCallback({ - result: { - simulation_id: '123', - }, - }); - - // Then - expect(simulationsActions.updateSimulationAtPosition).toHaveBeenCalledWith({ - position: 0, - updates: { - id: '123', - isCreated: true, - }, - }); - expect(mockOnNavigate).toHaveBeenCalledWith('submit'); - }); - - test('given submission not in subflow then clears simulation at position', async () => { - // Given - const mockCreateSimulation = vi.fn(); - vi.mocked(await import('@/hooks/useCreateSimulation')).useCreateSimulation.mockReturnValue({ - createSimulation: mockCreateSimulation, - isPending: false, - error: null, - }); - - const store = configureStore({ - reducer: { - simulation: () => null, - simulations: () => ({ - simulations: [null, mockSimulationComplete], - }), - flow: flowReducer, - policy: () => mockStateWithOldSimulation.policy, - population: () => mockStateWithOldSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: () => ({ - activeSimulationPosition: 1, - mode: 'report', - }), - }, - }); - - const user = userEvent.setup(); - vi.spyOn(simulationsActions, 'clearSimulationAtPosition'); - - // When - not in subflow - render( - - - - ); - - const submitButton = screen.getByRole('button', { name: /Save Simulation/i }); - await user.click(submitButton); - - // Simulate successful API response - const onSuccessCallback = mockCreateSimulation.mock.calls[0][1].onSuccess; - onSuccessCallback({ - result: { - simulation_id: '456', - }, - }); - - // Then - expect(simulationsActions.clearSimulationAtPosition).toHaveBeenCalledWith(1); - }); - - test('given submission in subflow then does not clear simulation', async () => { - // Given - const mockCreateSimulation = vi.fn(); - vi.mocked(await import('@/hooks/useCreateSimulation')).useCreateSimulation.mockReturnValue({ - createSimulation: mockCreateSimulation, - isPending: false, - error: null, - }); - - const store = configureStore({ - reducer: { - simulations: () => ({ - simulations: [mockSimulationComplete, null], - activePosition: 0, - }), - flow: flowReducer, - policy: () => mockStateWithOldSimulation.policy, - population: () => mockStateWithOldSimulation.population, - household: populationReducer, - metadata: metadataReducer, - report: reportReducer, - }, - }); - - const user = userEvent.setup(); - vi.spyOn(simulationsActions, 'clearSimulationAtPosition'); - - // When - in subflow - render( - - - - ); - - const submitButton = screen.getByRole('button', { name: /Save Simulation/i }); - await user.click(submitButton); - - // Simulate successful API response - const onSuccessCallback = mockCreateSimulation.mock.calls[0][1].onSuccess; - onSuccessCallback({ - result: { - simulation_id: '789', - }, - }); - - // Then - expect(simulationsActions.clearSimulationAtPosition).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/src/tests/unit/hooks/useIngredientReset.test.tsx b/app/src/tests/unit/hooks/useIngredientReset.test.tsx deleted file mode 100644 index 9b942231..00000000 --- a/app/src/tests/unit/hooks/useIngredientReset.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from 'react'; -import { renderHook } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useIngredientReset } from '@/hooks/useIngredientReset'; -import { - ACTION_TYPES, - createMockDispatch, - createMockStore, - TEST_COUNTRY_ID, - TEST_INGREDIENTS, - TEST_MODES, - TEST_POSITIONS, -} from '@/tests/fixtures/hooks/useIngredientResetMocks'; - -// Mock useCurrentCountry hook -vi.mock('@/hooks/useCurrentCountry', () => ({ - useCurrentCountry: vi.fn(() => 'us'), -})); - -// Mock the reducers - mocks must be defined inline due to hoisting -vi.mock('@/reducers/policyReducer', () => ({ - clearAllPolicies: vi.fn(() => ({ type: 'policy/clearAllPolicies' })), -})); - -vi.mock('@/reducers/populationReducer', () => ({ - clearAllPopulations: vi.fn(() => ({ type: 'population/clearAllPopulations' })), -})); - -vi.mock('@/reducers/reportReducer', () => ({ - clearReport: vi.fn((countryId: string) => ({ type: 'report/clearReport', payload: countryId })), - setMode: vi.fn((mode: string) => ({ type: 'report/setMode', payload: mode })), - setActiveSimulationPosition: vi.fn((position: number) => ({ - type: 'report/setActiveSimulationPosition', - payload: position, - })), -})); - -vi.mock('@/reducers/simulationsReducer', () => ({ - clearAllSimulations: vi.fn(() => ({ type: 'simulations/clearAllSimulations' })), -})); - -describe('useIngredientReset', () => { - let store: ReturnType; - let dispatch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - dispatch = createMockDispatch(); - store = createMockStore(dispatch); - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - test('given policy reset then clears policies and resets mode and position', () => { - // Given - const { result } = renderHook(() => useIngredientReset(), { wrapper }); - - // When - result.current.resetIngredient(TEST_INGREDIENTS.POLICY); - - // Then - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POLICIES }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setMode', - payload: TEST_MODES.STANDALONE, - }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setActiveSimulationPosition', - payload: TEST_POSITIONS.FIRST, - }); - }); - - test('given population reset then clears populations and resets mode and position', () => { - // Given - const { result } = renderHook(() => useIngredientReset(), { wrapper }); - - // When - result.current.resetIngredient(TEST_INGREDIENTS.POPULATION); - - // Then - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POPULATIONS }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setMode', - payload: TEST_MODES.STANDALONE, - }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setActiveSimulationPosition', - payload: TEST_POSITIONS.FIRST, - }); - }); - - test('given simulation reset then clears all and resets mode and position', () => { - // Given - const { result } = renderHook(() => useIngredientReset(), { wrapper }); - - // When - result.current.resetIngredient(TEST_INGREDIENTS.SIMULATION); - - // Then - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_SIMULATIONS }); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POLICIES }); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POPULATIONS }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setMode', - payload: TEST_MODES.STANDALONE, - }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setActiveSimulationPosition', - payload: TEST_POSITIONS.FIRST, - }); - }); - - test('given report reset then clears all and resets mode and position', () => { - // Given - const { result } = renderHook(() => useIngredientReset(), { wrapper }); - - // When - result.current.resetIngredient(TEST_INGREDIENTS.REPORT); - - // Then - expect(dispatch).toHaveBeenCalledWith({ - type: ACTION_TYPES.CLEAR_REPORT, - payload: TEST_COUNTRY_ID, - }); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_SIMULATIONS }); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POLICIES }); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPES.CLEAR_ALL_POPULATIONS }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setMode', - payload: TEST_MODES.STANDALONE, - }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'report/setActiveSimulationPosition', - payload: TEST_POSITIONS.FIRST, - }); - }); - - test('given multiple ingredients reset then processes in dependency order', () => { - // Given - const { result } = renderHook(() => useIngredientReset(), { wrapper }); - const ingredientsToReset = [TEST_INGREDIENTS.POLICY, TEST_INGREDIENTS.POPULATION]; - - // When - result.current.resetIngredients(ingredientsToReset); - - // Then - should process in dependency order and reset mode/position - expect(dispatch).toHaveBeenCalled(); - - // Both should trigger mode and position reset - const modeCalls = dispatch.mock.calls.filter( - (call: any) => call[0].type === ACTION_TYPES.SET_MODE - ); - const positionCalls = dispatch.mock.calls.filter( - (call: any) => call[0].type === ACTION_TYPES.SET_ACTIVE_SIMULATION_POSITION - ); - - expect(modeCalls.length).toBeGreaterThan(0); - expect(positionCalls.length).toBeGreaterThan(0); - }); -}); diff --git a/app/src/tests/unit/reducers/activeSelectors.test.ts b/app/src/tests/unit/reducers/activeSelectors.test.ts deleted file mode 100644 index c9b2e057..00000000 --- a/app/src/tests/unit/reducers/activeSelectors.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - selectActivePolicy, - selectActivePopulation, - selectActiveSimulation, - selectCurrentPosition, -} from '@/reducers/activeSelectors'; -import { - mockPolicy1, - mockPolicy2, - mockPopulation1, - mockPopulation2, - mockSimulation1, - mockSimulation2, - REPORT_MODE_POSITION_0_STATE, - REPORT_MODE_POSITION_1_STATE, - STANDALONE_MODE_STATE, - STATE_WITH_ALL_NULL, - STATE_WITH_NULL_AT_POSITION, -} from '@/tests/fixtures/reducers/activeSelectorsMocks'; - -describe('activeSelectors', () => { - describe('selectActiveSimulation', () => { - test('given standalone mode then returns simulation at position 0', () => { - // Given - const state = STANDALONE_MODE_STATE; - - // When - const result = selectActiveSimulation(state); - - // Then - expect(result).toEqual(mockSimulation1); - }); - - test('given report mode with position 0 then returns simulation at position 0', () => { - // Given - const state = REPORT_MODE_POSITION_0_STATE; - - // When - const result = selectActiveSimulation(state); - - // Then - expect(result).toEqual(mockSimulation1); - }); - - test('given report mode with position 1 then returns simulation at position 1', () => { - // Given - const state = REPORT_MODE_POSITION_1_STATE; - - // When - const result = selectActiveSimulation(state); - - // Then - expect(result).toEqual(mockSimulation2); - }); - - test('given null at active position then returns null', () => { - // Given - const state = STATE_WITH_NULL_AT_POSITION; - - // When - const result = selectActiveSimulation(state); - - // Then - expect(result).toBeNull(); - }); - - test('given all simulations are null then returns null', () => { - // Given - const state = STATE_WITH_ALL_NULL; - - // When - const result = selectActiveSimulation(state); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectActivePolicy', () => { - test('given standalone mode then returns policy at position 0', () => { - // Given - const state = STANDALONE_MODE_STATE; - - // When - const result = selectActivePolicy(state); - - // Then - expect(result).toEqual(mockPolicy1); - }); - - test('given report mode with position 0 then returns policy at position 0', () => { - // Given - const state = REPORT_MODE_POSITION_0_STATE; - - // When - const result = selectActivePolicy(state); - - // Then - expect(result).toEqual(mockPolicy1); - }); - - test('given report mode with position 1 then returns policy at position 1', () => { - // Given - const state = REPORT_MODE_POSITION_1_STATE; - - // When - const result = selectActivePolicy(state); - - // Then - expect(result).toEqual(mockPolicy2); - }); - - test('given null at active position then returns null', () => { - // Given - const state = STATE_WITH_NULL_AT_POSITION; - - // When - const result = selectActivePolicy(state); - - // Then - expect(result).toBeNull(); - }); - - test('given all policies are null then returns null', () => { - // Given - const state = STATE_WITH_ALL_NULL; - - // When - const result = selectActivePolicy(state); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectActivePopulation', () => { - test('given standalone mode then returns population at position 0', () => { - // Given - const state = STANDALONE_MODE_STATE; - - // When - const result = selectActivePopulation(state); - - // Then - expect(result).toEqual(mockPopulation1); - }); - - test('given report mode with position 0 then returns population at position 0', () => { - // Given - const state = REPORT_MODE_POSITION_0_STATE; - - // When - const result = selectActivePopulation(state); - - // Then - expect(result).toEqual(mockPopulation1); - }); - - test('given report mode with position 1 then returns population at position 1', () => { - // Given - const state = REPORT_MODE_POSITION_1_STATE; - - // When - const result = selectActivePopulation(state); - - // Then - expect(result).toEqual(mockPopulation2); - }); - - test('given null at active position then returns null', () => { - // Given - const state = STATE_WITH_NULL_AT_POSITION; - - // When - const result = selectActivePopulation(state); - - // Then - expect(result).toBeNull(); - }); - - test('given all populations are null then returns null', () => { - // Given - const state = STATE_WITH_ALL_NULL; - - // When - const result = selectActivePopulation(state); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectCurrentPosition', () => { - test('given standalone mode then returns 0', () => { - // Given - const state = STANDALONE_MODE_STATE; - - // When - const result = selectCurrentPosition(state); - - // Then - expect(result).toBe(0); - }); - - test('given report mode with position 0 then returns 0', () => { - // Given - const state = REPORT_MODE_POSITION_0_STATE; - - // When - const result = selectCurrentPosition(state); - - // Then - expect(result).toBe(0); - }); - - test('given report mode with position 1 then returns 1', () => { - // Given - const state = REPORT_MODE_POSITION_1_STATE; - - // When - const result = selectCurrentPosition(state); - - // Then - expect(result).toBe(1); - }); - - test('given standalone mode with activePosition 1 then still returns 0', () => { - // Given - const state = STANDALONE_MODE_STATE; // Has activePosition: 1 but mode is standalone - - // When - const result = selectCurrentPosition(state); - - // Then - expect(result).toBe(0); // Should ignore activePosition in standalone mode - }); - }); -}); diff --git a/app/src/tests/unit/reducers/flowReducer.test.ts b/app/src/tests/unit/reducers/flowReducer.test.ts deleted file mode 100644 index d148015d..00000000 --- a/app/src/tests/unit/reducers/flowReducer.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import flowReducer, { - clearFlow, - navigateToFlow, - navigateToFrame, - returnFromFlow, - setFlow, -} from '@/reducers/flowReducer'; -import { - createFlowStackEntry, - createFlowState, - expectedStateAfterClearFlow, - expectedStateAfterNavigateToFlow, - expectedStateAfterNavigateToFlowWithReturn, - expectedStateAfterNavigateToFrame, - expectedStateAfterReturnFromFlow, - expectedStateAfterSetFlow, - FRAME_NAMES, - INITIAL_STATE, - mockEmptyStack, - mockFlowWithNonStringInitialFrame, - mockFlowWithoutInitialFrame, - mockMainFlow, - mockNestedFlow, - mockSingleLevelStack, - mockStateWithMainFlow, - mockStateWithNestedFlow, - mockStateWithoutCurrentFlow, - mockStateWithoutCurrentFrame, - mockStateWithSubFlow, - mockSubFlow, - mockTwoLevelStack, -} from '@/tests/fixtures/reducers/flowReducerMocks'; - -describe('flowReducer', () => { - describe('Initial State', () => { - test('given undefined state then returns initial state', () => { - const state = flowReducer(undefined, { type: 'unknown' }); - - expect(state).toEqual(INITIAL_STATE); - expect(state.currentFlow).toBeNull(); - expect(state.currentFrame).toBeNull(); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - }); - - describe('clearFlow Action', () => { - test('given state with flow then clears all flow data', () => { - const state = flowReducer(mockStateWithMainFlow, clearFlow()); - - expect(state).toEqual(expectedStateAfterClearFlow); - expect(state.currentFlow).toBeNull(); - expect(state.currentFrame).toBeNull(); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given state with nested flows then clears entire stack', () => { - const state = flowReducer(mockStateWithNestedFlow, clearFlow()); - - expect(state).toEqual(expectedStateAfterClearFlow); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given already empty state then remains empty', () => { - const state = flowReducer(INITIAL_STATE, clearFlow()); - - expect(state).toEqual(expectedStateAfterClearFlow); - }); - }); - - describe('setFlow Action', () => { - test('given flow with initial frame then sets flow and frame', () => { - const state = flowReducer(INITIAL_STATE, setFlow({ flow: mockMainFlow })); - - expect(state).toEqual(expectedStateAfterSetFlow); - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given flow without initial frame then sets flow but not frame', () => { - const state = flowReducer(INITIAL_STATE, setFlow({ flow: mockFlowWithoutInitialFrame })); - - expect(state.currentFlow).toEqual(mockFlowWithoutInitialFrame); - expect(state.currentFrame).toBeNull(); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given flow with non-string initial frame then sets flow but not frame', () => { - const state = flowReducer( - INITIAL_STATE, - setFlow({ flow: mockFlowWithNonStringInitialFrame }) - ); - - expect(state.currentFlow).toEqual(mockFlowWithNonStringInitialFrame); - expect(state.currentFrame).toBeNull(); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given existing flow state then replaces with new flow and clears stack', () => { - const state = flowReducer(mockStateWithNestedFlow, setFlow({ flow: mockMainFlow })); - - expect(state).toEqual(expectedStateAfterSetFlow); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given flow with returnPath then sets returnPath', () => { - const returnPath = '/us/reports'; - const state = flowReducer(INITIAL_STATE, setFlow({ flow: mockMainFlow, returnPath })); - - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); - expect(state.returnPath).toEqual(returnPath); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given flow without returnPath then sets returnPath to null', () => { - const state = flowReducer(INITIAL_STATE, setFlow({ flow: mockMainFlow })); - - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.returnPath).toBeNull(); - }); - }); - - describe('navigateToFrame Action', () => { - test('given current flow then navigates to specified frame', () => { - const state = flowReducer(mockStateWithMainFlow, navigateToFrame(FRAME_NAMES.SECOND_FRAME)); - - expect(state).toEqual(expectedStateAfterNavigateToFrame); - expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME); - expect(state.currentFlow).toEqual(mockMainFlow); - }); - - test('given nested flow state then only changes current frame', () => { - const state = flowReducer( - mockStateWithSubFlow, - navigateToFrame(FRAME_NAMES.SUB_SECOND_FRAME) - ); - - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_SECOND_FRAME); - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.flowStack).toEqual(mockSingleLevelStack); - }); - - test('given no current flow then still updates frame', () => { - const state = flowReducer(INITIAL_STATE, navigateToFrame(FRAME_NAMES.SECOND_FRAME)); - - expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME); - expect(state.currentFlow).toBeNull(); - }); - }); - - describe('navigateToFlow Action', () => { - test('given current flow and frame then pushes to stack and navigates', () => { - const state = flowReducer(mockStateWithMainFlow, navigateToFlow({ flow: mockSubFlow })); - - expect(state).toEqual(expectedStateAfterNavigateToFlow); - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_INITIAL_FRAME); - expect(state.flowStack).toHaveLength(1); - expect(state.flowStack[0]).toEqual( - createFlowStackEntry(mockMainFlow, FRAME_NAMES.INITIAL_FRAME) - ); - }); - - test('given return frame then uses it in stack entry', () => { - const state = flowReducer( - mockStateWithMainFlow, - navigateToFlow({ - flow: mockSubFlow, - returnFrame: FRAME_NAMES.RETURN_FRAME, - }) - ); - - expect(state).toEqual(expectedStateAfterNavigateToFlowWithReturn); - expect(state.flowStack[0].frame).toEqual(FRAME_NAMES.RETURN_FRAME); - }); - - test('given no current flow then does not push to stack', () => { - const state = flowReducer(mockStateWithoutCurrentFlow, navigateToFlow({ flow: mockSubFlow })); - - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_INITIAL_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given no current frame then does not push to stack', () => { - const state = flowReducer( - mockStateWithoutCurrentFrame, - navigateToFlow({ flow: mockSubFlow }) - ); - - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_INITIAL_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given flow without initial frame then sets flow but keeps previous frame', () => { - const state = flowReducer( - mockStateWithMainFlow, - navigateToFlow({ flow: mockFlowWithoutInitialFrame }) - ); - - expect(state.currentFlow).toEqual(mockFlowWithoutInitialFrame); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); // Frame stays the same - expect(state.flowStack).toHaveLength(1); - }); - - test('given flow with non-string initial frame then sets flow but keeps previous frame', () => { - const state = flowReducer( - mockStateWithMainFlow, - navigateToFlow({ flow: mockFlowWithNonStringInitialFrame }) - ); - - expect(state.currentFlow).toEqual(mockFlowWithNonStringInitialFrame); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); // Frame stays the same - expect(state.flowStack).toHaveLength(1); - }); - - test('given nested navigation then creates multi-level stack', () => { - // Start with main flow - let state = flowReducer(INITIAL_STATE, setFlow({ flow: mockMainFlow })); - - // Navigate to sub flow - state = flowReducer(state, navigateToFlow({ flow: mockSubFlow })); - expect(state.flowStack).toHaveLength(1); - expect(state.currentFlow).toEqual(mockSubFlow); - - // Navigate to nested flow - state = flowReducer(state, navigateToFlow({ flow: mockNestedFlow })); - expect(state.flowStack).toHaveLength(2); - expect(state.currentFlow).toEqual(mockNestedFlow); - expect(state.flowStack[0].flow).toEqual(mockMainFlow); - expect(state.flowStack[1].flow).toEqual(mockSubFlow); - }); - }); - - describe('returnFromFlow Action', () => { - test('given single level stack then returns to previous flow', () => { - const state = flowReducer(mockStateWithSubFlow, returnFromFlow()); - - expect(state).toEqual(expectedStateAfterReturnFromFlow); - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given multi-level stack then returns one level', () => { - const state = flowReducer(mockStateWithNestedFlow, returnFromFlow()); - - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_INITIAL_FRAME); // Returns to the frame in the stack - expect(state.flowStack).toHaveLength(1); - expect(state.flowStack[0].flow).toEqual(mockMainFlow); - }); - - test('given empty stack then clears flow', () => { - const state = flowReducer(mockStateWithMainFlow, returnFromFlow()); - - expect(state.currentFlow).toBeNull(); - expect(state.currentFrame).toBeNull(); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given initial state then does nothing', () => { - const state = flowReducer(INITIAL_STATE, returnFromFlow()); - - expect(state).toEqual(INITIAL_STATE); - }); - - test('given custom return frame then returns to specified frame', () => { - const stateWithCustomReturn = createFlowState({ - currentFlow: mockSubFlow, - currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME, - flowStack: [createFlowStackEntry(mockMainFlow, FRAME_NAMES.THIRD_FRAME)], - }); - - const state = flowReducer(stateWithCustomReturn, returnFromFlow()); - - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.THIRD_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - }); - - describe('Complex Scenarios', () => { - test('given sequence of navigations then maintains correct state', () => { - let state = flowReducer(undefined, { type: 'init' }); - - // Set initial flow - state = flowReducer(state, setFlow({ flow: mockMainFlow })); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); - - // Navigate to second frame - state = flowReducer(state, navigateToFrame(FRAME_NAMES.SECOND_FRAME)); - expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME); - - // Navigate to sub flow - state = flowReducer( - state, - navigateToFlow({ - flow: mockSubFlow, - returnFrame: FRAME_NAMES.THIRD_FRAME, - }) - ); - expect(state.currentFlow).toEqual(mockSubFlow); - expect(state.flowStack).toHaveLength(1); - expect(state.flowStack[0].frame).toEqual(FRAME_NAMES.THIRD_FRAME); - - // Navigate within sub flow - state = flowReducer(state, navigateToFrame(FRAME_NAMES.SUB_SECOND_FRAME)); - expect(state.currentFrame).toEqual(FRAME_NAMES.SUB_SECOND_FRAME); - - // Return from sub flow - state = flowReducer(state, returnFromFlow()); - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.THIRD_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - - test('given clear flow in middle of navigation then resets everything', () => { - let state = createFlowState({ - currentFlow: mockNestedFlow, - currentFrame: FRAME_NAMES.NESTED_INITIAL_FRAME, - flowStack: mockTwoLevelStack, - }); - - state = flowReducer(state, clearFlow()); - - expect(state).toEqual(INITIAL_STATE); - }); - - test('given set flow in middle of navigation then replaces everything', () => { - let state = createFlowState({ - currentFlow: mockNestedFlow, - currentFrame: FRAME_NAMES.NESTED_INITIAL_FRAME, - flowStack: mockTwoLevelStack, - }); - - state = flowReducer(state, setFlow({ flow: mockMainFlow })); - - expect(state.currentFlow).toEqual(mockMainFlow); - expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME); - expect(state.flowStack).toEqual(mockEmptyStack); - }); - }); - - describe('Edge Cases', () => { - test('given unknown action then returns unchanged state', () => { - const state = flowReducer(mockStateWithMainFlow, { type: 'unknown/action' } as any); - - expect(state).toEqual(mockStateWithMainFlow); - }); - - test('given multiple returns then handles gracefully', () => { - let state = mockStateWithSubFlow; - - // First return - should work, returning to parent flow - state = flowReducer(state, returnFromFlow()); - expect(state.flowStack).toEqual(mockEmptyStack); - expect(state.currentFlow).toEqual(mockMainFlow); - - // Second return - empty stack, so clears flow - state = flowReducer(state, returnFromFlow()); - expect(state.currentFlow).toBeNull(); - expect(state.currentFrame).toBeNull(); - - // Third return - already null, stays null - state = flowReducer(state, returnFromFlow()); - expect(state.currentFlow).toBeNull(); - expect(state.currentFrame).toBeNull(); - }); - }); -}); diff --git a/app/src/tests/unit/reducers/policyReducer.test.ts b/app/src/tests/unit/reducers/policyReducer.test.ts deleted file mode 100644 index 1e585809..00000000 --- a/app/src/tests/unit/reducers/policyReducer.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import policyReducer, { - addPolicyParamAtPosition, - clearAllPolicies, - clearPolicyAtPosition, - createPolicyAtPosition, - selectAllPolicies, - selectHasPolicyAtPosition, - selectPolicyAtPosition, - updatePolicyAtPosition, -} from '@/reducers/policyReducer'; -import { Policy } from '@/types/ingredients/Policy'; - -// Test data -const TEST_POLICY_ID = 'policy-123'; -const TEST_LABEL = 'Test Policy'; -const TEST_LABEL_UPDATED = 'Updated Policy'; - -const mockPolicy1: Policy = { - id: TEST_POLICY_ID, - label: TEST_LABEL, - parameters: [], - isCreated: true, -}; - -const mockPolicy2: Policy = { - id: 'policy-456', - label: 'Second Policy', - parameters: [], - isCreated: false, -}; - -const emptyInitialState = { - policies: [null, null] as [Policy | null, Policy | null], -}; - -describe('policyReducer', () => { - describe('Creating Policies at Position', () => { - test('given createPolicyAtPosition with position 0 then policy created at first slot', () => { - // Given - const state = emptyInitialState; - - // When - const newState = policyReducer( - state, - createPolicyAtPosition({ - position: 0, - }) - ); - - // Then - expect(newState.policies[0]).toEqual({ - id: undefined, - label: null, - parameters: [], - isCreated: false, - }); - expect(newState.policies[1]).toBeNull(); - }); - - test('given createPolicyAtPosition with position 1 then policy created at second slot', () => { - // Given - const state = emptyInitialState; - - // When - const newState = policyReducer( - state, - createPolicyAtPosition({ - position: 1, - policy: { label: TEST_LABEL }, - }) - ); - - // Then - expect(newState.policies[0]).toBeNull(); - expect(newState.policies[1]).toEqual({ - id: undefined, - label: TEST_LABEL, - parameters: [], - isCreated: false, - }); - }); - - test('given createPolicyAtPosition with existing policy then preserves existing policy', () => { - // Given - const state = { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer( - state, - createPolicyAtPosition({ - position: 0, - policy: { label: 'New Policy' }, - }) - ); - - // Then - existing policy should be preserved, not replaced - expect(newState.policies[0]).toEqual(mockPolicy1); - expect(newState.policies[0]?.label).toBe(TEST_LABEL); // Original label preserved - expect(newState.policies[0]?.id).toBe(TEST_POLICY_ID); // Original ID preserved - }); - - test('given createPolicyAtPosition with null value then creates new policy', () => { - // Given - const state = { - policies: [null, mockPolicy1] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer( - state, - createPolicyAtPosition({ - position: 0, - policy: { label: 'New Policy' }, - }) - ); - - // Then - new policy should be created since position was null - expect(newState.policies[0]).toEqual({ - id: undefined, - label: 'New Policy', - parameters: [], - isCreated: false, - }); - expect(newState.policies[1]).toEqual(mockPolicy1); // Other position unchanged - }); - }); - - describe('Updating Policies at Position', () => { - test('given updatePolicyAtPosition updates existing policy', () => { - // Given - const state = { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer( - state, - updatePolicyAtPosition({ - position: 0, - updates: { label: TEST_LABEL_UPDATED }, - }) - ); - - // Then - expect(newState.policies[0]).toEqual({ - ...mockPolicy1, - label: TEST_LABEL_UPDATED, - }); - }); - - test('given updatePolicyAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - policyReducer( - state, - updatePolicyAtPosition({ - position: 0, - updates: { label: TEST_LABEL }, - }) - ); - }).toThrow('Cannot update policy at position 0: no policy exists at that position'); - }); - - test('given updatePolicyAtPosition with multiple updates then updates all items', () => { - // Given - const state = { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer( - state, - updatePolicyAtPosition({ - position: 0, - updates: { - label: TEST_LABEL_UPDATED, - isCreated: false, - id: 'new-id', - }, - }) - ); - - // Then - expect(newState.policies[0]).toEqual({ - ...mockPolicy1, - label: TEST_LABEL_UPDATED, - isCreated: false, - id: 'new-id', - }); - }); - }); - - describe('Adding Policy Parameters at Position', () => { - test('given addPolicyParamAtPosition adds parameter to existing policy', () => { - // Given - const state = { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer( - state, - addPolicyParamAtPosition({ - position: 0, - name: 'tax_rate', - valueInterval: { - startDate: `${CURRENT_YEAR}-01-01`, - endDate: `${CURRENT_YEAR}-12-31`, - value: 0.25, - }, - }) - ); - - // Then - expect(newState.policies[0]?.parameters).toHaveLength(1); - expect(newState.policies[0]?.parameters?.[0]).toEqual({ - name: 'tax_rate', - values: expect.arrayContaining([ - expect.objectContaining({ - startDate: `${CURRENT_YEAR}-01-01`, - endDate: `${CURRENT_YEAR}-12-31`, - value: 0.25, - }), - ]), - }); - }); - - test('given addPolicyParamAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - policyReducer( - state, - addPolicyParamAtPosition({ - position: 0, - name: 'tax_rate', - valueInterval: { - startDate: `${CURRENT_YEAR}-01-01`, - endDate: `${CURRENT_YEAR}-12-31`, - value: 0.25, - }, - }) - ); - }).toThrow('Cannot add parameter to policy at position 0: no policy exists at that position'); - }); - }); - - describe('Clearing Policies', () => { - test('given clearPolicyAtPosition then clears specific position', () => { - // Given - const state = { - policies: [mockPolicy1, mockPolicy2] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer(state, clearPolicyAtPosition(0)); - - // Then - expect(newState.policies[0]).toBeNull(); - expect(newState.policies[1]).toEqual(mockPolicy2); - }); - - test('given clearAllPolicies then clears all positions', () => { - // Given - const state = { - policies: [mockPolicy1, mockPolicy2] as [Policy | null, Policy | null], - }; - - // When - const newState = policyReducer(state, clearAllPolicies()); - - // Then - expect(newState.policies[0]).toBeNull(); - expect(newState.policies[1]).toBeNull(); - }); - }); - - describe('Selectors', () => { - describe('selectPolicyAtPosition', () => { - test('given policy exists at position then returns policy', () => { - // Given - const state = { - policy: { - policies: [mockPolicy1, mockPolicy2] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectPolicyAtPosition(state, 0); - - // Then - expect(result).toEqual(mockPolicy1); - }); - - test('given no policy at position then returns null', () => { - // Given - const state = { - policy: { - policies: [null, mockPolicy2] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectPolicyAtPosition(state, 0); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectAllPolicies', () => { - test('given two policies then returns array with both', () => { - // Given - const state = { - policy: { - policies: [mockPolicy1, mockPolicy2] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectAllPolicies(state); - - // Then - expect(result).toEqual([mockPolicy1, mockPolicy2]); - }); - - test('given one policy then returns array with one', () => { - // Given - const state = { - policy: { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectAllPolicies(state); - - // Then - expect(result).toEqual([mockPolicy1]); - }); - - test('given no policies then returns empty array', () => { - // Given - const state = { - policy: emptyInitialState, - }; - - // When - const result = selectAllPolicies(state); - - // Then - expect(result).toEqual([]); - }); - }); - - describe('selectHasPolicyAtPosition', () => { - test('given policy exists at position then returns true', () => { - // Given - const state = { - policy: { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectHasPolicyAtPosition(state, 0); - - // Then - expect(result).toBe(true); - }); - - test('given no policy at position then returns false', () => { - // Given - const state = { - policy: { - policies: [mockPolicy1, null] as [Policy | null, Policy | null], - }, - }; - - // When - const result = selectHasPolicyAtPosition(state, 1); - - // Then - expect(result).toBe(false); - }); - }); - }); -}); diff --git a/app/src/tests/unit/reducers/populationReducer.test.ts b/app/src/tests/unit/reducers/populationReducer.test.ts deleted file mode 100644 index ae1b3c37..00000000 --- a/app/src/tests/unit/reducers/populationReducer.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { CURRENT_YEAR } from '@/constants'; -import populationReducer, { - clearAllPopulations, - clearPopulationAtPosition, - createPopulationAtPosition, - initializeHouseholdAtPosition, - selectAllPopulations, - selectGeographyAtPosition, - selectHasPopulationAtPosition, - selectHouseholdAtPosition, - selectPopulationAtPosition, - setGeographyAtPosition, - setHouseholdAtPosition, - updatePopulationAtPosition, - updatePopulationIdAtPosition, -} from '@/reducers/populationReducer'; -import { - emptyInitialState, - mockGeography1, - mockGeography2, - mockHousehold1, - mockHousehold2, - mockPopulation1, - mockPopulation2, - TEST_LABEL, - TEST_LABEL_UPDATED, -} from '@/tests/fixtures/reducers/populationMocks'; -import { Population } from '@/types/ingredients/Population'; - -// Mock HouseholdBuilder -vi.mock('@/utils/HouseholdBuilder', () => ({ - HouseholdBuilder: vi.fn().mockImplementation((countryId: string) => ({ - build: () => ({ - id: undefined, - countryId, - householdData: { - people: {}, - }, - }), - })), -})); - -describe('populationReducer', () => { - describe('Creating Populations at Position', () => { - test('given createPopulationAtPosition with position 0 then population created at first slot', () => { - // Given - const state = emptyInitialState; - - // When - const newState = populationReducer( - state, - createPopulationAtPosition({ - position: 0, - }) - ); - - // Then - expect(newState.populations[0]).toEqual({ - label: null, - isCreated: false, - household: null, - geography: null, - }); - expect(newState.populations[1]).toBeNull(); - }); - - test('given createPopulationAtPosition with position 1 then population created at second slot', () => { - // Given - const state = emptyInitialState; - - // When - const newState = populationReducer( - state, - createPopulationAtPosition({ - position: 1, - population: { label: TEST_LABEL }, - }) - ); - - // Then - expect(newState.populations[0]).toBeNull(); - expect(newState.populations[1]).toEqual({ - label: TEST_LABEL, - isCreated: false, - household: null, - geography: null, - }); - }); - - test('given createPopulationAtPosition with existing population then preserves existing population', () => { - // Given - const state = { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - createPopulationAtPosition({ - position: 0, - population: { label: 'New Population' }, - }) - ); - - // Then - existing population should be preserved, not replaced - expect(newState.populations[0]).toEqual(mockPopulation1); - expect(newState.populations[0]?.label).toBe(TEST_LABEL); // Original label preserved - expect(newState.populations[0]?.household).toEqual(mockHousehold1); // Original household preserved - }); - - test('given createPopulationAtPosition with null value then creates new population', () => { - // Given - const state = { - populations: [null, mockPopulation1] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - createPopulationAtPosition({ - position: 0, - population: { label: 'New Population' }, - }) - ); - - // Then - new population should be created since position was null - expect(newState.populations[0]).toEqual({ - label: 'New Population', - isCreated: false, - household: null, - geography: null, - }); - expect(newState.populations[1]).toEqual(mockPopulation1); // Other position unchanged - }); - }); - - describe('Updating Populations at Position', () => { - test('given updatePopulationAtPosition updates existing population', () => { - // Given - const state = { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - updatePopulationAtPosition({ - position: 0, - updates: { label: TEST_LABEL_UPDATED, isCreated: false }, - }) - ); - - // Then - expect(newState.populations[0]).toEqual({ - ...mockPopulation1, - label: TEST_LABEL_UPDATED, - isCreated: false, - }); - }); - - test('given updatePopulationAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - populationReducer( - state, - updatePopulationAtPosition({ - position: 0, - updates: { label: TEST_LABEL }, - }) - ); - }).toThrow('Cannot update population at position 0: no population exists at that position'); - }); - - test('given updatePopulationIdAtPosition updates ID in household', () => { - // Given - const state = { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - updatePopulationIdAtPosition({ - position: 0, - id: 'new-household-id', - }) - ); - - // Then - expect(newState.populations[0]?.household?.id).toBe('new-household-id'); - }); - - test('given updatePopulationIdAtPosition updates ID in geography', () => { - // Given - const state = { - populations: [mockPopulation2, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - updatePopulationIdAtPosition({ - position: 0, - id: 'new-geo-id', - }) - ); - - // Then - expect(newState.populations[0]?.geography?.id).toBe('new-geo-id'); - }); - - test('given updatePopulationIdAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - populationReducer( - state, - updatePopulationIdAtPosition({ - position: 0, - id: 'some-id', - }) - ); - }).toThrow( - 'Cannot update population ID at position 0: no population exists at that position' - ); - }); - }); - - describe('Setting Household at Position', () => { - test('given setHouseholdAtPosition sets household and clears geography', () => { - // Given - const state = { - populations: [mockPopulation2, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - setHouseholdAtPosition({ - position: 0, - household: mockHousehold2, - }) - ); - - // Then - expect(newState.populations[0]?.household).toEqual(mockHousehold2); - expect(newState.populations[0]?.geography).toBeNull(); - }); - - test('given setHouseholdAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - populationReducer( - state, - setHouseholdAtPosition({ - position: 0, - household: mockHousehold1, - }) - ); - }).toThrow('Cannot set household at position 0: no population exists at that position'); - }); - - test('given initializeHouseholdAtPosition creates household', () => { - // Given - const state = { - populations: [ - { label: TEST_LABEL, isCreated: false, household: null, geography: null }, - null, - ] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - initializeHouseholdAtPosition({ - position: 0, - countryId: 'us', - year: CURRENT_YEAR, - }) - ); - - // Then - expect(newState.populations[0]?.household).toEqual({ - id: undefined, - countryId: 'us', - householdData: { - people: {}, - }, - }); - expect(newState.populations[0]?.geography).toBeNull(); - }); - - test('given initializeHouseholdAtPosition on empty slot creates population and household', () => { - // Given - const state = emptyInitialState; - - // When - const newState = populationReducer( - state, - initializeHouseholdAtPosition({ - position: 0, - countryId: 'uk', - year: '2023', - }) - ); - - // Then - expect(newState.populations[0]).toBeTruthy(); - expect(newState.populations[0]?.household).toEqual({ - id: undefined, - countryId: 'uk', - householdData: { - people: {}, - }, - }); - }); - }); - - describe('Setting Geography at Position', () => { - test('given setGeographyAtPosition sets geography and clears household', () => { - // Given - const state = { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer( - state, - setGeographyAtPosition({ - position: 0, - geography: mockGeography2, - }) - ); - - // Then - expect(newState.populations[0]?.geography).toEqual(mockGeography2); - expect(newState.populations[0]?.household).toBeNull(); - }); - - test('given setGeographyAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => { - populationReducer( - state, - setGeographyAtPosition({ - position: 0, - geography: mockGeography1, - }) - ); - }).toThrow('Cannot set geography at position 0: no population exists at that position'); - }); - }); - - describe('Clearing Populations', () => { - test('given clearPopulationAtPosition then clears specific position', () => { - // Given - const state = { - populations: [mockPopulation1, mockPopulation2] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer(state, clearPopulationAtPosition(0)); - - // Then - expect(newState.populations[0]).toBeNull(); - expect(newState.populations[1]).toEqual(mockPopulation2); - }); - - test('given clearAllPopulations then clears all positions', () => { - // Given - const state = { - populations: [mockPopulation1, mockPopulation2] as [Population | null, Population | null], - }; - - // When - const newState = populationReducer(state, clearAllPopulations()); - - // Then - expect(newState.populations[0]).toBeNull(); - expect(newState.populations[1]).toBeNull(); - }); - }); - - describe('Selectors', () => { - describe('selectPopulationAtPosition', () => { - test('given population exists at position then returns population', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, mockPopulation2] as [ - Population | null, - Population | null, - ], - }, - }; - - // When - const result = selectPopulationAtPosition(state, 0); - - // Then - expect(result).toEqual(mockPopulation1); - }); - - test('given no population at position then returns null', () => { - // Given - const state = { - population: { - populations: [null, mockPopulation2] as [Population | null, Population | null], - }, - }; - - // When - const result = selectPopulationAtPosition(state, 0); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectAllPopulations', () => { - test('given two populations then returns array with both', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, mockPopulation2] as [ - Population | null, - Population | null, - ], - }, - }; - - // When - const result = selectAllPopulations(state); - - // Then - expect(result).toEqual([mockPopulation1, mockPopulation2]); - }); - - test('given one population then returns array with one', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectAllPopulations(state); - - // Then - expect(result).toEqual([mockPopulation1]); - }); - - test('given no populations then returns empty array', () => { - // Given - const state = { - population: emptyInitialState, - }; - - // When - const result = selectAllPopulations(state); - - // Then - expect(result).toEqual([]); - }); - }); - - describe('selectHasPopulationAtPosition', () => { - test('given population exists at position then returns true', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectHasPopulationAtPosition(state, 0); - - // Then - expect(result).toBe(true); - }); - - test('given no population at position then returns false', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectHasPopulationAtPosition(state, 1); - - // Then - expect(result).toBe(false); - }); - }); - - describe('selectHouseholdAtPosition', () => { - test('given household exists at position then returns household', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectHouseholdAtPosition(state, 0); - - // Then - expect(result).toEqual(mockHousehold1); - }); - - test('given no household at position then returns null', () => { - // Given - const state = { - population: { - populations: [mockPopulation2, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectHouseholdAtPosition(state, 0); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectGeographyAtPosition', () => { - test('given geography exists at position then returns geography', () => { - // Given - const state = { - population: { - populations: [mockPopulation2, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectGeographyAtPosition(state, 0); - - // Then - expect(result).toEqual(mockGeography1); - }); - - test('given no geography at position then returns null', () => { - // Given - const state = { - population: { - populations: [mockPopulation1, null] as [Population | null, Population | null], - }, - }; - - // When - const result = selectGeographyAtPosition(state, 0); - - // Then - expect(result).toBeNull(); - }); - }); - }); -}); diff --git a/app/src/tests/unit/reducers/reportReducer.test.ts b/app/src/tests/unit/reducers/reportReducer.test.ts deleted file mode 100644 index e40db9fe..00000000 --- a/app/src/tests/unit/reducers/reportReducer.test.ts +++ /dev/null @@ -1,922 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import reportReducer, { - addSimulationId, - clearReport, - initializeReport, - markReportAsComplete, - markReportAsError, - removeSimulationId, - selectActiveSimulationPosition, - selectMode, - setActiveSimulationPosition, - setMode, - updateApiVersion, - updateCountryId, - updateLabel, - updateReportId, - updateReportOutput, - updateReportStatus, - updateTimestamps, -} from '@/reducers/reportReducer'; -import { - createMockReportState, - EXPECTED_INITIAL_STATE, - expectOutput, - expectReportId, - expectSimulationIds, - expectStateToEqual, - expectStatus, - expectTimestampsUpdated, - MOCK_COMPLETE_REPORT, - MOCK_ERROR_REPORT, - MOCK_PENDING_REPORT, - MOCK_REPORT_OUTPUT, - MOCK_REPORT_OUTPUT_ALTERNATIVE, - TEST_REPORT_ID_1, - TEST_REPORT_ID_2, - TEST_SIMULATION_ID_1, - TEST_SIMULATION_ID_2, - TEST_SIMULATION_ID_3, -} from '@/tests/fixtures/reducers/reportReducerMocks'; - -describe('reportReducer', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Mock Date to ensure consistent timestamps in tests - vi.useFakeTimers(); - vi.setSystemTime(new Date('2024-01-15T10:00:00.000Z')); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('initial state', () => { - test('given no action then returns initial state', () => { - // Given - const action = { type: 'unknown/action' }; - - // When - const state = reportReducer(undefined, action); - - // Then - expectStateToEqual(state, EXPECTED_INITIAL_STATE); - }); - }); - - describe('addSimulationId action', () => { - test('given new simulation id then adds to list', () => { - // Given - const initialState = createMockReportState(); - vi.advanceTimersByTime(1000); // Advance time to ensure different timestamp - const action = addSimulationId(TEST_SIMULATION_ID_2); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2]); - expectTimestampsUpdated(state, initialState); - }); - - test('given duplicate simulation id then does not add to list', () => { - // Given - const initialState = createMockReportState({ - simulationIds: [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2], - }); - const action = addSimulationId(TEST_SIMULATION_ID_1); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2]); - expect(state.updatedAt).toBe(initialState.updatedAt); - }); - - test('given empty simulationIds array then adds first id', () => { - // Given - const initialState = createMockReportState({ simulationIds: [] }); - vi.advanceTimersByTime(1000); - const action = addSimulationId(TEST_SIMULATION_ID_1); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1]); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('removeSimulationId action', () => { - test('given existing simulation id then removes from list', () => { - // Given - const initialState = createMockReportState({ - simulationIds: [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2, TEST_SIMULATION_ID_3], - }); - vi.advanceTimersByTime(1000); - const action = removeSimulationId(TEST_SIMULATION_ID_2); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_3]); - expectTimestampsUpdated(state, initialState); - }); - - test('given non-existent simulation id then keeps list unchanged', () => { - // Given - const initialState = createMockReportState({ - simulationIds: [TEST_SIMULATION_ID_1], - }); - vi.advanceTimersByTime(1000); - const action = removeSimulationId(TEST_SIMULATION_ID_2); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1]); - expectTimestampsUpdated(state, initialState); - }); - - test('given last simulation id then results in empty list', () => { - // Given - const initialState = createMockReportState({ - simulationIds: [TEST_SIMULATION_ID_1], - }); - vi.advanceTimersByTime(1000); - const action = removeSimulationId(TEST_SIMULATION_ID_1); - - // When - const state = reportReducer(initialState, action); - - // Then - expectSimulationIds(state, []); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('updateLabel action', () => { - test('given new label then updates label', () => { - // Given - const initialState = createMockReportState({ label: null }); - vi.advanceTimersByTime(1000); - const action = updateLabel('My Custom Report'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.label).toBe('My Custom Report'); - expectTimestampsUpdated(state, initialState); - }); - - test('given null label then sets to null', () => { - // Given - const initialState = createMockReportState({ label: 'Old Label' }); - vi.advanceTimersByTime(1000); - const action = updateLabel(null); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.label).toBe(null); - expectTimestampsUpdated(state, initialState); - }); - - test('given empty string then updates to empty string', () => { - // Given - const initialState = createMockReportState({ label: 'Old Label' }); - vi.advanceTimersByTime(1000); - const action = updateLabel(''); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.label).toBe(''); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('clearReport action', () => { - test('given populated report then resets to initial state and sets country from thunk', () => { - // Given - const initialState = { - ...MOCK_COMPLETE_REPORT, - activeSimulationPosition: 1 as 0 | 1, - mode: 'report' as 'standalone' | 'report', - }; - vi.advanceTimersByTime(1000); - const action = clearReport.fulfilled('uk', '', 'uk'); - - // When - const state = reportReducer(initialState, action); - - // Then - expectReportId(state, ''); - expect(state.label).toBe(null); - expectSimulationIds(state, []); - expectStatus(state, 'pending'); - expectOutput(state, null); - expect(state.countryId).toBe('uk'); // Set from thunk payload - expect(state.apiVersion).toBe('v1'); // Preserved - expect(state.createdAt).not.toBe('2024-01-15T10:00:00.000Z'); // Reset - expect(state.activeSimulationPosition).toBe(0); - expect(state.mode).toBe('standalone'); - }); - - test('given error report then resets all fields and sets country from thunk', () => { - // Given - const initialState = { - ...MOCK_ERROR_REPORT, - activeSimulationPosition: 1 as 0 | 1, - mode: 'report' as 'standalone' | 'report', - }; - const action = clearReport.fulfilled('us', '', 'us'); - - // When - const state = reportReducer(initialState, action); - - // Then - expectReportId(state, ''); - expectSimulationIds(state, []); - expectStatus(state, 'pending'); - expectOutput(state, null); - expect(state.countryId).toBe('us'); // Set from thunk payload - expect(state.apiVersion).toBe('v2'); // Preserved - expect(state.activeSimulationPosition).toBe(0); - expect(state.mode).toBe('standalone'); - }); - }); - - describe('updateReportId action', () => { - test('given new report id then updates id', () => { - // Given - const initialState = createMockReportState(); - vi.advanceTimersByTime(1000); - const action = updateReportId(TEST_REPORT_ID_2); - - // When - const state = reportReducer(initialState, action); - - // Then - expectReportId(state, TEST_REPORT_ID_2); - expectTimestampsUpdated(state, initialState); - }); - - test('given empty report id then sets empty string', () => { - // Given - const initialState = createMockReportState({ reportId: TEST_REPORT_ID_1 }); - vi.advanceTimersByTime(1000); - const action = updateReportId(''); - - // When - const state = reportReducer(initialState, action); - - // Then - expectReportId(state, ''); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('updateReportStatus action', () => { - test('given pending status then updates to pending', () => { - // Given - const initialState = createMockReportState({ status: 'complete' }); - vi.advanceTimersByTime(1000); - const action = updateReportStatus('pending'); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'pending'); - expectTimestampsUpdated(state, initialState); - }); - - test('given complete status then updates to complete', () => { - // Given - const initialState = MOCK_PENDING_REPORT; - vi.advanceTimersByTime(1000); - const action = updateReportStatus('complete'); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'complete'); - expectTimestampsUpdated(state, initialState); - }); - - test('given error status then updates to error', () => { - // Given - const initialState = MOCK_PENDING_REPORT; - vi.advanceTimersByTime(1000); - const action = updateReportStatus('error'); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'error'); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('updateReportOutput action', () => { - test('given report output then sets output', () => { - // Given - const initialState = MOCK_PENDING_REPORT; - vi.advanceTimersByTime(1000); - const action = updateReportOutput(MOCK_REPORT_OUTPUT); - - // When - const state = reportReducer(initialState, action); - - // Then - expectOutput(state, MOCK_REPORT_OUTPUT); - expectTimestampsUpdated(state, initialState); - }); - - test('given null output then clears output', () => { - // Given - const initialState = MOCK_COMPLETE_REPORT; - vi.advanceTimersByTime(1000); - const action = updateReportOutput(null); - - // When - const state = reportReducer(initialState, action); - - // Then - expectOutput(state, null); - expectTimestampsUpdated(state, initialState); - }); - - test('given different output then replaces existing output', () => { - // Given - const initialState = createMockReportState({ output: MOCK_REPORT_OUTPUT }); - vi.advanceTimersByTime(1000); - const action = updateReportOutput(MOCK_REPORT_OUTPUT_ALTERNATIVE); - - // When - const state = reportReducer(initialState, action); - - // Then - expectOutput(state, MOCK_REPORT_OUTPUT_ALTERNATIVE); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('markReportAsComplete action', () => { - test('given pending report then marks as complete', () => { - // Given - const initialState = MOCK_PENDING_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsComplete(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'complete'); - expectTimestampsUpdated(state, initialState); - }); - - test('given error report then marks as complete', () => { - // Given - const initialState = MOCK_ERROR_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsComplete(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'complete'); - expectTimestampsUpdated(state, initialState); - }); - - test('given already complete report then remains complete', () => { - // Given - const initialState = MOCK_COMPLETE_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsComplete(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'complete'); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('markReportAsError action', () => { - test('given pending report then marks as error', () => { - // Given - const initialState = MOCK_PENDING_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsError(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'error'); - expectTimestampsUpdated(state, initialState); - }); - - test('given complete report then marks as error', () => { - // Given - const initialState = MOCK_COMPLETE_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsError(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'error'); - expectTimestampsUpdated(state, initialState); - }); - - test('given already error report then remains error', () => { - // Given - const initialState = MOCK_ERROR_REPORT; - vi.advanceTimersByTime(1000); - const action = markReportAsError(); - - // When - const state = reportReducer(initialState, action); - - // Then - expectStatus(state, 'error'); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('updateApiVersion action', () => { - test('given new api version then updates version', () => { - // Given - const initialState = createMockReportState(); - const action = updateApiVersion('v2'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.apiVersion).toBe('v2'); - }); - - test('given null api version then sets to null', () => { - // Given - const initialState = createMockReportState({ apiVersion: 'v1' }); - const action = updateApiVersion(null); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.apiVersion).toBeNull(); - }); - }); - - describe('updateCountryId action', () => { - test('given new country id then updates country', () => { - // Given - const initialState = createMockReportState({ countryId: 'us' }); - const action = updateCountryId('uk'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.countryId).toBe('uk'); - }); - - test('given different country id then updates country', () => { - // Given - const initialState = createMockReportState({ countryId: 'uk' }); - const action = updateCountryId('ca'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.countryId).toBe('ca'); - }); - }); - - describe('updateTimestamps action', () => { - test('given createdAt only then updates createdAt', () => { - // Given - const initialState = createMockReportState(); - const newCreatedAt = '2024-02-01T12:00:00.000Z'; - const action = updateTimestamps({ createdAt: newCreatedAt }); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.createdAt).toBe(newCreatedAt); - expect(state.updatedAt).toBe(initialState.updatedAt); - }); - - test('given updatedAt only then updates updatedAt', () => { - // Given - const initialState = createMockReportState(); - const newUpdatedAt = '2024-02-01T14:00:00.000Z'; - const action = updateTimestamps({ updatedAt: newUpdatedAt }); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.createdAt).toBe(initialState.createdAt); - expect(state.updatedAt).toBe(newUpdatedAt); - }); - - test('given both timestamps then updates both', () => { - // Given - const initialState = createMockReportState(); - const newCreatedAt = '2024-02-01T12:00:00.000Z'; - const newUpdatedAt = '2024-02-01T14:00:00.000Z'; - const action = updateTimestamps({ - createdAt: newCreatedAt, - updatedAt: newUpdatedAt, - }); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.createdAt).toBe(newCreatedAt); - expect(state.updatedAt).toBe(newUpdatedAt); - }); - - test('given empty object then keeps timestamps unchanged', () => { - // Given - const initialState = createMockReportState(); - const action = updateTimestamps({}); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.createdAt).toBe(initialState.createdAt); - expect(state.updatedAt).toBe(initialState.updatedAt); - }); - }); - - describe('state transitions', () => { - test('given sequence of actions then maintains correct state', () => { - // Given - let state: any = EXPECTED_INITIAL_STATE; - - // When & Then - Update report ID - state = reportReducer(state as any, updateReportId(TEST_REPORT_ID_1)); - expectReportId(state, TEST_REPORT_ID_1); - - // When & Then - Add simulation IDs - state = reportReducer(state as any, addSimulationId(TEST_SIMULATION_ID_1)); - state = reportReducer(state as any, addSimulationId(TEST_SIMULATION_ID_2)); - expectSimulationIds(state, [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2]); - - // When & Then - Update output - state = reportReducer(state as any, updateReportOutput(MOCK_REPORT_OUTPUT)); - expectOutput(state, MOCK_REPORT_OUTPUT); - - // When & Then - Mark as complete - state = reportReducer(state as any, markReportAsComplete()); - expectStatus(state, 'complete'); - - // When & Then - Remove a simulation - state = reportReducer(state as any, removeSimulationId(TEST_SIMULATION_ID_1)); - expectSimulationIds(state, [TEST_SIMULATION_ID_2]); - - // When & Then - Mark as error - state = reportReducer(state as any, markReportAsError()); - expectStatus(state, 'error'); - - // When & Then - Clear report - state = reportReducer(state as any, clearReport.fulfilled('us', '', 'us')); - expectReportId(state, ''); - expectSimulationIds(state, []); - expectStatus(state, 'pending'); - expectOutput(state, null); - }); - - test('given complex workflow then handles all state changes correctly', () => { - // Given - Start with empty report - let state = reportReducer(undefined, { type: 'init' }); - - // When - Setup new report - state = reportReducer(state, updateReportId(TEST_REPORT_ID_1)); - state = reportReducer(state, addSimulationId(TEST_SIMULATION_ID_1)); - state = reportReducer(state, addSimulationId(TEST_SIMULATION_ID_2)); - - // Then - Verify initial setup - expectReportId(state, TEST_REPORT_ID_1); - expectSimulationIds(state, [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2]); - expectStatus(state, 'pending'); - - // When - Complete report with output - state = reportReducer(state, updateReportOutput(MOCK_REPORT_OUTPUT)); - state = reportReducer(state, markReportAsComplete()); - - // Then - Verify completion - expectStatus(state, 'complete'); - expectOutput(state, MOCK_REPORT_OUTPUT); - - // When - Error occurs, need to retry - state = reportReducer(state, markReportAsError()); - state = reportReducer(state, updateReportOutput(null)); - - // Then - Verify error state - expectStatus(state, 'error'); - expectOutput(state, null); - - // When - Retry with new simulation - state = reportReducer(state, updateReportStatus('pending')); - state = reportReducer(state, addSimulationId(TEST_SIMULATION_ID_3)); - state = reportReducer(state, updateReportOutput(MOCK_REPORT_OUTPUT_ALTERNATIVE)); - state = reportReducer(state, markReportAsComplete()); - - // Then - Verify successful retry - expectStatus(state, 'complete'); - expectOutput(state, MOCK_REPORT_OUTPUT_ALTERNATIVE); - expectSimulationIds(state, [ - TEST_SIMULATION_ID_1, - TEST_SIMULATION_ID_2, - TEST_SIMULATION_ID_3, - ]); - }); - }); - - describe('setActiveSimulationPosition action', () => { - test('given position 0 then sets to position 0', () => { - // Given - const initialState = createMockReportState(); - vi.advanceTimersByTime(1000); - const action = setActiveSimulationPosition(0); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.activeSimulationPosition).toBe(0); - expectTimestampsUpdated(state, initialState); - }); - - test('given position 1 then sets to position 1', () => { - // Given - const initialState = createMockReportState(); - vi.advanceTimersByTime(1000); - const action = setActiveSimulationPosition(1); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.activeSimulationPosition).toBe(1); - expectTimestampsUpdated(state, initialState); - }); - - test('given position 1 when already at 1 then remains at 1', () => { - // Given - const initialState = { - ...createMockReportState(), - activeSimulationPosition: 1 as 0 | 1, - }; - vi.advanceTimersByTime(1000); - const action = setActiveSimulationPosition(1); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.activeSimulationPosition).toBe(1); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('initializeReport action', () => { - test('given any state then initializes for report creation', () => { - // Given - a populated state with existing data - const initialState = { - ...MOCK_COMPLETE_REPORT, - activeSimulationPosition: 1 as 0 | 1, - mode: 'standalone' as 'standalone' | 'report', - }; - const action = initializeReport(); - - // When - const state = reportReducer(initialState, action); - - // Then - clears report data - expectReportId(state, ''); - expect(state.label).toBe(null); - expectSimulationIds(state, []); - expectStatus(state, 'pending'); - expectOutput(state, null); - - // Then - sets up for report mode - expect(state.mode).toBe('report'); - expect(state.activeSimulationPosition).toBe(0); - - // Then - preserves country and API version - expect(state.countryId).toBe('us'); - expect(state.apiVersion).toBe('v1'); - - // Then - updates timestamps - expect(state.createdAt).toBe('2024-01-15T10:00:00.000Z'); - expect(state.updatedAt).toBe('2024-01-15T10:00:00.000Z'); - }); - - test('given standalone mode then switches to report mode', () => { - // Given - const initialState = createMockReportState({ - mode: 'standalone' as 'standalone' | 'report', - activeSimulationPosition: 0 as 0 | 1, - }); - const action = initializeReport(); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.mode).toBe('report'); - expect(state.activeSimulationPosition).toBe(0); - }); - - test('given position 1 then resets to position 0', () => { - // Given - const initialState = createMockReportState({ - mode: 'report' as 'standalone' | 'report', - activeSimulationPosition: 1 as 0 | 1, - }); - const action = initializeReport(); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.activeSimulationPosition).toBe(0); - }); - - test('given different country and api version then preserves them', () => { - // Given - const initialState = createMockReportState({ - countryId: 'uk' as 'us' | 'uk' | 'ca' | 'ng' | 'il', - apiVersion: 'v2', - }); - const action = initializeReport(); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.countryId).toBe('uk'); - expect(state.apiVersion).toBe('v2'); - }); - }); - - describe('setMode action', () => { - test('given report mode then sets to report mode', () => { - // Given - const initialState = createMockReportState(); - vi.advanceTimersByTime(1000); - const action = setMode('report'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.mode).toBe('report'); - expectTimestampsUpdated(state, initialState); - }); - - test('given standalone mode then sets to standalone and resets position to 0', () => { - // Given - const initialState = { - ...createMockReportState(), - activeSimulationPosition: 1 as 0 | 1, - mode: 'report' as 'standalone' | 'report', - }; - vi.advanceTimersByTime(1000); - const action = setMode('standalone'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.mode).toBe('standalone'); - expect(state.activeSimulationPosition).toBe(0); - expectTimestampsUpdated(state, initialState); - }); - - test('given report mode when position is 1 then preserves position', () => { - // Given - const initialState = { - ...createMockReportState(), - activeSimulationPosition: 1 as 0 | 1, - mode: 'standalone' as 'standalone' | 'report', - }; - vi.advanceTimersByTime(1000); - const action = setMode('report'); - - // When - const state = reportReducer(initialState, action); - - // Then - expect(state.mode).toBe('report'); - expect(state.activeSimulationPosition).toBe(1); - expectTimestampsUpdated(state, initialState); - }); - }); - - describe('selectors', () => { - test('selectActiveSimulationPosition returns current position', () => { - // Given - const state = { - report: { - ...createMockReportState(), - activeSimulationPosition: 1 as 0 | 1, - }, - }; - - // When - const position = selectActiveSimulationPosition(state as any); - - // Then - expect(position).toBe(1); - }); - - test('selectMode returns current mode', () => { - // Given - const state = { - report: { - ...createMockReportState(), - mode: 'report' as 'standalone' | 'report', - }, - }; - - // When - const mode = selectMode(state as any); - - // Then - expect(mode).toBe('report'); - }); - }); - - describe('edge cases', () => { - test('given multiple duplicate simulation ids then only adds once', () => { - // Given - const initialState = createMockReportState({ simulationIds: [] }); - - // When - let state = reportReducer(initialState, addSimulationId(TEST_SIMULATION_ID_1)); - state = reportReducer(state, addSimulationId(TEST_SIMULATION_ID_1)); - state = reportReducer(state, addSimulationId(TEST_SIMULATION_ID_1)); - - // Then - expectSimulationIds(state, [TEST_SIMULATION_ID_1]); - }); - - test('given remove all simulations then results in empty array', () => { - // Given - const initialState = createMockReportState({ - simulationIds: [TEST_SIMULATION_ID_1, TEST_SIMULATION_ID_2, TEST_SIMULATION_ID_3], - }); - - // When - let state = reportReducer(initialState, removeSimulationId(TEST_SIMULATION_ID_1)); - state = reportReducer(state, removeSimulationId(TEST_SIMULATION_ID_2)); - state = reportReducer(state, removeSimulationId(TEST_SIMULATION_ID_3)); - - // Then - expectSimulationIds(state, []); - }); - - test('given status transitions then all combinations work', () => { - // Given - const statuses: Array<'pending' | 'complete' | 'error'> = ['pending', 'complete', 'error']; - - statuses.forEach((fromStatus) => { - statuses.forEach((toStatus) => { - // When - const initialState = createMockReportState({ status: fromStatus }); - const state = reportReducer(initialState, updateReportStatus(toStatus)); - - // Then - expectStatus(state, toStatus); - }); - }); - }); - }); -}); diff --git a/app/src/tests/unit/reducers/simulationsReducer.test.ts b/app/src/tests/unit/reducers/simulationsReducer.test.ts deleted file mode 100644 index d875cd38..00000000 --- a/app/src/tests/unit/reducers/simulationsReducer.test.ts +++ /dev/null @@ -1,743 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import simulationsReducer, { - clearAllSimulations, - clearSimulationAtPosition, - createSimulationAtPosition, - selectBothSimulations, - selectHasEmptySlot, - selectIsSlotEmpty, - selectSimulationAtPosition, - selectSimulationById, - swapSimulations, - updateSimulationAtPosition, -} from '@/reducers/simulationsReducer'; -import { - bothSimulationsWithoutIdState, - emptyInitialState, - mockSimulation1, - mockSimulation2, - mockSimulationWithoutId1, - mockSimulationWithoutId2, - multipleSimulationsState, - singleSimulationState, - TEST_HOUSEHOLD_ID, - TEST_LABEL_1, - TEST_LABEL_2, - TEST_LABEL_UPDATED, - TEST_PERMANENT_ID_1, - TEST_PERMANENT_ID_2, - TEST_POLICY_ID_1, - TEST_POLICY_ID_2, -} from '@/tests/fixtures/reducers/simulationsReducer'; -import { Simulation } from '@/types/ingredients/Simulation'; - -describe('simulationsReducer', () => { - describe('Creating Simulations at Position', () => { - test('given createSimulationAtPosition with position 0 then simulation created at first slot', () => { - // Given - const state = emptyInitialState; - - // When - const newState = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - }) - ); - - // Then - expect(newState.simulations[0]).toEqual({ - id: undefined, - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: null, - isCreated: false, - }); - expect(newState.simulations[1]).toBeNull(); - // activePosition removed - now in report reducer.toBe(0); - }); - - test('given createSimulationAtPosition with position 1 on empty state then only second slot filled', () => { - // Given - const state = emptyInitialState; - - // When - const newState = simulationsReducer( - state, - createSimulationAtPosition({ - position: 1, - simulation: { label: TEST_LABEL_2 }, - }) - ); - - // Then - expect(newState.simulations[0]).toBeNull(); - expect(newState.simulations[1]).toEqual({ - id: undefined, - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: TEST_LABEL_2, - isCreated: false, - }); - // activePosition removed - now in report reducer.toBe(1); - }); - - test('given createSimulationAtPosition with initial data then simulation contains that data', () => { - // Given - const state = emptyInitialState; - const initialData = { - label: TEST_LABEL_1, - policyId: TEST_POLICY_ID_1, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household' as const, - }; - - // When - const newState = simulationsReducer( - state, - createSimulationAtPosition({ - position: 1, - simulation: initialData, - }) - ); - - // Then - expect(newState.simulations[1]).toEqual({ - id: undefined, - populationId: TEST_HOUSEHOLD_ID, - policyId: TEST_POLICY_ID_1, - populationType: 'household', - label: TEST_LABEL_1, - isCreated: false, - }); - // activePosition removed - now in report reducer.toBe(1); - }); - - test('given createSimulationAtPosition when slot occupied then preserves existing simulation', () => { - // Given - const state = multipleSimulationsState; - const newSimulation = { - label: TEST_LABEL_UPDATED, - }; - - // When - const newState = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: newSimulation, - }) - ); - - // Then - existing simulation should be preserved, not replaced - expect(newState.simulations[0]).toEqual(mockSimulation1); - expect(newState.simulations[0]?.label).toBe(TEST_LABEL_1); // Original label preserved - expect(newState.simulations[0]?.policyId).toBe(TEST_POLICY_ID_1); // Original policy preserved - expect(newState.simulations[1]).toEqual(mockSimulation2); - }); - - test('given createSimulationAtPosition with null value then creates new simulation', () => { - // Given - const state = { - simulations: [null, mockSimulation2] as [Simulation | null, Simulation | null], - }; - - // When - const newState = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: { label: TEST_LABEL_UPDATED }, - }) - ); - - // Then - new simulation should be created since position was null - expect(newState.simulations[0]).toEqual({ - id: undefined, - populationId: undefined, - policyId: undefined, - populationType: undefined, - label: TEST_LABEL_UPDATED, - isCreated: false, - }); - expect(newState.simulations[1]).toEqual(mockSimulation2); // Other position unchanged - }); - - test('given createSimulationAtPosition at both positions then both slots filled', () => { - // Given - let state = emptyInitialState; - - // When - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: { label: TEST_LABEL_1 }, - }) - ); - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 1, - simulation: { label: TEST_LABEL_2 }, - }) - ); - - // Then - expect(state.simulations[0]?.label).toBe(TEST_LABEL_1); - expect(state.simulations[1]?.label).toBe(TEST_LABEL_2); - // activePosition removed - now in report reducer.toBe(1); - }); - }); - - describe('Updating Simulations at Position', () => { - test('given updateSimulationAtPosition then updates specific fields', () => { - // Given - const state = bothSimulationsWithoutIdState; - - // When - const newState = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 0, - updates: { - id: TEST_PERMANENT_ID_1, - isCreated: true, - }, - }) - ); - - // Then - expect(newState.simulations[0]).toEqual({ - ...mockSimulationWithoutId1, - id: TEST_PERMANENT_ID_1, - isCreated: true, - }); - expect(newState.simulations[1]).toEqual(mockSimulationWithoutId2); - }); - - test('given updateSimulationAtPosition on empty slot then throws error', () => { - // Given - const state = emptyInitialState; - - // When/Then - expect(() => - simulationsReducer( - state, - updateSimulationAtPosition({ - position: 0, - updates: { label: TEST_LABEL_1 }, - }) - ) - ).toThrow('Cannot update simulation at position 0: no simulation exists at that position'); - }); - - test('given updateSimulationAtPosition on empty position 1 then throws error', () => { - // Given - const state = singleSimulationState; // Has sim at position 0, but not 1 - - // When/Then - expect(() => - simulationsReducer( - state, - updateSimulationAtPosition({ - position: 1, - updates: { label: TEST_LABEL_2 }, - }) - ) - ).toThrow('Cannot update simulation at position 1: no simulation exists at that position'); - }); - - test('given updateSimulationAtPosition with partial updates then merges with existing', () => { - // Given - const state = multipleSimulationsState; - - // When - const newState = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 1, - updates: { - label: TEST_LABEL_UPDATED, - policyId: TEST_POLICY_ID_1, - }, - }) - ); - - // Then - expect(newState.simulations[1]).toEqual({ - ...mockSimulation2, - label: TEST_LABEL_UPDATED, - policyId: TEST_POLICY_ID_1, - }); - }); - }); - - describe('Clearing Simulations at Position', () => { - test('given clearSimulationAtPosition then slot becomes null', () => { - // Given - const state = multipleSimulationsState; - - // When - const newState = simulationsReducer(state, clearSimulationAtPosition(0)); - - // Then - expect(newState.simulations[0]).toBeNull(); - expect(newState.simulations[1]).toEqual(mockSimulation2); - }); - - test('given clearSimulationAtPosition of active then active position cleared', () => { - // Given - const state = { - ...multipleSimulationsState, - }; - - // When - const newState = simulationsReducer(state, clearSimulationAtPosition(1)); - - // Then - expect(newState.simulations[1]).toBeNull(); - // activePosition removed - now in report reducer.toBeNull(); - }); - - test('given clearSimulationAtPosition of non-active then active unchanged', () => { - // Given - const state = { - ...multipleSimulationsState, - }; - - // When - const newState = simulationsReducer(state, clearSimulationAtPosition(1)); - - // Then - expect(newState.simulations[1]).toBeNull(); - // activePosition removed - now in report reducer.toBe(0); - }); - }); - - // setActivePosition tests removed - activePosition now managed by report reducer - - describe('Swapping Simulations', () => { - test('given swapSimulations then positions are swapped', () => { - // Given - const state = multipleSimulationsState; - - // When - const newState = simulationsReducer(state, swapSimulations()); - - // Then - expect(newState.simulations[0]).toEqual(mockSimulation2); - expect(newState.simulations[1]).toEqual(mockSimulation1); - }); - - test('given swapSimulations with active position then active follows swap', () => { - // Given - const state = { - ...multipleSimulationsState, - }; - - // When - const newState = simulationsReducer(state, swapSimulations()); - - // Then - // activePosition removed - now in report reducer.toBe(1); - expect(newState.simulations[1]).toEqual(mockSimulation1); - }); - - test('given swapSimulations with one empty slot then swaps with null', () => { - // Given - const state = singleSimulationState; - - // When - const newState = simulationsReducer(state, swapSimulations()); - - // Then - expect(newState.simulations[0]).toBeNull(); - expect(newState.simulations[1]).toEqual(mockSimulationWithoutId1); - // activePosition removed - now in report reducer.toBe(1); - }); - - test('given swapSimulations with both empty then no change', () => { - // Given - const state = emptyInitialState; - - // When - const newState = simulationsReducer(state, swapSimulations()); - - // Then - expect(newState.simulations).toEqual([null, null]); - // activePosition removed - now in report reducer.toBeNull(); - }); - }); - - describe('Clearing All Simulations', () => { - test('given clearAllSimulations then resets to initial state', () => { - // Given - const state = multipleSimulationsState; - - // When - const newState = simulationsReducer(state, clearAllSimulations()); - - // Then - expect(newState).toEqual(emptyInitialState); - }); - - test('given clearAllSimulations from partial state then clears all', () => { - // Given - const state = singleSimulationState; - - // When - const newState = simulationsReducer(state, clearAllSimulations()); - - // Then - expect(newState.simulations).toEqual([null, null]); - // activePosition removed - now in report reducer.toBeNull(); - }); - }); - - describe('Selectors', () => { - describe('selectSimulationAtPosition', () => { - test('given simulation at position 0 then returns simulation', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationAtPosition(state, 0); - - // Then - expect(result).toEqual(mockSimulation1); - }); - - test('given simulation at position 1 then returns simulation', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationAtPosition(state, 1); - - // Then - expect(result).toEqual(mockSimulation2); - }); - - test('given empty position then returns null', () => { - // Given - const state = { simulations: singleSimulationState }; - - // When - const result = selectSimulationAtPosition(state, 1); - - // Then - expect(result).toBeNull(); - }); - }); - - describe('selectBothSimulations', () => { - test('given both simulations then returns tuple', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectBothSimulations(state); - - // Then - expect(result).toEqual([mockSimulation1, mockSimulation2]); - }); - - test('given one simulation then returns tuple with null', () => { - // Given - const state = { simulations: singleSimulationState }; - - // When - const result = selectBothSimulations(state); - - // Then - expect(result).toEqual([mockSimulationWithoutId1, null]); - }); - - test('given no simulations then returns tuple of nulls', () => { - // Given - const state = { simulations: emptyInitialState }; - - // When - const result = selectBothSimulations(state); - - // Then - expect(result).toEqual([null, null]); - }); - }); - - // selectActivePosition and selectActiveSimulation tests removed - activePosition now managed by report reducer - - describe('selectHasEmptySlot', () => { - test('given both slots filled then returns false', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectHasEmptySlot(state); - - // Then - expect(result).toBe(false); - }); - - test('given one slot empty then returns true', () => { - // Given - const state = { simulations: singleSimulationState }; - - // When - const result = selectHasEmptySlot(state); - - // Then - expect(result).toBe(true); - }); - - test('given both slots empty then returns true', () => { - // Given - const state = { simulations: emptyInitialState }; - - // When - const result = selectHasEmptySlot(state); - - // Then - expect(result).toBe(true); - }); - }); - - describe('selectIsSlotEmpty', () => { - test('given filled position 0 then returns false', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectIsSlotEmpty(state, 0); - - // Then - expect(result).toBe(false); - }); - - test('given empty position 1 then returns true', () => { - // Given - const state = { simulations: singleSimulationState }; - - // When - const result = selectIsSlotEmpty(state, 1); - - // Then - expect(result).toBe(true); - }); - - test('given both empty then returns true for both', () => { - // Given - const state = { simulations: emptyInitialState }; - - // When - const result0 = selectIsSlotEmpty(state, 0); - const result1 = selectIsSlotEmpty(state, 1); - - // Then - expect(result0).toBe(true); - expect(result1).toBe(true); - }); - }); - - describe('selectSimulationById', () => { - test('given ID matching first simulation then returns first', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationById(state, TEST_PERMANENT_ID_1); - - // Then - expect(result).toEqual(mockSimulation1); - }); - - test('given ID matching second simulation then returns second', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationById(state, TEST_PERMANENT_ID_2); - - // Then - expect(result).toEqual(mockSimulation2); - }); - - test('given non-existent ID then returns null', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationById(state, 'non-existent'); - - // Then - expect(result).toBeNull(); - }); - - test('given undefined ID then returns null', () => { - // Given - const state = { simulations: multipleSimulationsState }; - - // When - const result = selectSimulationById(state, undefined); - - // Then - expect(result).toBeNull(); - }); - - test('given simulations without IDs then returns null', () => { - // Given - const state = { simulations: bothSimulationsWithoutIdState }; - - // When - const result = selectSimulationById(state, TEST_PERMANENT_ID_1); - - // Then - expect(result).toBeNull(); - }); - }); - }); - - describe('Complex Scenarios', () => { - test('given series of position operations then maintains consistency', () => { - // Given - let state = emptyInitialState; - - // When - Create, update, swap, clear sequence - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: { label: 'First' }, - }) - ); - - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 1, - simulation: { label: 'Second' }, - }) - ); - - state = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 0, - updates: { id: 'id-1', isCreated: true }, - }) - ); - - state = simulationsReducer(state, swapSimulations()); - // After swap: position 0 has 'Second', position 1 has 'First' with id - // Active position was 1, now becomes 0 after swap - - state = simulationsReducer(state, clearSimulationAtPosition(1)); - // Clear position 1, active stays at 0 - - // Then - expect(state.simulations[0]).toMatchObject({ - label: 'Second', - }); - expect(state.simulations[1]).toBeNull(); - // activePosition removed - now in report reducer.toBe(0); // Active is still at position 0 - }); - - test('given API workflow then properly updates simulation', () => { - // Given - Start with draft simulation - let state = emptyInitialState; - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: { - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_1, - label: TEST_LABEL_1, - }, - }) - ); - - // When - API returns with ID - state = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 0, - updates: { - id: TEST_PERMANENT_ID_1, - isCreated: true, - }, - }) - ); - - // Then - expect(state.simulations[0]).toEqual({ - id: TEST_PERMANENT_ID_1, - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_1, - label: TEST_LABEL_1, - isCreated: true, - }); - }); - - test('given two simulations for report then maintains both independently', () => { - // Given - let state = emptyInitialState; - - // When - Set up two simulations for a report - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 0, - simulation: { - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_1, - label: 'Baseline', - }, - }) - ); - - state = simulationsReducer( - state, - createSimulationAtPosition({ - position: 1, - simulation: { - populationId: TEST_HOUSEHOLD_ID, - populationType: 'household', - policyId: TEST_POLICY_ID_2, - label: 'Reform', - }, - }) - ); - - // Update first to be created - state = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 0, - updates: { id: TEST_PERMANENT_ID_1, isCreated: true }, - }) - ); - - // Update second to be created - state = simulationsReducer( - state, - updateSimulationAtPosition({ - position: 1, - updates: { id: TEST_PERMANENT_ID_2, isCreated: true }, - }) - ); - - // Then - expect(state.simulations[0]?.id).toBe(TEST_PERMANENT_ID_1); - expect(state.simulations[0]?.label).toBe('Baseline'); - expect(state.simulations[1]?.id).toBe(TEST_PERMANENT_ID_2); - expect(state.simulations[1]?.label).toBe('Reform'); - }); - }); -}); diff --git a/app/src/tests/unit/types/flow.test.ts b/app/src/tests/unit/types/flow.test.ts deleted file mode 100644 index 8ec0e2bc..00000000 --- a/app/src/tests/unit/types/flow.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { - ALL_INVALID_KEYS, - ALL_VALID_COMPONENT_KEYS, - ALL_VALID_FLOW_KEYS, - ARRAY_OBJECT, - BOOLEAN_VALUE, - EMPTY_OBJECT, - FALSY_NAVIGATION_OBJECTS, - INVALID_KEYS, - NAVIGATION_OBJECT_MISSING_FLOW, - NAVIGATION_OBJECT_MISSING_RETURN, - NAVIGATION_OBJECT_WITH_EXTRA_PROPS, - NAVIGATION_OBJECT_WITH_NULL_FLOW, - NAVIGATION_OBJECT_WITH_NULL_RETURN, - NULL_OBJECT, - NUMBER_VALUE, - SPECIAL_VALUES, - STRING_TARGET, - TRUTHY_NAVIGATION_OBJECTS, - VALID_COMPONENT_KEYS, - VALID_FLOW_KEYS, - VALID_NAVIGATION_OBJECT, - VALID_NAVIGATION_OBJECT_ALT, -} from '@/tests/fixtures/types/flowMocks'; -import { isComponentKey, isFlowKey, isNavigationObject } from '@/types/flow'; - -// Mock the flowRegistry before any imports that use it -vi.mock('@/flows/registry', async () => { - const mocks = await import('@/tests/fixtures/types/flowMocks'); - return { - flowRegistry: mocks.mockFlowRegistry, - ComponentKey: {} as any, - FlowKey: {} as any, - }; -}); - -describe('flow type utilities', () => { - describe('isNavigationObject', () => { - describe('valid navigation objects', () => { - test('given object with flow and returnTo then returns true', () => { - const result = isNavigationObject(VALID_NAVIGATION_OBJECT); - - expect(result).toBe(true); - }); - - test('given alternative valid navigation object then returns true', () => { - const result = isNavigationObject(VALID_NAVIGATION_OBJECT_ALT); - - expect(result).toBe(true); - }); - - test('given navigation object with extra properties then returns true', () => { - const result = isNavigationObject(NAVIGATION_OBJECT_WITH_EXTRA_PROPS); - - expect(result).toBe(true); - }); - - test('given all valid navigation objects then all return true', () => { - TRUTHY_NAVIGATION_OBJECTS.forEach((obj) => { - expect(isNavigationObject(obj as any)).toBe(true); - }); - }); - }); - - describe('invalid navigation objects', () => { - test('given object missing flow property then returns false', () => { - const result = isNavigationObject(NAVIGATION_OBJECT_MISSING_FLOW as any); - - expect(result).toBe(false); - }); - - test('given object missing returnTo property then returns false', () => { - const result = isNavigationObject(NAVIGATION_OBJECT_MISSING_RETURN as any); - - expect(result).toBe(false); - }); - - test('given object with null flow then returns true', () => { - // The function only checks for property existence, not values - const result = isNavigationObject(NAVIGATION_OBJECT_WITH_NULL_FLOW as any); - - expect(result).toBe(true); - }); - - test('given object with null returnTo then returns true', () => { - // The function only checks for property existence, not values - const result = isNavigationObject(NAVIGATION_OBJECT_WITH_NULL_RETURN as any); - - expect(result).toBe(true); - }); - - test('given empty object then returns false', () => { - const result = isNavigationObject(EMPTY_OBJECT as any); - - expect(result).toBe(false); - }); - - test('given null then returns false', () => { - const result = isNavigationObject(NULL_OBJECT as any); - - expect(result).toBe(false); - }); - - test('given array then returns false', () => { - const result = isNavigationObject(ARRAY_OBJECT as any); - - expect(result).toBe(false); - }); - }); - - describe('non-object inputs', () => { - test('given string then returns false', () => { - const result = isNavigationObject(STRING_TARGET); - - expect(result).toBe(false); - }); - - test('given number then returns false', () => { - const result = isNavigationObject(NUMBER_VALUE as any); - - expect(result).toBe(false); - }); - - test('given boolean then returns false', () => { - const result = isNavigationObject(BOOLEAN_VALUE as any); - - expect(result).toBe(false); - }); - - test('given undefined then returns false', () => { - const result = isNavigationObject(SPECIAL_VALUES.UNDEFINED as any); - - expect(result).toBe(false); - }); - - test('given all falsy navigation objects then all return false', () => { - FALSY_NAVIGATION_OBJECTS.forEach((obj) => { - expect(isNavigationObject(obj as any)).toBe(false); - }); - }); - }); - }); - - describe('isFlowKey', () => { - describe('valid flow keys', () => { - test('given PolicyCreationFlow then returns true', () => { - const result = isFlowKey(VALID_FLOW_KEYS.POLICY_CREATION); - - expect(result).toBe(true); - }); - - test('given PolicyViewFlow then returns true', () => { - const result = isFlowKey(VALID_FLOW_KEYS.POLICY_VIEW); - - expect(result).toBe(true); - }); - - test('given PopulationCreationFlow then returns true', () => { - const result = isFlowKey(VALID_FLOW_KEYS.POPULATION_CREATION); - - expect(result).toBe(true); - }); - - test('given SimulationCreationFlow then returns true', () => { - const result = isFlowKey(VALID_FLOW_KEYS.SIMULATION_CREATION); - - expect(result).toBe(true); - }); - - test('given SimulationViewFlow then returns true', () => { - const result = isFlowKey(VALID_FLOW_KEYS.SIMULATION_VIEW); - - expect(result).toBe(true); - }); - - test('given all valid flow keys then all return true', () => { - ALL_VALID_FLOW_KEYS.forEach((key) => { - expect(isFlowKey(key)).toBe(true); - }); - }); - }); - - describe('invalid flow keys', () => { - test('given component key then returns false', () => { - const result = isFlowKey(VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME); - - expect(result).toBe(false); - }); - - test('given non-existent flow key then returns false', () => { - const result = isFlowKey(INVALID_KEYS.NON_EXISTENT_FLOW); - - expect(result).toBe(false); - }); - - test('given random string then returns false', () => { - const result = isFlowKey(INVALID_KEYS.RANDOM_STRING); - - expect(result).toBe(false); - }); - - test('given empty string then returns false', () => { - const result = isFlowKey(INVALID_KEYS.EMPTY_STRING); - - expect(result).toBe(false); - }); - - test('given special characters then returns false', () => { - const result = isFlowKey(INVALID_KEYS.SPECIAL_CHARS); - - expect(result).toBe(false); - }); - - test('given all invalid keys then all return false', () => { - ALL_INVALID_KEYS.forEach((key) => { - expect(isFlowKey(key)).toBe(false); - }); - }); - - test('given all component keys then all return false', () => { - ALL_VALID_COMPONENT_KEYS.forEach((key) => { - expect(isFlowKey(key)).toBe(false); - }); - }); - }); - - describe('edge cases', () => { - test('given return keyword then returns false', () => { - const result = isFlowKey(SPECIAL_VALUES.RETURN_KEYWORD); - - expect(result).toBe(false); - }); - - test('given numeric string then returns false', () => { - const result = isFlowKey(INVALID_KEYS.NUMBER_STRING); - - expect(result).toBe(false); - }); - }); - }); - - describe('isComponentKey', () => { - describe('valid component keys', () => { - test('given PolicyCreationFrame then returns true', () => { - const result = isComponentKey(VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME); - - expect(result).toBe(true); - }); - - test('given PolicyParameterSelectorFrame then returns true', () => { - const result = isComponentKey(VALID_COMPONENT_KEYS.POLICY_PARAMETER_SELECTOR); - - expect(result).toBe(true); - }); - - test('given HouseholdBuilderFrame then returns true', () => { - const result = isComponentKey(VALID_COMPONENT_KEYS.HOUSEHOLD_BUILDER); - - expect(result).toBe(true); - }); - - test('given all valid component keys then all return true', () => { - ALL_VALID_COMPONENT_KEYS.forEach((key) => { - expect(isComponentKey(key)).toBe(true); - }); - }); - - test('given non-existent component key then returns true', () => { - // isComponentKey returns true for anything that's not a flow key - const result = isComponentKey(INVALID_KEYS.NON_EXISTENT_COMPONENT); - - expect(result).toBe(true); - }); - - test('given random string then returns true', () => { - // isComponentKey returns true for anything that's not a flow key - const result = isComponentKey(INVALID_KEYS.RANDOM_STRING); - - expect(result).toBe(true); - }); - }); - - describe('invalid component keys (flow keys)', () => { - test('given PolicyCreationFlow then returns false', () => { - const result = isComponentKey(VALID_FLOW_KEYS.POLICY_CREATION); - - expect(result).toBe(false); - }); - - test('given PolicyViewFlow then returns false', () => { - const result = isComponentKey(VALID_FLOW_KEYS.POLICY_VIEW); - - expect(result).toBe(false); - }); - - test('given all flow keys then all return false', () => { - ALL_VALID_FLOW_KEYS.forEach((key) => { - expect(isComponentKey(key)).toBe(false); - }); - }); - }); - - describe('edge cases', () => { - test('given empty string then returns true', () => { - // Empty string is not a flow key, so it's considered a component key - const result = isComponentKey(INVALID_KEYS.EMPTY_STRING); - - expect(result).toBe(true); - }); - - test('given special characters then returns true', () => { - // Special chars are not a flow key, so considered a component key - const result = isComponentKey(INVALID_KEYS.SPECIAL_CHARS); - - expect(result).toBe(true); - }); - - test('given return keyword then returns true', () => { - // Return keyword is not a flow key, so considered a component key - const result = isComponentKey(SPECIAL_VALUES.RETURN_KEYWORD); - - expect(result).toBe(true); - }); - }); - - describe('relationship with isFlowKey', () => { - test('given any string then isComponentKey returns opposite of isFlowKey', () => { - const testStrings = [ - ...ALL_VALID_FLOW_KEYS, - ...ALL_VALID_COMPONENT_KEYS, - ...ALL_INVALID_KEYS, - SPECIAL_VALUES.RETURN_KEYWORD, - ]; - - testStrings.forEach((str) => { - const isFlow = isFlowKey(str); - const isComponent = isComponentKey(str); - - expect(isComponent).toBe(!isFlow); - }); - }); - }); - }); - - describe('type guard behavior', () => { - test('given navigation object type guard then narrows type correctly', () => { - const target: any = VALID_NAVIGATION_OBJECT; - - if (isNavigationObject(target)) { - // TypeScript should allow accessing flow and returnTo here - expect(target.flow).toBe(VALID_FLOW_KEYS.POLICY_CREATION); - expect(target.returnTo).toBe(VALID_COMPONENT_KEYS.POLICY_READ_VIEW); - } else { - // This branch should not be reached - expect(true).toBe(false); - } - }); - - test('given flow key type guard then narrows type correctly', () => { - const key: string = VALID_FLOW_KEYS.POLICY_CREATION; - - if (isFlowKey(key)) { - // TypeScript should treat key as FlowKey here - expect(key).toBe(VALID_FLOW_KEYS.POLICY_CREATION); - } else { - // This branch should not be reached - expect(true).toBe(false); - } - }); - - test('given component key type guard then narrows type correctly', () => { - const key: string = VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME; - - if (isComponentKey(key)) { - // TypeScript should treat key as ComponentKey here - expect(key).toBe(VALID_COMPONENT_KEYS.POLICY_CREATION_FRAME); - } else { - // This branch should not be reached - expect(true).toBe(false); - } - }); - }); -}); diff --git a/app/src/tests/unit/utils/populationCopy.test.ts b/app/src/tests/unit/utils/populationCopy.test.ts deleted file mode 100644 index bb874f44..00000000 --- a/app/src/tests/unit/utils/populationCopy.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - mockPopulationWithComplexHousehold, - mockPopulationWithGeography, - mockPopulationWithLabel, -} from '@/tests/fixtures/utils/populationCopyMocks'; -import { copyPopulationToPosition, deepCopyPopulation } from '@/utils/populationCopy'; - -describe('populationCopy', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('deepCopyPopulation', () => { - it('given population with household then creates new object references', () => { - // Given - const original = mockPopulationWithComplexHousehold(); - - // When - const copied = deepCopyPopulation(original); - - // Then - Verify top-level is a different object - expect(copied).not.toBe(original); - expect(copied.household).not.toBe(original.household); - }); - - it('given population with nested household data then deep copies all nested objects', () => { - // Given - const original = mockPopulationWithComplexHousehold(); - - // When - const copied = deepCopyPopulation(original); - - // Then - Verify nested objects are different references - expect(copied.household!.householdData).not.toBe(original.household!.householdData); - expect(copied.household!.householdData.people).not.toBe( - original.household!.householdData.people - ); - }); - - it('given population with household then modifying copy does not affect original', () => { - // Given - const original = mockPopulationWithComplexHousehold(); - const originalPersonName = original.household!.householdData.people.person1.name; - - // When - const copied = deepCopyPopulation(original); - copied.household!.householdData.people.person1.name = 'Modified Name'; - - // Then - Original should be unchanged - expect(original.household!.householdData.people.person1.name).toBe(originalPersonName); - expect(copied.household!.householdData.people.person1.name).toBe('Modified Name'); - }); - - it('given population with geography then creates new object references', () => { - // Given - const original = mockPopulationWithGeography(); - - // When - const copied = deepCopyPopulation(original); - - // Then - Verify top-level is a different object - expect(copied).not.toBe(original); - expect(copied.geography).not.toBe(original.geography); - }); - - it('given population with geography then modifying copy does not affect original', () => { - // Given - const original = mockPopulationWithGeography(); - const originalName = original.geography!.name; - - // When - const copied = deepCopyPopulation(original); - copied.geography!.name = 'Modified Geography'; - - // Then - Original should be unchanged - expect(original.geography!.name).toBe(originalName); - expect(copied.geography!.name).toBe('Modified Geography'); - }); - - it('given population with label then copies all properties correctly', () => { - // Given - const original = mockPopulationWithLabel('Test Label'); - - // When - const copied = deepCopyPopulation(original); - - // Then - expect(copied.label).toBe('Test Label'); - expect(copied.isCreated).toBe(true); - expect(copied.household).toBeNull(); - expect(copied.geography).toBeNull(); - }); - - it('given population with null household then handles null correctly', () => { - // Given - const original = mockPopulationWithLabel('Test'); - original.household = null; - - // When - const copied = deepCopyPopulation(original); - - // Then - expect(copied.household).toBeNull(); - }); - - it('given population with null geography then handles null correctly', () => { - // Given - const original = mockPopulationWithLabel('Test'); - original.geography = null; - - // When - const copied = deepCopyPopulation(original); - - // Then - expect(copied.geography).toBeNull(); - }); - }); - - describe('copyPopulationToPosition', () => { - it('given source population and target position then calls dispatch', () => { - // Given - const mockDispatch = vi.fn(); - const sourcePopulation = mockPopulationWithComplexHousehold(); - const targetPosition = 1; - - // When - copyPopulationToPosition(mockDispatch, sourcePopulation, targetPosition); - - // Then - Verify dispatch was called once - expect(mockDispatch).toHaveBeenCalledTimes(1); - }); - - it('given population with household then copies to position 0', () => { - // Given - const mockDispatch = vi.fn(); - const sourcePopulation = mockPopulationWithComplexHousehold(); - - // When - copyPopulationToPosition(mockDispatch, sourcePopulation, 0); - - // Then - Just verify dispatch was called (testing actual Redux action structure is brittle) - expect(mockDispatch).toHaveBeenCalledTimes(1); - }); - - it('given population with geography then copies to position 1', () => { - // Given - const mockDispatch = vi.fn(); - const sourcePopulation = mockPopulationWithGeography(); - - // When - copyPopulationToPosition(mockDispatch, sourcePopulation, 1); - - // Then - expect(mockDispatch).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/app/src/types/flow.ts b/app/src/types/flow.ts deleted file mode 100644 index 6d911a3c..00000000 --- a/app/src/types/flow.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ComponentKey, FlowKey, flowRegistry } from '../flows/registry'; - -// Navigation target can be a simple string or an object with flow and returnTo -export type NavigationTarget = - | string - | FlowKey - | { - flow: FlowKey; - returnTo: ComponentKey; - }; - -export interface EventList { - // TODO: Define events in a more structured way - [eventName: string]: NavigationTarget; -} - -export interface FlowFrame { - component: ComponentKey; - on: EventList; -} - -export interface Flow { - initialFrame: ComponentKey | FlowKey | null; - frames: Record; -} - -// Helper type to distinguish between component and flow references -export type FrameTarget = ComponentKey | FlowKey; - -export function isNavigationObject( - target: NavigationTarget -): target is { flow: FlowKey; returnTo: ComponentKey } { - return typeof target === 'object' && target !== null && 'flow' in target && 'returnTo' in target; -} - -export function isFlowKey(target: string): target is FlowKey { - return target in flowRegistry; -} - -export function isComponentKey(target: string): target is ComponentKey { - return !isFlowKey(target); -} - -// Define the props that all flow components receive -export interface FlowComponentProps { - onNavigate: (eventName: string) => void; - onReturn: () => void; - flowConfig: FlowFrame; - isInSubflow: boolean; - flowDepth: number; - parentFlowContext?: { - parentFrame: ComponentKey; - }; -} diff --git a/app/src/types/pathwayState/ReportStateProps.ts b/app/src/types/pathwayState/ReportStateProps.ts index 672a1cdb..df5d5d4c 100644 --- a/app/src/types/pathwayState/ReportStateProps.ts +++ b/app/src/types/pathwayState/ReportStateProps.ts @@ -15,6 +15,7 @@ import { SimulationStateProps } from './SimulationStateProps'; export interface ReportStateProps { id?: string; // Populated after API creation label: string | null; // Required field, can be null + year: string; // Tax/simulation year for the report countryId: (typeof countryIds)[number]; // Required - determines which API to use apiVersion: string | null; // API version for calculations status: 'pending' | 'complete' | 'error'; // Report generation status diff --git a/app/src/utils/pathwayCallbacks/reportCallbacks.ts b/app/src/utils/pathwayCallbacks/reportCallbacks.ts index 80030ec7..3421e992 100644 --- a/app/src/utils/pathwayCallbacks/reportCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/reportCallbacks.ts @@ -28,6 +28,13 @@ export function createReportCallbacks( setState((prev) => ({ ...prev, label })); }, [setState]); + /** + * Updates the report year + */ + const updateYear = useCallback((year: string) => { + setState((prev) => ({ ...prev, year })); + }, [setState]); + /** * Navigates to simulation selection for a specific simulation slot */ @@ -96,6 +103,7 @@ export function createReportCallbacks( return { updateLabel, + updateYear, navigateToSimulationSelection, handleSelectExistingSimulation, copyPopulationFromOtherSimulation, diff --git a/app/src/utils/pathwayState/initializeReportState.ts b/app/src/utils/pathwayState/initializeReportState.ts index 12028b0d..1451a0d4 100644 --- a/app/src/utils/pathwayState/initializeReportState.ts +++ b/app/src/utils/pathwayState/initializeReportState.ts @@ -1,3 +1,4 @@ +import { CURRENT_YEAR } from '@/constants'; import { ReportStateProps } from '@/types/pathwayState'; import { initializeSimulationState } from './initializeSimulationState'; @@ -16,6 +17,7 @@ export function initializeReportState(countryId: string): ReportStateProps { return { id: undefined, label: null, + year: CURRENT_YEAR, countryId: countryId as any, // Type assertion for countryIds type apiVersion: null, status: 'pending', diff --git a/app/src/utils/populationCopy.ts b/app/src/utils/populationCopy.ts deleted file mode 100644 index 36f16b1b..00000000 --- a/app/src/utils/populationCopy.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createPopulationAtPosition } from '@/reducers/populationReducer'; -import { AppDispatch } from '@/store'; -import { Geography } from '@/types/ingredients/Geography'; -import { Household } from '@/types/ingredients/Household'; -import { Population } from '@/types/ingredients/Population'; - -/** - * Deep copies a household object to avoid reference issues. - * The householdData property contains nested objects (people, families, etc.) - * that need to be cloned to prevent mutations. - * - * @param household - The household to copy - * @returns A deep copy of the household - */ -function deepCopyHousehold(household: Household): Household { - return { - id: household.id, - countryId: household.countryId, - // Use JSON serialization for deep nested structures - // This ensures people, families, taxUnits, etc. are all copied - householdData: JSON.parse(JSON.stringify(household.householdData)), - }; -} - -/** - * Deep copies a geography object. - * Geography is relatively flat, so we can copy fields explicitly. - * - * @param geography - The geography to copy - * @returns A deep copy of the geography - */ -function deepCopyGeography(geography: Geography): Geography { - return { - id: geography.id, - countryId: geography.countryId, - scope: geography.scope, - geographyId: geography.geographyId, - name: geography.name, - }; -} - -/** - * Deep copies a population object to avoid reference issues. - * This ensures that modifying the copied population doesn't affect the original. - * - * @param population - The population to copy - * @returns A deep copy of the population - */ -export function deepCopyPopulation(population: Population): Population { - return { - label: population.label, - isCreated: population.isCreated, - household: population.household ? deepCopyHousehold(population.household) : null, - geography: population.geography ? deepCopyGeography(population.geography) : null, - }; -} - -/** - * Copies a population from one source to a target position in the Redux store. - * This is a utility function that can be used outside of component context. - * - * The population is deep-copied to avoid reference issues, then dispatched - * to the specified position in the store. - * - * @param dispatch - The Redux dispatch function - * @param sourcePopulation - The population to copy - * @param targetPosition - The position (0 or 1) to copy the population to - */ -export function copyPopulationToPosition( - dispatch: AppDispatch, - sourcePopulation: Population, - targetPosition: 0 | 1 -): void { - const copiedPopulation = deepCopyPopulation(sourcePopulation); - - dispatch( - createPopulationAtPosition({ - position: targetPosition, - population: copiedPopulation, - }) - ); -} From 581876ce707924a59e20d33519fd7c1bb46a698c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 02:06:22 +0100 Subject: [PATCH 32/49] chore: Remove test buttons --- app/src/pages/Policies.page.tsx | 22 +++------------------- app/src/pages/Populations.page.tsx | 23 ++++------------------- app/src/pages/Reports.page.tsx | 23 ++++------------------- app/src/pages/Simulations.page.tsx | 20 ++++---------------- 4 files changed, 15 insertions(+), 73 deletions(-) diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index a2a5f709..90b45f7b 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Badge, Button, Group, Stack } from '@mantine/core'; +import { Stack } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; @@ -31,13 +31,6 @@ export default function PoliciesPage() { navigate(`/${countryId}/policies/create`); }; - const handleBuildPolicyV2 = () => { - console.log('[PoliciesPage] ========== NEW POLICY V2 CLICKED =========='); - const targetPath = `/${countryId}/policies/create-v2`; - console.log('[PoliciesPage] Navigating to:', targetPath); - navigate(targetPath); - }; - const handleSelectionChange = (recordId: string, selected: boolean) => { setSelectedIds((prev) => selected ? [...prev, recordId] : prev.filter((id) => id !== recordId) @@ -132,18 +125,9 @@ export default function PoliciesPage() { })) || []; return ( + <> + - {/* TEMPORARY: Test button for new PathwayWrapper system */} - - - NEW - - - - - <> { - console.log('[PopulationsPage] ========== NEW POPULATION V2 CLICKED =========='); - const targetPath = `/${countryId}/households/create-v2`; - console.log('[PopulationsPage] Navigating to:', targetPath); - navigate(targetPath); - }; - const handleSelectionChange = (recordId: string, selected: boolean) => { setSelectedIds((prev) => selected ? [...prev, recordId] : prev.filter((id) => id !== recordId) @@ -319,18 +312,10 @@ export default function PopulationsPage() { const transformedData: IngredientRecord[] = [...householdRecords, ...geographicRecords]; return ( + <> + - {/* TEMPORARY: Test button for new PathwayWrapper system */} - - - NEW - - - - - <> + { - console.log('[ReportsPage] ========== NEW REPORT V2 CLICKED =========='); - const targetPath = `/${countryId}/reports/create-v2`; - console.log('[ReportsPage] Navigating to:', targetPath); - navigate(targetPath); - }; - const handleSelectionChange = (recordId: string, selected: boolean) => { setSelectedIds((prev) => selected ? [...prev, recordId] : prev.filter((id) => id !== recordId) @@ -207,18 +200,10 @@ export default function ReportsPage() { ); return ( + <> + - {/* TEMPORARY: Test button for new PathwayWrapper system */} - - - NEW - - - - - <> + { - navigate(`/${countryId}/simulations/create-v2`); - }; - const handleSelectionChange = (recordId: string, selected: boolean) => { setSelectedIds((prev) => selected ? [...prev, recordId] : prev.filter((id) => id !== recordId) @@ -139,18 +135,10 @@ export default function SimulationsPage() { })) || []; return ( + <> + - {/* TEMPORARY: Testing button for v2 pathway */} - - - <> + Date: Thu, 20 Nov 2025 02:13:57 +0100 Subject: [PATCH 33/49] fix: Migrate naming --- app/src/Router.tsx | 16 +- app/src/components/AppLayout.tsx | 17 -- .../common/{FlowView.tsx => PathwayView.tsx} | 8 +- .../pathways/report/views/ReportLabelView.tsx | 4 +- .../pathways/report/views/ReportSetupView.tsx | 4 +- .../views/ReportSimulationExistingView.tsx | 10 +- .../views/ReportSimulationSelectionView.tsx | 6 +- .../views/policy/PolicyExistingView.tsx | 12 +- .../report/views/policy/PolicyLabelView.tsx | 4 +- .../population/GeographicConfirmationView.tsx | 4 +- .../views/population/HouseholdBuilderView.tsx | 124 +++++++--- .../population/PopulationExistingView.tsx | 12 +- .../views/population/PopulationLabelView.tsx | 4 +- .../views/population/PopulationScopeView.tsx | 4 +- .../views/simulation/SimulationLabelView.tsx | 4 +- .../simulation/SimulationPolicySetupView.tsx | 4 +- .../SimulationPopulationSetupView.tsx | 4 +- .../views/simulation/SimulationSetupView.tsx | 4 +- .../components/FlowContainerMocks.tsx | 178 --------------- ...FlowViewMocks.tsx => PathwayViewMocks.tsx} | 64 +++--- ...FlowView.test.tsx => PathwayView.test.tsx} | 216 +++++++++--------- 21 files changed, 287 insertions(+), 416 deletions(-) delete mode 100644 app/src/components/AppLayout.tsx rename app/src/components/common/{FlowView.tsx => PathwayView.tsx} (97%) delete mode 100644 app/src/tests/fixtures/components/FlowContainerMocks.tsx rename app/src/tests/fixtures/components/common/{FlowViewMocks.tsx => PathwayViewMocks.tsx} (74%) rename app/src/tests/unit/components/common/{FlowView.test.tsx => PathwayView.test.tsx} (51%) diff --git a/app/src/Router.tsx b/app/src/Router.tsx index f90f02b9..54682117 100644 --- a/app/src/Router.tsx +++ b/app/src/Router.tsx @@ -1,6 +1,6 @@ -import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; -import AppLayout from './components/AppLayout'; +import { createBrowserRouter, Outlet, Navigate, RouterProvider } from 'react-router-dom'; import PathwayLayout from './components/PathwayLayout'; +import StandardLayout from './components/StandardLayout'; import StaticLayout from './components/StaticLayout'; import AppPage from './pages/AppPage'; import BlogPage from './pages/Blog.page'; @@ -43,7 +43,11 @@ const router = createBrowserRouter( element: , children: [ { - element: , + element: ( + + + + ), children: [ { path: 'report-output/:reportId/:subpage?/:view?', @@ -59,7 +63,11 @@ const router = createBrowserRouter( children: [ // Regular routes with standard layout { - element: , + element: ( + + + + ), children: [ { path: 'dashboard', diff --git a/app/src/components/AppLayout.tsx b/app/src/components/AppLayout.tsx deleted file mode 100644 index 20c089d3..00000000 --- a/app/src/components/AppLayout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Outlet } from 'react-router-dom'; -import StandardLayout from './StandardLayout'; - -/** - * AppLayout - Standard application layout for most routes - * - * Wraps child routes with StandardLayout (AppShell with header/navbar). - * Also handles navigation logging. - */ -export default function AppLayout() { - // Wrap all child routes with standard layout - return ( - - - - ); -} diff --git a/app/src/components/common/FlowView.tsx b/app/src/components/common/PathwayView.tsx similarity index 97% rename from app/src/components/common/FlowView.tsx rename to app/src/components/common/PathwayView.tsx index 25406c11..e6a5bc21 100644 --- a/app/src/components/common/FlowView.tsx +++ b/app/src/components/common/PathwayView.tsx @@ -10,7 +10,7 @@ import { } from '@/components/flowView'; import MultiButtonFooter, { ButtonConfig } from './MultiButtonFooter'; -interface FlowViewProps { +interface PathwayViewProps { title: string; subtitle?: string; variant?: 'setupConditions' | 'buttonPanel' | 'cardList'; @@ -56,7 +56,7 @@ interface FlowViewProps { buttonPreset?: 'cancel-only' | 'cancel-primary' | 'none'; } -export default function FlowView({ +export default function PathwayView({ title, subtitle, variant, @@ -71,7 +71,7 @@ export default function FlowView({ cardListItems, itemsPerPage = 5, showPagination = true, -}: FlowViewProps) { +}: PathwayViewProps) { // Pagination state for cardList variant const [currentPage, setCurrentPage] = useState(1); @@ -186,4 +186,4 @@ export default function FlowView({ return {containerContent}; } -export type { FlowViewProps, ButtonConfig }; +export type { PathwayViewProps, ButtonConfig }; diff --git a/app/src/pathways/report/views/ReportLabelView.tsx b/app/src/pathways/report/views/ReportLabelView.tsx index 1db35096..f1c930e0 100644 --- a/app/src/pathways/report/views/ReportLabelView.tsx +++ b/app/src/pathways/report/views/ReportLabelView.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; import { Select, TextInput } from '@mantine/core'; -import FlowView from '@/components/common/FlowView'; +import PathwayView from '@/components/common/PathwayView'; import { CURRENT_YEAR } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { getTaxYears } from '@/libs/metadataUtils'; @@ -71,7 +71,7 @@ export default function ReportLabelView({ label, year, onUpdateLabel, onUpdateYe }; return ( - Loading simulations...} buttonPreset="none" @@ -79,7 +79,7 @@ export default function ReportSimulationExistingView({ if (isError) { return ( - Error: {(error as Error)?.message || 'Something went wrong.'}} buttonPreset="none" @@ -89,7 +89,7 @@ export default function ReportSimulationExistingView({ if (userSimulations.length === 0) { return ( - No simulations available. Please create a new simulation.} primaryAction={{ @@ -189,7 +189,7 @@ export default function ReportSimulationExistingView({ }; return ( - @@ -110,7 +110,7 @@ export default function ReportSimulationSelectionView({ // For reform simulation, just show the standard button panel return ( - Loading policies...} buttonPreset="none" @@ -132,7 +132,7 @@ export default function PolicyExistingView({ onSelectPolicy, onBack, onCancel }: if (isError) { return ( - Error: {(error as Error)?.message || 'Something went wrong.'} @@ -144,7 +144,7 @@ export default function PolicyExistingView({ onSelectPolicy, onBack, onCancel }: if (userPolicies.length === 0) { return ( - No policies available. Please create a new policy.} primaryAction={{ @@ -170,7 +170,7 @@ export default function PolicyExistingView({ onSelectPolicy, onBack, onCancel }: ); console.log('[PolicyExistingView] Filtered policies:', filteredPolicies); - // Build card list items from ALL filtered policies (pagination handled by FlowView) + // Build card list items from ALL filtered policies (pagination handled by PathwayView) const policyCardItems = filteredPolicies.map((association) => { let title = ''; let subtitle = ''; @@ -199,7 +199,7 @@ export default function PolicyExistingView({ onSelectPolicy, onBack, onCancel }: }; return ( - void; - onBack?: () => void; -} - -export default function HouseholdBuilderView({ - population, - countryId, - onSubmitSuccess, - onBack, -}: HouseholdBuilderViewProps) { - const { createHousehold, isPending } = useCreateHousehold(population?.label || ''); +export default function HouseholdBuilderFrame({ + onNavigate, + onReturn, + isInSubflow, +}: FlowComponentProps) { + const dispatch = useDispatch(); + const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state)); + const populationState = useSelector((state: RootState) => selectActivePopulation(state)); + const { createHousehold, isPending } = useCreateHousehold(populationState?.label || ''); + const { resetIngredient } = useIngredientReset(); + const countryId = useCurrentCountry(); const reportYear = useReportYear(); // Get metadata-driven options const basicInputFields = useSelector(getBasicInputFields); const variables = useSelector((state: RootState) => state.metadata.variables); + const { loading, error } = useSelector((state: RootState) => state.metadata); // Error boundary: Show error if no report year available @@ -73,7 +74,7 @@ export default function HouseholdBuilderView({ } buttonPreset="cancel-only" cancelAction={{ - onClick: onBack, + onClick: onReturn, }} /> ); @@ -81,8 +82,8 @@ export default function HouseholdBuilderView({ // Initialize with empty household if none exists const [household, setLocalHousehold] = useState(() => { - if (population?.household) { - return population.household; + if (populationState?.household) { + return populationState.household; } const builder = new HouseholdBuilder(countryId as any, reportYear); return builder.build(); @@ -99,6 +100,19 @@ export default function HouseholdBuilderView({ const [maritalStatus, setMaritalStatus] = useState<'single' | 'married'>('single'); const [numChildren, setNumChildren] = useState(0); + // Initialize household on mount if not exists + useEffect(() => { + if (!populationState?.household) { + dispatch( + initializeHouseholdAtPosition({ + position: currentPosition, + countryId, + year: reportYear, + }) + ); + } + }, [populationState?.household, countryId, dispatch, currentPosition, reportYear]); + // Build household based on form values useEffect(() => { const builder = new HouseholdBuilder(countryId as any, reportYear); @@ -285,7 +299,7 @@ export default function HouseholdBuilderView({ // Show error state if metadata failed to load if (error) { return ( - @@ -314,6 +328,14 @@ export default function HouseholdBuilderView({ }, shallowEqual); const handleSubmit = async () => { + // Sync final household to Redux before submit + dispatch( + setHouseholdAtPosition({ + position: currentPosition, + household, + }) + ); + // Validate household const validation = HouseholdValidation.isReadyForSimulation(household, reportYear); if (!validation.isValid) { @@ -331,7 +353,36 @@ export default function HouseholdBuilderView({ console.log('Household created successfully:', result); const householdId = result.result.household_id; - onSubmitSuccess(householdId, household); + const label = populationState?.label || ''; + + // Update population state with the created household ID + dispatch( + updatePopulationIdAtPosition({ + position: currentPosition, + id: householdId, + }) + ); + dispatch( + updatePopulationAtPosition({ + position: currentPosition, + updates: { + label, + isCreated: true, + }, + }) + ); + + // If standalone flow, reset + if (!isInSubflow) { + resetIngredient('population'); + } + + // Navigate + if (onReturn) { + onReturn(); + } else { + onNavigate('next'); + } } catch (err) { console.error('Failed to create household:', err); } @@ -425,8 +476,12 @@ export default function HouseholdBuilderView({ /> handleAdultChange('you', 'employment_income', val || 0)} min={0} @@ -543,7 +598,7 @@ export default function HouseholdBuilderView({ const canProceed = validation.isValid; const primaryAction = { - label: 'Create household ', + label: 'Create household', onClick: handleSubmit, isLoading: isPending, isDisabled: !canProceed, @@ -596,11 +651,14 @@ export default function HouseholdBuilderView({ ); return ( - ); } diff --git a/app/src/pathways/report/views/population/PopulationExistingView.tsx b/app/src/pathways/report/views/population/PopulationExistingView.tsx index d5caf9f3..e321f437 100644 --- a/app/src/pathways/report/views/population/PopulationExistingView.tsx +++ b/app/src/pathways/report/views/population/PopulationExistingView.tsx @@ -8,7 +8,7 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; import { Text } from '@mantine/core'; import { HouseholdAdapter } from '@/adapters'; -import FlowView from '@/components/common/FlowView'; +import PathwayView from '@/components/common/PathwayView'; import { MOCK_USER_ID } from '@/constants'; import { isGeographicMetadataWithAssociation, @@ -187,7 +187,7 @@ export default function PopulationExistingView({ if (isLoading) { return ( - Loading households...} buttonPreset="none" @@ -197,7 +197,7 @@ export default function PopulationExistingView({ if (isError) { return ( - Error: {(error as Error)?.message || 'Something went wrong.'} @@ -209,7 +209,7 @@ export default function PopulationExistingView({ if (householdPopulations.length === 0 && geographicPopulations.length === 0) { return ( - No households available. Please create new household(s).} primaryAction={{ @@ -235,7 +235,7 @@ export default function PopulationExistingView({ ); console.log('[PopulationExistingView] Filtered households:', filteredHouseholds); - // Combine all populations (pagination handled by FlowView) + // Combine all populations (pagination handled by PathwayView) const allPopulations = [...filteredHouseholds, ...geographicPopulations]; // Build card list items from ALL household populations @@ -331,7 +331,7 @@ export default function PopulationExistingView({ }; return ( - { - return ( -
-

{TEST_STRINGS.TEST_COMPONENT_TEXT}

- - - - - {isInSubflow &&

{TEST_STRINGS.IN_SUBFLOW_TEXT}

} - {flowDepth > 0 && ( -

- {TEST_STRINGS.FLOW_DEPTH_PREFIX} {flowDepth} -

- )} - {parentFlowContext && ( -

- {TEST_STRINGS.PARENT_PREFIX} {parentFlowContext.parentFrame} -

- )} -
- ); - } -); - -export const AnotherTestComponent = vi.fn(() => { - return
{TEST_STRINGS.ANOTHER_COMPONENT_TEXT}
; -}); - -export const mockComponentRegistry = { - [TEST_FRAME_NAMES.TEST_FRAME]: TestComponent, - [TEST_FRAME_NAMES.NEXT_FRAME]: AnotherTestComponent, -}; - -export const mockFlowRegistry = { - [TEST_FLOW_NAMES.TEST_FLOW]: mockFlow, - [TEST_FLOW_NAMES.ANOTHER_FLOW]: { - initialFrame: TEST_FRAME_NAMES.START_FRAME as any, - frames: { - [TEST_FRAME_NAMES.START_FRAME]: { - component: TEST_FRAME_NAMES.START_FRAME as any, - on: {}, - }, - }, - }, -}; - -export const createMockState = (overrides = {}) => ({ - flow: { - currentFlow: mockFlow, - currentFrame: TEST_FRAME_NAMES.TEST_FRAME, - flowStack: [], - ...overrides, - }, -}); - -// Additional mock functions for dynamic test scenarios -export const addEventToMockFlow = (eventName: string, target: any) => { - const testFrame = mockFlow.frames[TEST_FRAME_NAMES.TEST_FRAME]; - if (testFrame && testFrame.on) { - (testFrame.on as any)[eventName] = target; - } -}; - -export const cleanupDynamicEvents = () => { - // Reset to original state - const testFrame = mockFlow.frames[TEST_FRAME_NAMES.TEST_FRAME]; - if (testFrame && testFrame.on) { - testFrame.on = { - [TEST_EVENTS.NEXT]: TEST_FRAME_NAMES.NEXT_FRAME, - [TEST_EVENTS.SUBMIT]: NAVIGATION_TARGETS.RETURN_KEYWORD, - [TEST_EVENTS.GO_TO_FLOW]: { - flow: TEST_FLOW_NAMES.ANOTHER_FLOW, - returnTo: TEST_FRAME_NAMES.RETURN_FRAME as any, - }, - [TEST_EVENTS.INVALID_EVENT]: null, - }; - } -}; diff --git a/app/src/tests/fixtures/components/common/FlowViewMocks.tsx b/app/src/tests/fixtures/components/common/PathwayViewMocks.tsx similarity index 74% rename from app/src/tests/fixtures/components/common/FlowViewMocks.tsx rename to app/src/tests/fixtures/components/common/PathwayViewMocks.tsx index 58bfc93f..71f92d68 100644 --- a/app/src/tests/fixtures/components/common/FlowViewMocks.tsx +++ b/app/src/tests/fixtures/components/common/PathwayViewMocks.tsx @@ -1,10 +1,10 @@ import { vi } from 'vitest'; -import { ButtonConfig } from '@/components/common/FlowView'; +import { ButtonConfig } from '@/components/common/PathwayView'; // Test constants for strings -export const FLOW_VIEW_STRINGS = { +export const PATHWAY_VIEW_STRINGS = { // Titles - MAIN_TITLE: 'Test Flow Title', + MAIN_TITLE: 'Test Pathway Title', SUBTITLE: 'This is a test subtitle', // Button labels @@ -42,7 +42,7 @@ export const FLOW_VIEW_STRINGS = { } as const; // Test constants for variants -export const FLOW_VIEW_VARIANTS = { +export const PATHWAY_VIEW_VARIANTS = { SETUP_CONDITIONS: 'setupConditions' as const, BUTTON_PANEL: 'buttonPanel' as const, CARD_LIST: 'cardList' as const, @@ -72,24 +72,24 @@ export const mockItemClick = vi.fn(); // Mock setup condition cards export const mockSetupConditionCards = [ { - title: FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE, - description: FLOW_VIEW_STRINGS.SETUP_CARD_1_DESC, + title: PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE, + description: PATHWAY_VIEW_STRINGS.SETUP_CARD_1_DESC, onClick: mockCardClick, isSelected: false, isDisabled: false, isFulfilled: false, }, { - title: FLOW_VIEW_STRINGS.SETUP_CARD_2_TITLE, - description: FLOW_VIEW_STRINGS.SETUP_CARD_2_DESC, + title: PATHWAY_VIEW_STRINGS.SETUP_CARD_2_TITLE, + description: PATHWAY_VIEW_STRINGS.SETUP_CARD_2_DESC, onClick: mockCardClick, isSelected: true, isDisabled: false, isFulfilled: false, }, { - title: FLOW_VIEW_STRINGS.SETUP_CARD_3_TITLE, - description: FLOW_VIEW_STRINGS.SETUP_CARD_3_DESC, + title: PATHWAY_VIEW_STRINGS.SETUP_CARD_3_TITLE, + description: PATHWAY_VIEW_STRINGS.SETUP_CARD_3_DESC, onClick: mockCardClick, isSelected: false, isDisabled: false, @@ -100,15 +100,15 @@ export const mockSetupConditionCards = [ // Mock button panel cards export const mockButtonPanelCards = [ { - title: FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE, - description: FLOW_VIEW_STRINGS.PANEL_CARD_1_DESC, + title: PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE, + description: PATHWAY_VIEW_STRINGS.PANEL_CARD_1_DESC, onClick: mockCardClick, isSelected: false, isDisabled: false, }, { - title: FLOW_VIEW_STRINGS.PANEL_CARD_2_TITLE, - description: FLOW_VIEW_STRINGS.PANEL_CARD_2_DESC, + title: PATHWAY_VIEW_STRINGS.PANEL_CARD_2_TITLE, + description: PATHWAY_VIEW_STRINGS.PANEL_CARD_2_DESC, onClick: mockCardClick, isSelected: true, isDisabled: false, @@ -118,21 +118,21 @@ export const mockButtonPanelCards = [ // Mock card list items export const mockCardListItems = [ { - title: FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE, - subtitle: FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE, + title: PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE, + subtitle: PATHWAY_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE, onClick: mockItemClick, isSelected: false, isDisabled: false, }, { - title: FLOW_VIEW_STRINGS.LIST_ITEM_2_TITLE, - subtitle: FLOW_VIEW_STRINGS.LIST_ITEM_2_SUBTITLE, + title: PATHWAY_VIEW_STRINGS.LIST_ITEM_2_TITLE, + subtitle: PATHWAY_VIEW_STRINGS.LIST_ITEM_2_SUBTITLE, onClick: mockItemClick, isSelected: true, isDisabled: false, }, { - title: FLOW_VIEW_STRINGS.LIST_ITEM_3_TITLE, + title: PATHWAY_VIEW_STRINGS.LIST_ITEM_3_TITLE, onClick: mockItemClick, isSelected: false, isDisabled: true, @@ -142,46 +142,46 @@ export const mockCardListItems = [ // Mock button configurations export const mockExplicitButtons: ButtonConfig[] = [ { - label: FLOW_VIEW_STRINGS.BACK_BUTTON, + label: PATHWAY_VIEW_STRINGS.BACK_BUTTON, variant: BUTTON_VARIANTS.DEFAULT, onClick: mockOnClick, }, { - label: FLOW_VIEW_STRINGS.CONTINUE_BUTTON, + label: PATHWAY_VIEW_STRINGS.CONTINUE_BUTTON, variant: BUTTON_VARIANTS.FILLED, onClick: mockOnClick, }, ]; export const mockPrimaryAction = { - label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON, + label: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON, onClick: mockPrimaryClick, isLoading: false, isDisabled: false, }; export const mockPrimaryActionDisabled = { - label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON, + label: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON, onClick: mockPrimaryClick, isLoading: false, isDisabled: true, }; export const mockPrimaryActionLoading = { - label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON, + label: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON, onClick: mockPrimaryClick, isLoading: true, isDisabled: false, }; export const mockCancelAction = { - label: FLOW_VIEW_STRINGS.CANCEL_BUTTON, + label: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON, onClick: mockCancelClick, }; // Mock custom content component export const MockCustomContent = () => ( -
{FLOW_VIEW_STRINGS.CUSTOM_CONTENT}
+
{PATHWAY_VIEW_STRINGS.CUSTOM_CONTENT}
); // Helper function to reset all mocks @@ -214,8 +214,8 @@ vi.mock('@/components/common/MultiButtonFooter', () => ({ // Test data generators export const createSetupConditionCard = (overrides = {}) => ({ - title: FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE, - description: FLOW_VIEW_STRINGS.SETUP_CARD_1_DESC, + title: PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE, + description: PATHWAY_VIEW_STRINGS.SETUP_CARD_1_DESC, onClick: mockCardClick, isSelected: false, isDisabled: false, @@ -224,8 +224,8 @@ export const createSetupConditionCard = (overrides = {}) => ({ }); export const createButtonPanelCard = (overrides = {}) => ({ - title: FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE, - description: FLOW_VIEW_STRINGS.PANEL_CARD_1_DESC, + title: PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE, + description: PATHWAY_VIEW_STRINGS.PANEL_CARD_1_DESC, onClick: mockCardClick, isSelected: false, isDisabled: false, @@ -233,8 +233,8 @@ export const createButtonPanelCard = (overrides = {}) => ({ }); export const createCardListItem = (overrides = {}) => ({ - title: FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE, - subtitle: FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE, + title: PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE, + subtitle: PATHWAY_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE, onClick: mockItemClick, isSelected: false, isDisabled: false, diff --git a/app/src/tests/unit/components/common/FlowView.test.tsx b/app/src/tests/unit/components/common/PathwayView.test.tsx similarity index 51% rename from app/src/tests/unit/components/common/FlowView.test.tsx rename to app/src/tests/unit/components/common/PathwayView.test.tsx index c2c168ad..63993569 100644 --- a/app/src/tests/unit/components/common/FlowView.test.tsx +++ b/app/src/tests/unit/components/common/PathwayView.test.tsx @@ -1,13 +1,13 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import FlowView from '@/components/common/FlowView'; +import PathwayView from '@/components/common/PathwayView'; import { BUTTON_PRESETS, createButtonPanelCard, createCardListItem, createSetupConditionCard, - FLOW_VIEW_STRINGS, - FLOW_VIEW_VARIANTS, + PATHWAY_VIEW_STRINGS, + PATHWAY_VIEW_VARIANTS, mockButtonPanelCards, mockCancelAction, mockCardClick, @@ -21,9 +21,9 @@ import { mockPrimaryClick, mockSetupConditionCards, resetAllMocks, -} from '@/tests/fixtures/components/common/FlowViewMocks'; +} from '@/tests/fixtures/components/common/PathwayViewMocks'; -describe('FlowView', () => { +describe('PathwayView', () => { beforeEach(() => { resetAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -32,60 +32,60 @@ describe('FlowView', () => { describe('Basic Rendering', () => { test('given title and subtitle then renders both correctly', () => { render( - + ); - expect(screen.getByText(FLOW_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SUBTITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SUBTITLE)).toBeInTheDocument(); }); test('given only title then renders without subtitle', () => { - render(); + render(); - expect(screen.getByText(FLOW_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument(); - expect(screen.queryByText(FLOW_VIEW_STRINGS.SUBTITLE)).not.toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument(); + expect(screen.queryByText(PATHWAY_VIEW_STRINGS.SUBTITLE)).not.toBeInTheDocument(); }); test('given custom content then renders content', () => { - render(} />); + render(} />); expect(screen.getByTestId('custom-content')).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.CUSTOM_CONTENT)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.CUSTOM_CONTENT)).toBeInTheDocument(); }); }); describe('Setup Conditions Variant', () => { test('given setup condition cards then renders all cards', () => { render( - ); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_1_DESC)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_2_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_2_DESC)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_3_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_3_DESC)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_DESC)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_2_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_2_DESC)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_3_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.SETUP_CARD_3_DESC)).toBeInTheDocument(); }); test('given fulfilled condition then shows check icon', () => { const fulfilledCard = createSetupConditionCard({ isFulfilled: true }); render( - ); // The IconCheck component should be rendered when isFulfilled is true const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE), }); expect(card).toBeInTheDocument(); }); @@ -94,15 +94,15 @@ describe('FlowView', () => { const selectedCard = createSetupConditionCard({ isSelected: true }); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE), }); expect(card).toHaveAttribute('data-variant', 'setupCondition--active'); }); @@ -111,15 +111,15 @@ describe('FlowView', () => { const disabledCard = createSetupConditionCard({ isDisabled: true }); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE), }); expect(card).toBeDisabled(); }); @@ -128,15 +128,15 @@ describe('FlowView', () => { const user = userEvent.setup(); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE), }); await user.click(card); @@ -147,32 +147,32 @@ describe('FlowView', () => { describe('Button Panel Variant', () => { test('given button panel cards then renders all cards', () => { render( - ); - expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_1_DESC)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_2_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_2_DESC)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_DESC)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.PANEL_CARD_2_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.PANEL_CARD_2_DESC)).toBeInTheDocument(); }); test('given selected panel card then applies active variant', () => { const selectedCard = createButtonPanelCard({ isSelected: true }); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE), }); expect(card).toHaveAttribute('data-variant', 'buttonPanel--active'); }); @@ -181,15 +181,15 @@ describe('FlowView', () => { const user = userEvent.setup(); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE), }); await user.click(card); @@ -200,48 +200,48 @@ describe('FlowView', () => { describe('Card List Variant', () => { test('given card list items then renders all items', () => { render( - ); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_2_TITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_2_SUBTITLE)).toBeInTheDocument(); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_3_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_2_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_2_SUBTITLE)).toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_3_TITLE)).toBeInTheDocument(); }); test('given item without subtitle then renders without subtitle', () => { const itemWithoutSubtitle = createCardListItem({ subtitle: undefined }); render( - ); - expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument(); - expect(screen.queryByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).not.toBeInTheDocument(); + expect(screen.getByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument(); + expect(screen.queryByText(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).not.toBeInTheDocument(); }); test('given selected item then applies active variant', () => { const selectedItem = createCardListItem({ isSelected: true }); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE), }); expect(card).toHaveAttribute('data-variant', 'cardList--active'); }); @@ -250,15 +250,15 @@ describe('FlowView', () => { const disabledItem = createCardListItem({ isDisabled: true }); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE), }); expect(card).toBeDisabled(); }); @@ -267,15 +267,15 @@ describe('FlowView', () => { const user = userEvent.setup(); render( - ); const card = screen.getByRole('button', { - name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE), + name: new RegExp(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE), }); await user.click(card); @@ -285,96 +285,96 @@ describe('FlowView', () => { describe('Button Configuration', () => { test('given explicit buttons then renders them', () => { - render(); + render(); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.BACK_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.BACK_BUTTON }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CONTINUE_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CONTINUE_BUTTON }) ).toBeInTheDocument(); }); test('given cancel-only preset then renders only cancel button', () => { render( - ); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }) + screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }) ).not.toBeInTheDocument(); }); test('given none preset then renders no buttons', () => { - render(); + render(); expect(screen.queryByTestId('multi-button-footer')).not.toBeInTheDocument(); }); test('given primary and cancel actions then renders both buttons', () => { render( - ); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }) ).toBeInTheDocument(); }); test('given disabled primary action then renders disabled button', () => { render( - + ); - const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }); + const submitButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }); expect(submitButton).toBeDisabled(); }); test('given loading primary action then passes loading state', () => { render( - + ); - const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }); + const submitButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }); expect(submitButton).toHaveAttribute('data-loading', 'true'); }); test('given cancel button then renders as disabled', () => { render( - + ); - const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }); + const cancelButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }); expect(cancelButton).toBeDisabled(); }); test('given cancel action then renders disabled cancel button', () => { - render(); + render(); - const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }); + const cancelButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }); expect(cancelButton).toBeDisabled(); }); test('given user clicks primary button then calls primary handler', async () => { const user = userEvent.setup(); - render(); + render(); - const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }); + const submitButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }); await user.click(submitButton); expect(mockPrimaryClick).toHaveBeenCalledTimes(1); @@ -384,8 +384,8 @@ describe('FlowView', () => { describe('Button Precedence', () => { test('given explicit buttons and convenience props then explicit buttons take precedence', () => { render( - { // Should show explicit buttons, not the primary/cancel actions expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.BACK_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.BACK_BUTTON }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CONTINUE_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CONTINUE_BUTTON }) ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }) + screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }) ).not.toBeInTheDocument(); expect( - screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }) + screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) ).not.toBeInTheDocument(); }); test('given no actions and no preset then renders default cancel button', () => { - render(); + render(); expect( - screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) ).toBeInTheDocument(); }); }); From a0c993a55825049e0d035304256b2e71fe38b4a8 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 11:55:19 +0100 Subject: [PATCH 34/49] test: First round of tests --- .../components/DefaultBaselineOptionMocks.ts | 145 ++++++++++ .../utils/isDefaultBaselineSimulationMocks.ts | 189 ++++++++++++ .../isDefaultBaselineSimulationMocks.ts.orig | 189 ++++++++++++ .../isDefaultBaselineSimulationMocks.ts.rej | 8 + .../components/DefaultBaselineOption.test.tsx | 273 ++++++++++++++++++ .../utils/isDefaultBaselineSimulation.test.ts | 222 ++++++++++++++ 6 files changed, 1026 insertions(+) create mode 100644 app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts create mode 100644 app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts create mode 100644 app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.orig create mode 100644 app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.rej create mode 100644 app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx create mode 100644 app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts diff --git a/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts new file mode 100644 index 00000000..a92e09c3 --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts @@ -0,0 +1,145 @@ +import { vi } from 'vitest'; +import { EnhancedUserSimulation } from '@/hooks/useUserSimulations'; + +// Test constants +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', +} as const; + +export const TEST_USER_ID = 'test-user-123'; +export const TEST_CURRENT_LAW_ID = 1; +export const TEST_SIMULATION_ID = 'sim-123'; +export const TEST_EXISTING_SIMULATION_ID = 'existing-sim-456'; +export const TEST_GEOGRAPHY_ID = 'geo-789'; + +export const DEFAULT_BASELINE_LABELS = { + US: 'United States current law for all households nationwide', + UK: 'United Kingdom current law for all households nationwide', +} as const; + +// Mock existing simulation that matches default baseline criteria +export const mockExistingDefaultBaselineSimulation: any = { + userSimulation: { + id: 'user-sim-1', + userId: TEST_USER_ID, + simulationId: TEST_EXISTING_SIMULATION_ID, + label: DEFAULT_BASELINE_LABELS.US, + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T10:00:00Z', + }, + simulation: { + id: TEST_EXISTING_SIMULATION_ID, + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-1', + userId: TEST_USER_ID, + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T10:00:00Z', + }, +}; + +// Mock simulation with different policy (not default baseline) +export const mockNonDefaultSimulation: any = { + userSimulation: { + id: 'user-sim-2', + userId: TEST_USER_ID, + simulationId: 'sim-different', + label: 'Custom reform', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T11:00:00Z', + }, + simulation: { + id: 'sim-different', + policyId: '999', // Different policy + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-2', + userId: TEST_USER_ID, + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T11:00:00Z', + }, +}; + +// Mock callbacks +export const mockOnSelect = vi.fn(); + +// Mock API responses +export const mockGeographyCreationResponse = { + id: TEST_GEOGRAPHY_ID, + userId: TEST_USER_ID, + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national' as const, + label: 'US nationwide', + createdAt: new Date().toISOString(), +}; + +export const mockSimulationCreationResponse = { + status: 'ok' as const, + result: { + simulation_id: TEST_SIMULATION_ID, + }, +}; + +// Helper to reset all mocks +export const resetAllMocks = () => { + mockOnSelect.mockClear(); +}; + +// Mock hook return values +export const mockUseUserSimulationsEmpty = { + data: [], + isLoading: false, + isError: false, + error: null, + associations: { simulations: [], policies: [], households: [] }, + getSimulationWithFullContext: vi.fn(), + getSimulationsByPolicy: vi.fn(() => []), + getSimulationsByHousehold: vi.fn(() => []), + getSimulationsByGeography: vi.fn(() => []), + getNormalizedHousehold: vi.fn(), + getPolicyLabel: vi.fn(), +} as any; + +export const mockUseUserSimulationsWithExisting = { + data: [mockExistingDefaultBaselineSimulation, mockNonDefaultSimulation], + isLoading: false, + isError: false, + error: null, + associations: { simulations: [], policies: [], households: [] }, + getSimulationWithFullContext: vi.fn(), + getSimulationsByPolicy: vi.fn(() => []), + getSimulationsByHousehold: vi.fn(() => []), + getSimulationsByGeography: vi.fn(() => []), + getNormalizedHousehold: vi.fn(), + getPolicyLabel: vi.fn(), +} as any; + +export const mockUseCreateGeographicAssociation = { + mutateAsync: vi.fn().mockResolvedValue(mockGeographyCreationResponse), + isPending: false, + isError: false, + error: null, + mutate: vi.fn(), + reset: vi.fn(), + status: 'idle' as const, +} as any; + +export const mockUseCreateSimulation = { + createSimulation: vi.fn(), + isPending: false, + isError: false, + error: null, +} as any; diff --git a/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts new file mode 100644 index 00000000..1eec2501 --- /dev/null +++ b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts @@ -0,0 +1,189 @@ +import { EnhancedUserSimulation } from '@/hooks/useUserSimulations'; + +// Test constants +export const TEST_CURRENT_LAW_ID = 1; +export const TEST_CUSTOM_POLICY_ID = 999; + +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + NG: 'ng', + IL: 'il', + UNKNOWN: 'xyz', +} as const; + +export const EXPECTED_LABELS = { + US: 'United States current law for all households nationwide', + UK: 'United Kingdom current law for all households nationwide', + CA: 'Canada current law for all households nationwide', + NG: 'Nigeria current law for all households nationwide', + IL: 'Israel current law for all households nationwide', + UNKNOWN: 'XYZ current law for all households nationwide', +} as const; + +// Mock simulation that matches all default baseline criteria +export const mockDefaultBaselineSimulation: any = { + userSimulation: { + id: 'user-sim-1', + simulationId: 'sim-123', + label: EXPECTED_LABELS.US, + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T10:00:00Z', + }, + simulation: { + id: 'sim-123', + policyId: TEST_CURRENT_LAW_ID.toString(), + label: EXPECTED_LABELS.US, + isCreated: true, + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-1', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T10:00:00Z', + }, +}; + +// Mock simulation with custom policy (not current law) +export const mockCustomPolicySimulation: any = { + userSimulation: { + id: 'user-sim-2', + simulationId: 'sim-456', + label: 'Custom reform', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T11:00:00Z', + }, + simulation: { + label: 'test', + isCreated: true, + id: 'sim-456', + policyId: TEST_CUSTOM_POLICY_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-2', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T11:00:00Z', + }, +}; + +// Mock simulation with subnational geography +export const mockSubnationalSimulation: any = { + userSimulation: { + id: 'user-sim-3', + simulationId: 'sim-789', + label: 'California simulation', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T12:00:00Z', + }, + simulation: { + label: 'test', + isCreated: true, + id: 'sim-789', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: 'state/ca', // Subnational + }, + geography: { + id: 'geo-3', + countryId: TEST_COUNTRIES.US, + geographyId: 'state/ca', + scope: 'subnational', + label: 'California', + createdAt: '2024-01-15T12:00:00Z', + }, +}; + +// Mock simulation with household population type +export const mockHouseholdSimulation: any = { + userSimulation: { + id: 'user-sim-4', + simulationId: 'sim-101', + label: 'Household simulation', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T13:00:00Z', + }, + simulation: { + label: 'test', + isCreated: true, + id: 'sim-101', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'household', + populationId: 'household-123', + }, +}; + +// Mock simulation with wrong label +export const mockWrongLabelSimulation: any = { + userSimulation: { + id: 'user-sim-5', + simulationId: 'sim-202', + label: 'Wrong label here', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T14:00:00Z', + }, + simulation: { + label: 'test', + isCreated: true, + id: 'sim-202', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-5', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T14:00:00Z', + }, +}; + +// Mock simulation with missing nested data +export const mockIncompleteSimulation: any = { + userSimulation: { + id: 'user-sim-6', + simulationId: 'sim-303', + label: EXPECTED_LABELS.US, + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T15:00:00Z', + }, + simulation: undefined, +}; + +// Mock UK default baseline simulation +export const mockUKDefaultBaselineSimulation: any = { + userSimulation: { + id: 'user-sim-7', + simulationId: 'sim-404', + label: EXPECTED_LABELS.UK, + countryId: TEST_COUNTRIES.UK, + createdAt: '2024-01-15T16:00:00Z', + }, + simulation: { + label: 'test', + isCreated: true, + id: 'sim-404', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.UK, + }, + geography: { + id: 'geo-7', + countryId: TEST_COUNTRIES.UK, + geographyId: TEST_COUNTRIES.UK, + scope: 'national', + label: 'UK nationwide', + createdAt: '2024-01-15T16:00:00Z', + }, +}; diff --git a/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.orig b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.orig new file mode 100644 index 00000000..5887cd1b --- /dev/null +++ b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.orig @@ -0,0 +1,189 @@ +import { EnhancedUserSimulation } from '@/hooks/useUserSimulations'; + +// Test constants +export const TEST_CURRENT_LAW_ID = 1; +export const TEST_CUSTOM_POLICY_ID = 999; + +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + NG: 'ng', + IL: 'il', + UNKNOWN: 'xyz', +} as const; + +export const EXPECTED_LABELS = { + US: 'United States current law for all households nationwide', + UK: 'United Kingdom current law for all households nationwide', + CA: 'Canada current law for all households nationwide', + NG: 'Nigeria current law for all households nationwide', + IL: 'Israel current law for all households nationwide', + UNKNOWN: 'XYZ current law for all households nationwide', +} as const; + +// Mock simulation that matches all default baseline criteria +export const mockDefaultBaselineSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-1', + userId: 'test-user', + simulationId: 'sim-123', + label: EXPECTED_LABELS.US, + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T10:00:00Z', + }, + simulation: { + id: 'sim-123', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-1', + userId: 'test-user', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T10:00:00Z', + }, +}; + +// Mock simulation with custom policy (not current law) +export const mockCustomPolicySimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-2', + userId: 'test-user', + simulationId: 'sim-456', + label: 'Custom reform', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T11:00:00Z', + }, + simulation: { + id: 'sim-456', + policyId: TEST_CUSTOM_POLICY_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-2', + userId: 'test-user', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T11:00:00Z', + }, +}; + +// Mock simulation with subnational geography +export const mockSubnationalSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-3', + userId: 'test-user', + simulationId: 'sim-789', + label: 'California simulation', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T12:00:00Z', + }, + simulation: { + id: 'sim-789', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: 'state/ca', // Subnational + }, + geography: { + id: 'geo-3', + userId: 'test-user', + countryId: TEST_COUNTRIES.US, + geographyId: 'state/ca', + scope: 'subnational', + label: 'California', + createdAt: '2024-01-15T12:00:00Z', + }, +}; + +// Mock simulation with household population type +export const mockHouseholdSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-4', + userId: 'test-user', + simulationId: 'sim-101', + label: 'Household simulation', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T13:00:00Z', + }, + simulation: { + id: 'sim-101', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'household', + populationId: 'household-123', + }, +}; + +// Mock simulation with wrong label +export const mockWrongLabelSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-5', + userId: 'test-user', + simulationId: 'sim-202', + label: 'Wrong label here', + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T14:00:00Z', + }, + simulation: { + id: 'sim-202', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.US, + }, + geography: { + id: 'geo-5', + userId: 'test-user', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + label: 'US nationwide', + createdAt: '2024-01-15T14:00:00Z', + }, +}; + +// Mock simulation with missing nested data +export const mockIncompleteSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-6', + userId: 'test-user', + simulationId: 'sim-303', + label: EXPECTED_LABELS.US, + countryId: TEST_COUNTRIES.US, + createdAt: '2024-01-15T15:00:00Z', + }, + simulation: undefined, +}; + +// Mock UK default baseline simulation +export const mockUKDefaultBaselineSimulation: EnhancedUserSimulation = { + userSimulation: { + id: 'user-sim-7', + userId: 'test-user', + simulationId: 'sim-404', + label: EXPECTED_LABELS.UK, + countryId: TEST_COUNTRIES.UK, + createdAt: '2024-01-15T16:00:00Z', + }, + simulation: { + id: 'sim-404', + policyId: TEST_CURRENT_LAW_ID.toString(), + populationType: 'geography', + populationId: TEST_COUNTRIES.UK, + }, + geography: { + id: 'geo-7', + userId: 'test-user', + countryId: TEST_COUNTRIES.UK, + geographyId: TEST_COUNTRIES.UK, + scope: 'national', + label: 'UK nationwide', + createdAt: '2024-01-15T16:00:00Z', + }, +}; diff --git a/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.rej b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.rej new file mode 100644 index 00000000..2bbc5830 --- /dev/null +++ b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts.rej @@ -0,0 +1,8 @@ +@@ -42,7 +44,6 @@ + id: 'geo-1', +- userId: 'test-user', + countryId: TEST_COUNTRIES.US, + geographyId: TEST_COUNTRIES.US, + scope: 'national', + + diff --git a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx new file mode 100644 index 00000000..9177796e --- /dev/null +++ b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx @@ -0,0 +1,273 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import DefaultBaselineOption from '@/pathways/report/components/DefaultBaselineOption'; +import { + TEST_COUNTRIES, + TEST_CURRENT_LAW_ID, + DEFAULT_BASELINE_LABELS, + mockOnSelect, + mockUseUserSimulationsEmpty, + mockUseUserSimulationsWithExisting, + mockUseCreateGeographicAssociation, + mockUseCreateSimulation, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks'; + +// Mock hooks +vi.mock('@/hooks/useUserSimulations', () => ({ + useUserSimulations: vi.fn(), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useCreateGeographicAssociation: vi.fn(), +})); + +vi.mock('@/hooks/useCreateSimulation', () => ({ + useCreateSimulation: vi.fn(), +})); + +import { useUserSimulations } from '@/hooks/useUserSimulations'; +import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic'; +import { useCreateSimulation } from '@/hooks/useCreateSimulation'; + +describe('DefaultBaselineOption', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + + // Default mock implementations + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); + vi.mocked(useCreateGeographicAssociation).mockReturnValue(mockUseCreateGeographicAssociation); + vi.mocked(useCreateSimulation).mockReturnValue(mockUseCreateSimulation); + }); + + describe('Rendering', () => { + test('given component renders then displays default baseline label', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + expect( + screen.getByText('Use current law with all households nationwide as baseline') + ).toBeInTheDocument(); + }); + + test('given UK country then displays UK label', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); + }); + + test('given component renders then displays card as button', () => { + // When + render( + + ); + + // Then + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + test('given component renders then displays chevron icon', () => { + // When + const { container } = render( + + ); + + // Then + const chevronIcon = container.querySelector('svg'); + expect(chevronIcon).toBeInTheDocument(); + }); + }); + + describe('Detecting existing simulations', () => { + test('given existing default baseline simulation then detects it', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithExisting); + + // When + render( + + ); + + // Then - component renders successfully with existing simulation detected + expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + }); + + test('given no existing simulation then component renders correctly', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); + + // When + render( + + ); + + // Then + expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + }); + }); + + describe('User interactions', () => { + test('given button is clicked then becomes disabled', async () => { + // Given + const user = userEvent.setup(); + + render( + + ); + + const button = screen.getByRole('button'); + + // When + await user.click(button); + + // Then - button should be disabled to prevent double-clicks + expect(button).toBeDisabled(); + }); + + test('given button is clicked then displays loading text', async () => { + // Given + const user = userEvent.setup(); + + render( + + ); + + const button = screen.getByRole('button'); + + // When + await user.click(button); + + // Then - should show either "Creating simulation..." or "Applying simulation..." + const loadingText = screen.queryByText(/Creating simulation...|Applying simulation.../); + expect(loadingText).toBeInTheDocument(); + }); + + test('given existing simulation and button clicked then shows applying text', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithExisting); + + render( + + ); + + const button = screen.getByRole('button'); + + // When + await user.click(button); + + // Then + expect(screen.getByText('Applying simulation...')).toBeInTheDocument(); + }); + + test('given no existing simulation and button clicked then shows creating text', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); + + render( + + ); + + const button = screen.getByRole('button'); + + // When + await user.click(button); + + // Then + expect(screen.getByText('Creating simulation...')).toBeInTheDocument(); + }); + }); + + describe('Props handling', () => { + test('given different country IDs then generates correct labels', () => { + // Test US + const { rerender } = render( + + ); + expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + + // Test UK + rerender( + + ); + expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); + }); + + test('given onSelect callback then passes it through', () => { + // Given + const customCallback = vi.fn(); + + // When + render( + + ); + + // Then - component renders with callback (testing it's accepted as prop) + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts b/app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts new file mode 100644 index 00000000..83aa10b3 --- /dev/null +++ b/app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts @@ -0,0 +1,222 @@ +import { describe, test, expect } from 'vitest'; +import { + isDefaultBaselineSimulation, + getDefaultBaselineLabel, + countryNames, +} from '@/utils/isDefaultBaselineSimulation'; +import { + TEST_CURRENT_LAW_ID, + TEST_CUSTOM_POLICY_ID, + TEST_COUNTRIES, + EXPECTED_LABELS, + mockDefaultBaselineSimulation, + mockCustomPolicySimulation, + mockSubnationalSimulation, + mockHouseholdSimulation, + mockWrongLabelSimulation, + mockIncompleteSimulation, + mockUKDefaultBaselineSimulation, +} from '@/tests/fixtures/utils/isDefaultBaselineSimulationMocks'; + +describe('isDefaultBaselineSimulation', () => { + describe('Matching default baseline', () => { + test('given simulation matches all criteria then returns true', () => { + // When + const result = isDefaultBaselineSimulation( + mockDefaultBaselineSimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(true); + }); + + test('given UK default baseline then returns true for UK', () => { + // When + const result = isDefaultBaselineSimulation( + mockUKDefaultBaselineSimulation, + TEST_COUNTRIES.UK, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(true); + }); + }); + + describe('Non-matching policy', () => { + test('given custom policy then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockCustomPolicySimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + + test('given different current law ID then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockDefaultBaselineSimulation, + TEST_COUNTRIES.US, + 999 // Different current law ID + ); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Non-matching geography', () => { + test('given subnational geography then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockSubnationalSimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + + test('given wrong country then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockDefaultBaselineSimulation, + TEST_COUNTRIES.UK, // Wrong country + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Non-matching population type', () => { + test('given household population then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockHouseholdSimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Non-matching label', () => { + test('given wrong label then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockWrongLabelSimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Incomplete data', () => { + test('given missing simulation data then returns false', () => { + // When + const result = isDefaultBaselineSimulation( + mockIncompleteSimulation, + TEST_COUNTRIES.US, + TEST_CURRENT_LAW_ID + ); + + // Then + expect(result).toBe(false); + }); + }); +}); + +describe('getDefaultBaselineLabel', () => { + describe('Known countries', () => { + test('given US country code then returns US label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.US); + + // Then + expect(result).toBe(EXPECTED_LABELS.US); + }); + + test('given UK country code then returns UK label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.UK); + + // Then + expect(result).toBe(EXPECTED_LABELS.UK); + }); + + test('given CA country code then returns Canada label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.CA); + + // Then + expect(result).toBe(EXPECTED_LABELS.CA); + }); + + test('given NG country code then returns Nigeria label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.NG); + + // Then + expect(result).toBe(EXPECTED_LABELS.NG); + }); + + test('given IL country code then returns Israel label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.IL); + + // Then + expect(result).toBe(EXPECTED_LABELS.IL); + }); + }); + + describe('Unknown countries', () => { + test('given unknown country code then returns uppercase code in label', () => { + // When + const result = getDefaultBaselineLabel(TEST_COUNTRIES.UNKNOWN); + + // Then + expect(result).toBe(EXPECTED_LABELS.UNKNOWN); + }); + }); +}); + +describe('countryNames', () => { + test('given object then contains expected country mappings', () => { + // Then + expect(countryNames).toEqual({ + us: 'United States', + uk: 'United Kingdom', + ca: 'Canada', + ng: 'Nigeria', + il: 'Israel', + }); + }); + + test('given known country code then returns full name', () => { + // Then + expect(countryNames[TEST_COUNTRIES.US]).toBe('United States'); + expect(countryNames[TEST_COUNTRIES.UK]).toBe('United Kingdom'); + expect(countryNames[TEST_COUNTRIES.CA]).toBe('Canada'); + expect(countryNames[TEST_COUNTRIES.NG]).toBe('Nigeria'); + expect(countryNames[TEST_COUNTRIES.IL]).toBe('Israel'); + }); + + test('given unknown country code then returns undefined', () => { + // Then + expect(countryNames[TEST_COUNTRIES.UNKNOWN]).toBeUndefined(); + }); +}); From 7dec3f26702822133994fb376da71c82be858ce3 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 12:15:28 +0100 Subject: [PATCH 35/49] test: Add more tests --- .../report/ReportPathwayWrapperMocks.ts | 81 +++++++++ .../SimulationPathwayWrapperMocks.ts | 49 +++++ .../pathwayState/initializeStateMocks.ts | 40 +++++ .../policy/PolicyPathwayWrapper.test.tsx | 72 ++++++++ .../PopulationPathwayWrapper.test.tsx | 76 ++++++++ .../report/ReportPathwayWrapper.test.tsx | 169 ++++++++++++++++++ .../SimulationPathwayWrapper.test.tsx | 140 +++++++++++++++ .../initializeReportState.test.ts | 152 ++++++++++++++++ 8 files changed, 779 insertions(+) create mode 100644 app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts create mode 100644 app/src/tests/fixtures/pathways/simulation/SimulationPathwayWrapperMocks.ts create mode 100644 app/src/tests/fixtures/utils/pathwayState/initializeStateMocks.ts create mode 100644 app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx create mode 100644 app/src/tests/unit/pathways/population/PopulationPathwayWrapper.test.tsx create mode 100644 app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx create mode 100644 app/src/tests/unit/pathways/simulation/SimulationPathwayWrapper.test.tsx create mode 100644 app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts diff --git a/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts new file mode 100644 index 00000000..be221e47 --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts @@ -0,0 +1,81 @@ +import { vi } from 'vitest'; +import { ReportViewMode } from '@/types/pathwayModes/ReportViewMode'; + +// Test constants +export const TEST_COUNTRY_ID = 'us'; +export const TEST_INVALID_COUNTRY_ID = 'invalid'; +export const TEST_USER_ID = 'test-user-123'; +export const TEST_CURRENT_LAW_ID = 1; + +// Mock navigation +export const mockNavigate = vi.fn(); +export const mockOnComplete = vi.fn(); + +// Mock hook return values +export const mockUseParams = { + countryId: TEST_COUNTRY_ID, +}; + +export const mockUseParamsInvalid = { + countryId: TEST_INVALID_COUNTRY_ID, +}; + +export const mockUseParamsMissing = {}; + +export const mockMetadata = { + currentLawId: TEST_CURRENT_LAW_ID, + economyOptions: { + region: [], + }, +}; + +export const mockUseCreateReport = { + createReport: vi.fn(), + isPending: false, + isError: false, + error: null, +} as any; + +export const mockUseUserSimulations = { + data: [], + isLoading: false, + isError: false, + error: null, +} as any; + +export const mockUseUserPolicies = { + data: [], + isLoading: false, + isError: false, + error: null, +} as any; + +export const mockUseUserHouseholds = { + data: [], + isLoading: false, + isError: false, + error: null, +} as any; + +export const mockUseUserGeographics = { + data: [], + isLoading: false, + isError: false, + error: null, +} as any; + +// Helper to reset all mocks +export const resetAllMocks = () => { + mockNavigate.mockClear(); + mockOnComplete.mockClear(); + mockUseCreateReport.createReport.mockClear(); +}; + +// Expected view modes +export const REPORT_VIEW_MODES = { + LABEL: ReportViewMode.LABEL, + SETUP: ReportViewMode.SETUP, + SIMULATION_SELECTION: ReportViewMode.SIMULATION_SELECTION, + SIMULATION_EXISTING: ReportViewMode.SIMULATION_EXISTING, + SUBMIT: ReportViewMode.SUBMIT, +} as const; diff --git a/app/src/tests/fixtures/pathways/simulation/SimulationPathwayWrapperMocks.ts b/app/src/tests/fixtures/pathways/simulation/SimulationPathwayWrapperMocks.ts new file mode 100644 index 00000000..b7be50ae --- /dev/null +++ b/app/src/tests/fixtures/pathways/simulation/SimulationPathwayWrapperMocks.ts @@ -0,0 +1,49 @@ +import { vi } from 'vitest'; + +// Test constants +export const TEST_COUNTRY_ID = 'us'; +export const TEST_USER_ID = 'test-user-123'; +export const TEST_CURRENT_LAW_ID = 1; + +// Mock navigation +export const mockNavigate = vi.fn(); +export const mockOnComplete = vi.fn(); + +// Mock hook return values +export const mockUseParams = { + countryId: TEST_COUNTRY_ID, +}; + +export const mockMetadata = { + currentLawId: TEST_CURRENT_LAW_ID, + economyOptions: { + region: [], + }, +}; + +export const mockUseCreateSimulation = { + createSimulation: vi.fn(), + isPending: false, +} as any; + +export const mockUseUserPolicies = { + data: [], + isLoading: false, +} as any; + +export const mockUseUserHouseholds = { + data: [], + isLoading: false, +} as any; + +export const mockUseUserGeographics = { + data: [], + isLoading: false, +} as any; + +// Helper to reset all mocks +export const resetAllMocks = () => { + mockNavigate.mockClear(); + mockOnComplete.mockClear(); + mockUseCreateSimulation.createSimulation.mockClear(); +}; diff --git a/app/src/tests/fixtures/utils/pathwayState/initializeStateMocks.ts b/app/src/tests/fixtures/utils/pathwayState/initializeStateMocks.ts new file mode 100644 index 00000000..60fe4894 --- /dev/null +++ b/app/src/tests/fixtures/utils/pathwayState/initializeStateMocks.ts @@ -0,0 +1,40 @@ +// Test constants for pathway state initialization +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', +} as const; + +export const EXPECTED_REPORT_STATE_STRUCTURE = { + id: undefined, + label: null, + apiVersion: null, + status: 'pending', + outputType: undefined, + output: null, + simulations: expect.any(Array), +} as const; + +export const EXPECTED_SIMULATION_STATE_STRUCTURE = { + id: undefined, + label: null, + countryId: undefined, + apiVersion: undefined, + status: undefined, + output: null, + policy: expect.any(Object), + population: expect.any(Object), +} as const; + +export const EXPECTED_POLICY_STATE_STRUCTURE = { + id: null, + label: null, + parameters: [], +} as const; + +export const EXPECTED_POPULATION_STATE_STRUCTURE = { + label: null, + type: null, + household: null, + geography: null, +} as const; diff --git a/app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx new file mode 100644 index 00000000..ef771880 --- /dev/null +++ b/app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx @@ -0,0 +1,72 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@test-utils'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import PolicyPathwayWrapper from '@/pathways/policy/PolicyPathwayWrapper'; + +const mockNavigate = vi.fn(); +const mockUseParams = { countryId: 'us' }; +const mockMetadata = { currentLawId: 1, economyOptions: { parameters: {} } }; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: vi.fn(), + }; +}); + +import { useParams } from 'react-router-dom'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: vi.fn(() => mockMetadata), + }; +}); + +vi.mock('@/hooks/useCreatePolicy', () => ({ + useCreatePolicy: vi.fn(() => ({ createPolicy: vi.fn(), isPending: false })), +})); + +vi.mock('@/hooks/usePathwayNavigation', () => ({ + usePathwayNavigation: vi.fn(() => ({ + mode: 'LABEL', + navigateToMode: vi.fn(), + goBack: vi.fn(), + })), +})); + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +describe('PolicyPathwayWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useParams).mockReturnValue(mockUseParams); + vi.mocked(useCurrentCountry).mockReturnValue('us'); + }); + + test('given valid countryId then renders without error', () => { + // When + const { container } = render(); + + // Then + expect(container).toBeInTheDocument(); + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + test('given missing countryId then throws error', () => { + // Given + vi.mocked(useParams).mockReturnValue({}); + vi.mocked(useCurrentCountry).mockImplementation(() => { + throw new Error('useCurrentCountry must be used within country routes (protected by CountryGuard). Got countryId: undefined'); + }); + + // When/Then - Should throw error since CountryGuard would prevent this in real app + expect(() => render()).toThrow('useCurrentCountry must be used within country routes'); + }); +}); diff --git a/app/src/tests/unit/pathways/population/PopulationPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/population/PopulationPathwayWrapper.test.tsx new file mode 100644 index 00000000..3ecd771a --- /dev/null +++ b/app/src/tests/unit/pathways/population/PopulationPathwayWrapper.test.tsx @@ -0,0 +1,76 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@test-utils'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import PopulationPathwayWrapper from '@/pathways/population/PopulationPathwayWrapper'; + +const mockNavigate = vi.fn(); +const mockUseParams = { countryId: 'us' }; +const mockMetadata = { currentLawId: 1, economyOptions: { region: [] } }; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: vi.fn(), + }; +}); + +import { useParams } from 'react-router-dom'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: vi.fn(() => mockMetadata), + }; +}); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useCreateHousehold: vi.fn(() => ({ createHousehold: vi.fn(), isPending: false })), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useCreateGeographicAssociation: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), +})); + +vi.mock('@/hooks/usePathwayNavigation', () => ({ + usePathwayNavigation: vi.fn(() => ({ + mode: 'SCOPE', + navigateToMode: vi.fn(), + goBack: vi.fn(), + })), +})); + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +describe('PopulationPathwayWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useParams).mockReturnValue(mockUseParams); + vi.mocked(useCurrentCountry).mockReturnValue('us'); + }); + + test('given valid countryId then renders without error', () => { + // When + const { container } = render(); + + // Then + expect(container).toBeInTheDocument(); + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + test('given missing countryId then throws error', () => { + // Given + vi.mocked(useParams).mockReturnValue({}); + vi.mocked(useCurrentCountry).mockImplementation(() => { + throw new Error('useCurrentCountry must be used within country routes (protected by CountryGuard). Got countryId: undefined'); + }); + + // When/Then - Should throw error since CountryGuard would prevent this in real app + expect(() => render()).toThrow('useCurrentCountry must be used within country routes'); + }); +}); diff --git a/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx new file mode 100644 index 00000000..e5bd5406 --- /dev/null +++ b/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx @@ -0,0 +1,169 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@test-utils'; +import ReportPathwayWrapper from '@/pathways/report/ReportPathwayWrapper'; +import { + TEST_COUNTRY_ID, + TEST_INVALID_COUNTRY_ID, + mockNavigate, + mockOnComplete, + mockUseParams, + mockUseParamsInvalid, + mockUseParamsMissing, + mockMetadata, + mockUseCreateReport, + mockUseUserSimulations, + mockUseUserPolicies, + mockUseUserHouseholds, + mockUseUserGeographics, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/ReportPathwayWrapperMocks'; + +// Mock all dependencies +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: vi.fn(), + }; +}); + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: vi.fn((selector) => { + if (selector.toString().includes('currentLawId')) { + return mockMetadata.currentLawId; + } + return mockMetadata; + }), + }; +}); + +vi.mock('@/hooks/useUserSimulations', () => ({ + useUserSimulations: vi.fn(), +})); + +vi.mock('@/hooks/useUserPolicy', () => ({ + useUserPolicies: vi.fn(), +})); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: vi.fn(), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useUserGeographics: vi.fn(), +})); + +vi.mock('@/hooks/useCreateReport', () => ({ + useCreateReport: vi.fn(), +})); + +vi.mock('@/hooks/usePathwayNavigation', () => ({ + usePathwayNavigation: vi.fn(() => ({ + mode: 'LABEL', + navigateToMode: vi.fn(), + goBack: vi.fn(), + getBackMode: vi.fn(), + })), +})); + +import { useParams } from 'react-router-dom'; +import { useUserSimulations } from '@/hooks/useUserSimulations'; +import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useUserGeographics } from '@/hooks/useUserGeographic'; +import { useCreateReport } from '@/hooks/useCreateReport'; + +describe('ReportPathwayWrapper', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + + // Default mock implementations + vi.mocked(useParams).mockReturnValue(mockUseParams); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulations); + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPolicies); + vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholds); + vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographics); + vi.mocked(useCreateReport).mockReturnValue(mockUseCreateReport); + }); + + describe('Error handling', () => { + test('given missing countryId param then shows error message', () => { + // Given + vi.mocked(useParams).mockReturnValue(mockUseParamsMissing); + + // When + render(); + + // Then + expect(screen.getByText(/Country ID not found/i)).toBeInTheDocument(); + }); + + test('given invalid countryId then shows error message', () => { + // Given + vi.mocked(useParams).mockReturnValue(mockUseParamsInvalid); + + // When + render(); + + // Then + expect(screen.getByText(/Invalid country ID/i)).toBeInTheDocument(); + }); + }); + + describe('Basic rendering', () => { + test('given valid countryId then renders without error', () => { + // When + const { container } = render(); + + // Then - Should render something (not just error message) + expect(container).toBeInTheDocument(); + expect(screen.queryByText(/Country ID not found/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Invalid country ID/i)).not.toBeInTheDocument(); + }); + + test('given wrapper renders then initializes with hooks', () => { + // When + render(); + + // Then - Hooks should have been called + expect(useUserSimulations).toHaveBeenCalled(); + expect(useUserPolicies).toHaveBeenCalled(); + expect(useUserHouseholds).toHaveBeenCalled(); + expect(useUserGeographics).toHaveBeenCalled(); + expect(useCreateReport).toHaveBeenCalled(); + }); + }); + + describe('Props handling', () => { + test('given onComplete callback then accepts prop', () => { + // When + const { container } = render(); + + // Then - Component renders with callback + expect(container).toBeInTheDocument(); + }); + + test('given no onComplete callback then renders without error', () => { + // When + const { container } = render(); + + // Then + expect(container).toBeInTheDocument(); + }); + }); + + describe('State initialization', () => { + test('given wrapper renders then initializes report state with country', () => { + // When + render(); + + // Then - No errors, component initialized successfully + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/simulation/SimulationPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/simulation/SimulationPathwayWrapper.test.tsx new file mode 100644 index 00000000..1667441e --- /dev/null +++ b/app/src/tests/unit/pathways/simulation/SimulationPathwayWrapper.test.tsx @@ -0,0 +1,140 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@test-utils'; +import SimulationPathwayWrapper from '@/pathways/simulation/SimulationPathwayWrapper'; +import { + TEST_COUNTRY_ID, + mockNavigate, + mockOnComplete, + mockUseParams, + mockMetadata, + mockUseCreateSimulation, + mockUseUserPolicies, + mockUseUserHouseholds, + mockUseUserGeographics, + resetAllMocks, +} from '@/tests/fixtures/pathways/simulation/SimulationPathwayWrapperMocks'; + +// Mock dependencies +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: vi.fn(), + }; +}); + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: vi.fn((selector) => { + if (selector.toString().includes('currentLawId')) { + return mockMetadata.currentLawId; + } + return mockMetadata; + }), + }; +}); + +vi.mock('@/hooks/useCreateSimulation', () => ({ + useCreateSimulation: vi.fn(), +})); + +vi.mock('@/hooks/useUserPolicy', () => ({ + useUserPolicies: vi.fn(), +})); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: vi.fn(), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useUserGeographics: vi.fn(), +})); + +vi.mock('@/hooks/usePathwayNavigation', () => ({ + usePathwayNavigation: vi.fn(() => ({ + mode: 'LABEL', + navigateToMode: vi.fn(), + goBack: vi.fn(), + getBackMode: vi.fn(), + })), +})); + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +import { useParams } from 'react-router-dom'; +import { useCreateSimulation } from '@/hooks/useCreateSimulation'; +import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useUserGeographics } from '@/hooks/useUserGeographic'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +describe('SimulationPathwayWrapper', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + + vi.mocked(useParams).mockReturnValue(mockUseParams); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + vi.mocked(useCreateSimulation).mockReturnValue(mockUseCreateSimulation); + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPolicies); + vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholds); + vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographics); + }); + + describe('Error handling', () => { + test('given missing countryId param then shows error message', () => { + // Given + vi.mocked(useParams).mockReturnValue({}); + vi.mocked(useCurrentCountry).mockImplementation(() => { + throw new Error('useCurrentCountry must be used within country routes (protected by CountryGuard). Got countryId: undefined'); + }); + + // When/Then - Should throw error since CountryGuard would prevent this in real app + expect(() => render()).toThrow('useCurrentCountry must be used within country routes'); + }); + }); + + describe('Basic rendering', () => { + test('given valid countryId then renders without error', () => { + // When + const { container } = render(); + + // Then + expect(container).toBeInTheDocument(); + expect(screen.queryByText(/Country ID not found/i)).not.toBeInTheDocument(); + }); + + test('given wrapper renders then initializes with hooks', () => { + // Given - Clear previous calls before this specific test + vi.clearAllMocks(); + vi.mocked(useParams).mockReturnValue(mockUseParams); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPolicies); + vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholds); + vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographics); + + // When + render(); + + // Then + expect(useUserPolicies).toHaveBeenCalled(); + expect(useUserHouseholds).toHaveBeenCalled(); + expect(useUserGeographics).toHaveBeenCalled(); + }); + }); + + describe('Props handling', () => { + test('given onComplete callback then accepts prop', () => { + // When + const { container } = render(); + + // Then + expect(container).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts b/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts new file mode 100644 index 00000000..a63ac818 --- /dev/null +++ b/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts @@ -0,0 +1,152 @@ +import { describe, test, expect } from 'vitest'; +import { initializeReportState } from '@/utils/pathwayState/initializeReportState'; +import { + TEST_COUNTRIES, + EXPECTED_REPORT_STATE_STRUCTURE, +} from '@/tests/fixtures/utils/pathwayState/initializeStateMocks'; + +describe('initializeReportState', () => { + describe('Basic structure', () => { + test('given country ID then returns report state with correct structure', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result).toMatchObject(EXPECTED_REPORT_STATE_STRUCTURE); + expect(result.countryId).toBe(TEST_COUNTRIES.US); + }); + + test('given country ID then initializes with two simulations', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.simulations).toHaveLength(2); + expect(result.simulations[0]).toBeDefined(); + expect(result.simulations[1]).toBeDefined(); + }); + }); + + describe('Default values', () => { + test('given initialization then id is undefined', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.id).toBeUndefined(); + }); + + test('given initialization then label is null', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.label).toBeNull(); + }); + + test('given initialization then apiVersion is null', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.apiVersion).toBeNull(); + }); + + test('given initialization then status is pending', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.status).toBe('pending'); + }); + + test('given initialization then outputType is undefined', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.outputType).toBeUndefined(); + }); + + test('given initialization then output is null', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.output).toBeNull(); + }); + }); + + describe('Country ID handling', () => { + test('given US country ID then sets correct country', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.countryId).toBe(TEST_COUNTRIES.US); + }); + + test('given UK country ID then sets correct country', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.UK); + + // Then + expect(result.countryId).toBe(TEST_COUNTRIES.UK); + }); + + test('given CA country ID then sets correct country', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.CA); + + // Then + expect(result.countryId).toBe(TEST_COUNTRIES.CA); + }); + }); + + describe('Nested simulation state', () => { + test('given initialization then simulations have empty policy state', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.simulations[0].policy).toBeDefined(); + expect(result.simulations[0].policy.id).toBeUndefined(); + expect(result.simulations[0].policy.label).toBeNull(); + expect(result.simulations[0].policy.parameters).toEqual([]); + }); + + test('given initialization then simulations have empty population state', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result.simulations[0].population).toBeDefined(); + expect(result.simulations[0].population.label).toBeNull(); + expect(result.simulations[0].population.type).toBeNull(); + expect(result.simulations[0].population.household).toBeNull(); + expect(result.simulations[0].population.geography).toBeNull(); + }); + + test('given initialization then both simulations are independent objects', () => { + // When + const result = initializeReportState(TEST_COUNTRIES.US); + + // Then - Simulations should be different object references + expect(result.simulations[0]).not.toBe(result.simulations[1]); + expect(result.simulations[0].policy).not.toBe(result.simulations[1].policy); + expect(result.simulations[0].population).not.toBe(result.simulations[1].population); + }); + }); + + describe('Immutability', () => { + test('given multiple initializations then returns new objects each time', () => { + // When + const result1 = initializeReportState(TEST_COUNTRIES.US); + const result2 = initializeReportState(TEST_COUNTRIES.US); + + // Then + expect(result1).not.toBe(result2); + expect(result1.simulations).not.toBe(result2.simulations); + }); + }); +}); From 85cab583d233bad7d7ae26eacd1ad9624667475a Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 12:43:49 +0100 Subject: [PATCH 36/49] test: Add tests --- .../pathways/report/views/PolicyViewMocks.ts | 49 +++ .../report/views/PopulationViewMocks.ts | 53 +++ .../pathways/report/views/ReportViewMocks.ts | 153 ++++++++ .../report/views/SimulationViewMocks.ts | 90 +++++ .../report/views/ReportLabelView.test.tsx | 322 +++++++++++++++++ .../report/views/ReportSetupView.test.tsx | 328 ++++++++++++++++++ .../ReportSimulationExistingView.test.tsx | 259 ++++++++++++++ .../ReportSimulationSelectionView.test.tsx | 283 +++++++++++++++ .../report/views/ReportSubmitView.test.tsx | 274 +++++++++++++++ .../views/policy/PolicyExistingView.test.tsx | 157 +++++++++ .../views/policy/PolicyLabelView.test.tsx | 235 +++++++++++++ .../population/PopulationLabelView.test.tsx | 279 +++++++++++++++ .../population/PopulationScopeView.test.tsx | 112 ++++++ .../simulation/SimulationLabelView.test.tsx | 255 ++++++++++++++ .../simulation/SimulationSetupView.test.tsx | 313 +++++++++++++++++ 15 files changed, 3162 insertions(+) create mode 100644 app/src/tests/fixtures/pathways/report/views/PolicyViewMocks.ts create mode 100644 app/src/tests/fixtures/pathways/report/views/PopulationViewMocks.ts create mode 100644 app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts create mode 100644 app/src/tests/fixtures/pathways/report/views/SimulationViewMocks.ts create mode 100644 app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/policy/PolicyExistingView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/policy/PolicyLabelView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/population/PopulationLabelView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/population/PopulationScopeView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/simulation/SimulationLabelView.test.tsx create mode 100644 app/src/tests/unit/pathways/report/views/simulation/SimulationSetupView.test.tsx diff --git a/app/src/tests/fixtures/pathways/report/views/PolicyViewMocks.ts b/app/src/tests/fixtures/pathways/report/views/PolicyViewMocks.ts new file mode 100644 index 00000000..c545ae2b --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/views/PolicyViewMocks.ts @@ -0,0 +1,49 @@ +export const TEST_POLICY_LABEL = 'Test Policy'; +export const TEST_COUNTRY_ID = 'us'; + +export const mockOnUpdateLabel = vi.fn(); +export const mockOnNext = vi.fn(); +export const mockOnBack = vi.fn(); +export const mockOnCancel = vi.fn(); +export const mockOnSelectPolicy = vi.fn(); + +export const mockUserPolicyAssociation = { + association: { id: 1, label: 'My Policy', policy_id: '456', user_id: 1 }, + policy: { id: '456', label: 'Current Law', countryId: TEST_COUNTRY_ID, policy_json: {} }, +}; + +export const mockUseUserPoliciesEmpty = { + data: [], + isLoading: false, + isError: false, + error: null, +}; + +export const mockUseUserPoliciesWithData = { + data: [mockUserPolicyAssociation], + isLoading: false, + isError: false, + error: null, +}; + +export const mockUseUserPoliciesLoading = { + data: undefined, + isLoading: true, + isError: false, + error: null, +}; + +export const mockUseUserPoliciesError = { + data: undefined, + isLoading: false, + isError: true, + error: new Error('Failed to load policies'), +}; + +export function resetAllMocks() { + mockOnUpdateLabel.mockClear(); + mockOnNext.mockClear(); + mockOnBack.mockClear(); + mockOnCancel.mockClear(); + mockOnSelectPolicy.mockClear(); +} diff --git a/app/src/tests/fixtures/pathways/report/views/PopulationViewMocks.ts b/app/src/tests/fixtures/pathways/report/views/PopulationViewMocks.ts new file mode 100644 index 00000000..60c7ca26 --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/views/PopulationViewMocks.ts @@ -0,0 +1,53 @@ +import { PopulationStateProps } from '@/types/pathwayState'; + +export const TEST_POPULATION_LABEL = 'Test Population'; +export const TEST_COUNTRY_ID = 'us'; + +export const mockOnUpdateLabel = vi.fn(); +export const mockOnNext = vi.fn(); +export const mockOnBack = vi.fn(); +export const mockOnCancel = vi.fn(); +export const mockOnScopeSelected = vi.fn(); + +export const mockPopulationStateEmpty: PopulationStateProps = { + label: null, + type: null, + household: null, + geography: null, +}; + +export const mockPopulationStateWithHousehold: PopulationStateProps = { + label: 'My Household', + type: 'household', + household: { + id: '789', + label: 'My Household', + people: {}, + }, + geography: null, +}; + +export const mockPopulationStateWithGeography: PopulationStateProps = { + label: 'National Households', + type: 'geography', + household: null, + geography: { + id: undefined, + geographyId: 'us', + scope: 'national', + name: 'United States', + }, +}; + +export const mockRegionData: any[] = [ + { name: 'Alabama', code: 'al', geography_id: 'us_al' }, + { name: 'California', code: 'ca', geography_id: 'us_ca' }, +]; + +export function resetAllMocks() { + mockOnUpdateLabel.mockClear(); + mockOnNext.mockClear(); + mockOnBack.mockClear(); + mockOnCancel.mockClear(); + mockOnScopeSelected.mockClear(); +} diff --git a/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts b/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts new file mode 100644 index 00000000..f41ae592 --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts @@ -0,0 +1,153 @@ +import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState'; + +export const TEST_REPORT_LABEL = 'Test Report 2025'; +export const TEST_SIMULATION_LABEL = 'Test Simulation'; +export const TEST_COUNTRY_ID = 'us'; +export const TEST_CURRENT_LAW_ID = 1; + +export const mockOnUpdateLabel = vi.fn(); +export const mockOnNext = vi.fn(); +export const mockOnBack = vi.fn(); +export const mockOnCancel = vi.fn(); +export const mockOnCreateNew = vi.fn(); +export const mockOnLoadExisting = vi.fn(); +export const mockOnSelectDefaultBaseline = vi.fn(); +export const mockOnNavigateToSimulationSelection = vi.fn(); +export const mockOnPrefillPopulation2 = vi.fn(); +export const mockOnSelectSimulation = vi.fn(); +export const mockOnSubmit = vi.fn(); + +export const mockSimulationState: SimulationStateProps = { + id: undefined, + label: null, + countryId: TEST_COUNTRY_ID, + policy: { + id: undefined, + label: null, + countryId: TEST_COUNTRY_ID, + parameters: [], + }, + population: { + label: null, + type: null, + household: null, + geography: null, + }, + apiVersion: null, + status: 'pending', +}; + +export const mockConfiguredSimulation: SimulationStateProps = { + id: '123', + label: 'Baseline Simulation', + countryId: TEST_COUNTRY_ID, + policy: { + id: '456', + label: 'Current Law', + countryId: TEST_COUNTRY_ID, + parameters: [], + }, + population: { + label: 'My Household', + type: 'household', + household: { + id: '789', + label: 'My Household', + people: {}, + }, + geography: null, + }, + apiVersion: '0.1.0', + status: 'completed', +}; + +export const mockReportState: ReportStateProps = { + id: undefined, + label: null, + countryId: TEST_COUNTRY_ID, + simulations: [mockSimulationState, mockSimulationState], + apiVersion: null, + status: 'pending', + outputType: undefined, + output: null, +}; + +export const mockReportStateWithConfiguredBaseline: ReportStateProps = { + ...mockReportState, + simulations: [mockConfiguredSimulation, mockSimulationState], +}; + +export const mockReportStateWithBothConfigured: ReportStateProps = { + ...mockReportState, + simulations: [mockConfiguredSimulation, { ...mockConfiguredSimulation, id: '124', label: 'Reform Simulation' }], +}; + +export const mockUseCurrentCountry = vi.fn(() => TEST_COUNTRY_ID); + +export const mockUseUserSimulationsEmpty = { + data: [], + isLoading: false, + isError: false, + error: null, +}; + +export const mockEnhancedUserSimulation = { + userSimulation: { id: 1, label: 'My Simulation', simulation_id: '123', user_id: 1 }, + simulation: { + id: '123', + label: 'Test Simulation', + policyId: '456', + populationId: '789', + countryId: TEST_COUNTRY_ID, + }, + userPolicy: { id: 1, label: 'Test Policy', policy_id: '456', user_id: 1 }, + policy: { id: '456', label: 'Current Law', countryId: TEST_COUNTRY_ID }, + userHousehold: { id: 1, label: 'Test Household', household_id: '789', user_id: 1 }, + household: { id: '789', label: 'My Household', people: {} }, + geography: null, +}; + +export const mockUseUserSimulationsWithData = { + data: [mockEnhancedUserSimulation], + isLoading: false, + isError: false, + error: null, +}; + +export const mockUseUserSimulationsLoading = { + data: undefined, + isLoading: true, + isError: false, + error: null, +}; + +export const mockUseUserSimulationsError = { + data: undefined, + isLoading: false, + isError: true, + error: new Error('Failed to load simulations'), +}; + +export const mockUseUserHouseholdsEmpty = { + data: [], + isLoading: false, +}; + +export const mockUseUserGeographicsEmpty = { + data: [], + isLoading: false, +}; + +export function resetAllMocks() { + mockOnUpdateLabel.mockClear(); + mockOnNext.mockClear(); + mockOnBack.mockClear(); + mockOnCancel.mockClear(); + mockOnCreateNew.mockClear(); + mockOnLoadExisting.mockClear(); + mockOnSelectDefaultBaseline.mockClear(); + mockOnNavigateToSimulationSelection.mockClear(); + mockOnPrefillPopulation2.mockClear(); + mockOnSelectSimulation.mockClear(); + mockOnSubmit.mockClear(); +} diff --git a/app/src/tests/fixtures/pathways/report/views/SimulationViewMocks.ts b/app/src/tests/fixtures/pathways/report/views/SimulationViewMocks.ts new file mode 100644 index 00000000..ee7d8d1d --- /dev/null +++ b/app/src/tests/fixtures/pathways/report/views/SimulationViewMocks.ts @@ -0,0 +1,90 @@ +import { SimulationStateProps } from '@/types/pathwayState'; + +export const TEST_SIMULATION_LABEL = 'Test Simulation'; +export const TEST_COUNTRY_ID = 'us'; + +export const mockOnUpdateLabel = vi.fn(); +export const mockOnNext = vi.fn(); +export const mockOnBack = vi.fn(); +export const mockOnCancel = vi.fn(); +export const mockOnNavigateToPolicy = vi.fn(); +export const mockOnNavigateToPopulation = vi.fn(); +export const mockOnSubmit = vi.fn(); + +export const mockSimulationStateEmpty: SimulationStateProps = { + id: undefined, + label: null, + countryId: TEST_COUNTRY_ID, + policy: { + id: undefined, + label: null, + countryId: TEST_COUNTRY_ID, + parameters: [], + }, + population: { + label: null, + type: null, + household: null, + geography: null, + }, + apiVersion: null, + status: 'pending', +}; + +export const mockSimulationStateConfigured: SimulationStateProps = { + id: '123', + label: 'Test Simulation', + countryId: TEST_COUNTRY_ID, + policy: { + id: '456', + label: 'Current Law', + countryId: TEST_COUNTRY_ID, + parameters: [], + }, + population: { + label: 'My Household', + type: 'household', + household: { + id: '789', + label: 'My Household', + people: {}, + }, + geography: null, + }, + apiVersion: '0.1.0', + status: 'completed', +}; + +export const mockSimulationStateWithPolicy: SimulationStateProps = { + ...mockSimulationStateEmpty, + policy: { + id: '456', + label: 'Current Law', + countryId: TEST_COUNTRY_ID, + parameters: [], + }, +}; + +export const mockSimulationStateWithPopulation: SimulationStateProps = { + ...mockSimulationStateEmpty, + population: { + label: 'My Household', + type: 'household', + household: { + id: '789', + label: 'My Household', + people: {}, + }, + geography: null, + }, +}; + +export function resetAllMocks() { + mockOnUpdateLabel.mockClear(); + mockOnNext.mockClear(); + mockOnBack.mockClear(); + mockOnCancel.mockClear(); + mockOnNavigateToPolicy.mockClear(); + mockOnNavigateToPopulation.mockClear(); + mockOnSubmit.mockClear(); +} diff --git a/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx new file mode 100644 index 00000000..9566917a --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx @@ -0,0 +1,322 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import ReportLabelView from '@/pathways/report/views/ReportLabelView'; +import { + TEST_REPORT_LABEL, + TEST_COUNTRY_ID, + mockOnUpdateLabel, + mockOnNext, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +describe('ReportLabelView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /create report/i })).toBeInTheDocument(); + }); + + test('given component renders then displays report name input', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/report name/i)).toBeInTheDocument(); + }); + + test('given component renders then displays year select', () => { + // When + const { container } = render( + + ); + + // Then - Year select exists as a disabled input + const yearInputs = container.querySelectorAll('input[disabled]'); + const hasYearInput = Array.from(yearInputs).some(input => + input.getAttribute('aria-haspopup') === 'listbox' + ); + expect(hasYearInput).toBe(true); + }); + + test('given component renders then year select is disabled', () => { + // When + const { container } = render( + + ); + + // Then - Find the Select input (has aria-haspopup="listbox") + const selectInput = container.querySelector('input[aria-haspopup="listbox"]'); + expect(selectInput).toBeDisabled(); + }); + }); + + describe('US country specific', () => { + test('given US country then displays Initialize button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('us'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialize report/i })).toBeInTheDocument(); + }); + }); + + describe('UK country specific', () => { + test('given UK country then displays Initialise button with British spelling', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('uk'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialise report/i })).toBeInTheDocument(); + }); + }); + + describe('Pre-populated label', () => { + test('given existing label then input shows label value', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/report name/i)).toHaveValue(TEST_REPORT_LABEL); + }); + + test('given null label then input is empty', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/report name/i)).toHaveValue(''); + }); + }); + + describe('User interactions', () => { + test('given user types in label then input value updates', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/report name/i); + + // When + await user.type(input, 'New Report Name'); + + // Then + expect(input).toHaveValue('New Report Name'); + }); + + test('given user clicks submit then calls onUpdateLabel with entered value', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/report name/i); + const submitButton = screen.getByRole('button', { name: /initialize report/i }); + + // When + await user.type(input, 'Test Report'); + await user.click(submitButton); + + // Then + expect(mockOnUpdateLabel).toHaveBeenCalledWith('Test Report'); + }); + + test('given user clicks submit then calls onNext', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const submitButton = screen.getByRole('button', { name: /initialize report/i }); + + // When + await user.click(submitButton); + + // Then + expect(mockOnNext).toHaveBeenCalled(); + }); + + test('given user clicks submit with empty label then still submits empty string', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const submitButton = screen.getByRole('button', { name: /initialize report/i }); + + // When + await user.click(submitButton); + + // Then + expect(mockOnUpdateLabel).toHaveBeenCalledWith(''); + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onBack not provided then no back button', () => { + // When + render( + + ); + + // Then + expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + test('given user clicks back then calls onBack', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /back/i })); + + // Then + expect(mockOnBack).toHaveBeenCalled(); + }); + + test('given user clicks cancel then calls onCancel', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /cancel/i })); + + // Then + expect(mockOnCancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx new file mode 100644 index 00000000..fc0e2dea --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx @@ -0,0 +1,328 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import ReportSetupView from '@/pathways/report/views/ReportSetupView'; +import { + mockReportState, + mockReportStateWithConfiguredBaseline, + mockReportStateWithBothConfigured, + mockOnNavigateToSimulationSelection, + mockOnNext, + mockOnPrefillPopulation2, + mockOnBack, + mockOnCancel, + mockUseUserHouseholdsEmpty, + mockUseUserGeographicsEmpty, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; + +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: vi.fn(), + isHouseholdMetadataWithAssociation: vi.fn(), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useUserGeographics: vi.fn(), + isGeographicMetadataWithAssociation: vi.fn(), +})); + +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useUserGeographics } from '@/hooks/useUserGeographic'; + +describe('ReportSetupView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(useUserHouseholds).mockReturnValue(mockUseUserHouseholdsEmpty); + vi.mocked(useUserGeographics).mockReturnValue(mockUseUserGeographicsEmpty); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /configure report/i })).toBeInTheDocument(); + }); + + test('given component renders then displays baseline simulation card', () => { + // When + render( + + ); + + // Then - Multiple "Baseline simulation" texts exist, just verify at least one + expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); + }); + + test('given component renders then displays comparison simulation card', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); + }); + }); + + describe('Unconfigured simulations', () => { + test('given no simulations configured then comparison card shows waiting message', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/waiting for baseline/i)).toBeInTheDocument(); + }); + + test('given no simulations configured then comparison card is disabled', () => { + // When + const { container } = render( + + ); + + // Then - Find card by looking for the disabled state in the Card component + const cards = container.querySelectorAll('[data-variant^="setupCondition"]'); + const comparisonCard = Array.from(cards).find(card => + card.textContent?.includes('Comparison simulation') + ); + // The card should have disabled styling or be marked as disabled + expect(comparisonCard).toBeDefined(); + expect(comparisonCard?.textContent).toContain('Waiting for baseline'); + }); + + test('given no simulations configured then primary button is disabled', () => { + // When + render( + + ); + + // Then + const buttons = screen.getAllByRole('button'); + const primaryButton = buttons.find(btn => + btn.textContent?.includes('Configure baseline simulation') && + btn.className?.includes('Button') + ); + expect(primaryButton).toBeDisabled(); + }); + }); + + describe('Baseline configured', () => { + test('given baseline configured with household then comparison is optional', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/comparison simulation \(optional\)/i)).toBeInTheDocument(); + }); + + test('given baseline configured then comparison card is enabled', () => { + // When + render( + + ); + + // Then + const cards = screen.getAllByRole('button'); + const comparisonCard = cards.find(card => card.textContent?.includes('Comparison simulation')); + expect(comparisonCard).not.toHaveAttribute('data-disabled', 'true'); + }); + + test('given baseline configured with household then can proceed without comparison', () => { + // When + render( + + ); + + // Then + const buttons = screen.getAllByRole('button'); + const reviewButton = buttons.find(btn => btn.textContent?.includes('Review report')); + expect(reviewButton).not.toBeDisabled(); + }); + }); + + describe('Both simulations configured', () => { + test('given both simulations configured then shows Review report button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /review report/i })).toBeInTheDocument(); + }); + + test('given both simulations configured then Review button is enabled', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /review report/i })).not.toBeDisabled(); + }); + }); + + describe('User interactions', () => { + test('given user selects baseline card then calls navigation with index 0', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const cards = screen.getAllByRole('button'); + const baselineCard = cards.find(card => card.textContent?.includes('Baseline simulation')); + + // When + await user.click(baselineCard!); + const configureButton = screen.getByRole('button', { name: /configure baseline simulation/i }); + await user.click(configureButton); + + // Then + expect(mockOnNavigateToSimulationSelection).toHaveBeenCalledWith(0); + }); + + test('given user selects comparison card when baseline configured then prefills population', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const cards = screen.getAllByRole('button'); + const comparisonCard = cards.find(card => card.textContent?.includes('Comparison simulation')); + + // When + await user.click(comparisonCard!); + const configureButton = screen.getByRole('button', { name: /configure comparison simulation/i }); + await user.click(configureButton); + + // Then + expect(mockOnPrefillPopulation2).toHaveBeenCalled(); + expect(mockOnNavigateToSimulationSelection).toHaveBeenCalledWith(1); + }); + + test('given both configured and review clicked then calls onNext', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /review report/i })); + + // Then + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx new file mode 100644 index 00000000..e80738fd --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx @@ -0,0 +1,259 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import ReportSimulationExistingView from '@/pathways/report/views/ReportSimulationExistingView'; +import { + mockSimulationState, + mockEnhancedUserSimulation, + mockOnSelectSimulation, + mockOnNext, + mockOnBack, + mockOnCancel, + mockUseUserSimulationsEmpty, + mockUseUserSimulationsWithData, + mockUseUserSimulationsLoading, + mockUseUserSimulationsError, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; + +vi.mock('@/hooks/useUserSimulations', () => ({ + useUserSimulations: vi.fn(), +})); + +import { useUserSimulations } from '@/hooks/useUserSimulations'; + +describe('ReportSimulationExistingView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Loading state', () => { + test('given loading then displays loading message', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsLoading as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/loading simulations/i)).toBeInTheDocument(); + }); + }); + + describe('Error state', () => { + test('given error then displays error message', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsError as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/error/i)).toBeInTheDocument(); + expect(screen.getByText(/failed to load simulations/i)).toBeInTheDocument(); + }); + }); + + describe('Empty state', () => { + test('given no simulations then displays no simulations message', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/no simulations available/i)).toBeInTheDocument(); + }); + + test('given no simulations then next button is disabled', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + }); + + describe('With simulations', () => { + test('given simulations available then displays simulation cards', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/my simulation/i)).toBeInTheDocument(); + }); + + test('given simulations available then next button initially disabled', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + }); + + describe('User interactions', () => { + test('given user selects simulation then next button is enabled', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + render( + + ); + const simulationCard = screen.getByText(/my simulation/i).closest('button'); + + // When + await user.click(simulationCard!); + + // Then + expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); + }); + + test('given user selects and submits then calls callbacks', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + render( + + ); + const simulationCard = screen.getByText(/my simulation/i).closest('button'); + + // When + await user.click(simulationCard!); + await user.click(screen.getByRole('button', { name: /next/i })); + + // Then + expect(mockOnSelectSimulation).toHaveBeenCalledWith(mockEnhancedUserSimulation); + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Population compatibility', () => { + test('given incompatible population then simulation is disabled', () => { + // Given + const otherSim = { + ...mockSimulationState, + population: { + ...mockSimulationState.population, + household: { id: 'different-household-999', label: 'Different Household', people: {} }, + }, + }; + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/incompatible/i)).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx new file mode 100644 index 00000000..de8dcc75 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx @@ -0,0 +1,283 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import ReportSimulationSelectionView from '@/pathways/report/views/ReportSimulationSelectionView'; +import { + TEST_COUNTRY_ID, + TEST_CURRENT_LAW_ID, + mockOnCreateNew, + mockOnLoadExisting, + mockOnSelectDefaultBaseline, + mockOnBack, + mockOnCancel, + mockUseUserSimulationsEmpty, + mockUseUserSimulationsWithData, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; + +vi.mock('@/hooks/useUserSimulations', () => ({ + useUserSimulations: vi.fn(), +})); + +vi.mock('@/hooks/useCreateSimulation', () => ({ + useCreateSimulation: vi.fn(() => ({ + createSimulation: vi.fn(), + isPending: false, + })), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useCreateGeographicAssociation: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), +})); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: vi.fn(() => ({ data: [], isLoading: false })), +})); + +vi.mock('@/hooks/useUserPolicy', () => ({ + useUserPolicies: vi.fn(() => ({ data: [], isLoading: false })), +})); + +vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) })); + +import { useUserSimulations } from '@/hooks/useUserSimulations'; + +describe('ReportSimulationSelectionView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Baseline simulation (index 0)', () => { + test('given baseline simulation then displays default baseline option', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/current law for all households nationwide/i)).toBeInTheDocument(); + }); + + test('given baseline simulation then displays create new option', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/create new simulation/i)).toBeInTheDocument(); + }); + + test('given user has simulations then displays load existing option', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/load existing simulation/i)).toBeInTheDocument(); + }); + + test('given user has no simulations then load existing not shown', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.queryByText(/load existing simulation/i)).not.toBeInTheDocument(); + }); + }); + + describe('Comparison simulation (index 1)', () => { + test('given comparison simulation then default baseline option not shown', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.queryByText(/current law for all households nationwide/i)).not.toBeInTheDocument(); + }); + + test('given comparison simulation then only shows standard options', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByText(/create new simulation/i)).toBeInTheDocument(); + expect(screen.queryByText(/load existing simulation/i)).not.toBeInTheDocument(); + }); + }); + + describe('User interactions', () => { + test('given user clicks create new then calls onCreateNew', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + render( + + ); + const cards = screen.getAllByRole('button'); + const createNewCard = cards.find(card => card.textContent?.includes('Create new simulation')); + + // When + await user.click(createNewCard!); + await user.click(screen.getByRole('button', { name: /next/i })); + + // Then + expect(mockOnCreateNew).toHaveBeenCalled(); + }); + + test('given user clicks load existing then calls onLoadExisting', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithData as any); + render( + + ); + const cards = screen.getAllByRole('button'); + const loadExistingCard = cards.find(card => card.textContent?.includes('Load existing simulation')); + + // When + await user.click(loadExistingCard!); + await user.click(screen.getByRole('button', { name: /next/i })); + + // Then + expect(mockOnLoadExisting).toHaveBeenCalled(); + }); + + test('given no selection then next button is disabled', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // Given + vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty as any); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx b/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx new file mode 100644 index 00000000..46d42982 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx @@ -0,0 +1,274 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import ReportSubmitView from '@/pathways/report/views/ReportSubmitView'; +import { + mockReportState, + mockReportStateWithConfiguredBaseline, + mockReportStateWithBothConfigured, + mockOnSubmit, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/ReportViewMocks'; + +describe('ReportSubmitView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /review report configuration/i })).toBeInTheDocument(); + }); + + test('given component renders then displays subtitle', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/review your selected simulations/i)).toBeInTheDocument(); + }); + + test('given component renders then displays baseline simulation box', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/baseline simulation/i)).toBeInTheDocument(); + }); + + test('given component renders then displays comparison simulation box', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); + }); + + test('given component renders then displays create report button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).toBeInTheDocument(); + }); + }); + + describe('Configured baseline simulation', () => { + test('given baseline configured then shows simulation label', () => { + // When + render( + + ); + + // Then + expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); + }); + + test('given baseline configured then shows policy and population info', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/current law/i)).toBeInTheDocument(); + expect(screen.getByText(/my household/i)).toBeInTheDocument(); + }); + }); + + describe('Both simulations configured', () => { + test('given both configured then shows both simulation labels', () => { + // When + render( + + ); + + // Then + expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); + expect(screen.getByText(/reform simulation/i)).toBeInTheDocument(); + }); + }); + + describe('Unconfigured simulations', () => { + test('given no simulations configured then shows no simulation placeholders', () => { + // When + render( + + ); + + // Then + const noSimulationTexts = screen.getAllByText(/no simulation/i); + expect(noSimulationTexts).toHaveLength(2); + }); + }); + + describe('User interactions', () => { + test('given user clicks submit then calls onSubmit', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /create report/i })); + + // Then + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + test('given isSubmitting true then button shows loading state', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).toBeDisabled(); + }); + + test('given isSubmitting false then button is enabled', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).not.toBeDisabled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + test('given user clicks back then calls onBack', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /back/i })); + + // Then + expect(mockOnBack).toHaveBeenCalled(); + }); + + test('given user clicks cancel then calls onCancel', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /cancel/i })); + + // Then + expect(mockOnCancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/policy/PolicyExistingView.test.tsx b/app/src/tests/unit/pathways/report/views/policy/PolicyExistingView.test.tsx new file mode 100644 index 00000000..1a316a79 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/policy/PolicyExistingView.test.tsx @@ -0,0 +1,157 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import PolicyExistingView from '@/pathways/report/views/policy/PolicyExistingView'; +import { + mockOnSelectPolicy, + mockOnBack, + mockOnCancel, + mockUseUserPoliciesEmpty, + mockUseUserPoliciesWithData, + mockUseUserPoliciesLoading, + mockUseUserPoliciesError, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/PolicyViewMocks'; + +vi.mock('@/hooks/useUserPolicy', () => ({ + useUserPolicies: vi.fn(), + isPolicyMetadataWithAssociation: vi.fn((val) => val && val.policy && val.association), +})); + +import { useUserPolicies } from '@/hooks/useUserPolicy'; + +describe('PolicyExistingView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Loading state', () => { + test('given loading then displays loading message', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesLoading as any); + + // When + render(); + + // Then + expect(screen.getByText(/loading policies/i)).toBeInTheDocument(); + }); + }); + + describe('Error state', () => { + test('given error then displays error message', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesError as any); + + // When + render(); + + // Then + expect(screen.getByText(/error/i)).toBeInTheDocument(); + expect(screen.getByText(/failed to load policies/i)).toBeInTheDocument(); + }); + }); + + describe('Empty state', () => { + test('given no policies then displays no policies message', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesEmpty as any); + + // When + render(); + + // Then + expect(screen.getByText(/no policies available/i)).toBeInTheDocument(); + }); + + test('given no policies then next button is disabled', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesEmpty as any); + + // When + render(); + + // Then + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + }); + + describe('With policies', () => { + test('given policies available then displays policy cards', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesWithData as any); + + // When + render(); + + // Then + expect(screen.getByText(/my policy/i)).toBeInTheDocument(); + }); + + test('given policies available then next button initially disabled', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesWithData as any); + + // When + render(); + + // Then + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + }); + + describe('User interactions', () => { + test('given user selects policy then next button is enabled', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesWithData as any); + render(); + const policyCard = screen.getByText(/my policy/i).closest('button'); + + // When + await user.click(policyCard!); + + // Then + expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); + }); + + test('given user selects and submits then calls onSelectPolicy', async () => { + // Given + const user = userEvent.setup(); + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesWithData as any); + render(); + const policyCard = screen.getByText(/my policy/i).closest('button'); + + // When + await user.click(policyCard!); + await user.click(screen.getByRole('button', { name: /next/i })); + + // Then + expect(mockOnSelectPolicy).toHaveBeenCalled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesEmpty as any); + + // When + render(); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // Given + vi.mocked(useUserPolicies).mockReturnValue(mockUseUserPoliciesEmpty as any); + + // When + render(); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/policy/PolicyLabelView.test.tsx b/app/src/tests/unit/pathways/report/views/policy/PolicyLabelView.test.tsx new file mode 100644 index 00000000..2053683f --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/policy/PolicyLabelView.test.tsx @@ -0,0 +1,235 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import PolicyLabelView from '@/pathways/report/views/policy/PolicyLabelView'; +import { + TEST_POLICY_LABEL, + TEST_COUNTRY_ID, + mockOnUpdateLabel, + mockOnNext, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/PolicyViewMocks'; + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +describe('PolicyLabelView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + }); + + describe('Standalone mode', () => { + test('given standalone mode then displays create policy title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /create policy/i })).toBeInTheDocument(); + }); + + test('given standalone mode then displays policy title input', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toBeInTheDocument(); + }); + + test('given standalone mode and null label then shows default My policy', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toHaveValue('My policy'); + }); + }); + + describe('Report mode', () => { + test('given report mode without simulationIndex then throws error', () => { + // Given/When/Then + expect(() => render( + + )).toThrow('simulationIndex is required'); + }); + + test('given report mode baseline then shows baseline policy default label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toHaveValue('Baseline policy'); + }); + + test('given report mode reform then shows reform policy default label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toHaveValue('Reform policy'); + }); + }); + + describe('User interactions', () => { + test('given user types label then input updates', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/policy title/i); + + // When + await user.clear(input); + await user.type(input, TEST_POLICY_LABEL); + + // Then + expect(input).toHaveValue(TEST_POLICY_LABEL); + }); + + test('given user submits then calls onUpdateLabel and onNext', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const submitButton = screen.getByRole('button', { name: /initialize policy/i }); + + // When + await user.click(submitButton); + + // Then + expect(mockOnUpdateLabel).toHaveBeenCalled(); + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Country-specific text', () => { + test('given US country then shows Initialize button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('us'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialize policy/i })).toBeInTheDocument(); + }); + + test('given UK country then shows Initialise button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('uk'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialise policy/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/population/PopulationLabelView.test.tsx b/app/src/tests/unit/pathways/report/views/population/PopulationLabelView.test.tsx new file mode 100644 index 00000000..8e25267c --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/population/PopulationLabelView.test.tsx @@ -0,0 +1,279 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import PopulationLabelView from '@/pathways/report/views/population/PopulationLabelView'; +import { + TEST_POPULATION_LABEL, + TEST_COUNTRY_ID, + mockPopulationStateEmpty, + mockPopulationStateWithHousehold, + mockPopulationStateWithGeography, + mockOnUpdateLabel, + mockOnNext, + mockOnBack, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/PopulationViewMocks'; + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +describe('PopulationLabelView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /name your household/i })).toBeInTheDocument(); + }); + + test('given component renders then displays household label input', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toBeInTheDocument(); + }); + }); + + describe('Default labels', () => { + test('given household population then shows Custom Household default', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toHaveValue('Custom Household'); + }); + + test('given geography population then shows geography-based label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toHaveValue('National Households'); + }); + + test('given existing label then shows that label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toHaveValue('My Household'); + }); + }); + + describe('Report mode validation', () => { + test('given report mode without simulationIndex then throws error', () => { + // Given/When/Then + expect(() => render( + + )).toThrow('simulationIndex is required'); + }); + + test('given report mode with simulationIndex then renders without error', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /name your household/i })).toBeInTheDocument(); + }); + }); + + describe('User interactions', () => { + test('given user types label then input updates', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/household label/i); + + // When + await user.clear(input); + await user.type(input, TEST_POPULATION_LABEL); + + // Then + expect(input).toHaveValue(TEST_POPULATION_LABEL); + }); + + test('given user submits valid label then calls callbacks', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/household label/i); + const submitButton = screen.getByRole('button', { name: /initialize household/i }); + + // When + await user.clear(input); + await user.type(input, TEST_POPULATION_LABEL); + await user.click(submitButton); + + // Then + expect(mockOnUpdateLabel).toHaveBeenCalledWith(TEST_POPULATION_LABEL); + expect(mockOnNext).toHaveBeenCalled(); + }); + + test('given user submits empty label then shows error', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/household label/i); + const submitButton = screen.getByRole('button', { name: /initialize household/i }); + + // When + await user.clear(input); + await user.click(submitButton); + + // Then + expect(screen.getByText(/please enter a label/i)).toBeInTheDocument(); + expect(mockOnUpdateLabel).not.toHaveBeenCalled(); + expect(mockOnNext).not.toHaveBeenCalled(); + }); + }); + + describe('Country-specific text', () => { + test('given US country then shows Initialize button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('us'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialize household/i })).toBeInTheDocument(); + }); + + test('given UK country then shows Initialise button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('uk'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialise household/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given no onBack then no back button', () => { + // When + render( + + ); + + // Then + expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/population/PopulationScopeView.test.tsx b/app/src/tests/unit/pathways/report/views/population/PopulationScopeView.test.tsx new file mode 100644 index 00000000..bca6ef1f --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/population/PopulationScopeView.test.tsx @@ -0,0 +1,112 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@test-utils'; +import PopulationScopeView from '@/pathways/report/views/population/PopulationScopeView'; +import { + TEST_COUNTRY_ID, + mockRegionData, + mockOnScopeSelected, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/PopulationViewMocks'; + +describe('PopulationScopeView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /select household scope/i })).toBeInTheDocument(); + }); + + test('given component renders then displays select scope button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /select scope/i })).toBeInTheDocument(); + }); + }); + + describe('US country options', () => { + test('given US country then displays US geographic options', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/national/i)).toBeInTheDocument(); + }); + }); + + describe('UK country options', () => { + test('given UK country then renders without error', () => { + // When + const { container } = render( + + ); + + // Then + expect(container).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/simulation/SimulationLabelView.test.tsx b/app/src/tests/unit/pathways/report/views/simulation/SimulationLabelView.test.tsx new file mode 100644 index 00000000..6d84b711 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/simulation/SimulationLabelView.test.tsx @@ -0,0 +1,255 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import SimulationLabelView from '@/pathways/report/views/simulation/SimulationLabelView'; +import { + TEST_SIMULATION_LABEL, + TEST_COUNTRY_ID, + mockOnUpdateLabel, + mockOnNext, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/SimulationViewMocks'; + +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: vi.fn(), +})); + +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +describe('SimulationLabelView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(useCurrentCountry).mockReturnValue(TEST_COUNTRY_ID); + }); + + describe('Standalone mode', () => { + test('given standalone mode then displays create simulation title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /create simulation/i })).toBeInTheDocument(); + }); + + test('given standalone mode then displays simulation name input', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toBeInTheDocument(); + }); + + test('given standalone mode and null label then shows default My simulation', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('My simulation'); + }); + }); + + describe('Report mode', () => { + test('given report mode without simulationIndex then throws error', () => { + // Given/When/Then + expect(() => render( + + )).toThrow('simulationIndex is required'); + }); + + test('given report mode baseline then shows baseline simulation default label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('Baseline simulation'); + }); + + test('given report mode reform then shows reform simulation default label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('Reform simulation'); + }); + + test('given report label then incorporates into default label', () => { + // When + render( + + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('My Report baseline simulation'); + }); + }); + + describe('User interactions', () => { + test('given user types label then input updates', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/simulation name/i); + + // When + await user.clear(input); + await user.type(input, TEST_SIMULATION_LABEL); + + // Then + expect(input).toHaveValue(TEST_SIMULATION_LABEL); + }); + + test('given user submits then calls onUpdateLabel with value', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const input = screen.getByLabelText(/simulation name/i); + const submitButton = screen.getByRole('button', { name: /initialize simulation/i }); + + // When + await user.clear(input); + await user.type(input, TEST_SIMULATION_LABEL); + await user.click(submitButton); + + // Then + expect(mockOnUpdateLabel).toHaveBeenCalledWith(TEST_SIMULATION_LABEL); + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Country-specific text', () => { + test('given US country then shows Initialize button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('us'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialize simulation/i })).toBeInTheDocument(); + }); + + test('given UK country then shows Initialise button', () => { + // Given + vi.mocked(useCurrentCountry).mockReturnValue('uk'); + + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /initialise simulation/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/pathways/report/views/simulation/SimulationSetupView.test.tsx b/app/src/tests/unit/pathways/report/views/simulation/SimulationSetupView.test.tsx new file mode 100644 index 00000000..b67ac146 --- /dev/null +++ b/app/src/tests/unit/pathways/report/views/simulation/SimulationSetupView.test.tsx @@ -0,0 +1,313 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '@test-utils'; +import SimulationSetupView from '@/pathways/report/views/simulation/SimulationSetupView'; +import { + mockSimulationStateEmpty, + mockSimulationStateConfigured, + mockSimulationStateWithPolicy, + mockSimulationStateWithPopulation, + mockOnNavigateToPolicy, + mockOnNavigateToPopulation, + mockOnNext, + mockOnBack, + mockOnCancel, + resetAllMocks, +} from '@/tests/fixtures/pathways/report/views/SimulationViewMocks'; + +describe('SimulationSetupView', () => { + beforeEach(() => { + resetAllMocks(); + vi.clearAllMocks(); + }); + + describe('Basic rendering', () => { + test('given component renders then displays title', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('heading', { name: /configure simulation/i })).toBeInTheDocument(); + }); + + test('given empty simulation then displays add household card', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/add household\(s\)/i)).toBeInTheDocument(); + }); + + test('given empty simulation then displays add policy card', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/add policy/i)).toBeInTheDocument(); + }); + }); + + describe('Configured simulation', () => { + test('given fully configured simulation then shows policy label', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/current law/i)).toBeInTheDocument(); + }); + + test('given fully configured simulation then shows population label', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/my household/i)).toBeInTheDocument(); + }); + + test('given fully configured simulation then Next button is enabled', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); + }); + }); + + describe('Partial configuration', () => { + test('given only policy configured then primary button is disabled', () => { + // When + render( + + ); + + // Then + const buttons = screen.getAllByRole('button'); + const configureButton = buttons.find(btn => btn.textContent?.includes('Configure household')); + expect(configureButton).toBeDisabled(); + }); + + test('given only population configured then primary button is disabled', () => { + // When + render( + + ); + + // Then + const buttons = screen.getAllByRole('button'); + const configureButton = buttons.find(btn => btn.textContent?.includes('Configure household')); + expect(configureButton).toBeDisabled(); + }); + }); + + describe('Report mode simulation 2', () => { + test('given report mode sim 2 with population then shows from baseline', () => { + // When + render( + + ); + + // Then + expect(screen.getAllByText(/from baseline/i).length).toBeGreaterThan(0); + }); + + test('given report mode sim 2 with population then shows inherited message', () => { + // When + render( + + ); + + // Then + expect(screen.getByText(/inherited from baseline/i)).toBeInTheDocument(); + }); + }); + + describe('User interactions', () => { + test('given user selects population card then enables configure button', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const cards = screen.getAllByRole('button'); + const populationCard = cards.find(card => card.textContent?.includes('Add household')); + + // When + await user.click(populationCard!); + + // Then + const configureButton = screen.getByRole('button', { name: /configure household/i }); + expect(configureButton).not.toBeDisabled(); + }); + + test('given user configures population and submits then calls onNavigateToPopulation', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + const cards = screen.getAllByRole('button'); + const populationCard = cards.find(card => card.textContent?.includes('Add household')); + + // When + await user.click(populationCard!); + await user.click(screen.getByRole('button', { name: /configure household/i })); + + // Then + expect(mockOnNavigateToPopulation).toHaveBeenCalled(); + }); + + test('given fully configured and Next clicked then calls onNext', async () => { + // Given + const user = userEvent.setup(); + render( + + ); + + // When + await user.click(screen.getByRole('button', { name: /next/i })); + + // Then + expect(mockOnNext).toHaveBeenCalled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); From 7d14ec2f04d7e1dc8472e80f6ac6aa106759837a Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 13:07:02 +0100 Subject: [PATCH 37/49] test: Fix existing tests --- .../unit/api/geographicAssociation.test.ts | 13 ++++++---- .../components/common/PathwayView.test.tsx | 26 +++++++------------ .../unit/utils/reportPopulationLock.test.ts | 8 +++--- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/app/src/tests/unit/api/geographicAssociation.test.ts b/app/src/tests/unit/api/geographicAssociation.test.ts index 627a737f..98a94753 100644 --- a/app/src/tests/unit/api/geographicAssociation.test.ts +++ b/app/src/tests/unit/api/geographicAssociation.test.ts @@ -283,14 +283,17 @@ describe('LocalStorageGeographicStore', () => { expect(result.createdAt).toBeDefined(); }); - it('given duplicate population then throws error', async () => { + it('given duplicate population then allows creation', async () => { // Given await store.create(mockPopulation1); - // When/Then - await expect(store.create(mockPopulation1)).rejects.toThrow( - 'Geographic population already exists' - ); + // When + const result = await store.create(mockPopulation1); + + // Then - Implementation allows duplicates for multiple entries of same geography + expect(result).toEqual(mockPopulation1); + const allPopulations = await store.findByUser('user-123'); + expect(allPopulations).toHaveLength(2); }); }); diff --git a/app/src/tests/unit/components/common/PathwayView.test.tsx b/app/src/tests/unit/components/common/PathwayView.test.tsx index 63993569..15765a61 100644 --- a/app/src/tests/unit/components/common/PathwayView.test.tsx +++ b/app/src/tests/unit/components/common/PathwayView.test.tsx @@ -362,11 +362,11 @@ describe('PathwayView', () => { expect(cancelButton).toBeDisabled(); }); - test('given cancel action then renders disabled cancel button', () => { + test('given cancel action with onClick then renders enabled cancel button', () => { render(); const cancelButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }); - expect(cancelButton).toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); }); test('given user clicks primary button then calls primary handler', async () => { @@ -382,7 +382,7 @@ describe('PathwayView', () => { }); describe('Button Precedence', () => { - test('given explicit buttons and convenience props then explicit buttons take precedence', () => { + test('given explicit buttons and convenience props then uses new layout with actions', () => { render( { /> ); - // Should show explicit buttons, not the primary/cancel actions + // When convenience props are provided, they take precedence over explicit buttons expect( - screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.BACK_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }) ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CONTINUE_BUTTON }) + screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) - ).not.toBeInTheDocument(); }); - test('given no actions and no preset then renders default cancel button', () => { + test('given no actions and no preset then renders no buttons', () => { render(); - expect( - screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON }) - ).toBeInTheDocument(); + // Without any button configuration, no buttons are rendered + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); }); }); }); diff --git a/app/src/tests/unit/utils/reportPopulationLock.test.ts b/app/src/tests/unit/utils/reportPopulationLock.test.ts index ce8cbefa..25da491c 100644 --- a/app/src/tests/unit/utils/reportPopulationLock.test.ts +++ b/app/src/tests/unit/utils/reportPopulationLock.test.ts @@ -108,20 +108,20 @@ describe('reportPopulationLock', () => { }); describe('getPopulationSelectionTitle', () => { - it('given shouldLock true then returns Apply Household(s)', () => { + it('given shouldLock true then returns Apply household(s)', () => { // When const result = getPopulationSelectionTitle(true); // Then - expect(result).toBe('Apply Household(s)'); + expect(result).toBe('Apply household(s)'); }); - it('given shouldLock false then returns Select Household(s)', () => { + it('given shouldLock false then returns Select household(s)', () => { // When const result = getPopulationSelectionTitle(false); // Then - expect(result).toBe('Select Household(s)'); + expect(result).toBe('Select household(s)'); }); }); From 46a07e2b9f5e8b686709a001162a3404d7985dcf Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 20 Nov 2025 13:36:00 +0100 Subject: [PATCH 38/49] chore: Lint --- app/src/Router.tsx | 8 +- .../components/common/MultiButtonFooter.tsx | 7 +- .../components/common/PaginationControls.tsx | 6 +- app/src/components/common/PathwayView.tsx | 53 +- .../population/HouseholdBuilderFrame.tsx | 664 ------------------ app/src/hooks/usePathwayNavigation.ts | 13 +- .../pathways/policy/PolicyPathwayWrapper.tsx | 40 +- .../population/PopulationPathwayWrapper.tsx | 118 ++-- .../pathways/report/ReportPathwayWrapper.tsx | 171 +++-- .../components/DefaultBaselineOption.tsx | 22 +- .../PolicyParameterSelectorMain.tsx | 4 +- .../PolicyParameterSelectorValueSetter.tsx | 8 +- .../policyParameterSelector/Menu.tsx | 2 +- .../valueSetters/DateValueSelector.tsx | 15 +- .../valueSetters/DefaultValueSelector.tsx | 15 +- .../valueSetters/MultiYearValueSelector.tsx | 3 +- .../valueSetters/ValueSetterProps.ts | 2 +- .../valueSetters/YearlyValueSelector.tsx | 15 +- .../valueSetters/getDefaultValueForParam.ts | 2 +- .../report/components/valueSetters/index.ts | 12 +- .../pathways/report/views/ReportLabelView.tsx | 10 +- .../pathways/report/views/ReportSetupView.tsx | 25 +- .../views/ReportSimulationExistingView.tsx | 29 +- .../views/ReportSimulationSelectionView.tsx | 21 +- .../report/views/ReportSubmitView.tsx | 19 +- .../views/policy/PolicyExistingView.tsx | 20 +- .../report/views/policy/PolicyLabelView.tsx | 6 +- .../policy/PolicyParameterSelectorView.tsx | 8 +- .../report/views/policy/PolicySubmitView.tsx | 4 +- .../population/GeographicConfirmationView.tsx | 2 +- .../views/population/HouseholdBuilderView.tsx | 120 +--- .../population/PopulationExistingView.tsx | 11 +- .../views/population/PopulationLabelView.tsx | 6 +- .../views/population/PopulationScopeView.tsx | 2 +- .../views/simulation/SimulationLabelView.tsx | 6 +- .../simulation/SimulationPolicySetupView.tsx | 20 +- .../SimulationPopulationSetupView.tsx | 4 +- .../views/simulation/SimulationSetupView.tsx | 5 +- .../views/simulation/SimulationSubmitView.tsx | 6 +- .../simulation/SimulationPathwayWrapper.tsx | 105 +-- .../report/ReportPathwayWrapperMocks.ts | 10 +- .../components/DefaultBaselineOptionMocks.ts | 1 - .../report/views/PopulationViewMocks.ts | 9 +- .../pathways/report/views/ReportViewMocks.ts | 23 +- .../report/views/SimulationViewMocks.ts | 19 +- .../utils/isDefaultBaselineSimulationMocks.ts | 3 - .../components/common/PathwayView.test.tsx | 40 +- .../policy/PolicyPathwayWrapper.test.tsx | 16 +- .../PopulationPathwayWrapper.test.tsx | 16 +- .../report/ReportPathwayWrapper.test.tsx | 27 +- .../components/DefaultBaselineOption.test.tsx | 17 +- .../report/views/ReportLabelView.test.tsx | 89 +-- .../report/views/ReportSetupView.test.tsx | 48 +- .../ReportSimulationExistingView.test.tsx | 21 +- .../ReportSimulationSelectionView.test.tsx | 25 +- .../report/views/ReportSubmitView.test.tsx | 16 +- .../views/policy/PolicyExistingView.test.tsx | 11 +- .../views/policy/PolicyLabelView.test.tsx | 31 +- .../population/PopulationLabelView.test.tsx | 35 +- .../population/PopulationScopeView.test.tsx | 8 +- .../simulation/SimulationLabelView.test.tsx | 35 +- .../simulation/SimulationSetupView.test.tsx | 30 +- .../SimulationPathwayWrapper.test.tsx | 33 +- .../utils/isDefaultBaselineSimulation.test.ts | 23 +- .../initializeReportState.test.ts | 6 +- app/src/types/pathwayModes/ReportViewMode.ts | 6 +- app/src/types/pathwayModes/SharedViewModes.ts | 5 +- .../convertSimulationStateToApi.ts | 10 +- .../utils/ingredientReconstruction/index.ts | 5 +- .../reconstructPopulation.ts | 4 +- .../reconstructSimulation.ts | 6 +- .../utils/pathwayCallbacks/policyCallbacks.ts | 87 ++- .../pathwayCallbacks/populationCallbacks.ts | 40 +- .../utils/pathwayCallbacks/reportCallbacks.ts | 58 +- .../pathwayCallbacks/simulationCallbacks.ts | 135 ++-- .../utils/validation/ingredientValidation.ts | 40 +- 76 files changed, 1027 insertions(+), 1570 deletions(-) delete mode 100644 app/src/frames/population/HouseholdBuilderFrame.tsx diff --git a/app/src/Router.tsx b/app/src/Router.tsx index 54682117..217b23d2 100644 --- a/app/src/Router.tsx +++ b/app/src/Router.tsx @@ -17,15 +17,15 @@ import SimulationsPage from './pages/Simulations.page'; import SupportersPage from './pages/Supporters.page'; import TeamPage from './pages/Team.page'; import TermsPage from './pages/Terms.page'; +import PolicyPathwayWrapper from './pathways/policy/PolicyPathwayWrapper'; +import PopulationPathwayWrapper from './pathways/population/PopulationPathwayWrapper'; +import ReportPathwayWrapper from './pathways/report/ReportPathwayWrapper'; +import SimulationPathwayWrapper from './pathways/simulation/SimulationPathwayWrapper'; import { CountryAppGuard } from './routing/guards/CountryAppGuard'; import { CountryGuard } from './routing/guards/CountryGuard'; import { MetadataGuard } from './routing/guards/MetadataGuard'; import { MetadataLazyLoader } from './routing/guards/MetadataLazyLoader'; import { RedirectToCountry } from './routing/RedirectToCountry'; -import ReportPathwayWrapper from './pathways/report/ReportPathwayWrapper'; -import SimulationPathwayWrapper from './pathways/simulation/SimulationPathwayWrapper'; -import PopulationPathwayWrapper from './pathways/population/PopulationPathwayWrapper'; -import PolicyPathwayWrapper from './pathways/policy/PolicyPathwayWrapper'; const router = createBrowserRouter( [ diff --git a/app/src/components/common/MultiButtonFooter.tsx b/app/src/components/common/MultiButtonFooter.tsx index 6134e358..50ef2b43 100644 --- a/app/src/components/common/MultiButtonFooter.tsx +++ b/app/src/components/common/MultiButtonFooter.tsx @@ -1,5 +1,5 @@ -import { Box, Button, Group, SimpleGrid } from '@mantine/core'; import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import { Box, Button, Group, SimpleGrid } from '@mantine/core'; import PaginationControls, { PaginationConfig } from './PaginationControls'; export interface ButtonConfig { @@ -42,10 +42,7 @@ export default function MultiButtonFooter(props: MultiButtonFooterProps) { {/* Left side: Cancel button */} {cancelAction && ( - )} diff --git a/app/src/components/common/PaginationControls.tsx b/app/src/components/common/PaginationControls.tsx index 61406e14..ef05733c 100644 --- a/app/src/components/common/PaginationControls.tsx +++ b/app/src/components/common/PaginationControls.tsx @@ -1,5 +1,5 @@ -import { ActionIcon, Group, Text } from '@mantine/core'; import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import { ActionIcon, Group, Text } from '@mantine/core'; export interface PaginationConfig { currentPage: number; @@ -33,7 +33,9 @@ export default function PaginationControls({ pagination }: PaginationControlsPro pagination.onPageChange(Math.min(pagination.totalPages, pagination.currentPage + 1))} + onClick={() => + pagination.onPageChange(Math.min(pagination.totalPages, pagination.currentPage + 1)) + } disabled={pagination.currentPage === pagination.totalPages} aria-label="Next page" > diff --git a/app/src/components/common/PathwayView.tsx b/app/src/components/common/PathwayView.tsx index e6a5bc21..55c4e665 100644 --- a/app/src/components/common/PathwayView.tsx +++ b/app/src/components/common/PathwayView.tsx @@ -113,27 +113,35 @@ export default function PathwayView({ if (useNewLayout) { return { buttons: [] as ButtonConfig[], - cancelAction: cancelAction?.onClick ? { - label: cancelAction.label || 'Cancel', - onClick: cancelAction.onClick, - } : undefined, - backAction: backAction ? { - label: backAction.label || 'Back', - onClick: backAction.onClick, - } : undefined, - primaryAction: primaryAction ? { - label: primaryAction.label, - onClick: primaryAction.onClick, - isLoading: primaryAction.isLoading, - isDisabled: primaryAction.isDisabled, - } : undefined, - pagination: shouldShowPaginationInFooter ? { - currentPage, - totalPages, - totalItems, - itemsPerPage, - onPageChange: setCurrentPage, - } : undefined, + cancelAction: cancelAction?.onClick + ? { + label: cancelAction.label || 'Cancel', + onClick: cancelAction.onClick, + } + : undefined, + backAction: backAction + ? { + label: backAction.label || 'Back', + onClick: backAction.onClick, + } + : undefined, + primaryAction: primaryAction + ? { + label: primaryAction.label, + onClick: primaryAction.onClick, + isLoading: primaryAction.isLoading, + isDisabled: primaryAction.isDisabled, + } + : undefined, + pagination: shouldShowPaginationInFooter + ? { + currentPage, + totalPages, + totalItems, + itemsPerPage, + onPageChange: setCurrentPage, + } + : undefined, }; } @@ -158,7 +166,8 @@ export default function PathwayView({ }; const footerProps = getFooterProps(); - const hasFooter = footerProps.buttons?.length > 0 || + const hasFooter = + footerProps.buttons?.length > 0 || footerProps.cancelAction || footerProps.backAction || footerProps.primaryAction; diff --git a/app/src/frames/population/HouseholdBuilderFrame.tsx b/app/src/frames/population/HouseholdBuilderFrame.tsx deleted file mode 100644 index 9bce5346..00000000 --- a/app/src/frames/population/HouseholdBuilderFrame.tsx +++ /dev/null @@ -1,664 +0,0 @@ -import { useEffect, useState } from 'react'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { - Divider, - Group, - LoadingOverlay, - NumberInput, - Select, - Stack, - Text, - TextInput, -} from '@mantine/core'; -import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; -import FlowView from '@/components/common/FlowView'; -import { useCreateHousehold } from '@/hooks/useCreateHousehold'; -import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { useIngredientReset } from '@/hooks/useIngredientReset'; -import { useReportYear } from '@/hooks/useReportYear'; -import { - getBasicInputFields, - getFieldLabel, - getFieldOptions, - isDropdownField, -} from '@/libs/metadataUtils'; -import { selectActivePopulation, selectCurrentPosition } from '@/reducers/activeSelectors'; -import { - initializeHouseholdAtPosition, - setHouseholdAtPosition, - updatePopulationAtPosition, - updatePopulationIdAtPosition, -} from '@/reducers/populationReducer'; -import { RootState } from '@/store'; -import { FlowComponentProps } from '@/types/flow'; -import { Household } from '@/types/ingredients/Household'; -import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; -import * as HouseholdQueries from '@/utils/HouseholdQueries'; -import { HouseholdValidation } from '@/utils/HouseholdValidation'; -import { getInputFormattingProps } from '@/utils/householdValues'; - -export default function HouseholdBuilderFrame({ - onNavigate, - onReturn, - isInSubflow, -}: FlowComponentProps) { - const dispatch = useDispatch(); - const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state)); - const populationState = useSelector((state: RootState) => selectActivePopulation(state)); - const { createHousehold, isPending } = useCreateHousehold(populationState?.label || ''); - const { resetIngredient } = useIngredientReset(); - const countryId = useCurrentCountry(); - const reportYear = useReportYear(); - - // Get metadata-driven options - const basicInputFields = useSelector(getBasicInputFields); - const variables = useSelector((state: RootState) => state.metadata.variables); - - const { loading, error } = useSelector((state: RootState) => state.metadata); - - // Error boundary: Show error if no report year available - if (!reportYear) { - return ( - - - Configuration Error - - - No report year available. Please return to the report creation page and select a year - before creating a household. - -
- } - buttonPreset="cancel-only" - cancelAction={{ - onClick: onReturn, - }} - /> - ); - } - - // Initialize with empty household if none exists - const [household, setLocalHousehold] = useState(() => { - if (populationState?.household) { - return populationState.household; - } - const builder = new HouseholdBuilder(countryId as any, reportYear); - return builder.build(); - }); - - // Helper to get default value for a variable from metadata - const getVariableDefault = (variableName: string): any => { - const snakeCaseName = variableName.replace(/([A-Z])/g, '_$1').toLowerCase(); - const variable = variables?.[snakeCaseName] || variables?.[variableName]; - return variable?.defaultValue ?? 0; - }; - - // State for form controls - const [maritalStatus, setMaritalStatus] = useState<'single' | 'married'>('single'); - const [numChildren, setNumChildren] = useState(0); - - // Initialize household on mount if not exists - useEffect(() => { - if (!populationState?.household) { - dispatch( - initializeHouseholdAtPosition({ - position: currentPosition, - countryId, - year: reportYear, - }) - ); - } - }, [populationState?.household, countryId, dispatch, currentPosition, reportYear]); - - // Build household based on form values - useEffect(() => { - const builder = new HouseholdBuilder(countryId as any, reportYear); - - // Get current people to preserve their data - const currentPeople = Object.keys(household.householdData.people); - const hasYou = currentPeople.includes('you'); - const hasPartner = currentPeople.includes('your partner'); - - // Add or update primary adult - if (hasYou) { - // Preserve existing data - builder.loadHousehold(household); - } else { - // Add new "you" person with defaults from metadata - const ageDefault = getVariableDefault('age'); - const defaults: Record = {}; - basicInputFields.person.forEach((field: string) => { - if (field !== 'age') { - defaults[field] = getVariableDefault(field); - } - }); - builder.addAdult('you', ageDefault, defaults); - } - - // Handle spouse based on marital status - if (maritalStatus === 'married') { - if (!hasPartner) { - // Add partner with defaults from metadata - const ageDefault = getVariableDefault('age'); - const defaults: Record = {}; - basicInputFields.person.forEach((field: string) => { - if (field !== 'age') { - defaults[field] = getVariableDefault(field); - } - }); - builder.addAdult('your partner', ageDefault, defaults); - } - builder.setMaritalStatus('you', 'your partner'); - } else if (hasPartner) { - // Remove partner if switching to single - builder.removePerson('your partner'); - } - - // Handle children - const currentChildCount = HouseholdQueries.getChildCount(household, reportYear); - if (numChildren !== currentChildCount) { - // Remove all existing children - const children = HouseholdQueries.getChildren(household, reportYear); - children.forEach((child) => builder.removePerson(child.name)); - - // Add new children with defaults (age 10, other variables from metadata) - if (numChildren > 0) { - const parentIds = maritalStatus === 'married' ? ['you', 'your partner'] : ['you']; - const childDefaults: Record = {}; - basicInputFields.person.forEach((field: string) => { - if (field !== 'age') { - childDefaults[field] = getVariableDefault(field); - } - }); - - for (let i = 0; i < numChildren; i++) { - const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; - const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; - builder.addChild(childName, 10, parentIds, childDefaults); - } - } - } - - // Add required group entities for US - if (countryId === 'us') { - // Create household group - builder.assignToGroupEntity('you', 'households', 'your household'); - if (maritalStatus === 'married') { - builder.assignToGroupEntity('your partner', 'households', 'your household'); - } - for (let i = 0; i < numChildren; i++) { - const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; - const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; - builder.assignToGroupEntity(childName, 'households', 'your household'); - } - - // Create family - builder.assignToGroupEntity('you', 'families', 'your family'); - if (maritalStatus === 'married') { - builder.assignToGroupEntity('your partner', 'families', 'your family'); - } - for (let i = 0; i < numChildren; i++) { - const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; - const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; - builder.assignToGroupEntity(childName, 'families', 'your family'); - } - - // Create tax unit - builder.assignToGroupEntity('you', 'taxUnits', 'your tax unit'); - if (maritalStatus === 'married') { - builder.assignToGroupEntity('your partner', 'taxUnits', 'your tax unit'); - } - for (let i = 0; i < numChildren; i++) { - const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; - const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; - builder.assignToGroupEntity(childName, 'taxUnits', 'your tax unit'); - } - - // Create SPM unit - builder.assignToGroupEntity('you', 'spmUnits', 'your household'); - if (maritalStatus === 'married') { - builder.assignToGroupEntity('your partner', 'spmUnits', 'your household'); - } - for (let i = 0; i < numChildren; i++) { - const ordinals = ['first', 'second', 'third', 'fourth', 'fifth']; - const childName = `your ${ordinals[i] || `${i + 1}th`} dependent`; - builder.assignToGroupEntity(childName, 'spmUnits', 'your household'); - } - } - - setLocalHousehold(builder.build()); - }, [maritalStatus, numChildren, reportYear, countryId]); - - // Handle adult field changes - const handleAdultChange = (person: string, field: string, value: number | string) => { - const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; - const updatedHousehold = { ...household }; - - if (!updatedHousehold.householdData.people[person]) { - updatedHousehold.householdData.people[person] = {}; - } - - if (!updatedHousehold.householdData.people[person][field]) { - updatedHousehold.householdData.people[person][field] = {}; - } - - updatedHousehold.householdData.people[person][field][reportYear] = numValue; - setLocalHousehold(updatedHousehold); - }; - - // Handle child field changes - const handleChildChange = (childKey: string, field: string, value: number | string) => { - const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; - const updatedHousehold = { ...household }; - - if (!updatedHousehold.householdData.people[childKey]) { - updatedHousehold.householdData.people[childKey] = {}; - } - - if (!updatedHousehold.householdData.people[childKey][field]) { - updatedHousehold.householdData.people[childKey][field] = {}; - } - - updatedHousehold.householdData.people[childKey][field][reportYear] = numValue; - setLocalHousehold(updatedHousehold); - }; - - // Handle group entity field changes - const handleGroupEntityChange = ( - entityName: string, - groupKey: string, - field: string, - value: string | null - ) => { - const updatedHousehold = { ...household }; - - // Ensure entity exists - if (!updatedHousehold.householdData[entityName]) { - updatedHousehold.householdData[entityName] = {}; - } - - const entities = updatedHousehold.householdData[entityName] as Record; - - // Ensure group exists - if (!entities[groupKey]) { - entities[groupKey] = { members: [] }; - } - - entities[groupKey][field] = { [reportYear]: value || '' }; - setLocalHousehold(updatedHousehold); - }; - - // Convenience function for household-level fields - const handleHouseholdFieldChange = (field: string, value: string | null) => { - handleGroupEntityChange('households', 'your household', field, value); - }; - - // Show error state if metadata failed to load - if (error) { - return ( - - - Failed to Load Required Data - - - Unable to load household configuration data. Please refresh the page and try again. - -
- } - buttonPreset="cancel-only" - /> - ); - } - - // Get field options for all household fields at once - const fieldOptionsMap = useSelector((state: RootState) => { - const options: Record> = {}; - basicInputFields.household.forEach((field) => { - if (isDropdownField(state, field)) { - options[field] = getFieldOptions(state, field); - } - }); - return options; - }, shallowEqual); - - const handleSubmit = async () => { - // Sync final household to Redux before submit - dispatch( - setHouseholdAtPosition({ - position: currentPosition, - household, - }) - ); - - // Validate household - const validation = HouseholdValidation.isReadyForSimulation(household, reportYear); - if (!validation.isValid) { - console.error('Household validation failed:', validation.errors); - return; - } - - // Convert to API format - const payload = HouseholdAdapter.toCreationPayload(household.householdData, countryId); - - console.log('Creating household with payload:', payload); - - try { - const result = await createHousehold(payload); - console.log('Household created successfully:', result); - - const householdId = result.result.household_id; - const label = populationState?.label || ''; - - // Update population state with the created household ID - dispatch( - updatePopulationIdAtPosition({ - position: currentPosition, - id: householdId, - }) - ); - dispatch( - updatePopulationAtPosition({ - position: currentPosition, - updates: { - label, - isCreated: true, - }, - }) - ); - - // If standalone flow, reset - if (!isInSubflow) { - resetIngredient('population'); - } - - // Navigate - if (onReturn) { - onReturn(); - } else { - onNavigate('next'); - } - } catch (err) { - console.error('Failed to create household:', err); - } - }; - - // Render household-level fields dynamically - const renderHouseholdFields = () => { - if (!basicInputFields.household.length) { - return null; - } - - return ( - - - Location & Geographic Information - - {basicInputFields.household.map((field) => { - const fieldVariable = variables?.[field]; - const isDropdown = !!( - fieldVariable && - fieldVariable.possibleValues && - Array.isArray(fieldVariable.possibleValues) - ); - const fieldLabel = getFieldLabel(field); - const fieldValue = - household.householdData.households?.['your household']?.[field]?.[reportYear] || ''; - - if (isDropdown) { - const options = fieldOptionsMap[field] || []; - return ( - setMaritalStatus((val || 'single') as 'single' | 'married')} - data={[ - { value: 'single', label: 'Single' }, - { value: 'married', label: 'Married' }, - ]} - /> - - - - ); - - const primaryAction = { - label: 'Create report', - onClick: submissionHandler, - }; - - return ; -} diff --git a/app/src/frames/report/ReportSubmitFrame.tsx b/app/src/frames/report/ReportSubmitFrame.tsx deleted file mode 100644 index 9f229772..00000000 --- a/app/src/frames/report/ReportSubmitFrame.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useDispatch, useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; -import { ReportAdapter } from '@/adapters'; -import IngredientSubmissionView, { SummaryBoxItem } from '@/components/IngredientSubmissionView'; -import { useCreateReport } from '@/hooks/useCreateReport'; -import { useIngredientReset } from '@/hooks/useIngredientReset'; -import { clearFlow } from '@/reducers/flowReducer'; -import { selectGeographyAtPosition, selectHouseholdAtPosition } from '@/reducers/populationReducer'; -import { selectBothSimulations } from '@/reducers/simulationsReducer'; -import { RootState } from '@/store'; -import { FlowComponentProps } from '@/types/flow'; -import { Report } from '@/types/ingredients/Report'; -import { ReportCreationPayload } from '@/types/payloads'; -import { getReportOutputPath } from '@/utils/reportRouting'; - -export default function ReportSubmitFrame({ isInSubflow }: FlowComponentProps) { - console.log('[ReportSubmitFrame] ========== COMPONENT RENDER =========='); - console.log('[ReportSubmitFrame] isInSubflow:', isInSubflow); - // Get navigation hook - const navigate = useNavigate(); - const dispatch = useDispatch(); - - // Get report state from Redux - const reportState = useSelector((state: RootState) => state.report); - // Use selectBothSimulations to get simulations at positions 0 and 1 - const [simulation1, simulation2] = useSelector((state: RootState) => - selectBothSimulations(state) - ); - - // Get population data (household or geography) for each simulation - const household1 = useSelector((state: RootState) => selectHouseholdAtPosition(state, 0)); - const household2 = useSelector((state: RootState) => selectHouseholdAtPosition(state, 1)); - const geography1 = useSelector((state: RootState) => selectGeographyAtPosition(state, 0)); - const geography2 = useSelector((state: RootState) => selectGeographyAtPosition(state, 1)); - - const { createReport, isPending } = useCreateReport(reportState.label || undefined); - const { resetIngredient } = useIngredientReset(); - - function handleSubmit() { - console.log('[ReportSubmitFrame] ========== SUBMIT CLICKED =========='); - console.log('[ReportSubmitFrame] Report state:', reportState); - console.log('[ReportSubmitFrame] Simulation1:', simulation1); - console.log('[ReportSubmitFrame] Simulation2:', simulation2); - // TODO: This code isn't really correct. Simulations should be created in - // the SimulationSubmitFrame, then their IDs should be passed over to the - // simulation reducer, then used here. This will be dealt with in separate commit. - // Get the simulation IDs from the simulations - const sim1Id = simulation1?.id; - const sim2Id = simulation2?.id; - - // Validation: Prevent 0-simulation reports - // At least one simulation must be configured - if (!sim1Id) { - console.error('[ReportSubmitFrame] Cannot submit report: no simulations configured'); - return; - } - - // Submit both simulations if they exist and aren't created yet - // TODO: Add logic to create simulations if !isCreated before submitting report - - // Prepare the report data for creation - const reportData: Partial = { - countryId: reportState.countryId, - year: reportState.year, - simulationIds: [sim1Id, sim2Id].filter(Boolean) as string[], - apiVersion: reportState.apiVersion, - }; - - const serializedReportCreationPayload: ReportCreationPayload = ReportAdapter.toCreationPayload( - reportData as Report - ); - - // The createReport hook expects countryId, payload, and simulation metadata - createReport( - { - countryId: reportState.countryId, - payload: serializedReportCreationPayload, - simulations: { - simulation1, - simulation2, - }, - populations: { - household1, - household2, - geography1, - geography2, - }, - }, - { - onSuccess: (data) => { - console.log('[ReportSubmitFrame] ========== REPORT CREATED SUCCESSFULLY =========='); - console.log('[ReportSubmitFrame] Created report:', data.userReport); - const outputPath = getReportOutputPath(reportState.countryId, data.userReport.id); - console.log('[ReportSubmitFrame] Navigating to:', outputPath); - navigate(outputPath); - console.log('[ReportSubmitFrame] isInSubflow:', isInSubflow); - if (!isInSubflow) { - console.log('[ReportSubmitFrame] Calling clearFlow() and resetIngredient("report")'); - dispatch(clearFlow()); - resetIngredient('report'); - } else { - console.log('[ReportSubmitFrame] Skipping clearFlow and resetIngredient (in subflow)'); - } - }, - } - ); - } - - // Create summary boxes based on the simulations - const summaryBoxes: SummaryBoxItem[] = [ - { - title: 'Baseline simulation', - description: - simulation1?.label || (simulation1?.id ? `Simulation #${simulation1.id}` : 'No simulation'), - isFulfilled: !!simulation1, - badge: simulation1 - ? `Policy #${simulation1.policyId} • Population #${simulation1.populationId}` - : undefined, - }, - { - title: 'Comparison simulation', - description: - simulation2?.label || (simulation2?.id ? `Simulation #${simulation2.id}` : 'No simulation'), - isFulfilled: !!simulation2, - isDisabled: !simulation2, - badge: simulation2 - ? `Policy #${simulation2.policyId} • Population #${simulation2.populationId}` - : undefined, - }, - ]; - - return ( - - ); -} diff --git a/app/src/hooks/useReportYear.ts b/app/src/hooks/useReportYear.ts index 11949e5e..0fc4f53f 100644 --- a/app/src/hooks/useReportYear.ts +++ b/app/src/hooks/useReportYear.ts @@ -1,17 +1,19 @@ -import { useSelector } from 'react-redux'; -import { selectReportYear } from '@/reducers/reportReducer'; +import { useReportYearContext } from '@/contexts/ReportYearContext'; /** - * Hook to access the current report year from Redux state + * Hook to access the current report year from context * - * @returns The current report year (e.g., '2025') + * @returns The current report year (e.g., '2025') or null if not in a report pathway * * @example * ```tsx * const reportYear = useReportYear(); + * if (!reportYear) { + * return
No report year available
; + * } * console.log(`Calculating for year: ${reportYear}`); * ``` */ -export function useReportYear(): string { - return useSelector(selectReportYear); +export function useReportYear(): string | null { + return useReportYearContext(); } diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 90b45f7b..0703fc70 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -126,9 +126,8 @@ export default function PoliciesPage() { return ( <> - - - + - +
- - - - + - +
+ - + ); } diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index 1a65f663..a42918a9 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -201,10 +201,8 @@ export default function ReportsPage() { return ( <> - - - - + - +
- - - - + - + state.metadata); + // Early return if no report year available (shouldn't happen in report output context) + if (!reportYear) { + return ( + + Error: Report year not available + + ); + } + // Get policy data for variations const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); diff --git a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx index ee5c0d83..1be22c57 100644 --- a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx +++ b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx @@ -55,6 +55,15 @@ export default function MarginalTaxRatesSubPage({ const metadata = useSelector((state: RootState) => state.metadata); const chartHeight = getClampedChartHeight(viewportHeight, mobile); + // Early return if no report year available (shouldn't happen in report output context) + if (!reportYear) { + return ( + + Error: Report year not available + + ); + } + // Get policy data for variations const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); diff --git a/app/src/pathways/report/ReportPathwayWrapper.tsx b/app/src/pathways/report/ReportPathwayWrapper.tsx index c3c5e09c..58a71e5a 100644 --- a/app/src/pathways/report/ReportPathwayWrapper.tsx +++ b/app/src/pathways/report/ReportPathwayWrapper.tsx @@ -13,6 +13,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { ReportAdapter } from '@/adapters'; import StandardLayout from '@/components/StandardLayout'; import { MOCK_USER_ID } from '@/constants'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; import { useCreateReport } from '@/hooks/useCreateReport'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; import { useUserGeographics } from '@/hooks/useUserGeographic'; @@ -593,6 +594,11 @@ export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrappe // Views in MODES_WITH_OWN_LAYOUT manage their own AppShell const needsStandardLayout = !MODES_WITH_OWN_LAYOUT.has(currentMode); + // Wrap with ReportYearProvider so child components can access the year + const wrappedView = ( + {currentView} + ); + // This is a workaround to allow the param setter to manage its own AppShell - return needsStandardLayout ? {currentView} : currentView; + return needsStandardLayout ? {wrappedView} : wrappedView; } diff --git a/app/src/pathways/report/views/ReportLabelView.tsx b/app/src/pathways/report/views/ReportLabelView.tsx index 3514ff17..02ece3fb 100644 --- a/app/src/pathways/report/views/ReportLabelView.tsx +++ b/app/src/pathways/report/views/ReportLabelView.tsx @@ -18,9 +18,9 @@ interface ReportLabelViewProps { export default function ReportLabelView({ label, - year, + year, onUpdateLabel, - onUpdateYear, + onUpdateYear, onNext, onBack, onCancel, diff --git a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx index 268f6fdc..8028a582 100644 --- a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx +++ b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx @@ -425,8 +425,12 @@ export default function HouseholdBuilderView({ /> handleAdultChange('you', 'employment_income', val || 0)} min={0} diff --git a/app/src/reducers/reportReducer.ts b/app/src/reducers/reportReducer.ts deleted file mode 100644 index be55ae0d..00000000 --- a/app/src/reducers/reportReducer.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { CURRENT_YEAR } from '@/constants'; -import { countryIds, DEFAULT_COUNTRY } from '@/libs/countries'; -import { RootState } from '@/store'; -import { Report, ReportOutput } from '@/types/ingredients/Report'; - -// Local report state - countryId synced from URL via metadata state -interface ReportState extends Report { - activeSimulationPosition: 0 | 1; - mode: 'standalone' | 'report'; - createdAt: string; // Local state tracking - updatedAt: string; // Local state tracking -} - -const initialState: ReportState = { - id: '', - label: null, - countryId: DEFAULT_COUNTRY, // Fallback until clearReport thunk sets actual country from URL - year: CURRENT_YEAR, // Default to current year until set by ReportCreationFrame - simulationIds: [], - apiVersion: null, - status: 'pending', - output: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - activeSimulationPosition: 0, - mode: 'standalone', -}; - -/** - * Thunk to clear report and initialize with country from URL - * Accepts countryId directly from route parameters to avoid race conditions - * with metadata state synchronization - */ -export const clearReport = createAsyncThunk< - (typeof countryIds)[number], - (typeof countryIds)[number], // Accept countryId as parameter - { state: RootState } ->('report/clearReport', async (countryId) => { - return countryId; -}); - -export const reportSlice = createSlice({ - name: 'report', - initialState, - reducers: { - // Add a simulation ID to the report - addSimulationId: (state, action: PayloadAction) => { - if (!state.simulationIds.includes(action.payload)) { - state.simulationIds.push(action.payload); - state.updatedAt = new Date().toISOString(); - } - }, - - // Remove a simulation ID from the report - removeSimulationId: (state, action: PayloadAction) => { - state.simulationIds = state.simulationIds.filter((id) => id !== action.payload); - state.updatedAt = new Date().toISOString(); - }, - - // Update API version - updateApiVersion: (state, action: PayloadAction) => { - state.apiVersion = action.payload; - }, - - // Update country ID (rarely used - clearReport thunk handles initialization) - updateCountryId: (state, action: PayloadAction) => { - state.countryId = action.payload; - }, - - // Update report year - updateYear: (state, action: PayloadAction) => { - state.year = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report label - updateLabel: (state, action: PayloadAction) => { - state.label = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report ID - updateReportId: (state, action: PayloadAction) => { - state.id = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report status - updateReportStatus: (state, action: PayloadAction<'pending' | 'complete' | 'error'>) => { - state.status = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report output - updateReportOutput: (state, action: PayloadAction) => { - state.output = action.payload as any; // Can be economy or household output - state.updatedAt = new Date().toISOString(); - }, - - // Mark report as complete (sets status to complete) - markReportAsComplete: (state) => { - state.status = 'complete'; - state.updatedAt = new Date().toISOString(); - }, - - markReportAsError: (state) => { - state.status = 'error'; - state.updatedAt = new Date().toISOString(); - }, - - // Update timestamps - updateTimestamps: ( - state, - action: PayloadAction<{ createdAt?: string; updatedAt?: string }> - ) => { - if (action.payload.createdAt) { - state.createdAt = action.payload.createdAt; - } - if (action.payload.updatedAt) { - state.updatedAt = action.payload.updatedAt; - } - }, - - // Set the active simulation position (0 or 1) - setActiveSimulationPosition: (state, action: PayloadAction<0 | 1>) => { - state.activeSimulationPosition = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Set the mode (standalone or report) - setMode: (state, action: PayloadAction<'standalone' | 'report'>) => { - state.mode = action.payload; - if (action.payload === 'standalone') { - state.activeSimulationPosition = 0; - } - state.updatedAt = new Date().toISOString(); - }, - - // Initialize report for creation - sets up initial state for report creation flow - initializeReport: (state) => { - // Clear any existing report data - state.id = ''; - state.label = null; - state.year = CURRENT_YEAR; // Reset to current year - state.simulationIds = []; - state.status = 'pending'; - state.output = null; - - // Set up for report mode - state.mode = 'report'; - state.activeSimulationPosition = 0; - - // Update timestamps - const now = new Date().toISOString(); - state.createdAt = now; - state.updatedAt = now; - - // Preserve countryId and apiVersion - }, - }, - extraReducers: (builder) => { - builder.addCase(clearReport.fulfilled, (state, action) => { - console.log('[REPORT REDUCER] ========== clearReport.fulfilled =========='); - console.log('[REPORT REDUCER] Before clear - state:', state); - // Clear all report data - state.id = ''; - state.label = null; - state.year = CURRENT_YEAR; // Reset to current year - state.simulationIds = []; - state.status = 'pending'; - state.output = null; - state.createdAt = new Date().toISOString(); - state.updatedAt = new Date().toISOString(); - // Reset to initial position and mode - state.activeSimulationPosition = 0; - state.mode = 'standalone'; - // Set country from metadata (payload from thunk) - state.countryId = action.payload; - console.log('[REPORT REDUCER] After clear - countryId:', state.countryId); - console.log('[REPORT REDUCER] After clear - mode:', state.mode); - console.log('[REPORT REDUCER] ========== clearReport COMPLETE =========='); - // Preserve apiVersion - }); - }, -}); - -// Action creators are generated for each case reducer function -export const { - addSimulationId, - removeSimulationId, - updateApiVersion, - updateCountryId, - updateYear, - updateLabel, - updateReportId, - updateReportStatus, - updateReportOutput, - markReportAsComplete, - markReportAsError, - updateTimestamps, - setActiveSimulationPosition, - setMode, - initializeReport, -} = reportSlice.actions; - -// Selectors -export const selectActiveSimulationPosition = (state: RootState): 0 | 1 => - state.report.activeSimulationPosition; - -export const selectMode = (state: RootState): 'standalone' | 'report' => state.report.mode; - -export const selectReportYear = (state: RootState): string => state.report.year; - -export default reportSlice.reducer; diff --git a/app/src/tests/fixtures/frames/ReportSubmitFrameMocks.ts b/app/src/tests/fixtures/frames/ReportSubmitFrameMocks.ts deleted file mode 100644 index 7328e895..00000000 --- a/app/src/tests/fixtures/frames/ReportSubmitFrameMocks.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { vi } from 'vitest'; -import { FlowFrame } from '@/types/flow'; -import { Report } from '@/types/ingredients/Report'; -import { Simulation } from '@/types/ingredients/Simulation'; - -// Mock simulations -export const mockSimulation1: Simulation = { - id: '1', - label: 'Test Simulation 1', - policyId: '1', - populationId: '1', - populationType: 'household', - isCreated: true, -}; - -export const mockSimulation2: Simulation = { - id: '2', - label: 'Test Simulation 2', - policyId: '2', - populationId: '2', - populationType: 'household', - isCreated: true, -}; - -export const mockSimulation1NoLabel: Simulation = { - ...mockSimulation1, - label: null, -}; - -export const mockSimulation2NoLabel: Simulation = { - ...mockSimulation2, - label: null, -}; - -// Mock report state - must be complete Report, not Partial -export const mockReportWithLabel: Report = { - id: '', - label: 'My Test Report', - countryId: 'us' as const, - year: '2024', - simulationIds: ['1', '2'], - apiVersion: 'v1', - status: 'pending' as const, - output: null, -}; - -export const mockReportNoLabel: Report = { - ...mockReportWithLabel, - label: null, -}; - -// Mock Redux state - using position-based storage -export const createMockReportState = () => ({ - report: { - reportId: undefined, - label: 'My Test Report', - countryId: 'us' as const, - simulationIds: ['1', '2'], - apiVersion: 'v1', - status: 'pending' as const, - output: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - activeSimulationPosition: 0 as 0 | 1, - mode: 'report' as const, - }, - simulations: { - simulations: [mockSimulation1, mockSimulation2] as [Simulation | null, Simulation | null], - activePosition: null as 0 | 1 | null, - }, -}); - -export const createMockReportStateNoLabels = () => ({ - report: { - reportId: undefined, - label: null, - countryId: 'us' as const, - simulationIds: ['1', '2'], - apiVersion: 'v1', - status: 'pending' as const, - output: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - activeSimulationPosition: 0 as 0 | 1, - mode: 'report' as const, - }, - simulations: { - simulations: [mockSimulation1NoLabel, mockSimulation2NoLabel] as [ - Simulation | null, - Simulation | null, - ], - activePosition: null as 0 | 1 | null, - }, -}); - -// Mock hooks -export const mockCreateReport = vi.fn(); -export const mockResetIngredient = vi.fn(); -export const mockOnNavigate = vi.fn(); -export const mockOnReturn = vi.fn(); - -// Mock flow config -export const mockFlowConfig: FlowFrame = { - component: 'ReportSubmitFrame', - on: { - submit: '__return__', - }, -}; - -// Default flow props -export const defaultFlowProps = { - onNavigate: mockOnNavigate, - onReturn: mockOnReturn, - flowConfig: mockFlowConfig, - isInSubflow: false, - flowDepth: 0, -}; - -// Clear all mocks helper -export const clearAllMocks = () => { - mockCreateReport.mockClear(); - mockResetIngredient.mockClear(); - mockOnNavigate.mockClear(); - mockOnReturn.mockClear(); -}; - -// Mock report creation result data -export const createMockReportCreationResult = (baseReportId: string, userReportId: string) => ({ - report: { - id: baseReportId, - status: 'pending', - country_id: 'us', - }, - userReport: { - id: userReportId, - label: 'Test Report', - }, - metadata: { - baseReportId, - userReportId, - countryId: 'us', - }, -}); - -export const MOCK_REPORT_123 = createMockReportCreationResult('report-123', 'sur-report-123'); -export const MOCK_REPORT_456 = createMockReportCreationResult('report-456', 'sur-report-456'); -export const MOCK_REPORT_789 = createMockReportCreationResult('report-789', 'sur-report-789'); diff --git a/app/src/tests/fixtures/frames/SimulationSetupPolicyFrameMocks.ts b/app/src/tests/fixtures/frames/SimulationSetupPolicyFrameMocks.ts deleted file mode 100644 index d9f16bcf..00000000 --- a/app/src/tests/fixtures/frames/SimulationSetupPolicyFrameMocks.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { vi } from 'vitest'; -import { RootState } from '@/store'; - -// Test constants -export const TEST_CURRENT_LAW_IDS = { - US: 2, - UK: 1, -} as const; - -export const TEST_COUNTRIES = { - US: 'us', - UK: 'uk', -} as const; - -// Button order constants for testing -export const BUTTON_ORDER = { - CURRENT_LAW: 0, - LOAD_EXISTING: 1, - CREATE_NEW: 2, -} as const; - -export const BUTTON_TEXT = { - CURRENT_LAW: { - title: 'Current Law', - description: 'Use the baseline tax-benefit system with no reforms', - }, - LOAD_EXISTING: { - title: 'Load Existing Policy', - description: 'Use a policy you have already created', - }, - CREATE_NEW: { - title: 'Create New Policy', - description: 'Build a new policy', - }, -} as const; - -// Mock navigation function -export const mockOnNavigate = vi.fn(); - -// Mock dispatch function -export const mockDispatch = vi.fn(); - -// Helper to create mock Redux state for SimulationSetupPolicyFrame -export const createMockSimulationSetupPolicyState = (overrides?: { - countryId?: string; - currentLawId?: number; - mode?: 'standalone' | 'report'; - activeSimulationPosition?: 0 | 1; -}): Partial => { - const { - countryId = TEST_COUNTRIES.US, - currentLawId = TEST_CURRENT_LAW_IDS.US, - mode = 'standalone', - activeSimulationPosition = 0, - } = overrides || {}; - - return { - metadata: { - currentCountry: countryId, - loading: false, - error: null, - variables: {}, - parameters: {}, - entities: {}, - variableModules: {}, - economyOptions: { region: [], time_period: [], datasets: [] }, - currentLawId, - basicInputs: [], - modelledPolicies: { core: {}, filtered: {} }, - version: '1.0.0', - parameterTree: null, - }, - report: { - id: '', - label: null, - countryId: countryId as any, - year: '2024', - apiVersion: null, - simulationIds: [], - status: 'pending', - output: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - activeSimulationPosition, - mode, - }, - policy: { - policies: [null, null], - }, - }; -}; - -// Expected policy payloads for current law -export const expectedCurrentLawPolicyUS = { - id: TEST_CURRENT_LAW_IDS.US.toString(), - label: 'Current law', - parameters: [], - isCreated: true, - countryId: TEST_COUNTRIES.US, -}; - -export const expectedCurrentLawPolicyUK = { - id: TEST_CURRENT_LAW_IDS.UK.toString(), - label: 'Current law', - parameters: [], - isCreated: true, - countryId: TEST_COUNTRIES.UK, -}; diff --git a/app/src/utils/pathwayCallbacks/reportCallbacks.ts b/app/src/utils/pathwayCallbacks/reportCallbacks.ts index 84d2f05d..3cc1ae08 100644 --- a/app/src/utils/pathwayCallbacks/reportCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/reportCallbacks.ts @@ -34,9 +34,12 @@ export function createReportCallbacks( /** * Updates the report year */ - const updateYear = useCallback((year: string) => { - setState((prev) => ({ ...prev, year })); - }, [setState]); + const updateYear = useCallback( + (year: string) => { + setState((prev) => ({ ...prev, year })); + }, + [setState] + ); /** * Navigates to simulation selection for a specific simulation slot From d79aacf6471fe893db2ab4860f6cafce55f141ca Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 25 Nov 2025 00:30:59 +0100 Subject: [PATCH 44/49] fix: Clean up and use shared callbacks --- .../pathways/policy/PolicyPathwayWrapper.tsx | 41 ++++----- .../population/PopulationPathwayWrapper.tsx | 89 ++++++------------- .../pathways/report/ReportPathwayWrapper.tsx | 9 +- .../simulation/SimulationPathwayWrapper.tsx | 46 ++++------ .../utils/pathwayCallbacks/policyCallbacks.ts | 14 ++- .../pathwayCallbacks/populationCallbacks.ts | 27 ++++-- .../pathwayCallbacks/simulationCallbacks.ts | 14 ++- 7 files changed, 107 insertions(+), 133 deletions(-) diff --git a/app/src/pathways/policy/PolicyPathwayWrapper.tsx b/app/src/pathways/policy/PolicyPathwayWrapper.tsx index 976c973a..10201c4a 100644 --- a/app/src/pathways/policy/PolicyPathwayWrapper.tsx +++ b/app/src/pathways/policy/PolicyPathwayWrapper.tsx @@ -5,13 +5,14 @@ * Reuses shared views from the report pathway with mode="standalone". */ -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import StandardLayout from '@/components/StandardLayout'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; import { PolicyViewMode } from '@/types/pathwayModes/PolicyViewMode'; import { PolicyStateProps } from '@/types/pathwayState'; +import { createPolicyCallbacks } from '@/utils/pathwayCallbacks'; import { initializePolicyState } from '@/utils/pathwayState/initializePolicyState'; // Policy views (reusing from report pathway) import PolicyLabelView from '../report/views/policy/PolicyLabelView'; @@ -42,31 +43,19 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe ); // ========== CALLBACKS ========== - const updateLabel = useCallback((label: string) => { - setPolicyState((prev) => ({ ...prev, label })); - }, []); - - const updatePolicy = useCallback((updatedPolicy: PolicyStateProps) => { - setPolicyState(updatedPolicy); - }, []); - - const handleSubmitSuccess = useCallback( + // Use shared callback factory with onPolicyComplete for standalone navigation + const policyCallbacks = createPolicyCallbacks( + setPolicyState, + (state) => state, // policySelector: return the state itself (PolicyStateProps) + (_state, policy) => policy, // policyUpdater: replace entire state with new policy + navigateToMode, + PolicyViewMode.SUBMIT, // returnMode (not used in standalone mode) (policyId: string) => { + // onPolicyComplete: custom navigation for standalone pathway console.log('[PolicyPathwayWrapper] Policy created with ID:', policyId); - - setPolicyState((prev) => ({ - ...prev, - id: policyId, - })); - - // Navigate back to policies list page navigate(`/${countryId}/policies`); - - if (onComplete) { - onComplete(); - } - }, - [navigate, countryId, onComplete] + onComplete?.(); + } ); // ========== VIEW RENDERING ========== @@ -78,7 +67,7 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe navigateToMode(PolicyViewMode.PARAMETER_SELECTOR)} onBack={canGoBack ? goBack : undefined} onCancel={() => navigate(`/${countryId}/policies`)} @@ -90,7 +79,7 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe currentView = ( navigateToMode(PolicyViewMode.SUBMIT)} onBack={canGoBack ? goBack : undefined} /> @@ -102,7 +91,7 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe navigate(`/${countryId}/policies`)} /> diff --git a/app/src/pathways/population/PopulationPathwayWrapper.tsx b/app/src/pathways/population/PopulationPathwayWrapper.tsx index 4ba013e6..9e8b5bf4 100644 --- a/app/src/pathways/population/PopulationPathwayWrapper.tsx +++ b/app/src/pathways/population/PopulationPathwayWrapper.tsx @@ -5,17 +5,17 @@ * Reuses shared views from the report pathway with mode="standalone". */ -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import StandardLayout from '@/components/StandardLayout'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; import { RootState } from '@/store'; -import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { PopulationViewMode } from '@/types/pathwayModes/PopulationViewMode'; import { PopulationStateProps } from '@/types/pathwayState'; +import { createPopulationCallbacks } from '@/utils/pathwayCallbacks'; import { initializePopulationState } from '@/utils/pathwayState/initializePopulationState'; import GeographicConfirmationView from '../report/views/population/GeographicConfirmationView'; import HouseholdBuilderView from '../report/views/population/HouseholdBuilderView'; @@ -47,62 +47,27 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw ); // ========== CALLBACKS ========== - const updateLabel = useCallback((label: string) => { - setPopulationState((prev) => ({ ...prev, label })); - }, []); - - const handleScopeSelected = useCallback( - (geography: Geography | null, _scopeType: string) => { - setPopulationState((prev) => ({ - ...prev, - geography: geography || null, - type: geography ? 'geography' : 'household', - })); - navigateToMode(PopulationViewMode.LABEL); - }, - [navigateToMode] - ); - - const handleHouseholdSubmitSuccess = useCallback( - (householdId: string, household: Household) => { - console.log('[PopulationPathwayWrapper] Household created with ID:', householdId); - - setPopulationState((prev) => ({ - ...prev, - household: { ...household, id: householdId }, - })); - - // Navigate back to populations list page - navigate(`/${countryId}/households`); - - if (onComplete) { - onComplete(); - } - }, - [navigate, countryId, onComplete] - ); - - const handleGeographicSubmitSuccess = useCallback( - (geographyId: string, label: string) => { - console.log('[PopulationPathwayWrapper] Geographic population created with ID:', geographyId); - - setPopulationState((prev) => { - const updatedPopulation = { ...prev }; - if (updatedPopulation.geography) { - updatedPopulation.geography.id = geographyId; - } - updatedPopulation.label = label; - return updatedPopulation; - }); - - // Navigate back to populations list page - navigate(`/${countryId}/households`); - - if (onComplete) { - onComplete(); - } - }, - [navigate, countryId, onComplete] + // Use shared callback factory with onPopulationComplete for standalone navigation + const populationCallbacks = createPopulationCallbacks( + setPopulationState, + (state) => state, // populationSelector: return the state itself (PopulationStateProps) + (_state, population) => population, // populationUpdater: replace entire state + navigateToMode, + PopulationViewMode.GEOGRAPHIC_CONFIRM, // returnMode (not used in standalone mode) + PopulationViewMode.LABEL, // labelMode + { + // Custom navigation for standalone pathway: exit to households list + onHouseholdComplete: (householdId: string, _household: Household) => { + console.log('[PopulationPathwayWrapper] Household created with ID:', householdId); + navigate(`/${countryId}/households`); + onComplete?.(); + }, + onGeographyComplete: (geographyId: string, _label: string) => { + console.log('[PopulationPathwayWrapper] Geographic population created with ID:', geographyId); + navigate(`/${countryId}/households`); + onComplete?.(); + }, + } ); // ========== VIEW RENDERING ========== @@ -114,7 +79,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw navigate(`/${countryId}/households`)} /> @@ -126,7 +91,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw { // Navigate based on population type if (populationState.type === 'household') { @@ -145,7 +110,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw ); @@ -156,7 +121,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw ); diff --git a/app/src/pathways/report/ReportPathwayWrapper.tsx b/app/src/pathways/report/ReportPathwayWrapper.tsx index 58a71e5a..dde7d722 100644 --- a/app/src/pathways/report/ReportPathwayWrapper.tsx +++ b/app/src/pathways/report/ReportPathwayWrapper.tsx @@ -139,7 +139,8 @@ export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrappe return { ...state, simulations: newSimulations }; }, navigateToMode, - ReportViewMode.SIMULATION_SETUP + ReportViewMode.SIMULATION_SETUP, + undefined // No onPolicyComplete - stays within report pathway ); // Population callbacks for active simulation @@ -156,7 +157,8 @@ export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrappe }, navigateToMode, ReportViewMode.SIMULATION_SETUP, - ReportViewMode.POPULATION_LABEL + ReportViewMode.POPULATION_LABEL, + undefined // No onPopulationComplete - stays within report pathway ); // Simulation callbacks for active simulation @@ -172,7 +174,8 @@ export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrappe return { ...state, simulations: newSimulations }; }, navigateToMode, - ReportViewMode.REPORT_SETUP + ReportViewMode.REPORT_SETUP, + undefined // No onSimulationComplete - stays within report pathway ); // ========== CUSTOM WRAPPERS FOR SPECIFIC REPORT LOGIC ========== diff --git a/app/src/pathways/simulation/SimulationPathwayWrapper.tsx b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx index bb0fd979..1206659d 100644 --- a/app/src/pathways/simulation/SimulationPathwayWrapper.tsx +++ b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx @@ -99,32 +99,40 @@ export default function SimulationPathwayWrapper({ onComplete }: SimulationPathw }, [hasExistingPopulations, navigateToMode]); // ========== CALLBACK FACTORIES ========== - // Simulation-level callbacks + // Simulation-level callbacks with custom completion handler const simulationCallbacks = createSimulationCallbacks( setSimulationState, (state) => state, (_state, simulation) => simulation, navigateToMode, - SimulationViewMode.SETUP + SimulationViewMode.SETUP, + (simulationId: string) => { + // onSimulationComplete: custom navigation for standalone pathway + console.log('[SimulationPathwayWrapper] Simulation created with ID:', simulationId); + navigate(`/${countryId}/simulations`); + onComplete?.(); + } ); - // Policy callbacks + // Policy callbacks - no custom completion (stays within simulation pathway) const policyCallbacks = createPolicyCallbacks( setSimulationState, (state) => state.policy, (state, policy) => ({ ...state, policy }), navigateToMode, - SimulationViewMode.SETUP + SimulationViewMode.SETUP, + undefined // No onPolicyComplete - stays within simulation pathway ); - // Population callbacks + // Population callbacks - no custom completion (stays within simulation pathway) const populationCallbacks = createPopulationCallbacks( setSimulationState, (state) => state.population, (state, population) => ({ ...state, population }), navigateToMode, SimulationViewMode.SETUP, - SimulationViewMode.POPULATION_LABEL + SimulationViewMode.POPULATION_LABEL, + undefined // No onPopulationComplete - stays within simulation pathway ); // ========== SPECIAL HANDLERS ========== @@ -148,30 +156,6 @@ export default function SimulationPathwayWrapper({ onComplete }: SimulationPathw navigateToMode(SimulationViewMode.SETUP); }, [currentLawId, navigateToMode]); - // Handle successful simulation creation (called by SimulationSubmitView after creating the base simulation) - // This mirrors the old Redux flow's behavior where the view creates the simulation, - // then the parent updates state and navigates - const handleSimulationSubmitSuccess = useCallback( - (simulationId: string) => { - console.log('[SimulationPathwayWrapper] Simulation created with ID:', simulationId); - - // Update simulation state with the returned ID - setSimulationState((prev) => ({ - ...prev, - id: simulationId, - status: 'complete', - })); - - // Navigate back to simulations list page - navigate(`/${countryId}/simulations`); - - if (onComplete) { - onComplete(); - } - }, - [navigate, countryId, onComplete] - ); - // ========== VIEW RENDERING ========== let currentView: React.ReactElement; @@ -209,7 +193,7 @@ export default function SimulationPathwayWrapper({ onComplete }: SimulationPathw currentView = ( navigate(`/${countryId}/simulations`)} /> diff --git a/app/src/utils/pathwayCallbacks/policyCallbacks.ts b/app/src/utils/pathwayCallbacks/policyCallbacks.ts index 7df80bc8..46c37802 100644 --- a/app/src/utils/pathwayCallbacks/policyCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/policyCallbacks.ts @@ -11,13 +11,15 @@ import { Parameter } from '@/types/subIngredients/parameter'; * @param policyUpdater - Function to update policy in state * @param navigateToMode - Navigation function * @param returnMode - Mode to navigate to after completing policy operations + * @param onPolicyComplete - Optional callback for custom navigation after policy submission (e.g., exit to list page) */ export function createPolicyCallbacks( setState: React.Dispatch>, policySelector: (state: TState) => PolicyStateProps, policyUpdater: (state: TState, policy: PolicyStateProps) => TState, navigateToMode: (mode: TMode) => void, - returnMode: TMode + returnMode: TMode, + onPolicyComplete?: (policyId: string) => void ) { const updateLabel = useCallback( (label: string) => { @@ -73,9 +75,15 @@ export function createPolicyCallbacks( id: policyId, }); }); - navigateToMode(returnMode); + + // Use custom navigation if provided, otherwise use default + if (onPolicyComplete) { + onPolicyComplete(policyId); + } else { + navigateToMode(returnMode); + } }, - [setState, policySelector, policyUpdater, navigateToMode, returnMode] + [setState, policySelector, policyUpdater, navigateToMode, returnMode, onPolicyComplete] ); return { diff --git a/app/src/utils/pathwayCallbacks/populationCallbacks.ts b/app/src/utils/pathwayCallbacks/populationCallbacks.ts index 40bacd25..04ac0e4c 100644 --- a/app/src/utils/pathwayCallbacks/populationCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/populationCallbacks.ts @@ -13,6 +13,7 @@ import { PopulationStateProps } from '@/types/pathwayState'; * @param navigateToMode - Navigation function * @param returnMode - Mode to navigate to after completing population operations * @param labelMode - Mode to navigate to for labeling + * @param onPopulationComplete - Optional callbacks for custom navigation after population submission */ export function createPopulationCallbacks( setState: React.Dispatch>, @@ -20,7 +21,11 @@ export function createPopulationCallbacks( populationUpdater: (state: TState, population: PopulationStateProps) => TState, navigateToMode: (mode: TMode) => void, returnMode: TMode, - labelMode: TMode + labelMode: TMode, + onPopulationComplete?: { + onHouseholdComplete?: (householdId: string, household: Household) => void; + onGeographyComplete?: (geographyId: string, label: string) => void; + } ) { const updateLabel = useCallback( (label: string) => { @@ -86,9 +91,15 @@ export function createPopulationCallbacks( household: { ...household, id: householdId }, }); }); - navigateToMode(returnMode); + + // Use custom navigation if provided, otherwise use default + if (onPopulationComplete?.onHouseholdComplete) { + onPopulationComplete.onHouseholdComplete(householdId, household); + } else { + navigateToMode(returnMode); + } }, - [setState, populationSelector, populationUpdater, navigateToMode, returnMode] + [setState, populationSelector, populationUpdater, navigateToMode, returnMode, onPopulationComplete] ); const handleGeographicSubmitSuccess = useCallback( @@ -102,9 +113,15 @@ export function createPopulationCallbacks( updatedPopulation.label = label; return populationUpdater(prev, updatedPopulation); }); - navigateToMode(returnMode); + + // Use custom navigation if provided, otherwise use default + if (onPopulationComplete?.onGeographyComplete) { + onPopulationComplete.onGeographyComplete(geographyId, label); + } else { + navigateToMode(returnMode); + } }, - [setState, populationSelector, populationUpdater, navigateToMode, returnMode] + [setState, populationSelector, populationUpdater, navigateToMode, returnMode, onPopulationComplete] ); return { diff --git a/app/src/utils/pathwayCallbacks/simulationCallbacks.ts b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts index b0914796..43c79c69 100644 --- a/app/src/utils/pathwayCallbacks/simulationCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts @@ -11,13 +11,15 @@ import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/ * @param simulationUpdater - Function to update simulation in state * @param navigateToMode - Navigation function * @param returnMode - Mode to navigate to after completing simulation operations + * @param onSimulationComplete - Optional callback for custom navigation after simulation submission */ export function createSimulationCallbacks( setState: React.Dispatch>, simulationSelector: (state: TState) => SimulationStateProps, simulationUpdater: (state: TState, simulation: SimulationStateProps) => TState, navigateToMode: (mode: TMode) => void, - returnMode: TMode + returnMode: TMode, + onSimulationComplete?: (simulationId: string) => void ) { const updateLabel = useCallback( (label: string) => { @@ -38,9 +40,15 @@ export function createSimulationCallbacks( id: simulationId, }); }); - navigateToMode(returnMode); + + // Use custom navigation if provided, otherwise use default + if (onSimulationComplete) { + onSimulationComplete(simulationId); + } else { + navigateToMode(returnMode); + } }, - [setState, simulationSelector, simulationUpdater, navigateToMode, returnMode] + [setState, simulationSelector, simulationUpdater, navigateToMode, returnMode, onSimulationComplete] ); const handleSelectExisting = useCallback( From 8da17caa2aea7b143c20a2df3471cd4cb9e16678 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 25 Nov 2025 00:45:19 +0100 Subject: [PATCH 45/49] fix: Fix naming to make clear difference between view modes --- .../pathways/policy/PolicyPathwayWrapper.tsx | 20 +++++++++---------- .../population/PopulationPathwayWrapper.tsx | 20 +++++++++---------- app/src/types/pathwayModes/PolicyViewMode.ts | 8 ++++++-- .../types/pathwayModes/PopulationViewMode.ts | 8 ++++++-- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/app/src/pathways/policy/PolicyPathwayWrapper.tsx b/app/src/pathways/policy/PolicyPathwayWrapper.tsx index 10201c4a..07558188 100644 --- a/app/src/pathways/policy/PolicyPathwayWrapper.tsx +++ b/app/src/pathways/policy/PolicyPathwayWrapper.tsx @@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom'; import StandardLayout from '@/components/StandardLayout'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; -import { PolicyViewMode } from '@/types/pathwayModes/PolicyViewMode'; +import { StandalonePolicyViewMode } from '@/types/pathwayModes/PolicyViewMode'; import { PolicyStateProps } from '@/types/pathwayState'; import { createPolicyCallbacks } from '@/utils/pathwayCallbacks'; import { initializePolicyState } from '@/utils/pathwayState/initializePolicyState'; @@ -20,7 +20,7 @@ import PolicyParameterSelectorView from '../report/views/policy/PolicyParameterS import PolicySubmitView from '../report/views/policy/PolicySubmitView'; // View modes that manage their own AppShell (don't need StandardLayout wrapper) -const MODES_WITH_OWN_LAYOUT = new Set([PolicyViewMode.PARAMETER_SELECTOR]); +const MODES_WITH_OWN_LAYOUT = new Set([StandalonePolicyViewMode.PARAMETER_SELECTOR]); interface PolicyPathwayWrapperProps { onComplete?: () => void; @@ -39,7 +39,7 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe // ========== NAVIGATION ========== const { currentMode, navigateToMode, goBack, canGoBack } = usePathwayNavigation( - PolicyViewMode.LABEL + StandalonePolicyViewMode.LABEL ); // ========== CALLBACKS ========== @@ -49,7 +49,7 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe (state) => state, // policySelector: return the state itself (PolicyStateProps) (_state, policy) => policy, // policyUpdater: replace entire state with new policy navigateToMode, - PolicyViewMode.SUBMIT, // returnMode (not used in standalone mode) + StandalonePolicyViewMode.SUBMIT, // returnMode (not used in standalone mode) (policyId: string) => { // onPolicyComplete: custom navigation for standalone pathway console.log('[PolicyPathwayWrapper] Policy created with ID:', policyId); @@ -62,31 +62,31 @@ export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrappe let currentView: React.ReactElement; switch (currentMode) { - case PolicyViewMode.LABEL: + case StandalonePolicyViewMode.LABEL: currentView = ( navigateToMode(PolicyViewMode.PARAMETER_SELECTOR)} + onNext={() => navigateToMode(StandalonePolicyViewMode.PARAMETER_SELECTOR)} onBack={canGoBack ? goBack : undefined} onCancel={() => navigate(`/${countryId}/policies`)} /> ); break; - case PolicyViewMode.PARAMETER_SELECTOR: + case StandalonePolicyViewMode.PARAMETER_SELECTOR: currentView = ( navigateToMode(PolicyViewMode.SUBMIT)} + onNext={() => navigateToMode(StandalonePolicyViewMode.SUBMIT)} onBack={canGoBack ? goBack : undefined} /> ); break; - case PolicyViewMode.SUBMIT: + case StandalonePolicyViewMode.SUBMIT: currentView = ( state, // populationSelector: return the state itself (PopulationStateProps) (_state, population) => population, // populationUpdater: replace entire state navigateToMode, - PopulationViewMode.GEOGRAPHIC_CONFIRM, // returnMode (not used in standalone mode) - PopulationViewMode.LABEL, // labelMode + StandalonePopulationViewMode.GEOGRAPHIC_CONFIRM, // returnMode (not used in standalone mode) + StandalonePopulationViewMode.LABEL, // labelMode { // Custom navigation for standalone pathway: exit to households list onHouseholdComplete: (householdId: string, _household: Household) => { @@ -74,7 +74,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw let currentView: React.ReactElement; switch (currentMode) { - case PopulationViewMode.SCOPE: + case StandalonePopulationViewMode.SCOPE: currentView = ( { // Navigate based on population type if (populationState.type === 'household') { - navigateToMode(PopulationViewMode.HOUSEHOLD_BUILDER); + navigateToMode(StandalonePopulationViewMode.HOUSEHOLD_BUILDER); } else { - navigateToMode(PopulationViewMode.GEOGRAPHIC_CONFIRM); + navigateToMode(StandalonePopulationViewMode.GEOGRAPHIC_CONFIRM); } }} onBack={canGoBack ? goBack : undefined} @@ -105,7 +105,7 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw ); break; - case PopulationViewMode.HOUSEHOLD_BUILDER: + case StandalonePopulationViewMode.HOUSEHOLD_BUILDER: currentView = ( Date: Tue, 25 Nov 2025 00:51:21 +0100 Subject: [PATCH 46/49] chore: Lint --- app/src/Router.tsx | 2 +- .../population/PopulationPathwayWrapper.tsx | 5 ++++- .../pathwayCallbacks/populationCallbacks.ts | 18 ++++++++++++++++-- .../pathwayCallbacks/simulationCallbacks.ts | 9 ++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/src/Router.tsx b/app/src/Router.tsx index 217b23d2..06beac16 100644 --- a/app/src/Router.tsx +++ b/app/src/Router.tsx @@ -1,4 +1,4 @@ -import { createBrowserRouter, Outlet, Navigate, RouterProvider } from 'react-router-dom'; +import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom'; import PathwayLayout from './components/PathwayLayout'; import StandardLayout from './components/StandardLayout'; import StaticLayout from './components/StaticLayout'; diff --git a/app/src/pathways/population/PopulationPathwayWrapper.tsx b/app/src/pathways/population/PopulationPathwayWrapper.tsx index 0310e148..a02bd987 100644 --- a/app/src/pathways/population/PopulationPathwayWrapper.tsx +++ b/app/src/pathways/population/PopulationPathwayWrapper.tsx @@ -63,7 +63,10 @@ export default function PopulationPathwayWrapper({ onComplete }: PopulationPathw onComplete?.(); }, onGeographyComplete: (geographyId: string, _label: string) => { - console.log('[PopulationPathwayWrapper] Geographic population created with ID:', geographyId); + console.log( + '[PopulationPathwayWrapper] Geographic population created with ID:', + geographyId + ); navigate(`/${countryId}/households`); onComplete?.(); }, diff --git a/app/src/utils/pathwayCallbacks/populationCallbacks.ts b/app/src/utils/pathwayCallbacks/populationCallbacks.ts index 04ac0e4c..107bcc15 100644 --- a/app/src/utils/pathwayCallbacks/populationCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/populationCallbacks.ts @@ -99,7 +99,14 @@ export function createPopulationCallbacks( navigateToMode(returnMode); } }, - [setState, populationSelector, populationUpdater, navigateToMode, returnMode, onPopulationComplete] + [ + setState, + populationSelector, + populationUpdater, + navigateToMode, + returnMode, + onPopulationComplete, + ] ); const handleGeographicSubmitSuccess = useCallback( @@ -121,7 +128,14 @@ export function createPopulationCallbacks( navigateToMode(returnMode); } }, - [setState, populationSelector, populationUpdater, navigateToMode, returnMode, onPopulationComplete] + [ + setState, + populationSelector, + populationUpdater, + navigateToMode, + returnMode, + onPopulationComplete, + ] ); return { diff --git a/app/src/utils/pathwayCallbacks/simulationCallbacks.ts b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts index 43c79c69..01f34fd0 100644 --- a/app/src/utils/pathwayCallbacks/simulationCallbacks.ts +++ b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts @@ -48,7 +48,14 @@ export function createSimulationCallbacks( navigateToMode(returnMode); } }, - [setState, simulationSelector, simulationUpdater, navigateToMode, returnMode, onSimulationComplete] + [ + setState, + simulationSelector, + simulationUpdater, + navigateToMode, + returnMode, + onSimulationComplete, + ] ); const handleSelectExisting = useCallback( From 95c40d1e491ba2a35d08288e52f3ba91e8629f58 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 25 Nov 2025 01:27:34 +0100 Subject: [PATCH 47/49] fix: Fix display of country default simulation --- .../pathways/report/ReportPathwayWrapper.tsx | 8 +- .../components/DefaultBaselineOption.tsx | 6 -- .../report/ReportPathwayWrapperMocks.ts | 18 ++++ .../ReportSimulationSelectionLogic.test.tsx | 82 +++++++++++++++++++ 4 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx diff --git a/app/src/pathways/report/ReportPathwayWrapper.tsx b/app/src/pathways/report/ReportPathwayWrapper.tsx index dde7d722..ce09f6a7 100644 --- a/app/src/pathways/report/ReportPathwayWrapper.tsx +++ b/app/src/pathways/report/ReportPathwayWrapper.tsx @@ -180,15 +180,17 @@ export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrappe // ========== CUSTOM WRAPPERS FOR SPECIFIC REPORT LOGIC ========== // Wrapper for navigating to simulation selection (needs to update active index) - // Skips selection view if user has no existing simulations + // Skips selection view if user has no existing simulations (except for baseline, which has DefaultBaselineOption) const handleNavigateToSimulationSelection = useCallback( (simulationIndex: 0 | 1) => { console.log('[ReportPathwayWrapper] Setting active simulation index:', simulationIndex); setActiveSimulationIndex(simulationIndex); - if (hasExistingSimulations) { + // Always show selection view for baseline (index 0) because it has DefaultBaselineOption + // For reform (index 1), skip if no existing simulations + if (simulationIndex === 0 || hasExistingSimulations) { reportCallbacks.navigateToSimulationSelection(simulationIndex); } else { - // Skip selection view, go directly to create new + // Skip selection view, go directly to create new (reform simulation only) navigateToMode(ReportViewMode.SIMULATION_LABEL); } }, diff --git a/app/src/pathways/report/components/DefaultBaselineOption.tsx b/app/src/pathways/report/components/DefaultBaselineOption.tsx index 1734bc4f..1c3c9297 100644 --- a/app/src/pathways/report/components/DefaultBaselineOption.tsx +++ b/app/src/pathways/report/components/DefaultBaselineOption.tsx @@ -61,8 +61,6 @@ export default function DefaultBaselineOption({ // If exact simulation already exists, reuse it if (existingBaseline && existingSimulationId) { - console.log('[DefaultBaselineOption] Reusing existing simulation:', existingSimulationId); - // Build the simulation state from existing data const policy: PolicyStateProps = { id: currentLawId.toString(), @@ -101,7 +99,6 @@ export default function DefaultBaselineOption({ // Otherwise, create new geography and simulation try { // Step 1: Create geography association - console.log('[DefaultBaselineOption] Creating geographic association'); const geographyResult = await createGeographicAssociation({ id: `${userId}-${Date.now()}`, userId, @@ -110,10 +107,8 @@ export default function DefaultBaselineOption({ scope: 'national', label: `${countryName} nationwide`, }); - console.log('[DefaultBaselineOption] Geography created:', geographyResult); // Step 2: Create simulation with the new geography - console.log('[DefaultBaselineOption] Creating simulation'); const simulationData: Partial = { populationId: geographyResult.geographyId, policyId: currentLawId.toString(), @@ -125,7 +120,6 @@ export default function DefaultBaselineOption({ createSimulation(serializedPayload, { onSuccess: (data) => { - console.log('[DefaultBaselineOption] Simulation created:', data); const simulationId = data.result.simulation_id; // Build the simulation state with the created IDs diff --git a/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts index 9dfcd846..63a6f50e 100644 --- a/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts +++ b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts @@ -79,3 +79,21 @@ export const REPORT_VIEW_MODES = { SIMULATION_EXISTING: ReportViewMode.REPORT_SELECT_EXISTING_SIMULATION, SUBMIT: ReportViewMode.REPORT_SUBMIT, } as const; + +/** + * Test constants for simulation indices + */ +export const SIMULATION_INDEX = { + BASELINE: 0 as const, + REFORM: 1 as const, +} as const; + +/** + * Mock user simulations data with existing simulations + */ +export const mockUserSimulationsWithData = { + data: [{ id: 'sim-1', label: 'Test Simulation' }], + isLoading: false, + isError: false, + error: null, +} as any; diff --git a/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx b/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx new file mode 100644 index 00000000..5d29ebfa --- /dev/null +++ b/app/src/tests/unit/pathways/report/ReportSimulationSelectionLogic.test.tsx @@ -0,0 +1,82 @@ +/** + * Tests for Report pathway simulation selection logic + * + * Tests the fix for the issue where automated simulation setup wasn't working. + * The baseline simulation selection view should always be shown, even when there are + * no existing simulations, because it contains the DefaultBaselineOption component + * for quick setup with "Current law + Nationwide population". + * + * KEY BEHAVIOR: + * - Baseline simulation (index 0): ALWAYS show selection view (even with no existing simulations) + * - Reform simulation (index 1): Skip selection when no existing simulations + */ + +import { describe, expect, test } from 'vitest'; +import { SIMULATION_INDEX } from '@/tests/fixtures/pathways/report/ReportPathwayWrapperMocks'; + +/** + * Helper function that implements the logic from ReportPathwayWrapper.tsx + * for determining whether to show the simulation selection view + */ +function shouldShowSimulationSelectionView( + simulationIndex: 0 | 1, + hasExistingSimulations: boolean +): boolean { + // Always show selection view for baseline (index 0) because it has DefaultBaselineOption + // For reform (index 1), skip if no existing simulations + return simulationIndex === 0 || hasExistingSimulations; +} + +describe('Report pathway simulation selection logic', () => { + describe('Baseline simulation (index 0)', () => { + test('given no existing simulations then should show selection view', () => { + // Given + const simulationIndex = SIMULATION_INDEX.BASELINE; + const hasExistingSimulations = false; + + // When + const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); + + // Then + expect(result).toBe(true); + }); + + test('given existing simulations then should show selection view', () => { + // Given + const simulationIndex = SIMULATION_INDEX.BASELINE; + const hasExistingSimulations = true; + + // When + const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); + + // Then + expect(result).toBe(true); + }); + }); + + describe('Reform simulation (index 1)', () => { + test('given no existing simulations then should skip selection view', () => { + // Given + const simulationIndex = SIMULATION_INDEX.REFORM; + const hasExistingSimulations = false; + + // When + const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); + + // Then + expect(result).toBe(false); + }); + + test('given existing simulations then should show selection view', () => { + // Given + const simulationIndex = SIMULATION_INDEX.REFORM; + const hasExistingSimulations = true; + + // When + const result = shouldShowSimulationSelectionView(simulationIndex, hasExistingSimulations); + + // Then + expect(result).toBe(true); + }); + }); +}); From c93c1c3ab3ded97ad44d69e7b2e48a5b841f1e22 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 25 Nov 2025 01:46:23 +0100 Subject: [PATCH 48/49] fix: Fix display of default baseline selection --- .../components/DefaultBaselineOption.tsx | 180 ++------------- .../views/ReportSimulationSelectionView.tsx | 207 ++++++++++++++++-- .../components/DefaultBaselineOptionMocks.ts | 2 + .../components/DefaultBaselineOption.test.tsx | 164 ++++---------- 4 files changed, 246 insertions(+), 307 deletions(-) diff --git a/app/src/pathways/report/components/DefaultBaselineOption.tsx b/app/src/pathways/report/components/DefaultBaselineOption.tsx index 1c3c9297..74ea1d3e 100644 --- a/app/src/pathways/report/components/DefaultBaselineOption.tsx +++ b/app/src/pathways/report/components/DefaultBaselineOption.tsx @@ -1,198 +1,44 @@ /** * DefaultBaselineOption - Option card for selecting default baseline simulation * - * This is a standalone component that renders an option for "Current law + Nationwide population" + * This is a selectable card that renders an option for "Current law + Nationwide population" * as a quick-select for the baseline simulation in a report. * - * It checks if the user already has a matching simulation and reuses that ID if found. + * Unlike other cards, this one doesn't navigate anywhere - it just marks itself as selected + * and the parent view handles creation when "Next" is clicked. */ -import { useState } from 'react'; +import { Card, Group, Stack, Text } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; -import { Card, Group, Loader, Stack, Text } from '@mantine/core'; -import { SimulationAdapter } from '@/adapters'; -import { MOCK_USER_ID } from '@/constants'; import { spacing } from '@/designTokens'; -import { useCreateSimulation } from '@/hooks/useCreateSimulation'; -import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; -import { Simulation } from '@/types/ingredients/Simulation'; -import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; -import { SimulationCreationPayload } from '@/types/payloads'; -import { - countryNames, - getDefaultBaselineLabel, - isDefaultBaselineSimulation, -} from '@/utils/isDefaultBaselineSimulation'; +import { getDefaultBaselineLabel } from '@/utils/isDefaultBaselineSimulation'; interface DefaultBaselineOptionProps { countryId: string; - currentLawId: number; - onSelect: (simulationState: SimulationStateProps, simulationId: string) => void; + isSelected: boolean; + onClick: () => void; } export default function DefaultBaselineOption({ countryId, - currentLawId, - onSelect, + isSelected, + onClick, }: DefaultBaselineOptionProps) { - const userId = MOCK_USER_ID.toString(); - const { data: userSimulations } = useUserSimulations(userId); - const { mutateAsync: createGeographicAssociation } = useCreateGeographicAssociation(); const simulationLabel = getDefaultBaselineLabel(countryId); - const { createSimulation } = useCreateSimulation(simulationLabel); - const [isCreating, setIsCreating] = useState(false); - - // Find existing default baseline simulation for this country - const existingBaseline = userSimulations?.find((sim) => - isDefaultBaselineSimulation(sim, countryId, currentLawId) - ); - - // Get the simulation ID from the nested structure - const existingSimulationId = existingBaseline?.userSimulation?.simulationId; - - const handleClick = async () => { - if (isCreating) { - return; - } // Prevent double-click - - setIsCreating(true); - const countryName = countryNames[countryId] || countryId.toUpperCase(); - - // If exact simulation already exists, reuse it - if (existingBaseline && existingSimulationId) { - // Build the simulation state from existing data - const policy: PolicyStateProps = { - id: currentLawId.toString(), - label: 'Current law', - parameters: [], - }; - - const population: PopulationStateProps = { - label: `${countryName} nationwide`, - type: 'geography', - household: null, - geography: { - id: existingBaseline.geography?.geographyId || countryId, - countryId: countryId as any, - scope: 'national', - geographyId: countryId, - name: 'National', - }, - }; - - const simulationState: SimulationStateProps = { - id: existingSimulationId, - label: simulationLabel, - countryId, - apiVersion: undefined, - status: undefined, - output: null, - policy, - population, - }; - - onSelect(simulationState, existingSimulationId); - return; - } - - // Otherwise, create new geography and simulation - try { - // Step 1: Create geography association - const geographyResult = await createGeographicAssociation({ - id: `${userId}-${Date.now()}`, - userId, - countryId: countryId as any, - geographyId: countryId, - scope: 'national', - label: `${countryName} nationwide`, - }); - - // Step 2: Create simulation with the new geography - const simulationData: Partial = { - populationId: geographyResult.geographyId, - policyId: currentLawId.toString(), - populationType: 'geography', - }; - - const serializedPayload: SimulationCreationPayload = - SimulationAdapter.toCreationPayload(simulationData); - - createSimulation(serializedPayload, { - onSuccess: (data) => { - const simulationId = data.result.simulation_id; - - // Build the simulation state with the created IDs - const policy: PolicyStateProps = { - id: currentLawId.toString(), - label: 'Current law', - parameters: [], - }; - - const population: PopulationStateProps = { - label: `${countryName} nationwide`, - type: 'geography', - household: null, - geography: { - id: geographyResult.geographyId, - countryId: countryId as any, - scope: 'national', - geographyId: countryId, - name: 'National', - }, - }; - - const simulationState: SimulationStateProps = { - id: simulationId, - label: simulationLabel, - countryId, - apiVersion: undefined, - status: undefined, - output: null, - policy, - population, - }; - - onSelect(simulationState, simulationId); - }, - onError: (error) => { - console.error('[DefaultBaselineOption] Failed to create simulation:', error); - setIsCreating(false); - }, - }); - } catch (error) { - console.error('[DefaultBaselineOption] Failed to create geographic association:', error); - setIsCreating(false); - } - }; - - const hasExisting = !!(existingBaseline && existingSimulationId); - const loadingText = hasExisting ? 'Applying simulation...' : 'Creating simulation...'; return ( - - {isCreating ? ( - <> - - {loadingText} - - ) : ( - simulationLabel - )} - + {simulationLabel} Use current law with all households nationwide as baseline diff --git a/app/src/pathways/report/views/ReportSimulationSelectionView.tsx b/app/src/pathways/report/views/ReportSimulationSelectionView.tsx index 9173c26c..4623746f 100644 --- a/app/src/pathways/report/views/ReportSimulationSelectionView.tsx +++ b/app/src/pathways/report/views/ReportSimulationSelectionView.tsx @@ -1,12 +1,81 @@ import { useState } from 'react'; import { Stack } from '@mantine/core'; +import { SimulationAdapter } from '@/adapters'; import PathwayView from '@/components/common/PathwayView'; import { ButtonPanelVariant } from '@/components/flowView'; import { MOCK_USER_ID } from '@/constants'; +import { useCreateSimulation } from '@/hooks/useCreateSimulation'; +import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic'; import { useUserSimulations } from '@/hooks/useUserSimulations'; -import { SimulationStateProps } from '@/types/pathwayState'; +import { Simulation } from '@/types/ingredients/Simulation'; +import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; +import { SimulationCreationPayload } from '@/types/payloads'; +import { + countryNames, + getDefaultBaselineLabel, + isDefaultBaselineSimulation, +} from '@/utils/isDefaultBaselineSimulation'; import DefaultBaselineOption from '../components/DefaultBaselineOption'; +/** + * Helper functions for creating default baseline simulation + */ + +/** + * Creates a policy state for current law + */ +function createCurrentLawPolicy(currentLawId: number): PolicyStateProps { + return { + id: currentLawId.toString(), + label: 'Current law', + parameters: [], + }; +} + +/** + * Creates a population state for nationwide geography + */ +function createNationwidePopulation( + countryId: string, + geographyId: string, + countryName: string +): PopulationStateProps { + return { + label: `${countryName} nationwide`, + type: 'geography', + household: null, + geography: { + id: geographyId, + countryId: countryId as any, + scope: 'national', + geographyId: countryId, + name: 'National', + }, + }; +} + +/** + * Creates a simulation state from policy and population + */ +function createSimulationState( + simulationId: string, + simulationLabel: string, + countryId: string, + policy: PolicyStateProps, + population: PopulationStateProps +): SimulationStateProps { + return { + id: simulationId, + label: simulationLabel, + countryId, + apiVersion: undefined, + status: undefined, + output: null, + policy, + population, + }; +} + type SetupAction = 'createNew' | 'loadExisting' | 'defaultBaseline'; interface ReportSimulationSelectionViewProps { @@ -35,6 +104,17 @@ export default function ReportSimulationSelectionView({ const hasExistingSimulations = (userSimulations?.length ?? 0) > 0; const [selectedAction, setSelectedAction] = useState(null); + const [isCreatingBaseline, setIsCreatingBaseline] = useState(false); + + const { mutateAsync: createGeographicAssociation } = useCreateGeographicAssociation(); + const simulationLabel = getDefaultBaselineLabel(countryId); + const { createSimulation } = useCreateSimulation(simulationLabel); + + // Find existing default baseline simulation for this country + const existingBaseline = userSimulations?.find((sim) => + isDefaultBaselineSimulation(sim, countryId, currentLawId) + ); + const existingSimulationId = existingBaseline?.userSimulation?.simulationId; const isBaseline = simulationIndex === 0; @@ -48,23 +128,115 @@ export default function ReportSimulationSelectionView({ } } - // DefaultBaselineOption handles its own creation - this just passes through - function handleSelectDefaultBaseline( - simulationState: SimulationStateProps, - simulationId: string - ) { - if (onSelectDefaultBaseline) { - onSelectDefaultBaseline(simulationState, simulationId); + function handleClickDefaultBaseline() { + setSelectedAction('defaultBaseline'); + } + + /** + * Reuses an existing default baseline simulation + */ + function reuseExistingBaseline() { + if (!existingBaseline || !existingSimulationId || !onSelectDefaultBaseline) { + return; + } + + const countryName = countryNames[countryId] || countryId.toUpperCase(); + const geographyId = existingBaseline.geography?.geographyId || countryId; + + const policy = createCurrentLawPolicy(currentLawId); + const population = createNationwidePopulation(countryId, geographyId, countryName); + const simulationState = createSimulationState( + existingSimulationId, + simulationLabel, + countryId, + policy, + population + ); + + onSelectDefaultBaseline(simulationState, existingSimulationId); + } + + /** + * Creates a new default baseline simulation + */ + async function createNewBaseline() { + if (!onSelectDefaultBaseline) { + return; + } + + setIsCreatingBaseline(true); + const countryName = countryNames[countryId] || countryId.toUpperCase(); + + try { + // Create geography association + const geographyResult = await createGeographicAssociation({ + id: `${userId}-${Date.now()}`, + userId, + countryId: countryId as any, + geographyId: countryId, + scope: 'national', + label: `${countryName} nationwide`, + }); + + // Create simulation + const simulationData: Partial = { + populationId: geographyResult.geographyId, + policyId: currentLawId.toString(), + populationType: 'geography', + }; + + const serializedPayload: SimulationCreationPayload = + SimulationAdapter.toCreationPayload(simulationData); + + createSimulation(serializedPayload, { + onSuccess: (data) => { + const simulationId = data.result.simulation_id; + + const policy = createCurrentLawPolicy(currentLawId); + const population = createNationwidePopulation( + countryId, + geographyResult.geographyId, + countryName + ); + const simulationState = createSimulationState( + simulationId, + simulationLabel, + countryId, + policy, + population + ); + + if (onSelectDefaultBaseline) { + onSelectDefaultBaseline(simulationState, simulationId); + } + }, + onError: (error) => { + console.error('[ReportSimulationSelectionView] Failed to create simulation:', error); + setIsCreatingBaseline(false); + }, + }); + } catch (error) { + console.error( + '[ReportSimulationSelectionView] Failed to create geographic association:', + error + ); + setIsCreatingBaseline(false); } } - function handleClickSubmit() { + async function handleClickSubmit() { if (selectedAction === 'createNew') { onCreateNew(); } else if (selectedAction === 'loadExisting') { onLoadExisting(); + } else if (selectedAction === 'defaultBaseline') { + // Reuse existing or create new default baseline simulation + if (existingBaseline && existingSimulationId) { + reuseExistingBaseline(); + } else { + await createNewBaseline(); + } } - // Note: defaultBaseline is handled directly by DefaultBaselineOption } const buttonPanelCards = [ @@ -87,10 +259,17 @@ export default function ReportSimulationSelectionView({ }, ]; + const hasExistingBaselineText = existingBaseline && existingSimulationId; + const primaryAction = { - label: 'Next', + label: isCreatingBaseline + ? hasExistingBaselineText + ? 'Applying simulation...' + : 'Creating simulation...' + : 'Next', onClick: handleClickSubmit, - isDisabled: !selectedAction, + isLoading: isCreatingBaseline, + isDisabled: !selectedAction || isCreatingBaseline, }; // For baseline simulation, combine default baseline option with other cards @@ -102,8 +281,8 @@ export default function ReportSimulationSelectionView({ diff --git a/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts index 7c4d1917..70774cac 100644 --- a/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts +++ b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts @@ -73,6 +73,7 @@ export const mockNonDefaultSimulation: any = { // Mock callbacks export const mockOnSelect = vi.fn(); +export const mockOnClick = vi.fn(); // Mock API responses export const mockGeographyCreationResponse = { @@ -95,6 +96,7 @@ export const mockSimulationCreationResponse = { // Helper to reset all mocks export const resetAllMocks = () => { mockOnSelect.mockClear(); + mockOnClick.mockClear(); }; // Mock hook return values diff --git a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx index ea65f56a..b03682ce 100644 --- a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx +++ b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx @@ -1,43 +1,17 @@ import { render, screen, userEvent } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useCreateSimulation } from '@/hooks/useCreateSimulation'; -import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic'; -import { useUserSimulations } from '@/hooks/useUserSimulations'; import DefaultBaselineOption from '@/pathways/report/components/DefaultBaselineOption'; import { DEFAULT_BASELINE_LABELS, - mockOnSelect, - mockUseCreateGeographicAssociation, - mockUseCreateSimulation, - mockUseUserSimulationsEmpty, - mockUseUserSimulationsWithExisting, + mockOnClick, resetAllMocks, TEST_COUNTRIES, - TEST_CURRENT_LAW_ID, } from '@/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks'; -// Mock hooks -vi.mock('@/hooks/useUserSimulations', () => ({ - useUserSimulations: vi.fn(), -})); - -vi.mock('@/hooks/useUserGeographic', () => ({ - useCreateGeographicAssociation: vi.fn(), -})); - -vi.mock('@/hooks/useCreateSimulation', () => ({ - useCreateSimulation: vi.fn(), -})); - describe('DefaultBaselineOption', () => { beforeEach(() => { resetAllMocks(); vi.clearAllMocks(); - - // Default mock implementations - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); - vi.mocked(useCreateGeographicAssociation).mockReturnValue(mockUseCreateGeographicAssociation); - vi.mocked(useCreateSimulation).mockReturnValue(mockUseCreateSimulation); }); describe('Rendering', () => { @@ -46,8 +20,8 @@ describe('DefaultBaselineOption', () => { render( ); @@ -63,8 +37,8 @@ describe('DefaultBaselineOption', () => { render( ); @@ -77,8 +51,8 @@ describe('DefaultBaselineOption', () => { render( ); @@ -93,8 +67,8 @@ describe('DefaultBaselineOption', () => { const { container } = render( ); @@ -104,52 +78,49 @@ describe('DefaultBaselineOption', () => { }); }); - describe('Detecting existing simulations', () => { - test('given existing default baseline simulation then detects it', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithExisting); - + describe('Selection state', () => { + test('given isSelected is false then shows inactive variant', () => { // When - render( + const { container } = render( ); - // Then - component renders successfully with existing simulation detected - expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + // Then + const button = container.querySelector('[data-variant="buttonPanel--inactive"]'); + expect(button).toBeInTheDocument(); }); - test('given no existing simulation then component renders correctly', () => { - // Given - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); - + test('given isSelected is true then shows active variant', () => { // When - render( + const { container } = render( ); // Then - expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + const button = container.querySelector('[data-variant="buttonPanel--active"]'); + expect(button).toBeInTheDocument(); }); }); describe('User interactions', () => { - test('given button is clicked then becomes disabled', async () => { + test('given button is clicked then onClick callback is invoked', async () => { // Given const user = userEvent.setup(); + const mockCallback = vi.fn(); render( ); @@ -158,19 +129,20 @@ describe('DefaultBaselineOption', () => { // When await user.click(button); - // Then - button should be disabled to prevent double-clicks - expect(button).toBeDisabled(); + // Then + expect(mockCallback).toHaveBeenCalledOnce(); }); - test('given button is clicked then displays loading text', async () => { + test('given button is clicked multiple times then onClick is called each time', async () => { // Given const user = userEvent.setup(); + const mockCallback = vi.fn(); render( ); @@ -178,54 +150,11 @@ describe('DefaultBaselineOption', () => { // When await user.click(button); - - // Then - should show either "Creating simulation..." or "Applying simulation..." - const loadingText = screen.queryByText(/Creating simulation...|Applying simulation.../); - expect(loadingText).toBeInTheDocument(); - }); - - test('given existing simulation and button clicked then shows applying text', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsWithExisting); - - render( - - ); - - const button = screen.getByRole('button'); - - // When await user.click(button); - - // Then - expect(screen.getByText('Applying simulation...')).toBeInTheDocument(); - }); - - test('given no existing simulation and button clicked then shows creating text', async () => { - // Given - const user = userEvent.setup(); - vi.mocked(useUserSimulations).mockReturnValue(mockUseUserSimulationsEmpty); - - render( - - ); - - const button = screen.getByRole('button'); - - // When await user.click(button); // Then - expect(screen.getByText('Creating simulation...')).toBeInTheDocument(); + expect(mockCallback).toHaveBeenCalledTimes(3); }); }); @@ -235,8 +164,8 @@ describe('DefaultBaselineOption', () => { const { rerender } = render( ); expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); @@ -245,28 +174,11 @@ describe('DefaultBaselineOption', () => { rerender( ); expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); }); - - test('given onSelect callback then passes it through', () => { - // Given - const customCallback = vi.fn(); - - // When - render( - - ); - - // Then - component renders with callback (testing it's accepted as prop) - expect(screen.getByRole('button')).toBeInTheDocument(); - }); }); }); From 2095c61ff404c2dbbb1926db138bf63f2075b136 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 25 Nov 2025 01:52:57 +0100 Subject: [PATCH 49/49] chore: Lint --- .../pathways/report/components/DefaultBaselineOption.tsx | 2 +- .../report/components/DefaultBaselineOption.test.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/pathways/report/components/DefaultBaselineOption.tsx b/app/src/pathways/report/components/DefaultBaselineOption.tsx index 74ea1d3e..fa40a43f 100644 --- a/app/src/pathways/report/components/DefaultBaselineOption.tsx +++ b/app/src/pathways/report/components/DefaultBaselineOption.tsx @@ -8,8 +8,8 @@ * and the parent view handles creation when "Next" is clicked. */ -import { Card, Group, Stack, Text } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; +import { Card, Group, Stack, Text } from '@mantine/core'; import { spacing } from '@/designTokens'; import { getDefaultBaselineLabel } from '@/utils/isDefaultBaselineSimulation'; diff --git a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx index b03682ce..936e0b57 100644 --- a/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx +++ b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx @@ -97,11 +97,7 @@ describe('DefaultBaselineOption', () => { test('given isSelected is true then shows active variant', () => { // When const { container } = render( - + ); // Then