diff --git a/app/src/Router.tsx b/app/src/Router.tsx
index 890c792b..06beac16 100644
--- a/app/src/Router.tsx
+++ b/app/src/Router.tsx
@@ -1,11 +1,7 @@
-import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
-import FlowRouter from './components/FlowRouter';
-import Layout from './components/Layout';
+import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
+import PathwayLayout from './components/PathwayLayout';
+import StandardLayout from './components/StandardLayout';
import StaticLayout from './components/StaticLayout';
-import { PolicyCreationFlow } from './flows/policyCreationFlow';
-import { PopulationCreationFlow } from './flows/populationCreationFlow';
-import { ReportCreationFlow } from './flows/reportCreationFlow';
-import { SimulationCreationFlow } from './flows/simulationCreationFlow';
import AppPage from './pages/AppPage';
import BlogPage from './pages/Blog.page';
import DashboardPage from './pages/Dashboard.page';
@@ -21,6 +17,10 @@ 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';
@@ -43,7 +43,11 @@ const router = createBrowserRouter(
element: ,
children: [
{
- element: ,
+ element: (
+
+
+
+ ),
children: [
{
path: 'report-output/:reportId/:subpage?/:view?',
@@ -57,8 +61,13 @@ const router = createBrowserRouter(
{
element: ,
children: [
+ // Regular routes with standard layout
{
- element: ,
+ element: (
+
+
+
+ ),
children: [
{
path: 'dashboard',
@@ -69,49 +78,45 @@ const router = createBrowserRouter(
path: 'reports',
element: ,
},
- {
- path: 'reports/create',
- element: ,
- },
{
path: 'simulations',
element: ,
},
- {
- path: 'simulations/create',
- element: ,
- },
{
path: 'households',
element: ,
},
- {
- path: 'households/create',
- element: ,
- },
{
path: 'policies',
element: ,
},
- {
- path: 'policies/create',
- element: ,
- },
{
path: 'account',
element:
Account settings page
,
},
],
},
- ],
- },
- // Routes that don't need metadata at all (no guard)
- {
- element: ,
- children: [
+ // Pathway routes that manage their own layouts
{
- path: 'configurations',
- element: Configurations page
,
+ element: ,
+ children: [
+ {
+ path: 'reports/create',
+ element: ,
+ },
+ {
+ path: 'simulations/create',
+ element: ,
+ },
+ {
+ path: 'households/create',
+ element: ,
+ },
+ {
+ path: 'policies/create',
+ element: ,
+ },
+ ],
},
],
},
diff --git a/app/src/api/geographicAssociation.ts b/app/src/api/geographicAssociation.ts
index 879dcb39..e2b3b5e0 100644
--- a/app/src/api/geographicAssociation.ts
+++ b/app/src/api/geographicAssociation.ts
@@ -116,15 +116,8 @@ export class LocalStorageGeographicStore implements UserGeographicStore {
const populations = this.getStoredPopulations();
- // Check for duplicates
- const exists = populations.some(
- (p) => p.userId === population.userId && p.geographyId === population.geographyId
- );
-
- if (exists) {
- throw new Error('Geographic population already exists');
- }
-
+ // Allow duplicates - users can create multiple entries for the same geography
+ // Each entry has a unique ID from the caller
const updatedPopulations = [...populations, newPopulation];
this.setStoredPopulations(updatedPopulations);
diff --git a/app/src/components/FlowContainer.tsx b/app/src/components/FlowContainer.tsx
deleted file mode 100644
index 099b647e..00000000
--- a/app/src/components/FlowContainer.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { useDispatch, useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
-import { componentRegistry, flowRegistry } from '@/flows/registry';
-import { navigateToFlow, navigateToFrame, returnFromFlow } from '@/reducers/flowReducer';
-import { isComponentKey, isFlowKey, isNavigationObject } from '@/types/flow';
-
-export default function FlowContainer() {
- const { currentFlow, currentFrame, flowStack, returnPath } = useSelector(
- (state: any) => state.flow
- );
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- console.log('[FlowContainer] RENDER - currentFrame:', currentFrame);
- console.log('[FlowContainer] RENDER - currentFlow:', currentFlow?.initialFrame);
- console.log('[FlowContainer] RENDER - flowStack length:', flowStack?.length);
-
- if (!currentFlow || !currentFrame) {
- return No flow available
;
- }
-
- const isInSubflow = flowStack.length > 0;
- const flowDepth = flowStack.length;
- const parentFlowContext = isInSubflow
- ? {
- parentFrame: flowStack[flowStack.length - 1].frame,
- }
- : undefined;
-
- // Handle navigation function that components can use
- const handleNavigate = (eventName: string) => {
- console.log('[FlowContainer] ========== handleNavigate START ==========');
- console.log('[FlowContainer] eventName:', eventName);
- console.log('[FlowContainer] currentFlow:', currentFlow);
- console.log('[FlowContainer] currentFrame:', currentFrame);
-
- const frameConfig = currentFlow.frames[currentFrame];
- const target = frameConfig.on[eventName];
-
- console.log('[FlowContainer] frameConfig:', frameConfig);
- console.log('[FlowContainer] target:', target);
-
- if (!target) {
- console.error(
- `No target defined for event ${eventName} in frame ${currentFrame}; available events: ${Object.keys(frameConfig.on).join(', ')}`
- );
- return;
- }
-
- // Handle special return keyword
- if (target === '__return__') {
- console.log('[FlowContainer] Target is __return__, dispatching returnFromFlow');
- dispatch(returnFromFlow());
- return;
- }
-
- // Handle navigation object with flow and returnTo
- if (isNavigationObject(target)) {
- console.log('[FlowContainer] Target is navigation object, dispatching navigateToFlow');
- const targetFlow = flowRegistry[target.flow];
- dispatch(
- navigateToFlow({
- flow: targetFlow,
- returnFrame: target.returnTo,
- })
- );
- return;
- }
-
- // Handle string targets (existing logic)
- if (typeof target === 'string') {
- console.log('[FlowContainer] Target is string:', target);
- // Check if target is a flow or component
- if (isFlowKey(target)) {
- console.log('[FlowContainer] Target is flow key, dispatching navigateToFlow');
- const targetFlow = flowRegistry[target];
- dispatch(navigateToFlow({ flow: targetFlow }));
- } else if (isComponentKey(target)) {
- console.log('[FlowContainer] Target is component key, dispatching navigateToFrame');
- dispatch(navigateToFrame(target));
- } else {
- console.error(`Unknown target type: ${target}`);
- }
- }
- console.log('[FlowContainer] ========== handleNavigate END ==========');
- };
-
- // Handle returning from a subflow
- const handleReturn = () => {
- const isTopLevel = flowStack.length === 0;
- dispatch(returnFromFlow());
- if (isTopLevel && returnPath) {
- console.log(`[FlowContainer] Navigating to returnPath: ${returnPath}`);
-
- navigate(returnPath);
- }
- };
-
- // Get the component to render
- const componentKey = currentFrame as keyof typeof componentRegistry;
-
- // Check if the component exists in the registry
- if (!(componentKey in componentRegistry)) {
- return (
-
-
Component not found: {currentFrame}
-
Available components: {Object.keys(componentRegistry).join(', ')}
-
- );
- }
-
- const Component = componentRegistry[componentKey];
-
- console.log(`Rendering component: ${componentKey} for frame: ${currentFrame}`);
-
- return (
- <>
-
- >
- );
-}
diff --git a/app/src/components/FlowRouter.tsx b/app/src/components/FlowRouter.tsx
deleted file mode 100644
index 074beaed..00000000
--- a/app/src/components/FlowRouter.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useEffect } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { useParams } from 'react-router-dom';
-import { setFlow } from '@/reducers/flowReducer';
-import { Flow } from '@/types/flow';
-import FlowContainer from './FlowContainer';
-
-interface FlowRouterProps {
- flow: Flow;
- returnPath: string; // Relative path (e.g., 'reports') - will be prefixed with /:countryId
-}
-
-export default function FlowRouter({ flow, returnPath }: FlowRouterProps) {
- console.log('[FlowRouter] ========== COMPONENT RENDER ==========');
- const dispatch = useDispatch();
- const { countryId } = useParams<{ countryId: string }>();
- const currentFlow = useSelector((state: any) => state.flow.currentFlow);
-
- console.log('[FlowRouter] currentFlow from Redux:', currentFlow);
- console.log('[FlowRouter] flow prop:', flow);
- console.log('[FlowRouter] returnPath prop:', returnPath);
-
- // Construct absolute path from countryId and returnPath
- const absoluteReturnPath = `/${countryId}/${returnPath}`;
-
- // Initialize ONLY if there's no current flow set, to avoid resetting mid-flow;
- // relevant when a component above (Layout) causes re-render.
- useEffect(() => {
- console.log('[FlowRouter] ========== useEffect RUNNING ==========');
- console.log('[FlowRouter] Effect running - currentFlow:', currentFlow);
- console.log('[FlowRouter] Expected flow:', flow);
- if (!currentFlow) {
- console.log(
- '[FlowRouter] No current flow, initializing with returnPath:',
- absoluteReturnPath
- );
- dispatch(setFlow({ flow, returnPath: absoluteReturnPath }));
- console.log('[FlowRouter] setFlow dispatched');
- } else {
- console.log('[FlowRouter] Flow already exists, skipping setFlow');
- console.log('[FlowRouter] Existing flow initialFrame:', currentFlow.initialFrame);
- }
- console.log('[FlowRouter] ========== useEffect COMPLETE ==========');
- // Initialize flow once on mount, hence empty deps array
- // This is not an anti-pattern; see
- // https://react.dev/reference/react/useEffect#specifying-reactive-dependencies,
- // where React gives example of initializing on mount without dependencies.
- }, []);
-
- return ;
-}
diff --git a/app/src/components/IngredientSubmissionView.tsx b/app/src/components/IngredientSubmissionView.tsx
index 71f75bab..d8f73242 100644
--- a/app/src/components/IngredientSubmissionView.tsx
+++ b/app/src/components/IngredientSubmissionView.tsx
@@ -35,6 +35,8 @@ interface IngredientSubmissionViewProps {
submitButtonText?: string; // Defaults to title
submissionHandler: CallableFunction; // Function to handle form submission
submitButtonLoading?: boolean;
+ onBack?: () => void;
+ onCancel?: () => void;
// Content modes - only one should be provided
content?: React.ReactNode; // Original free-form content
@@ -51,20 +53,11 @@ export default function IngredientSubmissionView({
submitButtonText,
submissionHandler,
submitButtonLoading,
+ onBack,
+ onCancel,
}: IngredientSubmissionViewProps) {
- const buttonConfig: ButtonConfig[] = [
- {
- label: 'Cancel',
- variant: 'disabled' as const,
- onClick: () => {},
- },
- {
- label: submitButtonText || title,
- variant: 'filled' as const,
- onClick: () => submissionHandler(),
- isLoading: submitButtonLoading,
- },
- ];
+ // Use new layout if back or cancel provided
+ const useNewLayout = onBack || onCancel;
// Render content based on the provided content type
const renderContent = () => {
@@ -173,6 +166,34 @@ export default function IngredientSubmissionView({
return content;
};
+ // Build footer props
+ const footerProps = useNewLayout
+ ? {
+ buttons: [] as ButtonConfig[],
+ cancelAction: onCancel ? { label: 'Cancel', onClick: onCancel } : undefined,
+ backAction: onBack ? { label: 'Back', onClick: onBack } : undefined,
+ primaryAction: {
+ label: submitButtonText || title,
+ onClick: () => submissionHandler(),
+ isLoading: submitButtonLoading,
+ },
+ }
+ : {
+ buttons: [
+ {
+ label: 'Cancel',
+ variant: 'disabled' as const,
+ onClick: () => {},
+ },
+ {
+ label: submitButtonText || title,
+ variant: 'filled' as const,
+ onClick: () => submissionHandler(),
+ isLoading: submitButtonLoading,
+ },
+ ] as ButtonConfig[],
+ };
+
return (
<>
@@ -188,7 +209,7 @@ export default function IngredientSubmissionView({
{renderContent()}
-
+
>
);
diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx
index 0ae41b7f..59bc0abe 100644
--- a/app/src/components/Layout.tsx
+++ b/app/src/components/Layout.tsx
@@ -1,25 +1,17 @@
import { useEffect, useRef } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
import { Outlet, useLocation } from 'react-router-dom';
import { AppShell } from '@mantine/core';
import { spacing } from '@/designTokens';
-import { useIngredientReset } from '@/hooks/useIngredientReset';
-import { clearFlow } from '@/reducers/flowReducer';
-import { RootState } from '@/store';
import { cacheMonitor } from '@/utils/cacheMonitor';
-import AutumnBudgetBanner from './shared/AutumnBudgetBanner';
import HeaderNavigation from './shared/HomeHeader';
import LegacyBanner from './shared/LegacyBanner';
import Sidebar from './Sidebar';
export default function Layout() {
- const dispatch = useDispatch();
- const { currentFrame, currentFlow } = useSelector((state: RootState) => state.flow);
- const { resetIngredient } = useIngredientReset();
const location = useLocation();
const previousLocation = useRef(location.pathname);
- // Log navigation events for cache monitoring and handle flow clearing
+ // Log navigation events for cache monitoring
useEffect(() => {
const from = previousLocation.current;
const to = location.pathname;
@@ -28,34 +20,10 @@ export default function Layout() {
console.log('[Layout] ========== NAVIGATION DETECTED ==========');
console.log('[Layout] From:', from);
console.log('[Layout] To:', to);
- console.log('[Layout] currentFlow:', currentFlow);
cacheMonitor.logNavigation(from, to);
-
- // Clear flow and all ingredients when navigating away from /create routes
- if (currentFlow && from.includes('/create') && !to.includes('/create')) {
- console.log('[Layout] Condition met: clearing flow and ingredients');
- console.log('[Layout] - currentFlow exists:', !!currentFlow);
- console.log('[Layout] - from.includes("/create"):', from.includes('/create'));
- console.log('[Layout] - !to.includes("/create"):', !to.includes('/create'));
- dispatch(clearFlow());
- console.log('[Layout] Dispatched clearFlow()');
- resetIngredient('report'); // Cascades to clear all ingredients
- console.log('[Layout] Called resetIngredient("report")');
- } else {
- console.log('[Layout] Condition NOT met - no clearing');
- console.log('[Layout] - currentFlow exists:', !!currentFlow);
- console.log('[Layout] - from.includes("/create"):', from.includes('/create'));
- console.log('[Layout] - !to.includes("/create"):', !to.includes('/create'));
- }
-
previousLocation.current = to;
}
- }, [location.pathname, currentFlow, dispatch]);
-
- // If PolicyParameterSelectorFrame is active, let it manage its own layout completely
- if (currentFrame === 'PolicyParameterSelectorFrame') {
- return ;
- }
+ }, [location.pathname]);
// Otherwise, render the normal layout with AppShell
return (
@@ -70,7 +38,6 @@ export default function Layout() {
-
diff --git a/app/src/components/PathwayLayout.tsx b/app/src/components/PathwayLayout.tsx
new file mode 100644
index 00000000..63cf1063
--- /dev/null
+++ b/app/src/components/PathwayLayout.tsx
@@ -0,0 +1,12 @@
+import { Outlet } from 'react-router-dom';
+
+/**
+ * PathwayLayout - Layout wrapper for pathway routes
+ *
+ * Renders a bare Outlet, allowing pathways to manage their own layouts.
+ * This prevents unmounting when pathways switch between views.
+ */
+export default function PathwayLayout() {
+ // Always render bare Outlet - pathways manage their own layouts
+ return ;
+}
diff --git a/app/src/components/StandardLayout.tsx b/app/src/components/StandardLayout.tsx
new file mode 100644
index 00000000..f0bcf65a
--- /dev/null
+++ b/app/src/components/StandardLayout.tsx
@@ -0,0 +1,40 @@
+/**
+ * StandardLayout - Standard application layout with AppShell
+ *
+ * Extracted from Layout component to be reusable by pathways.
+ * Provides header, navbar, and main content area.
+ */
+
+import { AppShell } from '@mantine/core';
+import { spacing } from '@/designTokens';
+import HeaderNavigation from './shared/HomeHeader';
+import LegacyBanner from './shared/LegacyBanner';
+import Sidebar from './Sidebar';
+
+interface StandardLayoutProps {
+ children: React.ReactNode;
+}
+
+export default function StandardLayout({ children }: StandardLayoutProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/app/src/components/common/MultiButtonFooter.tsx b/app/src/components/common/MultiButtonFooter.tsx
index f5f7eee1..50ef2b43 100644
--- a/app/src/components/common/MultiButtonFooter.tsx
+++ b/app/src/components/common/MultiButtonFooter.tsx
@@ -1,4 +1,6 @@
-import { Button, Grid } 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 {
label: string;
@@ -7,38 +9,97 @@ export interface ButtonConfig {
isLoading?: boolean;
}
+export type { PaginationConfig };
+
export interface MultiButtonFooterProps {
buttons: ButtonConfig[];
+ /** New layout: Cancel on left, Back/Next on right with responsive stacking */
+ cancelAction?: {
+ label: string;
+ onClick: () => void;
+ };
+ backAction?: {
+ label: string;
+ onClick: () => void;
+ };
+ primaryAction?: {
+ label: string;
+ onClick: () => void;
+ isLoading?: boolean;
+ isDisabled?: boolean;
+ };
+ /** Pagination controls to show in the center */
+ pagination?: PaginationConfig;
}
export default function MultiButtonFooter(props: MultiButtonFooterProps) {
- const { buttons } = props;
+ const { buttons, cancelAction, backAction, primaryAction, pagination } = props;
+
+ // New layout: Grid with equal spacing - Cancel left, Pagination center, Back/Next right
+ if (cancelAction || backAction || primaryAction) {
+ return (
+
+ {/* Left side: Cancel button */}
+
+ {cancelAction && (
+
+ )}
+
+
+ {/* Center: Pagination controls (if provided) */}
+
+ {pagination && }
+
- // Determine grid size based on number of buttons
- const GRID_WIDTH = 12;
- const DESIRED_COLS_FOR_TWO_BUTTONS = 2;
- const DESIRED_COLS_OTHERWISE = 3;
+ {/* Right side: Back and Primary buttons */}
+
+
+ {backAction && (
+ }
+ >
+ {backAction.label}
+
+ )}
+ {primaryAction && (
+ }
+ >
+ {primaryAction.label}
+
+ )}
+
+
+
+ );
+ }
- const gridSize =
- buttons.length <= 2
- ? GRID_WIDTH / DESIRED_COLS_FOR_TWO_BUTTONS
- : GRID_WIDTH / DESIRED_COLS_OTHERWISE;
+ // Legacy layout for backward compatibility
+ if (buttons.length === 0) {
+ return null;
+ }
return (
-
+
{buttons.map((button, index) => (
-
-
-
+
))}
-
+
);
}
diff --git a/app/src/components/common/PaginationControls.tsx b/app/src/components/common/PaginationControls.tsx
new file mode 100644
index 00000000..ef05733c
--- /dev/null
+++ b/app/src/components/common/PaginationControls.tsx
@@ -0,0 +1,46 @@
+import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
+import { ActionIcon, Group, Text } from '@mantine/core';
+
+export interface PaginationConfig {
+ currentPage: number;
+ totalPages: number;
+ totalItems: number;
+ itemsPerPage: number;
+ onPageChange: (page: number) => void;
+}
+
+interface PaginationControlsProps {
+ pagination: PaginationConfig;
+}
+
+export default function PaginationControls({ pagination }: PaginationControlsProps) {
+ if (pagination.totalPages <= 1) {
+ return null;
+ }
+
+ return (
+
+ pagination.onPageChange(Math.max(1, pagination.currentPage - 1))}
+ disabled={pagination.currentPage === 1}
+ aria-label="Previous page"
+ >
+
+
+
+ {pagination.currentPage} / {pagination.totalPages}
+
+
+ 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/FlowView.tsx b/app/src/components/common/PathwayView.tsx
similarity index 51%
rename from app/src/components/common/FlowView.tsx
rename to app/src/components/common/PathwayView.tsx
index 4bcff5e5..55c4e665 100644
--- a/app/src/components/common/FlowView.tsx
+++ b/app/src/components/common/PathwayView.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import { Container, Divider, Stack, Text, Title } from '@mantine/core';
import {
ButtonPanelVariant,
@@ -9,7 +10,7 @@ import {
} from '@/components/flowView';
import MultiButtonFooter, { ButtonConfig } from './MultiButtonFooter';
-interface FlowViewProps {
+interface PathwayViewProps {
title: string;
subtitle?: string;
variant?: 'setupConditions' | 'buttonPanel' | 'cardList';
@@ -43,20 +44,26 @@ interface FlowViewProps {
cancelAction?: {
label?: string; // defaults to "Cancel"
- onClick?: () => void; // defaults to console.log placeholder
+ onClick?: () => void;
+ };
+
+ backAction?: {
+ label?: string; // defaults to "Back"
+ onClick: () => void;
};
// Preset configurations
buttonPreset?: 'cancel-only' | 'cancel-primary' | 'none';
}
-export default function FlowView({
+export default function PathwayView({
title,
subtitle,
variant,
buttons,
primaryAction,
cancelAction,
+ backAction,
buttonPreset,
content,
setupConditionCards,
@@ -64,53 +71,14 @@ export default function FlowView({
cardListItems,
itemsPerPage = 5,
showPagination = true,
-}: FlowViewProps) {
- // Generate buttons from convenience props if explicit buttons not provided
- function getButtons(): ButtonConfig[] {
- // If explicit buttons provided, use them
- if (buttons) {
- return buttons;
- }
-
- // Handle preset configurations
- if (buttonPreset === 'none') {
- return [];
- }
-
- if (buttonPreset === 'cancel-only') {
- return [
- {
- label: cancelAction?.label || 'Cancel',
- variant: 'disabled',
- onClick: () => {},
- },
- ];
- }
-
- // Default behavior: cancel + primary (or just cancel if no primary action)
- const generatedButtons: ButtonConfig[] = [];
-
- // Always add cancel button unless explicitly disabled
- generatedButtons.push({
- label: cancelAction?.label || 'Cancel',
- variant: 'disabled',
- onClick: () => {},
- });
-
- // Add primary action if provided
- if (primaryAction) {
- generatedButtons.push({
- label: primaryAction.label,
- variant: primaryAction.isDisabled ? 'disabled' : 'filled',
- onClick: primaryAction.onClick,
- isLoading: primaryAction.isLoading,
- });
- }
-
- return generatedButtons;
- }
+}: PathwayViewProps) {
+ // Pagination state for cardList variant
+ const [currentPage, setCurrentPage] = useState(1);
- const finalButtons = getButtons();
+ // Calculate pagination info for cardList
+ const totalItems = cardListItems?.length ?? 0;
+ const totalPages = Math.ceil(totalItems / itemsPerPage);
+ const shouldShowPaginationInFooter = variant === 'cardList' && showPagination && totalPages > 1;
const renderContent = () => {
switch (variant) {
@@ -125,7 +93,7 @@ export default function FlowView({
);
@@ -134,6 +102,76 @@ export default function FlowView({
}
};
+ // Build footer props based on configuration
+ const getFooterProps = () => {
+ if (buttonPreset === 'none') {
+ return { buttons: [] as ButtonConfig[] };
+ }
+
+ // Use new layout if any of the new action props are provided
+ const useNewLayout = cancelAction?.onClick || backAction || primaryAction;
+ 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,
+ };
+ }
+
+ // Legacy button array support
+ if (buttons) {
+ return { buttons };
+ }
+
+ // Generate legacy buttons from convenience props
+ const generatedButtons: ButtonConfig[] = [];
+
+ if (buttonPreset === 'cancel-only') {
+ generatedButtons.push({
+ label: cancelAction?.label || 'Cancel',
+ variant: 'disabled',
+ onClick: () => {},
+ });
+ return { buttons: generatedButtons };
+ }
+
+ return { buttons: generatedButtons };
+ };
+
+ const footerProps = getFooterProps();
+ const hasFooter =
+ footerProps.buttons?.length > 0 ||
+ footerProps.cancelAction ||
+ footerProps.backAction ||
+ footerProps.primaryAction;
+
const containerContent = (
<>
@@ -150,11 +188,11 @@ export default function FlowView({
{renderContent()}
- {finalButtons.length > 0 && }
+ {hasFooter && }
>
);
return {containerContent};
}
-export type { FlowViewProps, ButtonConfig };
+export type { PathwayViewProps, ButtonConfig };
diff --git a/app/src/components/flowView/CardListVariant.tsx b/app/src/components/flowView/CardListVariant.tsx
index b5ecf480..f81f9846 100644
--- a/app/src/components/flowView/CardListVariant.tsx
+++ b/app/src/components/flowView/CardListVariant.tsx
@@ -1,9 +1,8 @@
-import { useState } from 'react';
-import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
-import { ActionIcon, Card, Group, Stack, Text } from '@mantine/core';
+import { Card, Stack, Text } from '@mantine/core';
import { spacing } from '@/designTokens';
export interface CardListItem {
+ id?: string; // Unique identifier for React key
title: string;
subtitle?: string;
onClick: () => void;
@@ -14,99 +13,53 @@ export interface CardListItem {
interface CardListVariantProps {
items?: CardListItem[];
itemsPerPage?: number;
- showPagination?: boolean;
+ currentPage?: number;
}
export default function CardListVariant({
items,
itemsPerPage = 5,
- showPagination = true,
+ currentPage = 1,
}: CardListVariantProps) {
- const [currentPage, setCurrentPage] = useState(1);
-
if (!items) {
return null;
}
- const allItems = items;
- const totalPages = Math.ceil(allItems.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
- const paginatedItems = allItems.slice(startIndex, endIndex);
- const shouldShowPagination = showPagination && totalPages > 1;
-
- console.log('[CardListVariant] ========== PAGINATION ==========');
- console.log('[CardListVariant] Total items:', allItems.length);
- console.log('[CardListVariant] Items per page:', itemsPerPage);
- console.log('[CardListVariant] Total pages:', totalPages);
- console.log('[CardListVariant] Current page:', currentPage);
- console.log('[CardListVariant] Should show pagination:', shouldShowPagination);
- console.log('[CardListVariant] Paginated items count:', paginatedItems.length);
+ const paginatedItems = items.slice(startIndex, endIndex);
return (
-
- {/* Card list */}
-
- {paginatedItems.map((item: CardListItem, index: number) => {
- // Determine variant based on disabled state first, then selection
- let variant = 'cardList--inactive';
- if (item.isDisabled) {
- variant = 'cardList--disabled';
- } else if (item.isSelected) {
- variant = 'cardList--active';
- }
-
- return (
-
-
- {item.title}
- {item.subtitle && (
-
- {item.subtitle}
-
- )}
-
-
- );
- })}
-
+
+ {paginatedItems.map((item: CardListItem, index: number) => {
+ // Determine variant based on disabled state first, then selection
+ let variant = 'cardList--inactive';
+ if (item.isDisabled) {
+ variant = 'cardList--disabled';
+ } else if (item.isSelected) {
+ variant = 'cardList--active';
+ }
- {/* Pagination footer */}
- {shouldShowPagination && (
-
-
- Showing {startIndex + 1}-{Math.min(endIndex, allItems.length)} of {allItems.length}
-
-
- setCurrentPage((p) => Math.max(1, p - 1))}
- disabled={currentPage === 1}
- aria-label="Previous page"
- >
-
-
-
- Page {currentPage} of {totalPages}
-
- setCurrentPage((p) => Math.min(totalPages, p + 1))}
- disabled={currentPage === totalPages}
- aria-label="Next page"
- >
-
-
-
-
- )}
+ return (
+
+
+ {item.title}
+ {item.subtitle && (
+
+ {item.subtitle}
+
+ )}
+
+
+ );
+ })}
);
}
diff --git a/app/src/components/policyParameterSelectorFrame/Footer.tsx b/app/src/components/policyParameterSelectorFrame/Footer.tsx
deleted file mode 100644
index 8e917e5d..00000000
--- a/app/src/components/policyParameterSelectorFrame/Footer.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { IconChevronRight } from '@tabler/icons-react';
-import { useSelector } from 'react-redux';
-import { Box, Button, Group, Text } from '@mantine/core';
-import { colors } from '@/designTokens/colors';
-import { selectActivePolicy } from '@/reducers/activeSelectors';
-import { FlowComponentProps } from '@/types/flow';
-import { countPolicyModifications } from '@/utils/countParameterChanges';
-
-export default function PolicyParameterSelectorFooter({ onNavigate }: FlowComponentProps) {
- // Get the active policy to count modifications
- const activePolicy = useSelector(selectActivePolicy);
- const modificationCount = countPolicyModifications(activePolicy);
-
- function handleNext() {
- // Dispatch an action to move to the next step
- onNavigate('next');
- }
-
- return (
-
-
- {modificationCount > 0 && (
-
-
-
- {modificationCount} parameter modification{modificationCount !== 1 ? 's' : ''}
-
-
- )}
- }>
- Review my policy
-
-
- );
-}
diff --git a/app/src/components/policyParameterSelectorFrame/ValueSetter.tsx b/app/src/components/policyParameterSelectorFrame/ValueSetter.tsx
deleted file mode 100644
index fe36b68b..00000000
--- a/app/src/components/policyParameterSelectorFrame/ValueSetter.tsx
+++ /dev/null
@@ -1,601 +0,0 @@
-import dayjs from 'dayjs';
-import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
-import { IconSettings } from '@tabler/icons-react';
-import { useDispatch, useSelector } from 'react-redux';
-import {
- ActionIcon,
- Box,
- Button,
- Container,
- Divider,
- Group,
- Menu,
- NumberInput,
- SimpleGrid,
- Stack,
- Switch,
- Text,
-} from '@mantine/core';
-import { DatePickerInput, YearPickerInput } from '@mantine/dates';
-import { FOREVER } from '@/constants';
-import { getDateRange, getTaxYears } from '@/libs/metadataUtils';
-import { selectActivePolicy, selectCurrentPosition } from '@/reducers/activeSelectors';
-import { addPolicyParamAtPosition } from '@/reducers/policyReducer';
-import { RootState } from '@/store';
-import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
-import { getParameterByName } from '@/types/subIngredients/parameter';
-import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval';
-import { fromISODateString, toISODateString } from '@/utils/dateUtils';
-
-enum ValueSetterMode {
- DEFAULT = 'default',
- YEARLY = 'yearly',
- DATE = 'date',
- MULTI_YEAR = 'multi-year',
-}
-
-/**
- * Helper function to get default value for a parameter at a specific date
- * Priority: 1) User's reform value, 2) Baseline current law value
- */
-function getDefaultValueForParam(param: ParameterMetadata, activePolicy: any, date: string): any {
- // First check if user has set a reform value for this parameter
- if (activePolicy) {
- const userParam = getParameterByName(activePolicy, param.parameter);
- if (userParam && userParam.values && userParam.values.length > 0) {
- const userCollection = new ValueIntervalCollection(userParam.values);
- const userValue = userCollection.getValueAtDate(date);
- if (userValue !== undefined) {
- return userValue;
- }
- }
- }
-
- // Fall back to baseline current law value from metadata
- if (param.values) {
- const collection = new ValueIntervalCollection(param.values as any);
- const value = collection.getValueAtDate(date);
- if (value !== undefined) {
- return value;
- }
- }
-
- // Last resort: default based on unit type
- return param.unit === 'bool' ? false : 0;
-}
-
-interface ValueSetterContainerProps {
- param: ParameterMetadata;
- onSubmit?: () => void;
-}
-
-interface ValueSetterProps {
- minDate: string;
- maxDate: string;
- param: ParameterMetadata;
- intervals: ValueInterval[];
- setIntervals: Dispatch>;
- startDate: string;
- setStartDate: Dispatch>;
- endDate: string;
- setEndDate: Dispatch>;
-}
-
-interface ValueInputBoxProps {
- label?: string;
- param: ParameterMetadata;
- value?: any;
- onChange?: (value: any) => void;
-}
-
-const ValueSetterComponents = {
- [ValueSetterMode.DEFAULT]: DefaultValueSelector,
- [ValueSetterMode.YEARLY]: YearlyValueSelector,
- [ValueSetterMode.DATE]: DateValueSelector,
- [ValueSetterMode.MULTI_YEAR]: MultiYearValueSelector,
-} as const;
-
-export default function PolicyParameterSelectorValueSetterContainer(
- props: ValueSetterContainerProps
-) {
- const { param } = props;
-
- const [mode, setMode] = useState(ValueSetterMode.DEFAULT);
- const dispatch = useDispatch();
-
- // Get the current position from the cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
-
- // Get date ranges from metadata using utility selector
- const { minDate, maxDate } = useSelector(getDateRange);
-
- const [intervals, setIntervals] = useState([]);
-
- // Hoisted date state for all non-multi-year selectors
- const currentYear = new Date().getFullYear();
- const [startDate, setStartDate] = useState(`${currentYear}-01-01`);
- const [endDate, setEndDate] = useState(`${currentYear}-12-31`);
-
- function resetValueSettingState() {
- setIntervals([]);
- }
-
- function handleModeChange(newMode: ValueSetterMode) {
- resetValueSettingState();
- setMode(newMode);
- }
-
- function handleSubmit() {
- intervals.forEach((interval) => {
- dispatch(
- addPolicyParamAtPosition({
- position: currentPosition,
- name: param.parameter,
- valueInterval: interval,
- })
- );
- });
- }
-
- const ValueSetterToRender = ValueSetterComponents[mode];
-
- const valueSetterProps: ValueSetterProps = {
- minDate,
- maxDate,
- param,
- intervals,
- setIntervals,
- startDate,
- setStartDate,
- endDate,
- setEndDate,
- };
-
- return (
-
-
- Current value
-
-
-
-
-
-
-
-
- );
-}
-
-export function ModeSelectorButton(props: { setMode: (mode: ValueSetterMode) => void }) {
- const { setMode } = props;
- return (
-
- );
-}
-
-export function DefaultValueSelector(props: ValueSetterProps) {
- const { param, setIntervals, minDate, maxDate, startDate, setStartDate, endDate, setEndDate } =
- props;
-
- // Get active policy to check for user-set reform values
- const activePolicy = useSelector(selectActivePolicy);
-
- // Local state for param value
- const [paramValue, setParamValue] = useState(
- getDefaultValueForParam(param, activePolicy, startDate)
- );
-
- // Set endDate to 2100-12-31 for default mode
- useEffect(() => {
- setEndDate(FOREVER);
- }, [setEndDate]);
-
- // Update param value when startDate changes
- useEffect(() => {
- if (startDate) {
- const newValue = getDefaultValueForParam(param, activePolicy, startDate);
- setParamValue(newValue);
- }
- }, [startDate, param, activePolicy]);
-
- // Update intervals whenever local state changes
- useEffect(() => {
- if (startDate && endDate) {
- const newInterval: ValueInterval = {
- startDate,
- endDate,
- value: paramValue,
- };
- setIntervals([newInterval]);
- } else {
- setIntervals([]);
- }
- }, [startDate, endDate, paramValue, setIntervals]);
-
- function handleStartDateChange(value: Date | string | null) {
- setStartDate(toISODateString(value));
- }
-
- return (
-
-
-
-
- onward:
-
-
-
-
-
-
- );
-}
-
-export function YearlyValueSelector(props: ValueSetterProps) {
- const { param, setIntervals, minDate, maxDate, startDate, setStartDate, endDate, setEndDate } =
- props;
-
- // Get active policy to check for user-set reform values
- const activePolicy = useSelector(selectActivePolicy);
-
- // Local state for param value
- const [paramValue, setParamValue] = useState(
- getDefaultValueForParam(param, activePolicy, startDate)
- );
-
- // Set endDate to end of year of startDate
- useEffect(() => {
- if (startDate) {
- const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD');
- setEndDate(endOfYearDate);
- }
- }, [startDate, setEndDate]);
-
- // Update param value when startDate changes
- useEffect(() => {
- if (startDate) {
- const newValue = getDefaultValueForParam(param, activePolicy, startDate);
- setParamValue(newValue);
- }
- }, [startDate, param, activePolicy]);
-
- // Update intervals whenever local state changes
- useEffect(() => {
- if (startDate && endDate) {
- const newInterval: ValueInterval = {
- startDate,
- endDate,
- value: paramValue,
- };
- setIntervals([newInterval]);
- } else {
- setIntervals([]);
- }
- }, [startDate, endDate, paramValue, setIntervals]);
-
- function handleStartDateChange(value: Date | string | null) {
- setStartDate(toISODateString(value));
- }
-
- function handleEndDateChange(value: Date | string | null) {
- const isoString = toISODateString(value);
- if (isoString) {
- const endOfYearDate = dayjs(isoString).endOf('year').format('YYYY-MM-DD');
- setEndDate(endOfYearDate);
- } else {
- setEndDate('');
- }
- }
-
- return (
-
-
-
-
-
- );
-}
-
-export function DateValueSelector(props: ValueSetterProps) {
- const { param, setIntervals, minDate, maxDate, startDate, setStartDate, endDate, setEndDate } =
- props;
-
- // Get active policy to check for user-set reform values
- const activePolicy = useSelector(selectActivePolicy);
-
- // Local state for param value
- const [paramValue, setParamValue] = useState(
- getDefaultValueForParam(param, activePolicy, startDate)
- );
-
- // Set endDate to end of year of startDate
- useEffect(() => {
- if (startDate) {
- const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD');
- setEndDate(endOfYearDate);
- }
- }, [startDate, setEndDate]);
-
- // Update param value when startDate changes
- useEffect(() => {
- if (startDate) {
- const newValue = getDefaultValueForParam(param, activePolicy, startDate);
- setParamValue(newValue);
- }
- }, [startDate, param, activePolicy]);
-
- // Update intervals whenever local state changes
- useEffect(() => {
- if (startDate && endDate) {
- const newInterval: ValueInterval = {
- startDate,
- endDate,
- value: paramValue,
- };
- setIntervals([newInterval]);
- } else {
- setIntervals([]);
- }
- }, [startDate, endDate, paramValue, setIntervals]);
-
- function handleStartDateChange(value: Date | string | null) {
- setStartDate(toISODateString(value));
- }
-
- function handleEndDateChange(value: Date | string | null) {
- setEndDate(toISODateString(value));
- }
-
- return (
-
-
-
-
-
- );
-}
-
-export function MultiYearValueSelector(props: ValueSetterProps) {
- const { param, setIntervals } = props;
-
- // Get active policy to check for user-set reform values
- const activePolicy = useSelector(selectActivePolicy);
-
- // Get available years from metadata
- const availableYears = useSelector(getTaxYears);
- const countryId = useSelector((state: RootState) => state.metadata.currentCountry);
-
- // Country-specific max years configuration
- const MAX_YEARS_BY_COUNTRY: Record = {
- us: 10,
- uk: 5,
- };
-
- // Generate years from metadata, starting from current year
- const generateYears = () => {
- const currentYear = new Date().getFullYear();
- const maxYears = MAX_YEARS_BY_COUNTRY[countryId || 'us'] || 10;
-
- // Filter available years from metadata to only include current year onwards
- const futureYears = availableYears
- .map((option) => parseInt(option.value, 10))
- .filter((year) => year >= currentYear)
- .sort((a, b) => a - b);
-
- // Take only the configured max years for this country
- return futureYears.slice(0, maxYears);
- };
-
- const years = generateYears();
-
- // Get values for each year - check reform first, then baseline
- const getInitialYearValues = useMemo(() => {
- const initialValues: Record = {};
- years.forEach((year) => {
- initialValues[year] = getDefaultValueForParam(param, activePolicy, `${year}-01-01`);
- });
- return initialValues;
- }, [param, activePolicy, years]);
-
- const [yearValues, setYearValues] = useState>(getInitialYearValues);
-
- // Update intervals whenever yearValues changes
- useEffect(() => {
- const newIntervals: ValueInterval[] = Object.keys(yearValues).map((year: string) => ({
- startDate: `${year}-01-01`,
- endDate: `${year}-12-31`,
- value: yearValues[year],
- }));
-
- setIntervals(newIntervals);
- }, [yearValues, setIntervals]);
-
- const handleYearValueChange = (year: number, value: any) => {
- setYearValues((prev) => ({
- ...prev,
- [year]: value,
- }));
- };
-
- // Split years into two columns
- const midpoint = Math.ceil(years.length / 2);
- const leftColumn = years.slice(0, midpoint);
- const rightColumn = years.slice(midpoint);
-
- return (
-
-
-
- {leftColumn.map((year) => (
-
-
- {year}
-
- handleYearValueChange(year, value)}
- />
-
- ))}
-
-
- {rightColumn.map((year) => (
-
-
- {year}
-
- handleYearValueChange(year, value)}
- />
-
- ))}
-
-
-
- );
-}
-
-export function ValueInputBox(props: ValueInputBoxProps) {
- const { param, value, onChange, label } = props;
-
- // US and UK packages use these type designations inconsistently
- const USD_UNITS = ['currency-USD', 'currency_USD', 'USD'];
- const GBP_UNITS = ['currency-GBP', 'currency_GBP', 'GBP'];
-
- const prefix = USD_UNITS.includes(String(param.unit))
- ? '$'
- : GBP_UNITS.includes(String(param.unit))
- ? '£'
- : '';
-
- const isPercentage = param.unit === '/1';
- const isBool = param.unit === 'bool';
-
- if (param.type !== 'parameter') {
- console.error("ValueInputBox expects a parameter type of 'parameter', got:", param.type);
- return ;
- }
-
- const handleChange = (newValue: any) => {
- if (onChange) {
- // Convert percentage display value (0-100) to decimal (0-1) for storage
- const valueToStore = isPercentage ? newValue / 100 : newValue;
- onChange(valueToStore);
- }
- };
-
- const handleBoolChange = (checked: boolean) => {
- if (onChange) {
- onChange(checked);
- }
- };
-
- // Convert decimal value (0-1) to percentage display value (0-100)
- // Defensive: ensure value is a number, not an object/array/string
- const numericValue = typeof value === 'number' ? value : 0;
- const displayValue = isPercentage ? numericValue * 100 : numericValue;
-
- if (isBool) {
- return (
-
- {label && (
-
- {label}
-
- )}
-
-
- False
-
- handleBoolChange(event.currentTarget.checked)}
- size="md"
- />
-
- True
-
-
-
- );
- }
-
- return (
-
- );
-}
diff --git a/app/src/contexts/ReportYearContext.tsx b/app/src/contexts/ReportYearContext.tsx
new file mode 100644
index 00000000..8d3bfdd2
--- /dev/null
+++ b/app/src/contexts/ReportYearContext.tsx
@@ -0,0 +1,25 @@
+import { createContext, ReactNode, useContext } from 'react';
+
+interface ReportYearContextValue {
+ year: string | null;
+}
+
+const ReportYearContext = createContext(undefined);
+
+interface ReportYearProviderProps {
+ year: string | null;
+ children: ReactNode;
+}
+
+export function ReportYearProvider({ year, children }: ReportYearProviderProps) {
+ return {children};
+}
+
+export function useReportYearContext(): string | null {
+ const context = useContext(ReportYearContext);
+ if (context === undefined) {
+ // Not inside a ReportYearProvider - return null to indicate no year available
+ return null;
+ }
+ return context.year;
+}
diff --git a/app/src/flows/README.md b/app/src/flows/README.md
deleted file mode 100644
index 8ec47ea8..00000000
--- a/app/src/flows/README.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# Flow Management System
-
-The flow management system is a series of code structures meant to handle multi-step user interfaces through Redux state management and component orchestration. The system uses **flows** (sequences of connected components) and **frames** (individual steps) for complex navigation patterns. One **flow** consists of one or more **frames**, and flows can nest within one another, allowing for complex routing. Flows and frames are both currently defined using title case.
-
-## Core Components
-
-**Reducer (`flowSlice`)**
-- Manages navigation state: `currentFlow`, `currentFrame`, and `flowStack`
-- Key actions: `setFlow`, `navigateToFrame`, `navigateToFlow`, `returnFromFlow`
-- Flow stack enables nested flows by preserving calling flow state
-
-**Registry System**
-- `componentRegistry`: Maps `ComponentKey` strings to React components; `ComponentKey`s allow for serializable TypeScript-friendly referencing of components
-- `flowRegistry`: Maps `FlowKey` strings to `Flow` objects; `FlowKey`s allow for serializable TypeScript-friendly referencing of flows
-
-**FlowContainer**
-- Renders current frame's component from `componentRegistry`
-- Provides `onNavigate`, `onReturn`, and `flowConfig` props to components; these must be passed to components as explicit props to enable navigation
-- Handles navigation logic and component resolution
-
-## Flow Structure
-
-**Flow Definition (`Flow` type)**
-- `initialFrame`: Entry point (`ComponentKey | FlowKey | null`)
-- `frames`: Record mapping frame names to `FlowFrame` objects
-
-**Frame Configuration (`FlowFrame` type)**
-- `component`: `ComponentKey` specifying which component to render
-- `on`: `EventList` mapping event names to navigation targets
-- Targets can be: `ComponentKey` (same flow), `FlowKey` (subflow), or `"__return__"` (exit flow)
-
-## Adding New Components
-
-**1. Create Component with Required Props**
-```typescript
-export default function MyComponent({ onNavigate, onReturn, flowConfig }: FlowComponentProps) {
- const handleNext = () => onNavigate('next');
- const handleBack = () => onNavigate('back');
-
- const returnFromFlow = () => onReturn();
-
- // flowConfig is used to access flow configuration within component
-
- return (
-
-
-
-
- );
-}
-```
-
-**2. Register Component**
-```typescript
-// In registry.ts
-export const componentRegistry = {
- "MyComponent": MyComponent,
- // ... other components
-} as const;
-```
-
-**3. Use in Flow Definition**
-```typescript
-const MyFlow: Flow = {
- initialFrame: "MyComponent",
- frames: {
- MyComponent: {
- component: "MyComponent",
- on: {
- "next": "AnotherComponent",
- "back": "__return__"
- }
- }
- }
-};
-```
-
-## Adding New Flows
-
-**1. Define Flow Structure**
-```typescript
-export const MyNewFlow: Flow = {
- initialFrame: "StartComponent",
- frames: {
- StartComponent: {
- component: "StartComponent",
- on: {
- "next": "MiddleComponent",
- "skip": "EndComponent"
- }
- },
- MiddleComponent: {
- component: "MiddleComponent",
- on: {
- "next": "EndComponent",
- "back": "StartComponent",
- "subflow": "AnotherFlow" // Navigate to subflow
- }
- },
- EndComponent: {
- component: "EndComponent",
- on: {
- "finish": "__return__"
- }
- }
- }
-};
-```
-
-**2. Register Flow**
-```typescript
-// In registry.ts
-export const flowRegistry = {
- "MyNewFlow": MyNewFlow,
- // ... other flows
-} as const;
-```
-
-**3. Trigger Flow**
-```typescript
-// In any component
-const dispatch = useDispatch();
-dispatch(setFlow(MyNewFlow));
-```
-
-## Navigation and Events
-
-**Action Dispatching**
-- Components call `onNavigate(eventName)` to trigger navigation
-- `eventName` must match keys in the frame's `on` configuration
-- FlowContainer resolves targets and dispatches appropriate Redux actions
-
-**Special Navigation**
-- `"__return__"`: Exit current flow (pops from `flowStack`)
-- `FlowKey` targets: Enter subflow (pushes current state to stack)
-- `ComponentKey` targets: Navigate within current flow
\ No newline at end of file
diff --git a/app/src/flows/policyCreationFlow.ts b/app/src/flows/policyCreationFlow.ts
deleted file mode 100644
index 357d7493..00000000
--- a/app/src/flows/policyCreationFlow.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const PolicyCreationFlow: Flow = {
- initialFrame: 'PolicyCreationFrame',
- frames: {
- PolicyCreationFrame: {
- component: 'PolicyCreationFrame',
- on: {
- next: 'PolicyParameterSelectorFrame',
- },
- },
- PolicyParameterSelectorFrame: {
- component: 'PolicyParameterSelectorFrame',
- on: {
- next: 'PolicySubmitFrame',
- },
- },
- PolicySubmitFrame: {
- component: 'PolicySubmitFrame',
- on: {
- cancel: '__return__',
- },
- },
- },
-};
diff --git a/app/src/flows/policyViewFlow.ts b/app/src/flows/policyViewFlow.ts
deleted file mode 100644
index d50be4bf..00000000
--- a/app/src/flows/policyViewFlow.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const PolicyViewFlow: Flow = {
- initialFrame: 'PolicyReadView',
- frames: {
- PolicyReadView: {
- component: 'PolicyReadView',
- on: {
- next: '__return__',
- // Optional: could add 'edit', 'delete', 'share' etc. here later
- },
- },
- },
-};
diff --git a/app/src/flows/populationCreationFlow.ts b/app/src/flows/populationCreationFlow.ts
deleted file mode 100644
index c4678b85..00000000
--- a/app/src/flows/populationCreationFlow.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const PopulationCreationFlow: Flow = {
- initialFrame: 'SelectGeographicScopeFrame',
- frames: {
- SelectGeographicScopeFrame: {
- component: 'SelectGeographicScopeFrame',
- on: {
- household: 'SetPopulationLabelFrame',
- state: 'SetPopulationLabelFrame',
- national: 'SetPopulationLabelFrame',
- country: 'SetPopulationLabelFrame',
- constituency: 'SetPopulationLabelFrame',
- },
- },
- SetPopulationLabelFrame: {
- component: 'SetPopulationLabelFrame',
- on: {
- household: 'HouseholdBuilderFrame',
- geographic: 'GeographicConfirmationFrame',
- back: 'SelectGeographicScopeFrame',
- },
- },
- HouseholdBuilderFrame: {
- component: 'HouseholdBuilderFrame',
- on: {
- next: '__return__',
- back: 'SetPopulationLabelFrame',
- },
- },
- GeographicConfirmationFrame: {
- component: 'GeographicConfirmationFrame',
- on: {
- next: '__return__',
- back: 'SetPopulationLabelFrame',
- },
- },
- },
-};
diff --git a/app/src/flows/populationViewFlow.ts b/app/src/flows/populationViewFlow.ts
deleted file mode 100644
index 1f1a7a93..00000000
--- a/app/src/flows/populationViewFlow.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const PopulationViewFlow: Flow = {
- initialFrame: 'PopulationReadView',
- frames: {
- PopulationReadView: {
- component: 'PopulationReadView',
- on: {
- next: '__return__',
- // Optional: could add 'edit', 'delete', 'share' etc. here later
- },
- },
- },
-};
diff --git a/app/src/flows/registry.ts b/app/src/flows/registry.ts
deleted file mode 100644
index a997fa1f..00000000
--- a/app/src/flows/registry.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { PolicyViewFlow } from '@/flows/policyViewFlow';
-import PolicyCreationFrame from '@/frames/policy/PolicyCreationFrame';
-import PolicyParameterSelectorFrame from '@/frames/policy/PolicyParameterSelectorFrame';
-import PolicySubmitFrame from '@/frames/policy/PolicySubmitFrame';
-import GeographicConfirmationFrame from '@/frames/population/GeographicConfirmationFrame';
-import HouseholdBuilderFrame from '@/frames/population/HouseholdBuilderFrame';
-import SelectGeographicScopeFrame from '@/frames/population/SelectGeographicScopeFrame';
-import SetPopulationLabelFrame from '@/frames/population/SetPopulationLabelFrame';
-import ReportCreationFrame from '@/frames/report/ReportCreationFrame';
-import ReportSelectExistingSimulationFrame from '@/frames/report/ReportSelectExistingSimulationFrame';
-import ReportSelectSimulationFrame from '@/frames/report/ReportSelectSimulationFrame';
-import ReportSetupFrame from '@/frames/report/ReportSetupFrame';
-import ReportSubmitFrame from '@/frames/report/ReportSubmitFrame';
-import SimulationCreationFrame from '@/frames/simulation/SimulationCreationFrame';
-import SimulationSelectExistingPolicyFrame from '@/frames/simulation/SimulationSelectExistingPolicyFrame';
-import SimulationSelectExistingPopulationFrame from '@/frames/simulation/SimulationSelectExistingPopulationFrame';
-import SimulationSetupFrame from '@/frames/simulation/SimulationSetupFrame';
-import SimulationSetupPolicyFrame from '@/frames/simulation/SimulationSetupPolicyFrame';
-import SimulationSetupPopulationFrame from '@/frames/simulation/SimulationSetupPopulationFrame';
-import SimulationSubmitFrame from '@/frames/simulation/SimulationSubmitFrame';
-import PoliciesPage from '@/pages/Policies.page';
-import PopulationsPage from '@/pages/Populations.page';
-import ReportsPage from '@/pages/Reports.page';
-import SimulationsPage from '@/pages/Simulations.page';
-import { PolicyCreationFlow } from './policyCreationFlow';
-import { PopulationCreationFlow } from './populationCreationFlow';
-import { ReportCreationFlow } from './reportCreationFlow';
-import { ReportViewFlow } from './reportViewFlow';
-import { SimulationCreationFlow } from './simulationCreationFlow';
-import { SimulationViewFlow } from './simulationViewFlow';
-
-export const componentRegistry = {
- PolicyCreationFrame,
- PolicyParameterSelectorFrame,
- PolicySubmitFrame,
- PolicyReadView: PoliciesPage,
- SelectGeographicScopeFrame,
- SetPopulationLabelFrame,
- GeographicConfirmationFrame,
- HouseholdBuilderFrame,
- PopulationReadView: PopulationsPage,
- ReportCreationFrame,
- ReportSetupFrame,
- ReportSelectSimulationFrame,
- ReportSelectExistingSimulationFrame,
- ReportSubmitFrame,
- ReportReadView: ReportsPage,
- SimulationCreationFrame,
- SimulationSetupFrame,
- SimulationSubmitFrame,
- SimulationSetupPolicyFrame,
- SimulationSelectExistingPolicyFrame,
- SimulationReadView: SimulationsPage,
- SimulationSetupPopulationFrame,
- SimulationSelectExistingPopulationFrame,
-} as const;
-
-export const flowRegistry = {
- PolicyCreationFlow,
- PolicyViewFlow,
- PopulationCreationFlow,
- ReportCreationFlow,
- ReportViewFlow,
- SimulationCreationFlow,
- SimulationViewFlow,
-} as const;
-
-export type ComponentKey = keyof typeof componentRegistry;
-export type FlowKey = keyof typeof flowRegistry;
diff --git a/app/src/flows/reportCreationFlow.ts b/app/src/flows/reportCreationFlow.ts
deleted file mode 100644
index f3e5067c..00000000
--- a/app/src/flows/reportCreationFlow.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const ReportCreationFlow: Flow = {
- initialFrame: 'ReportCreationFrame',
- frames: {
- ReportCreationFrame: {
- component: 'ReportCreationFrame',
- on: {
- next: 'ReportSetupFrame',
- },
- },
- ReportSetupFrame: {
- component: 'ReportSetupFrame',
- on: {
- setupSimulation1: 'ReportSelectSimulationFrame',
- setupSimulation2: 'ReportSelectSimulationFrame',
- next: 'ReportSubmitFrame',
- },
- },
- ReportSelectSimulationFrame: {
- component: 'ReportSelectSimulationFrame',
- on: {
- createNew: {
- flow: 'SimulationCreationFlow',
- returnTo: 'ReportSetupFrame',
- },
- loadExisting: 'ReportSelectExistingSimulationFrame',
- },
- },
- ReportSelectExistingSimulationFrame: {
- component: 'ReportSelectExistingSimulationFrame',
- on: {
- next: 'ReportSetupFrame',
- },
- },
- ReportSubmitFrame: {
- component: 'ReportSubmitFrame',
- on: {
- submit: '__return__', // Report creation navigates directly via React Router
- },
- },
- },
-};
diff --git a/app/src/flows/reportViewFlow.ts b/app/src/flows/reportViewFlow.ts
deleted file mode 100644
index aea7a761..00000000
--- a/app/src/flows/reportViewFlow.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const ReportViewFlow: Flow = {
- initialFrame: 'ReportReadView',
- frames: {
- ReportReadView: {
- component: 'ReportReadView',
- on: {
- next: '__return__',
- },
- },
- },
-};
diff --git a/app/src/flows/simulationCreationFlow.ts b/app/src/flows/simulationCreationFlow.ts
deleted file mode 100644
index e9fccda3..00000000
--- a/app/src/flows/simulationCreationFlow.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const SimulationCreationFlow: Flow = {
- initialFrame: 'SimulationCreationFrame',
- frames: {
- SimulationCreationFrame: {
- component: 'SimulationCreationFrame',
- on: {
- next: 'SimulationSetupFrame',
- },
- },
- SimulationSetupFrame: {
- component: 'SimulationSetupFrame',
- on: {
- setupPolicy: 'SimulationSetupPolicyFrame',
- setupPopulation: 'SimulationSetupPopulationFrame',
- next: 'SimulationSubmitFrame',
- },
- },
- SimulationSetupPolicyFrame: {
- component: 'SimulationSetupPolicyFrame',
- on: {
- createNew: {
- flow: 'PolicyCreationFlow',
- returnTo: 'SimulationSetupFrame',
- },
- loadExisting: 'SimulationSelectExistingPolicyFrame',
- selectCurrentLaw: 'SimulationSetupFrame',
- },
- },
- SimulationSelectExistingPolicyFrame: {
- component: 'SimulationSelectExistingPolicyFrame',
- on: {
- next: 'SimulationSetupFrame',
- },
- },
- SimulationSetupPopulationFrame: {
- component: 'SimulationSetupPopulationFrame',
- on: {
- createNew: {
- flow: 'PopulationCreationFlow',
- returnTo: 'SimulationSetupFrame',
- },
- loadExisting: 'SimulationSelectExistingPopulationFrame',
- copyExisting: 'SimulationSetupFrame',
- },
- },
- SimulationSelectExistingPopulationFrame: {
- component: 'SimulationSelectExistingPopulationFrame',
- on: {
- next: 'SimulationSetupFrame',
- },
- },
- SimulationSubmitFrame: {
- component: 'SimulationSubmitFrame',
- on: {
- submit: '__return__',
- },
- },
- },
-};
diff --git a/app/src/flows/simulationViewFlow.ts b/app/src/flows/simulationViewFlow.ts
deleted file mode 100644
index 206ea26a..00000000
--- a/app/src/flows/simulationViewFlow.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Flow } from '../types/flow';
-
-export const SimulationViewFlow: Flow = {
- initialFrame: 'SimulationReadView',
- frames: {
- SimulationReadView: {
- component: 'SimulationReadView',
- on: {
- next: '__return__',
- },
- },
- },
-};
diff --git a/app/src/frames/policy/PolicyCreationFrame.tsx b/app/src/frames/policy/PolicyCreationFrame.tsx
deleted file mode 100644
index 9ae500ed..00000000
--- a/app/src/frames/policy/PolicyCreationFrame.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { TextInput } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import {
- createPolicyAtPosition,
- selectPolicyAtPosition,
- updatePolicyAtPosition,
-} from '@/reducers/policyReducer';
-import { setMode } from '@/reducers/reportReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-export default function PolicyCreationFrame({ onNavigate, isInSubflow }: FlowComponentProps) {
- const dispatch = useDispatch();
-
- // Get the current position from the cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const policy = useSelector((state: RootState) => selectPolicyAtPosition(state, currentPosition));
-
- // Get report state for auto-naming
- const reportState = useSelector((state: RootState) => state.report);
-
- // Generate default label based on context
- const getDefaultLabel = () => {
- if (reportState.mode === 'report' && reportState.label) {
- // Report mode WITH report name: prefix with report name
- const baseName = currentPosition === 0 ? 'baseline policy' : 'reform policy';
- return `${reportState.label} ${baseName}`;
- }
- // All other cases: use standalone label
- const baseName = currentPosition === 0 ? 'Baseline policy' : 'Reform policy';
- return baseName;
- };
-
- const [localLabel, setLocalLabel] = useState(getDefaultLabel());
-
- console.log('[PolicyCreationFrame] RENDER - currentPosition:', currentPosition);
- console.log('[PolicyCreationFrame] RENDER - policy:', policy);
-
- // Set mode to standalone if not in a subflow
- useEffect(() => {
- console.log('[PolicyCreationFrame] Mode effect - isInSubflow:', isInSubflow);
- if (!isInSubflow) {
- dispatch(setMode('standalone'));
- }
-
- return () => {
- console.log('[PolicyCreationFrame] Cleanup - mode effect');
- };
- }, [dispatch, isInSubflow]);
-
- useEffect(() => {
- console.log('[PolicyCreationFrame] Create policy effect - policy exists?:', !!policy);
- // If there's no policy at current position, create one
- if (!policy) {
- console.log('[PolicyCreationFrame] Creating policy at position', currentPosition);
- dispatch(createPolicyAtPosition({ position: currentPosition }));
- }
-
- return () => {
- console.log('[PolicyCreationFrame] Cleanup - create policy effect');
- };
- }, [currentPosition, policy, dispatch]);
-
- function handleLocalLabelChange(value: string) {
- setLocalLabel(value);
- }
-
- function submissionHandler() {
- console.log('[PolicyCreationFrame] ========== submissionHandler START ==========');
- console.log('[PolicyCreationFrame] Updating policy with label:', localLabel);
- // Update the policy at the current position with the label
- dispatch(
- updatePolicyAtPosition({
- position: currentPosition,
- updates: { label: localLabel },
- })
- );
- console.log('[PolicyCreationFrame] Calling onNavigate("next")');
- onNavigate('next');
- console.log('[PolicyCreationFrame] ========== submissionHandler END ==========');
- }
-
- const formInputs = (
- handleLocalLabelChange(e.currentTarget.value)}
- />
- );
-
- const primaryAction = {
- label: 'Create a policy',
- onClick: submissionHandler,
- };
-
- return ;
-}
diff --git a/app/src/frames/policy/PolicySubmitFrame.tsx b/app/src/frames/policy/PolicySubmitFrame.tsx
deleted file mode 100644
index 675cad88..00000000
--- a/app/src/frames/policy/PolicySubmitFrame.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useDispatch, useSelector } from 'react-redux';
-import { PolicyAdapter } from '@/adapters';
-import IngredientSubmissionView, {
- DateIntervalValue,
- TextListItem,
- TextListSubItem,
-} from '@/components/IngredientSubmissionView';
-import { useCreatePolicy } from '@/hooks/useCreatePolicy';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { selectActivePolicy, selectCurrentPosition } from '@/reducers/activeSelectors';
-import { clearPolicyAtPosition, updatePolicyAtPosition } from '@/reducers/policyReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { Policy } from '@/types/ingredients/Policy';
-import { PolicyCreationPayload } from '@/types/payloads';
-import { formatDate } from '@/utils/dateUtils';
-
-export default function PolicySubmitFrame({ onReturn, isInSubflow }: FlowComponentProps) {
- const dispatch = useDispatch();
- const countryId = useCurrentCountry();
-
- // Read position from report reducer via cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
-
- // Get the active policy at the current position
- const policyState = useSelector((state: RootState) => selectActivePolicy(state));
- const params = policyState?.parameters || [];
-
- const { createPolicy, isPending } = useCreatePolicy(policyState?.label || undefined);
-
- // Convert Redux state to Policy type structure
- const policy: Partial = {
- parameters: policyState?.parameters,
- };
-
- function handleSubmit() {
- if (!policyState) {
- console.error('No policy found at current position');
- return;
- }
-
- const serializedPolicyCreationPayload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(
- policy as Policy
- );
- console.log('serializedPolicyCreationPayload', serializedPolicyCreationPayload);
- createPolicy(serializedPolicyCreationPayload, {
- onSuccess: (data) => {
- console.log('Policy created successfully:', data);
- // Update the policy at the current position with the ID and mark as created
- dispatch(
- updatePolicyAtPosition({
- position: currentPosition,
- updates: {
- id: data.result.policy_id,
- isCreated: true,
- },
- })
- );
- // If we've created this policy as part of a standalone policy creation flow,
- // we're done; clear the policy at current position
- if (!isInSubflow) {
- dispatch(clearPolicyAtPosition(currentPosition));
- }
- onReturn();
- },
- });
- }
-
- // Helper function to format date range string (UTC timezone-agnostic)
- const formatDateRange = (startDate: string, endDate: string): string => {
- const start = formatDate(startDate, 'short-month-day-year', countryId);
- const end =
- endDate === '9999-12-31' ? 'Ongoing' : formatDate(endDate, 'short-month-day-year', countryId);
- return `${start} - ${end}`;
- };
-
- // Create hierarchical provisions list with header and date intervals
- const provisions: TextListItem[] = [
- {
- text: 'Provision',
- isHeader: true, // Use larger size for header
- subItems: params.map((param) => {
- const dateIntervals: DateIntervalValue[] = param.values.map((valueInterval) => ({
- dateRange: formatDateRange(valueInterval.startDate, valueInterval.endDate),
- value: valueInterval.value,
- }));
-
- return {
- label: param.name, // Parameter name
- dateIntervals,
- } as TextListSubItem;
- }),
- },
- ];
-
- return (
-
- );
-}
diff --git a/app/src/frames/population/GeographicConfirmationFrame.tsx b/app/src/frames/population/GeographicConfirmationFrame.tsx
deleted file mode 100644
index a190d12d..00000000
--- a/app/src/frames/population/GeographicConfirmationFrame.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { useDispatch, useSelector } from 'react-redux';
-import { Stack, Text } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { MOCK_USER_ID } from '@/constants';
-import { useIngredientReset } from '@/hooks/useIngredientReset';
-import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic';
-import { selectActivePopulation, selectCurrentPosition } from '@/reducers/activeSelectors';
-import {
- updatePopulationAtPosition,
- updatePopulationIdAtPosition,
-} from '@/reducers/populationReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation';
-import { getCountryLabel, getRegionLabel, getRegionTypeLabel } from '@/utils/geographyUtils';
-
-export default function GeographicConfirmationFrame({
- onNavigate,
- onReturn,
- isInSubflow,
-}: FlowComponentProps) {
- const dispatch = useDispatch();
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const populationState = useSelector((state: RootState) => selectActivePopulation(state));
- const { mutateAsync: createGeographicAssociation, isPending } = useCreateGeographicAssociation();
- const { resetIngredient } = useIngredientReset();
-
- // Hardcoded for now - TODO: Replace with actual user from auth context
- const currentUserId = MOCK_USER_ID;
- // Get metadata from state
- const metadata = useSelector((state: RootState) => state.metadata);
- const userDefinedLabel = populationState?.label || null;
-
- // Build geographic population data from existing geography in reducer
- const buildGeographicPopulation = (): Omit => {
- if (!populationState?.geography) {
- throw new Error('No geography found in population state');
- }
-
- const basePopulation = {
- id: `${currentUserId}-${Date.now()}`, // TODO: May need to modify this after changes to API
- userId: currentUserId,
- countryId: populationState.geography.countryId,
- geographyId: populationState.geography.geographyId,
- scope: populationState.geography.scope,
- label: userDefinedLabel || populationState.geography.name || undefined,
- };
-
- return basePopulation;
- };
-
- const handleSubmit = async () => {
- const populationData = buildGeographicPopulation();
- console.log('Creating geographic population:', populationData);
-
- try {
- const result = await createGeographicAssociation(populationData);
- console.log('Geographic population created successfully:', result);
-
- // Update population state with the created population ID and mark as created
- dispatch(
- updatePopulationIdAtPosition({
- position: currentPosition,
- id: result.geographyId,
- })
- );
- dispatch(
- updatePopulationAtPosition({
- position: currentPosition,
- updates: {
- label: result.label || '',
- isCreated: true,
- },
- })
- );
-
- // If we've created this population as part of a standalone population creation flow,
- // we're done; clear the population reducer
- if (!isInSubflow) {
- resetIngredient('population');
- }
-
- // Return to calling flow or navigate back
- if (onReturn) {
- onReturn();
- } else {
- // For standalone flows, we should return/exit instead of navigating to 'next'
- onNavigate('__return__');
- }
- } catch (err) {
- console.error('Failed to create geographic association:', err);
- }
- };
-
- // Build display content based on geographic scope
- const buildDisplayContent = () => {
- if (!populationState?.geography) {
- return (
-
- No geography selected
-
- );
- }
-
- const geographyCountryId = populationState.geography.countryId;
-
- if (populationState.geography.scope === 'national') {
- return (
-
-
- Confirm household collection
-
-
- Scope: National
-
-
- Country: {getCountryLabel(geographyCountryId)}
-
-
- );
- }
-
- // Subnational
- // geographyId now contains full prefixed value like "constituency/Sheffield Central"
- const regionCode = populationState.geography.geographyId;
- const regionLabel = getRegionLabel(regionCode, metadata);
- const regionTypeName = getRegionTypeLabel(geographyCountryId, regionCode, metadata);
-
- console.log(
- `[GeographicConfirmationFrame] regionTypeName: ${regionTypeName}, regionLabel: ${regionLabel}`
- );
-
- return (
-
-
- Confirm household collection
-
-
- Scope: {regionTypeName}
-
-
- {regionTypeName}: {regionLabel}
-
-
- );
- };
-
- const primaryAction = {
- label: 'Create household collection',
- onClick: handleSubmit,
- isLoading: isPending,
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/population/SelectGeographicScopeFrame.tsx b/app/src/frames/population/SelectGeographicScopeFrame.tsx
deleted file mode 100644
index 263d51b5..00000000
--- a/app/src/frames/population/SelectGeographicScopeFrame.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { Stack } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { selectActivePopulation, selectCurrentPosition } from '@/reducers/activeSelectors';
-import { createPopulationAtPosition, setGeographyAtPosition } from '@/reducers/populationReducer';
-import { setMode } from '@/reducers/reportReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { Geography } from '@/types/ingredients/Geography';
-import {
- createGeographyFromScope,
- getUKConstituencies,
- getUKCountries,
- getUSStates,
-} from '@/utils/regionStrategies';
-import UKGeographicOptions from './UKGeographicOptions';
-import USGeographicOptions from './USGeographicOptions';
-
-type ScopeType = 'national' | 'country' | 'constituency' | 'state' | 'household';
-
-export default function SelectGeographicScopeFrame({
- onNavigate,
- isInSubflow,
-}: FlowComponentProps) {
- const dispatch = useDispatch();
- const [scope, setScope] = useState('national');
- const [selectedRegion, setSelectedRegion] = useState('');
-
- // Get current position and population
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const populationState = useSelector((state: RootState) => selectActivePopulation(state));
-
- // Get current country from URL and metadata from Redux
- const currentCountry = useCurrentCountry();
- const metadata = useSelector((state: RootState) => state.metadata);
-
- // Set mode to standalone if not in a subflow (this is the first frame of population flow)
- useEffect(() => {
- if (!isInSubflow) {
- dispatch(setMode('standalone'));
- }
- }, [dispatch, isInSubflow]);
-
- // Create population at current position if it doesn't exist
- useEffect(() => {
- if (!populationState) {
- dispatch(createPopulationAtPosition({ position: currentPosition }));
- }
- }, [dispatch, currentPosition, populationState]);
-
- // Get region data from metadata
- const regionData = metadata.economyOptions?.region || [];
-
- // Get region options based on country
- const usStates = currentCountry === 'us' ? getUSStates(regionData) : [];
- const ukCountries = currentCountry === 'uk' ? getUKCountries(regionData) : [];
- const ukConstituencies = currentCountry === 'uk' ? getUKConstituencies(regionData) : [];
-
- const handleScopeChange = (value: ScopeType) => {
- setScope(value);
- setSelectedRegion(''); // Clear selection when scope changes
- };
-
- function submissionHandler() {
- // Validate that if a regional scope is selected, a region must be chosen
- const needsRegion = ['state', 'country', 'constituency'].includes(scope);
- if (needsRegion && !selectedRegion) {
- console.warn(`${scope} selected but no region chosen`);
- return;
- }
-
- // Create geography from scope selection
- const geography = createGeographyFromScope(scope, currentCountry, selectedRegion);
-
- // Dispatch geography if created (not household)
- if (geography) {
- dispatch(
- setGeographyAtPosition({
- position: currentPosition,
- geography: geography as Geography,
- })
- );
- }
-
- // Navigate based on scope - household goes to household builder, others to confirmation
- onNavigate(scope === 'household' ? 'household' : scope);
- }
-
- const formInputs = (
-
- {currentCountry === 'uk' ? (
- handleScopeChange(newScope)}
- onRegionChange={setSelectedRegion}
- />
- ) : (
- handleScopeChange(newScope)}
- onRegionChange={setSelectedRegion}
- />
- )}
-
- );
-
- const primaryAction = {
- label: 'Select Scope',
- onClick: submissionHandler,
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/population/SetPopulationLabelFrame.tsx b/app/src/frames/population/SetPopulationLabelFrame.tsx
deleted file mode 100644
index 0f72c04e..00000000
--- a/app/src/frames/population/SetPopulationLabelFrame.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { Stack, Text, TextInput } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { selectActivePopulation, selectCurrentPosition } from '@/reducers/activeSelectors';
-import {
- createPopulationAtPosition,
- updatePopulationAtPosition,
-} from '@/reducers/populationReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { extractRegionDisplayValue } from '@/utils/regionStrategies';
-
-export default function SetPopulationLabelFrame({ onNavigate }: FlowComponentProps) {
- const dispatch = useDispatch();
-
- // Read position from report reducer via cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
-
- // Get the active population at the current position
- const populationState = useSelector((state: RootState) => selectActivePopulation(state));
-
- // Create population at current position if it doesn't exist
- useEffect(() => {
- if (!populationState) {
- dispatch(createPopulationAtPosition({ position: currentPosition }));
- }
- }, [dispatch, currentPosition, populationState]);
-
- // Initialize with existing label or generate a default based on population type
- const getDefaultLabel = () => {
- if (populationState?.label) {
- return populationState.label;
- }
-
- if (populationState?.geography) {
- // Geographic population
- if (populationState.geography.scope === 'national') {
- return 'National Households';
- } else if (populationState.geography.geographyId) {
- // Use display value (strip prefix for UK regions)
- const displayValue = extractRegionDisplayValue(populationState.geography.geographyId);
- return `${displayValue} Households`;
- }
- return 'Regional Households';
- }
- // Household population
- return 'Custom Household';
- };
-
- const [label, setLabel] = useState(getDefaultLabel());
- const [error, setError] = useState('');
-
- const handleSubmit = () => {
- // Validate label
- if (!label.trim()) {
- setError('Please enter a label for your household(s)');
- return;
- }
-
- if (label.length > 100) {
- setError('Label must be less than 100 characters');
- return;
- }
-
- // Update the population label at the current position
- dispatch(
- updatePopulationAtPosition({
- position: currentPosition,
- updates: { label: label.trim() },
- })
- );
-
- // Navigate based on population type
- if (populationState?.geography) {
- onNavigate('geographic');
- } else {
- onNavigate('household');
- }
- };
-
- const formInputs = (
-
-
- Give your household(s) a descriptive name.
-
-
- {
- setLabel(event.currentTarget.value);
- setError(''); // Clear error when user types
- }}
- error={error}
- required
- maxLength={100}
- />
-
-
- This label will help you identify this household(s) when creating simulations.
-
-
- );
-
- const primaryAction = {
- label: 'Continue',
- onClick: handleSubmit,
- };
-
- const cancelAction = {
- label: 'Back',
- onClick: () => onNavigate('back'),
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/report/ReportCreationFrame.tsx b/app/src/frames/report/ReportCreationFrame.tsx
deleted file mode 100644
index ac64fbe2..00000000
--- a/app/src/frames/report/ReportCreationFrame.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { Select, TextInput } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { CURRENT_YEAR } from '@/constants';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { getTaxYears } from '@/libs/metadataUtils';
-import { clearReport, updateLabel, updateYear } from '@/reducers/reportReducer';
-import { AppDispatch } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-export default function ReportCreationFrame({ onNavigate }: FlowComponentProps) {
- console.log('[ReportCreationFrame] ========== COMPONENT RENDER ==========');
- const dispatch = useDispatch();
- const countryId = useCurrentCountry();
- const [localLabel, setLocalLabel] = useState('');
-
- // Get available years from metadata
- const availableYears = useSelector(getTaxYears);
- const [localYear, setLocalYear] = useState(CURRENT_YEAR);
-
- // Clear any existing report data when mounting and initialize with current year
- useEffect(() => {
- console.log('[ReportCreationFrame] Mounting - clearing report for country:', countryId);
- dispatch(clearReport(countryId));
- // Initialize report year to current year
- dispatch(updateYear(CURRENT_YEAR));
- setLocalYear(CURRENT_YEAR);
- }, [dispatch, countryId]);
-
- function handleLocalLabelChange(value: string) {
- setLocalLabel(value);
- }
-
- function handleYearChange(value: string | null) {
- const newYear = value || CURRENT_YEAR;
- console.log('[ReportCreationFrame] Year changed to:', newYear);
- setLocalYear(newYear);
- dispatch(updateYear(newYear));
- }
-
- function submissionHandler() {
- console.log('[ReportCreationFrame] Submit clicked - label:', localLabel, 'year:', localYear);
- dispatch(updateLabel(localLabel));
- console.log('[ReportCreationFrame] Navigating to next frame');
- onNavigate('next');
- }
-
- const formInputs = (
- <>
- handleLocalLabelChange(e.currentTarget.value)}
- />
-
- >
- );
-
- const primaryAction = {
- label: 'Create report',
- onClick: submissionHandler,
- };
-
- return ;
-}
diff --git a/app/src/frames/report/ReportSelectExistingSimulationFrame.tsx b/app/src/frames/report/ReportSelectExistingSimulationFrame.tsx
deleted file mode 100644
index d91010d0..00000000
--- a/app/src/frames/report/ReportSelectExistingSimulationFrame.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import { useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { Text } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { MOCK_USER_ID } from '@/constants';
-import { EnhancedUserSimulation, useUserSimulations } from '@/hooks/useUserSimulations';
-import { selectActiveSimulationPosition } from '@/reducers/reportReducer';
-import {
- selectSimulationAtPosition,
- updateSimulationAtPosition,
-} from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { arePopulationsCompatible } from '@/utils/populationCompatibility';
-
-export default function ReportSelectExistingSimulationFrame({ onNavigate }: FlowComponentProps) {
- const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic
- const dispatch = useDispatch();
-
- // Get the active simulation position from report reducer
- const activeSimulationPosition = useSelector((state: RootState) =>
- selectActiveSimulationPosition(state)
- );
-
- // Get the other simulation to check population compatibility
- const otherPosition = activeSimulationPosition === 0 ? 1 : 0;
- const otherSimulation = useSelector((state: RootState) =>
- selectSimulationAtPosition(state, otherPosition)
- );
-
- const { data, isLoading, isError, error } = useUserSimulations(userId);
- const [localSimulation, setLocalSimulation] = useState(null);
-
- console.log('[ReportSelectExistingSimulationFrame] ========== DATA FETCH ==========');
- console.log('[ReportSelectExistingSimulationFrame] Raw data:', data);
- console.log('[ReportSelectExistingSimulationFrame] Raw data length:', data?.length);
- console.log('[ReportSelectExistingSimulationFrame] isLoading:', isLoading);
- console.log('[ReportSelectExistingSimulationFrame] isError:', isError);
- console.log('[ReportSelectExistingSimulationFrame] Error:', error);
-
- function canProceed() {
- if (!localSimulation) {
- return false;
- }
- return localSimulation.simulation?.id !== null && localSimulation.simulation?.id !== undefined;
- }
-
- function handleSimulationSelect(enhancedSimulation: EnhancedUserSimulation) {
- if (!enhancedSimulation) {
- return;
- }
-
- setLocalSimulation(enhancedSimulation);
- }
-
- function handleSubmit() {
- if (!localSimulation || !localSimulation.simulation) {
- return;
- }
-
- console.log('Submitting Simulation in handleSubmit:', localSimulation);
-
- // Update the simulation at the active position from report reducer
- dispatch(
- updateSimulationAtPosition({
- position: activeSimulationPosition,
- updates: {
- ...localSimulation.simulation,
- label: localSimulation.userSimulation?.label || localSimulation.simulation.label || '',
- },
- })
- );
-
- onNavigate('next');
- }
-
- const userSimulations = data || [];
-
- console.log('[ReportSelectExistingSimulationFrame] ========== BEFORE FILTERING ==========');
- console.log(
- '[ReportSelectExistingSimulationFrame] User simulations count:',
- userSimulations.length
- );
- console.log('[ReportSelectExistingSimulationFrame] User simulations:', userSimulations);
-
- // TODO: For all of these, refactor into something more reusable
- if (isLoading) {
- return (
- Loading simulations...}
- buttonPreset="none"
- />
- );
- }
-
- if (isError) {
- return (
- Error: {(error as Error)?.message || 'Something went wrong.'}}
- buttonPreset="none"
- />
- );
- }
-
- if (userSimulations.length === 0) {
- return (
- No simulations available. Please create a new simulation.}
- buttonPreset="cancel-only"
- />
- );
- }
-
- // Filter simulations with loaded data
- const filteredSimulations = userSimulations.filter((enhancedSim) => enhancedSim.simulation?.id);
-
- console.log('[ReportSelectExistingSimulationFrame] ========== AFTER FILTERING ==========');
- console.log(
- '[ReportSelectExistingSimulationFrame] Filtered simulations count:',
- filteredSimulations.length
- );
- console.log(
- '[ReportSelectExistingSimulationFrame] Filter criteria: enhancedSim.simulation?.id exists'
- );
- console.log('[ReportSelectExistingSimulationFrame] Filtered simulations:', filteredSimulations);
-
- // Sort simulations to show compatible first, then incompatible
- const sortedSimulations = [...filteredSimulations].sort((a, b) => {
- const aCompatible = arePopulationsCompatible(
- otherSimulation?.populationId,
- a.simulation!.populationId
- );
- const bCompatible = arePopulationsCompatible(
- otherSimulation?.populationId,
- b.simulation!.populationId
- );
-
- // Compatible items first (true > false in our sort)
- // If both are same compatibility, keep original order (return 0)
- // If a is compatible and b is not, a comes first (return -1)
- // If b is compatible and a is not, b comes first (return 1)
- return bCompatible === aCompatible ? 0 : aCompatible ? -1 : 1;
- });
-
- console.log('[ReportSelectExistingSimulationFrame] ========== AFTER SORTING ==========');
- console.log(
- '[ReportSelectExistingSimulationFrame] Sorted simulations count:',
- sortedSimulations.length
- );
-
- // Build card list items from sorted simulations (pagination handled by FlowView)
- const simulationCardItems = sortedSimulations.map((enhancedSim) => {
- const simulation = enhancedSim.simulation!;
-
- // Check compatibility with other simulation
- const isCompatible = arePopulationsCompatible(
- otherSimulation?.populationId,
- simulation.populationId
- );
-
- let title = '';
- let subtitle = '';
-
- if (enhancedSim.userSimulation?.label) {
- title = enhancedSim.userSimulation.label;
- subtitle = `Simulation #${simulation.id}`;
- } else {
- title = `Simulation #${simulation.id}`;
- }
-
- // Add policy and population info to subtitle if available
- const policyLabel =
- enhancedSim.userPolicy?.label || enhancedSim.policy?.label || enhancedSim.policy?.id;
- const populationLabel =
- enhancedSim.userHousehold?.label || enhancedSim.geography?.name || simulation.populationId;
-
- if (policyLabel && populationLabel) {
- subtitle = subtitle
- ? `${subtitle} • Policy: ${policyLabel} • Population: ${populationLabel}`
- : `Policy: ${policyLabel} • Population: ${populationLabel}`;
- }
-
- // If incompatible, add explanation to subtitle
- if (!isCompatible) {
- subtitle = subtitle
- ? `${subtitle} • Incompatible: different population than configured simulation`
- : 'Incompatible: different population than configured simulation';
- }
-
- return {
- title,
- subtitle,
- onClick: () => handleSimulationSelect(enhancedSim),
- isSelected: localSimulation?.simulation?.id === simulation.id,
- isDisabled: !isCompatible,
- };
- });
-
- const primaryAction = {
- label: 'Next',
- onClick: handleSubmit,
- isDisabled: !canProceed(),
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/report/ReportSelectSimulationFrame.tsx b/app/src/frames/report/ReportSelectSimulationFrame.tsx
deleted file mode 100644
index cc044558..00000000
--- a/app/src/frames/report/ReportSelectSimulationFrame.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useState } from 'react';
-import FlowView from '@/components/common/FlowView';
-import { FlowComponentProps } from '@/types/flow';
-
-type SetupAction = 'createNew' | 'loadExisting';
-
-export default function ReportSelectSimulationFrame({ onNavigate }: FlowComponentProps) {
- const [selectedAction, setSelectedAction] = useState(null);
-
- function handleClickCreateNew() {
- setSelectedAction('createNew');
- }
-
- function handleClickExisting() {
- setSelectedAction('loadExisting');
- }
-
- function handleClickSubmit() {
- if (selectedAction) {
- onNavigate(selectedAction);
- }
- }
-
- const buttonPanelCards = [
- {
- title: 'Load Existing Simulation',
- description: 'Use a simulation you have already created',
- onClick: handleClickExisting,
- isSelected: selectedAction === 'loadExisting',
- },
- {
- title: 'Create New Simulation',
- description: 'Build a new simulation',
- onClick: handleClickCreateNew,
- isSelected: selectedAction === 'createNew',
- },
- ];
-
- const primaryAction = {
- label: 'Next',
- onClick: handleClickSubmit,
- isDisabled: !selectedAction,
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/report/ReportSetupFrame.tsx b/app/src/frames/report/ReportSetupFrame.tsx
deleted file mode 100644
index 6267aab9..00000000
--- a/app/src/frames/report/ReportSetupFrame.tsx
+++ /dev/null
@@ -1,368 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { HouseholdAdapter } from '@/adapters';
-import FlowView from '@/components/common/FlowView';
-import { MOCK_USER_ID } from '@/constants';
-import { isGeographicMetadataWithAssociation, useUserGeographics } from '@/hooks/useUserGeographic';
-import { isHouseholdMetadataWithAssociation, useUserHouseholds } from '@/hooks/useUserHousehold';
-import {
- createPopulationAtPosition,
- selectPopulationAtPosition,
- setGeographyAtPosition,
- setHouseholdAtPosition,
- updatePopulationAtPosition,
-} from '@/reducers/populationReducer';
-import { setActiveSimulationPosition, setMode } from '@/reducers/reportReducer';
-import {
- createSimulationAtPosition,
- selectSimulationAtPosition,
-} from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { Simulation } from '@/types/ingredients/Simulation';
-import { findMatchingPopulation } from '@/utils/populationMatching';
-
-type SimulationCard = 'simulation1' | 'simulation2';
-
-interface ReportSetupFrameProps extends FlowComponentProps {}
-
-export default function ReportSetupFrame({ onNavigate }: ReportSetupFrameProps) {
- const dispatch = useDispatch();
- const [selectedCard, setSelectedCard] = useState(null);
-
- // Set mode to 'report' on mount
- useEffect(() => {
- dispatch(setMode('report'));
- }, [dispatch]);
-
- // Use position-based selectors - position IS the stable reference
- const simulation1 = useSelector((state: RootState) => selectSimulationAtPosition(state, 0));
- const simulation2 = useSelector((state: RootState) => selectSimulationAtPosition(state, 1));
-
- // Fetch population data for pre-filling simulation 2
- const userId = MOCK_USER_ID.toString();
- const { data: householdData } = useUserHouseholds(userId);
- const { data: geographicData } = useUserGeographics(userId);
-
- // Get population at position 1 to check if already filled
- const population2 = useSelector((state: RootState) => selectPopulationAtPosition(state, 1));
-
- // Check if simulations are fully configured
- const simulation1Configured = !!(simulation1?.policyId && simulation1?.populationId);
- const simulation2Configured = !!(simulation2?.policyId && simulation2?.populationId);
-
- // Check if population data is loaded (needed for simulation2 prefill)
- const isPopulationDataLoaded = householdData !== undefined && geographicData !== undefined;
-
- // Determine if simulation2 is optional based on population type of simulation1
- // Household reports: simulation2 is optional (single-sim allowed)
- // Geography reports: simulation2 is required (comparison only)
- // If simulation1 doesn't exist yet, we can't determine optionality
- const isHouseholdReport = simulation1?.populationType === 'household';
- const isSimulation2Optional = simulation1Configured && isHouseholdReport;
-
- const handleSimulation1Select = () => {
- setSelectedCard('simulation1');
- console.log('Adding simulation 1');
- };
-
- const handleSimulation2Select = () => {
- setSelectedCard('simulation2');
- console.log('Adding simulation 2');
- };
-
- /**
- * Pre-fills population for simulation 2 by copying from simulation 1.
- * Called when user clicks to setup simulation 2.
- * This ensures both simulations use the same population as required by reports.
- */
- function prefillPopulation2FromSimulation1() {
- console.log('[ReportSetupFrame] ===== PRE-FILLING POPULATION 2 =====');
-
- if (!simulation1?.populationId) {
- console.error('[ReportSetupFrame] Cannot prefill: simulation1 has no population');
- return;
- }
-
- if (population2?.isCreated) {
- console.log('[ReportSetupFrame] Population 2 already exists, skipping prefill');
- return;
- }
-
- console.log('[ReportSetupFrame] simulation1:', simulation1);
- console.log('[ReportSetupFrame] householdData:', householdData);
- console.log('[ReportSetupFrame] geographicData:', geographicData);
-
- // Find matching population from simulation1
- const matchedPopulation = findMatchingPopulation(simulation1, householdData, geographicData);
-
- console.log('[ReportSetupFrame] matchedPopulation:', matchedPopulation);
-
- if (!matchedPopulation) {
- console.error('[ReportSetupFrame] No matching population found for simulation1');
- return;
- }
-
- // Handle household population
- if (isHouseholdMetadataWithAssociation(matchedPopulation)) {
- console.log('[ReportSetupFrame] Pre-filling household population');
- const householdToSet = HouseholdAdapter.fromMetadata(matchedPopulation.household!);
-
- // Create population with isCreated: true
- dispatch(
- createPopulationAtPosition({
- position: 1,
- population: {
- label: matchedPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
- );
-
- // Set household data
- dispatch(
- setHouseholdAtPosition({
- position: 1,
- household: householdToSet,
- })
- );
-
- // Ensure isCreated flag is set (handles case where population already existed)
- dispatch(
- updatePopulationAtPosition({
- position: 1,
- updates: { isCreated: true },
- })
- );
-
- console.log('[ReportSetupFrame] Household population pre-filled successfully');
- }
- // Handle geographic population
- else if (isGeographicMetadataWithAssociation(matchedPopulation)) {
- console.log('[ReportSetupFrame] Pre-filling geographic population');
-
- // Create population with isCreated: true
- dispatch(
- createPopulationAtPosition({
- position: 1,
- population: {
- label: matchedPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
- );
-
- // Set geography data
- dispatch(
- setGeographyAtPosition({
- position: 1,
- geography: matchedPopulation.geography!,
- })
- );
-
- // Ensure isCreated flag is set (handles case where population already existed)
- dispatch(
- updatePopulationAtPosition({
- position: 1,
- updates: { isCreated: true },
- })
- );
-
- console.log('[ReportSetupFrame] Geographic population pre-filled successfully');
- }
- }
-
- const handleNext = () => {
- if (selectedCard === 'simulation1') {
- console.log('Setting up simulation 1');
- // Create simulation at position 0 if needed
- if (!simulation1) {
- dispatch(createSimulationAtPosition({ position: 0 }));
- }
- // Set position 0 as active in report reducer
- dispatch(setActiveSimulationPosition(0));
- // Navigate to simulation selection frame
- onNavigate('setupSimulation1');
- } else if (selectedCard === 'simulation2') {
- console.log('Setting up simulation 2');
- // Create simulation at position 1 if needed
- if (!simulation2) {
- dispatch(createSimulationAtPosition({ position: 1 }));
- }
- // PRE-FILL POPULATION FROM SIMULATION 1
- prefillPopulation2FromSimulation1();
- // Set position 1 as active in report reducer
- dispatch(setActiveSimulationPosition(1));
- // Navigate to simulation selection frame
- onNavigate('setupSimulation2');
- } else if (canProceed) {
- console.log('Both simulations configured, proceeding to next step');
- onNavigate('next');
- }
- };
-
- const setupConditionCards = [
- {
- title: getBaselineCardTitle(simulation1, simulation1Configured),
- description: getBaselineCardDescription(simulation1, simulation1Configured),
- onClick: handleSimulation1Select,
- isSelected: selectedCard === 'simulation1',
- isFulfilled: simulation1Configured,
- isDisabled: false,
- },
- {
- title: getComparisonCardTitle(
- simulation2,
- simulation2Configured,
- simulation1Configured,
- isSimulation2Optional
- ),
- description: getComparisonCardDescription(
- simulation2,
- simulation2Configured,
- simulation1Configured,
- isSimulation2Optional,
- !isPopulationDataLoaded
- ),
- onClick: handleSimulation2Select,
- isSelected: selectedCard === 'simulation2',
- isFulfilled: simulation2Configured,
- isDisabled: !simulation1Configured, // Disable until simulation1 is configured
- },
- ];
-
- // Determine if we can proceed to submission
- // Household reports: Only simulation1 required (simulation2 optional)
- // Geography reports: Both simulations required
- const canProceed: boolean =
- simulation1Configured && (isSimulation2Optional || simulation2Configured);
-
- // Determine the primary action label and state
- const getPrimaryAction = () => {
- // Allow setting up simulation1 if selected and not configured
- if (selectedCard === 'simulation1' && !simulation1Configured) {
- return {
- label: 'Setup baseline simulation',
- onClick: handleNext,
- isDisabled: false,
- };
- }
- // Allow setting up simulation2 if selected and not configured
- else if (selectedCard === 'simulation2' && !simulation2Configured) {
- return {
- label: 'Setup comparison simulation',
- onClick: handleNext,
- isDisabled: !isPopulationDataLoaded, // Disable if data not loaded
- };
- }
- // Allow proceeding if requirements met
- else if (canProceed) {
- return {
- label: 'Review report',
- onClick: handleNext,
- isDisabled: false,
- };
- }
- // Disable if requirements not met
- return {
- label: 'Review report',
- onClick: handleNext,
- isDisabled: true,
- };
- };
-
- const primaryAction = getPrimaryAction();
-
- return (
-
- );
-}
-
-/**
- * Get title for baseline simulation card
- */
-function getBaselineCardTitle(simulation: Simulation | null, isConfigured: boolean): string {
- if (isConfigured) {
- const label = simulation?.label || simulation?.id || 'Configured';
- return `Baseline: ${label}`;
- }
- return 'Baseline simulation';
-}
-
-/**
- * Get description for baseline simulation card
- */
-function getBaselineCardDescription(simulation: Simulation | null, isConfigured: boolean): string {
- if (isConfigured) {
- return `Policy #${simulation?.policyId} • Household(s) #${simulation?.populationId}`;
- }
- return 'Select your baseline simulation';
-}
-
-/**
- * Get title for comparison simulation card
- */
-function getComparisonCardTitle(
- simulation: Simulation | null,
- isConfigured: boolean,
- baselineConfigured: boolean,
- isOptional: boolean
-): string {
- // If configured, show simulation name
- if (isConfigured) {
- const label = simulation?.label || simulation?.id || 'Configured';
- return `Comparison: ${label}`;
- }
-
- // If baseline not configured yet, show waiting message
- if (!baselineConfigured) {
- return 'Comparison simulation · Waiting for baseline';
- }
-
- // Baseline configured: show optional or required
- if (isOptional) {
- return 'Comparison simulation (optional)';
- }
- return 'Comparison simulation';
-}
-
-/**
- * Get description for comparison simulation card
- */
-function getComparisonCardDescription(
- simulation: Simulation | null,
- isConfigured: boolean,
- baselineConfigured: boolean,
- isOptional: boolean,
- dataLoading: boolean
-): string {
- // If configured, show simulation details
- if (isConfigured) {
- return `Policy #${simulation?.policyId} • Household(s) #${simulation?.populationId}`;
- }
-
- // If baseline not configured yet, show waiting message
- if (!baselineConfigured) {
- return 'Set up your baseline simulation first';
- }
-
- // If baseline configured but data still loading, show loading message
- if (dataLoading && baselineConfigured && !isConfigured) {
- return 'Loading household data...';
- }
-
- // Baseline configured: show optional or required message
- if (isOptional) {
- return 'Optional: add a second simulation to compare';
- }
- return 'Required: add a second simulation to compare';
-}
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/frames/simulation/SimulationCreationFrame.tsx b/app/src/frames/simulation/SimulationCreationFrame.tsx
deleted file mode 100644
index 7346a074..00000000
--- a/app/src/frames/simulation/SimulationCreationFrame.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { TextInput } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import { setMode } from '@/reducers/reportReducer';
-import {
- createSimulationAtPosition,
- selectSimulationAtPosition,
- updateSimulationAtPosition,
-} from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-export default function SimulationCreationFrame({ onNavigate, isInSubflow }: FlowComponentProps) {
- const dispatch = useDispatch();
-
- // Get the current position from the cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const simulation = useSelector((state: RootState) =>
- selectSimulationAtPosition(state, currentPosition)
- );
-
- // Get report state for auto-naming
- const reportState = useSelector((state: RootState) => state.report);
-
- // Generate default label based on context
- const getDefaultLabel = () => {
- if (reportState.mode === 'report' && reportState.label) {
- // Report mode WITH report name: prefix with report name
- const baseName = currentPosition === 0 ? 'baseline simulation' : 'reform simulation';
- return `${reportState.label} ${baseName}`;
- }
- // All other cases: use standalone label
- const baseName = currentPosition === 0 ? 'Baseline simulation' : 'Reform simulation';
- return baseName;
- };
-
- const [localLabel, setLocalLabel] = useState(getDefaultLabel());
-
- console.log('[SimulationCreationFrame] RENDER - currentPosition:', currentPosition);
- console.log('[SimulationCreationFrame] RENDER - simulation:', simulation);
-
- // Set mode to standalone if not in a subflow
- useEffect(() => {
- console.log('[SimulationCreationFrame] Mode effect - isInSubflow:', isInSubflow);
- if (!isInSubflow) {
- dispatch(setMode('standalone'));
- }
-
- return () => {
- console.log('[SimulationCreationFrame] Cleanup - mode effect');
- };
- }, [dispatch, isInSubflow]);
-
- useEffect(() => {
- console.log(
- '[SimulationCreationFrame] Create simulation effect - simulation exists?:',
- !!simulation
- );
- // If there's no simulation at current position, create one
- if (!simulation) {
- console.log('[SimulationCreationFrame] Creating simulation at position', currentPosition);
- dispatch(createSimulationAtPosition({ position: currentPosition }));
- }
-
- return () => {
- console.log('[SimulationCreationFrame] Cleanup - create simulation effect');
- };
- }, [currentPosition, simulation, dispatch]);
-
- function handleLocalLabelChange(value: string) {
- setLocalLabel(value);
- }
-
- function submissionHandler() {
- console.log('[SimulationCreationFrame] ========== submissionHandler START ==========');
- console.log('[SimulationCreationFrame] Updating simulation with label:', localLabel);
- // Update the simulation at the current position
- dispatch(
- updateSimulationAtPosition({
- position: currentPosition,
- updates: { label: localLabel },
- })
- );
- console.log('[SimulationCreationFrame] Calling onNavigate("next")');
- onNavigate('next');
- console.log('[SimulationCreationFrame] ========== submissionHandler END ==========');
- }
-
- const formInputs = (
- handleLocalLabelChange(e.currentTarget.value)}
- />
- );
-
- const primaryAction = {
- label: 'Create simulation',
- onClick: submissionHandler,
- };
-
- return ;
-}
diff --git a/app/src/frames/simulation/SimulationSelectExistingPolicyFrame.tsx b/app/src/frames/simulation/SimulationSelectExistingPolicyFrame.tsx
deleted file mode 100644
index d3c5906b..00000000
--- a/app/src/frames/simulation/SimulationSelectExistingPolicyFrame.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { Text } from '@mantine/core';
-import FlowView from '@/components/common/FlowView';
-import { MOCK_USER_ID } from '@/constants';
-import {
- isPolicyMetadataWithAssociation,
- UserPolicyMetadataWithAssociation,
- useUserPolicies,
-} from '@/hooks/useUserPolicy';
-import { countryIds } from '@/libs/countries';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import { addPolicyParamAtPosition, createPolicyAtPosition } from '@/reducers/policyReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-export default function SimulationSelectExistingPolicyFrame({ onNavigate }: FlowComponentProps) {
- const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic
- const dispatch = useDispatch();
-
- // Read position from report reducer via cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
-
- const { data, isLoading, isError, error } = useUserPolicies(userId);
- const [localPolicy, setLocalPolicy] = useState(null);
-
- console.log('[SimulationSelectExistingPolicyFrame] ========== DATA FETCH ==========');
- console.log('[SimulationSelectExistingPolicyFrame] Raw data:', data);
- console.log('[SimulationSelectExistingPolicyFrame] Raw data length:', data?.length);
- console.log('[SimulationSelectExistingPolicyFrame] isLoading:', isLoading);
- console.log('[SimulationSelectExistingPolicyFrame] isError:', isError);
- console.log('[SimulationSelectExistingPolicyFrame] Error:', error);
-
- function canProceed() {
- if (!localPolicy) {
- return false;
- }
- if (isPolicyMetadataWithAssociation(localPolicy)) {
- return localPolicy.policy?.id !== null && localPolicy.policy?.id !== undefined;
- }
- return false;
- }
-
- function handlePolicySelect(association: UserPolicyMetadataWithAssociation) {
- if (!association) {
- return;
- }
-
- setLocalPolicy(association);
- }
-
- function handleSubmit() {
- if (!localPolicy) {
- return;
- }
-
- console.log('Submitting Policy in handleSubmit:', localPolicy);
-
- if (isPolicyMetadataWithAssociation(localPolicy)) {
- console.log('Use policy handler');
- handleSubmitPolicy();
- }
-
- onNavigate('next');
- }
-
- function handleSubmitPolicy() {
- if (!localPolicy || !isPolicyMetadataWithAssociation(localPolicy)) {
- return;
- }
-
- console.log('[POLICY SELECT] === SUBMIT START ===');
- console.log('[POLICY SELECT] Local Policy on Submit:', localPolicy);
- console.log('[POLICY SELECT] Association:', localPolicy.association);
- console.log('[POLICY SELECT] Association countryId:', localPolicy.association?.countryId);
- console.log('[POLICY SELECT] Policy metadata:', localPolicy.policy);
- console.log('[POLICY SELECT] Policy ID:', localPolicy.policy?.id);
-
- // Create a new policy at the current position
- console.log('[POLICY SELECT] Dispatching createPolicyAtPosition with:', {
- position: currentPosition,
- id: localPolicy.policy?.id?.toString(),
- label: localPolicy.association?.label || '',
- isCreated: true,
- countryId: localPolicy.policy?.country_id,
- });
- dispatch(
- createPolicyAtPosition({
- position: currentPosition,
- policy: {
- id: localPolicy.policy?.id?.toString(),
- label: localPolicy.association?.label || '',
- isCreated: true,
- countryId: localPolicy.policy?.country_id as (typeof countryIds)[number],
- parameters: [],
- },
- })
- );
-
- // Load all policy parameters using position-based action
- // Parameters must be added one at a time with individual value intervals
- if (localPolicy.policy?.policy_json) {
- const policyJson = localPolicy.policy.policy_json;
- console.log('[POLICY SELECT] Adding parameters from policy_json:', Object.keys(policyJson));
- Object.entries(policyJson).forEach(([paramName, valueIntervals]) => {
- if (Array.isArray(valueIntervals) && valueIntervals.length > 0) {
- // Add each value interval separately as required by PolicyParamAdditionPayload
- valueIntervals.forEach((vi: any) => {
- dispatch(
- addPolicyParamAtPosition({
- position: currentPosition,
- name: paramName,
- valueInterval: {
- startDate: vi.start || vi.startDate,
- endDate: vi.end || vi.endDate,
- value: vi.value,
- },
- })
- );
- });
- }
- });
- }
- console.log('[POLICY SELECT] === SUBMIT END ===');
- }
-
- const userPolicies = data || [];
-
- console.log('[SimulationSelectExistingPolicyFrame] ========== BEFORE FILTERING ==========');
- console.log('[SimulationSelectExistingPolicyFrame] User policies count:', userPolicies.length);
- console.log('[SimulationSelectExistingPolicyFrame] User policies:', userPolicies);
-
- // TODO: For all of these, refactor into something more reusable
- if (isLoading) {
- return (
- Loading policies...}
- buttonPreset="none"
- />
- );
- }
-
- if (isError) {
- return (
- Error: {(error as Error)?.message || 'Something went wrong.'}}
- buttonPreset="none"
- />
- );
- }
-
- if (userPolicies.length === 0) {
- return (
- No policies available. Please create a new policy.}
- buttonPreset="cancel-only"
- />
- );
- }
-
- // Filter policies with loaded data
- const filteredPolicies = userPolicies.filter((association) =>
- isPolicyMetadataWithAssociation(association)
- );
-
- console.log('[SimulationSelectExistingPolicyFrame] ========== AFTER FILTERING ==========');
- console.log(
- '[SimulationSelectExistingPolicyFrame] Filtered policies count:',
- filteredPolicies.length
- );
- console.log(
- '[SimulationSelectExistingPolicyFrame] Filter criteria: isPolicyMetadataWithAssociation(association)'
- );
- console.log('[SimulationSelectExistingPolicyFrame] Filtered policies:', filteredPolicies);
-
- // Build card list items from ALL filtered policies (pagination handled by FlowView)
- const policyCardItems = filteredPolicies.map((association) => {
- let title = '';
- let subtitle = '';
- if ('label' in association.association && association.association.label) {
- title = association.association.label;
- subtitle = `Policy #${association.policy!.id}`;
- } else {
- title = `Policy #${association.policy!.id}`;
- }
-
- return {
- title,
- subtitle,
- onClick: () => handlePolicySelect(association),
- isSelected:
- isPolicyMetadataWithAssociation(localPolicy) &&
- localPolicy.policy?.id === association.policy!.id,
- };
- });
-
- const primaryAction = {
- label: 'Next',
- onClick: handleSubmit,
- isDisabled: !canProceed(),
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/simulation/SimulationSetupFrame.tsx b/app/src/frames/simulation/SimulationSetupFrame.tsx
deleted file mode 100644
index ea887ec7..00000000
--- a/app/src/frames/simulation/SimulationSetupFrame.tsx
+++ /dev/null
@@ -1,247 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import FlowView from '@/components/common/FlowView';
-import {
- selectActivePolicy,
- selectActivePopulation,
- selectCurrentPosition,
-} from '@/reducers/activeSelectors';
-import {
- createSimulationAtPosition,
- selectSimulationAtPosition,
- updateSimulationAtPosition,
-} from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-type SetupCard = 'population' | 'policy';
-
-export default function SimulationSetupFrame({ onNavigate }: FlowComponentProps) {
- const dispatch = useDispatch();
-
- // Get the current position from the cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const simulation = useSelector((state: RootState) =>
- selectSimulationAtPosition(state, currentPosition)
- );
-
- // Get policy and population at the current position
- const policy = useSelector((state: RootState) => selectActivePolicy(state));
- const population = useSelector((state: RootState) => selectActivePopulation(state));
-
- // Detect if we're in report mode for simulation 2 (population will be inherited)
- const mode = useSelector((state: RootState) => state.report.mode);
- const isReportMode = mode === 'report';
- const isSimulation2InReport = isReportMode && currentPosition === 1;
-
- console.log('[SimulationSetupFrame] currentPosition: ', currentPosition);
- console.log('[SimulationSetupFrame] policy: ', policy);
- console.log('[SimulationSetupFrame] population: ', population);
- console.log('[SimulationSetupFrame] isSimulation2InReport: ', isSimulation2InReport);
-
- const [selectedCard, setSelectedCard] = useState(null);
-
- // Ensure we have a simulation at the current position
- useEffect(() => {
- if (!simulation) {
- dispatch(createSimulationAtPosition({ position: currentPosition }));
- }
- }, [simulation, currentPosition, dispatch]);
-
- const handlePopulationSelect = () => {
- setSelectedCard('population');
- };
-
- const handlePolicySelect = () => {
- setSelectedCard('policy');
- };
-
- const handleNext = () => {
- if (selectedCard === 'population' && !population?.isCreated) {
- onNavigate('setupPopulation');
- } else if (selectedCard === 'policy' && !policy?.isCreated) {
- onNavigate('setupPolicy');
- } else if (simulation?.policyId && simulation?.populationId) {
- // Both are fulfilled, proceed to next step
- onNavigate('next');
- }
- };
-
- // Listen for policy creation and update simulation with policy ID
- useEffect(() => {
- if (policy?.isCreated && policy?.id && !simulation?.policyId) {
- // Update the simulation at the current position
- dispatch(
- updateSimulationAtPosition({
- position: currentPosition,
- updates: { policyId: policy.id },
- })
- );
- }
- }, [policy?.isCreated, policy?.id, simulation?.policyId, currentPosition, dispatch]);
-
- // Listen for population creation and update simulation with population ID
- useEffect(() => {
- console.log('Population state in new effect hook:', population);
- console.log('Simulation state in new effect hook:', simulation);
- if (population?.isCreated && !simulation?.populationId) {
- console.log('Responding to update to population in new effect hook');
- if (population?.household?.id) {
- dispatch(
- updateSimulationAtPosition({
- position: currentPosition,
- updates: {
- populationId: population.household.id,
- populationType: 'household',
- },
- })
- );
- } else if (population?.geography?.id) {
- dispatch(
- updateSimulationAtPosition({
- position: currentPosition,
- updates: {
- populationId: population.geography.id,
- populationType: 'geography',
- },
- })
- );
- }
- }
- }, [
- population?.isCreated,
- population?.household,
- population?.geography,
- simulation?.populationId,
- currentPosition,
- dispatch,
- ]);
-
- const canProceed: boolean = !!(simulation?.policyId && simulation?.populationId);
-
- function generatePopulationCardTitle() {
- if (!population || !population.isCreated) {
- return 'Add household(s)';
- }
-
- // In simulation 2 of a report, indicate population is inherited from baseline
- if (isSimulation2InReport) {
- return `${population.label || 'Household(s)'} (from baseline)`;
- }
-
- if (population.label) {
- return population.label;
- }
- if (population.household) {
- return `Household #${population.household.id}`;
- }
- // TODO: Add proper labelling for geographic populations here
- if (population.geography) {
- return `Household(s) #${population.geography.id}`;
- }
- return '';
- }
-
- function generatePopulationCardDescription() {
- if (!population || !population.isCreated) {
- return 'Select a household collection or custom household';
- }
-
- // In simulation 2 of a report, indicate population is inherited from baseline
- if (isSimulation2InReport) {
- const popId = population.household?.id || population.geography?.id;
- const popType = population.household ? 'Household' : 'Household collection';
- return `${popType} #${popId} • Inherited from baseline simulation`;
- }
-
- if (population.label && population.household) {
- return `Household #${population.household.id}`;
- }
- // TODO: Add proper descriptions for geographic populations here
- if (population.label && population.geography) {
- return `Household collection #${population.geography.id}`;
- }
- return '';
- }
-
- function generatePolicyCardTitle() {
- if (!policy || !policy.isCreated) {
- return 'Add Policy';
- }
- if (policy.label) {
- return policy.label;
- }
- if (policy.id) {
- return `Policy #${policy.id}`;
- }
- return '';
- }
-
- function generatePolicyCardDescription() {
- if (!policy || !policy.isCreated) {
- return 'Select a policy to apply to the simulation';
- }
- if (policy.label && policy.id) {
- return `Policy #${policy.id}`;
- }
- return '';
- }
-
- const setupConditionCards = [
- {
- title: generatePopulationCardTitle(),
- description: generatePopulationCardDescription(),
- onClick: handlePopulationSelect,
- isSelected: selectedCard === 'population',
- isFulfilled: population?.isCreated || false,
- isDisabled: false,
- },
- {
- title: generatePolicyCardTitle(),
- description: generatePolicyCardDescription(),
- onClick: handlePolicySelect,
- isSelected: selectedCard === 'policy',
- isFulfilled: policy?.isCreated || false,
- isDisabled: false,
- },
- ];
-
- // Determine the primary action label and state
- const getPrimaryAction = () => {
- if (selectedCard === 'population' && !population?.isCreated) {
- return {
- label: 'Setup household(s)',
- onClick: handleNext,
- isDisabled: false,
- };
- } else if (selectedCard === 'policy' && !policy?.isCreated) {
- return {
- label: 'Setup Policy',
- onClick: handleNext,
- isDisabled: false,
- };
- } else if (canProceed) {
- return {
- label: 'Next',
- onClick: handleNext,
- isDisabled: false,
- };
- }
- return {
- label: 'Next',
- onClick: handleNext,
- isDisabled: true,
- };
- };
-
- const primaryAction = getPrimaryAction();
-
- return (
-
- );
-}
diff --git a/app/src/frames/simulation/SimulationSetupPolicyFrame.tsx b/app/src/frames/simulation/SimulationSetupPolicyFrame.tsx
deleted file mode 100644
index 00fca4f4..00000000
--- a/app/src/frames/simulation/SimulationSetupPolicyFrame.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import FlowView from '@/components/common/FlowView';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import { createPolicyAtPosition } from '@/reducers/policyReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-
-type SetupAction = 'createNew' | 'loadExisting' | 'selectCurrentLaw';
-
-export default function SimulationSetupPolicyFrame({ onNavigate }: FlowComponentProps) {
- const dispatch = useDispatch();
- const country = useCurrentCountry();
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId);
-
- const [selectedAction, setSelectedAction] = useState(null);
-
- function handleClickCreateNew() {
- setSelectedAction('createNew');
- }
-
- function handleClickExisting() {
- setSelectedAction('loadExisting');
- }
-
- function handleClickCurrentLaw() {
- setSelectedAction('selectCurrentLaw');
- }
-
- function handleSubmitCurrentLaw() {
- // Create current law policy at the current position
- dispatch(
- createPolicyAtPosition({
- position: currentPosition,
- policy: {
- id: currentLawId.toString(),
- label: 'Current law',
- parameters: [], // Empty parameters = current law
- isCreated: true, // Already exists (it's the baseline)
- countryId: country,
- },
- })
- );
- }
-
- function handleClickSubmit() {
- if (selectedAction === 'selectCurrentLaw') {
- handleSubmitCurrentLaw();
- onNavigate(selectedAction);
- } else if (selectedAction) {
- onNavigate(selectedAction);
- }
- }
-
- const buttonPanelCards = [
- {
- title: 'Current Law',
- description: 'Use the baseline tax-benefit system with no reforms',
- onClick: handleClickCurrentLaw,
- isSelected: selectedAction === 'selectCurrentLaw',
- },
- {
- title: 'Load Existing Policy',
- description: 'Use a policy you have already created',
- onClick: handleClickExisting,
- isSelected: selectedAction === 'loadExisting',
- },
- {
- title: 'Create New Policy',
- description: 'Build a new policy',
- onClick: handleClickCreateNew,
- isSelected: selectedAction === 'createNew',
- },
- ];
-
- const primaryAction = {
- label: 'Next',
- onClick: handleClickSubmit,
- isDisabled: !selectedAction,
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/simulation/SimulationSetupPopulationFrame.tsx b/app/src/frames/simulation/SimulationSetupPopulationFrame.tsx
deleted file mode 100644
index 8819ba37..00000000
--- a/app/src/frames/simulation/SimulationSetupPopulationFrame.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { HouseholdAdapter } from '@/adapters';
-import FlowView from '@/components/common/FlowView';
-import { MOCK_USER_ID } from '@/constants';
-import { isGeographicMetadataWithAssociation, useUserGeographics } from '@/hooks/useUserGeographic';
-import { isHouseholdMetadataWithAssociation, useUserHouseholds } from '@/hooks/useUserHousehold';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import {
- createPopulationAtPosition,
- selectPopulationAtPosition,
- setGeographyAtPosition,
- setHouseholdAtPosition,
-} from '@/reducers/populationReducer';
-import { selectActiveSimulationPosition } from '@/reducers/reportReducer';
-import { selectSimulationAtPosition } from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { getPopulationLabel, getSimulationLabel } from '@/utils/populationCompatibility';
-import { findMatchingPopulation } from '@/utils/populationMatching';
-import {
- getPopulationLockConfig,
- getPopulationSelectionSubtitle,
- getPopulationSelectionTitle,
-} from '@/utils/reportPopulationLock';
-
-type SetupAction = 'createNew' | 'loadExisting' | 'copyExisting';
-
-export default function SimulationSetupPopulationFrame({ onNavigate }: FlowComponentProps) {
- const dispatch = useDispatch();
- const userId = MOCK_USER_ID.toString();
- const [selectedAction, setSelectedAction] = useState(null);
-
- // Get current mode and position information
- const mode = useSelector((state: RootState) => state.report.mode);
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const activeSimulationPosition = useSelector((state: RootState) =>
- selectActiveSimulationPosition(state)
- );
-
- // Get the other simulation and its population to check if we should lock
- const otherPosition = activeSimulationPosition === 0 ? 1 : 0;
- const otherSimulation = useSelector((state: RootState) =>
- selectSimulationAtPosition(state, otherPosition)
- );
- const otherPopulation = useSelector((state: RootState) =>
- selectPopulationAtPosition(state, otherPosition)
- );
-
- // Fetch ALL user populations (mimicking the policy pattern)
- const { data: householdData } = useUserHouseholds(userId);
- const { data: geographicData } = useUserGeographics(userId);
-
- // Determine if population selection should be locked
- const { shouldLock: shouldLockToOtherPopulation } = getPopulationLockConfig(
- mode,
- otherSimulation,
- otherPopulation
- );
-
- // Auto-select and populate the locked population when in locked mode
- useEffect(() => {
- if (!shouldLockToOtherPopulation || !otherSimulation?.populationId) {
- return;
- }
-
- // Find the matching population from fetched data
- const matchedPopulation = findMatchingPopulation(
- otherSimulation,
- householdData,
- geographicData
- );
-
- if (!matchedPopulation) {
- return;
- }
-
- // Populate Redux with the matched population (mimicking SimulationSelectExistingPopulationFrame)
- if (isHouseholdMetadataWithAssociation(matchedPopulation)) {
- // Handle household population
- const householdToSet = HouseholdAdapter.fromMetadata(matchedPopulation.household!);
-
- dispatch(
- createPopulationAtPosition({
- position: currentPosition,
- population: {
- label: matchedPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
- );
-
- dispatch(
- setHouseholdAtPosition({
- position: currentPosition,
- household: householdToSet,
- })
- );
- } else if (isGeographicMetadataWithAssociation(matchedPopulation)) {
- // Handle geographic population
- dispatch(
- createPopulationAtPosition({
- position: currentPosition,
- population: {
- label: matchedPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
- );
-
- dispatch(
- setGeographyAtPosition({
- position: currentPosition,
- geography: matchedPopulation.geography!,
- })
- );
- }
- }, [
- shouldLockToOtherPopulation,
- otherSimulation,
- householdData,
- geographicData,
- currentPosition,
- dispatch,
- ]);
-
- function handleClickCreateNew() {
- setSelectedAction('createNew');
- }
-
- function handleClickExisting() {
- setSelectedAction('loadExisting');
- }
-
- function handleClickCopyExisting() {
- // The population is already populated in Redux by the useEffect above
- // We just need to set the action and navigate
- setSelectedAction('copyExisting');
- }
-
- function handleClickSubmit() {
- if (selectedAction) {
- onNavigate(selectedAction);
- }
- }
-
- // Define card arrays separately for clarity
- const lockedCards = [
- // Card 1: Load Existing Population (disabled)
- {
- title: 'Load Existing Household(s)',
- description:
- 'Cannot load different household(s) when another simulation is already configured',
- onClick: handleClickExisting,
- isSelected: false,
- isDisabled: true,
- },
- // Card 2: Create New Population (disabled)
- {
- title: 'Create New Household(s)',
- description: 'Cannot create new household(s) when another simulation is already configured',
- onClick: handleClickCreateNew,
- isSelected: false,
- isDisabled: true,
- },
- // Card 3: Use Population from Other Simulation (enabled)
- {
- title: `Use household(s) from ${getSimulationLabel(otherSimulation)}`,
- description: `Household(s): ${getPopulationLabel(otherPopulation)}`,
- onClick: handleClickCopyExisting,
- isSelected: selectedAction === 'copyExisting',
- isDisabled: false,
- },
- ];
-
- const normalCards = [
- {
- title: 'Load Existing Household(s)',
- description: 'Use household(s) you have already created',
- onClick: handleClickExisting,
- isSelected: selectedAction === 'loadExisting',
- },
- {
- title: 'Create New Household(s)',
- description: 'Build new household(s)',
- onClick: handleClickCreateNew,
- isSelected: selectedAction === 'createNew',
- },
- ];
-
- // Select appropriate cards based on lock state
- const buttonPanelCards = shouldLockToOtherPopulation ? lockedCards : normalCards;
-
- const viewTitle = getPopulationSelectionTitle(shouldLockToOtherPopulation);
- const viewSubtitle = getPopulationSelectionSubtitle(shouldLockToOtherPopulation);
-
- const primaryAction = {
- label: 'Next',
- onClick: handleClickSubmit,
- isDisabled: shouldLockToOtherPopulation ? false : !selectedAction,
- };
-
- return (
-
- );
-}
diff --git a/app/src/frames/simulation/SimulationSubmitFrame.tsx b/app/src/frames/simulation/SimulationSubmitFrame.tsx
deleted file mode 100644
index 970b8d8e..00000000
--- a/app/src/frames/simulation/SimulationSubmitFrame.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useDispatch, useSelector } from 'react-redux';
-import { SimulationAdapter } from '@/adapters';
-import IngredientSubmissionView, { SummaryBoxItem } from '@/components/IngredientSubmissionView';
-import { useCreateSimulation } from '@/hooks/useCreateSimulation';
-import {
- selectActivePolicy,
- selectActivePopulation,
- selectActiveSimulation,
- selectCurrentPosition,
-} from '@/reducers/activeSelectors';
-import {
- clearSimulationAtPosition,
- updateSimulationAtPosition,
-} from '@/reducers/simulationsReducer';
-import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
-import { Simulation } from '@/types/ingredients/Simulation';
-import { SimulationCreationPayload } from '@/types/payloads';
-
-export default function SimulationSubmitFrame({ onNavigate, isInSubflow }: FlowComponentProps) {
- const dispatch = useDispatch();
-
- // Get the current position and active simulation from cross-cutting selectors
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
- const simulation = useSelector((state: RootState) => selectActiveSimulation(state));
-
- // Get policy and population at the current position
- const policy = useSelector((state: RootState) => selectActivePolicy(state));
- const population = useSelector((state: RootState) => selectActivePopulation(state));
-
- console.log('Simulation label: ', simulation?.label);
- console.log('Simulation in SimulationSubmitFrame: ', simulation);
- const { createSimulation, isPending } = useCreateSimulation(simulation?.label || undefined);
-
- function handleSubmit() {
- // Convert state to partial Simulation for adapter
- const simulationData: Partial = {
- populationId: simulation?.populationId || undefined,
- policyId: simulation?.policyId || undefined,
- populationType: simulation?.populationType || undefined,
- };
-
- const serializedSimulationCreationPayload: SimulationCreationPayload =
- SimulationAdapter.toCreationPayload(simulationData);
-
- console.log('Submitting simulation:', serializedSimulationCreationPayload);
- createSimulation(serializedSimulationCreationPayload, {
- onSuccess: (data) => {
- console.log('Simulation created successfully:', data);
-
- // Update the simulation at current position with the API response
- dispatch(
- updateSimulationAtPosition({
- position: currentPosition,
- updates: {
- id: data.result.simulation_id,
- isCreated: true,
- },
- })
- );
-
- // Navigate to the next step
- onNavigate('submit');
-
- // If we're not in a subflow, clear just this specific simulation
- if (!isInSubflow) {
- dispatch(clearSimulationAtPosition(currentPosition));
- }
- },
- });
- }
-
- // Create summary boxes based on the current simulation state
- const summaryBoxes: SummaryBoxItem[] = [
- {
- title: 'Population Added',
- description: population?.label || `Household #${simulation?.populationId}`,
- isFulfilled: !!simulation?.populationId,
- badge: population?.label || `Household #${simulation?.populationId}`,
- },
- {
- title: 'Policy Reform Added',
- description: policy?.label || `Policy #${simulation?.policyId}`,
- isFulfilled: !!simulation?.policyId,
- badge: policy?.label || `Policy #${simulation?.policyId}`,
- },
- ];
-
- return (
-
- );
-}
diff --git a/app/src/hooks/useIngredientReset.ts b/app/src/hooks/useIngredientReset.ts
deleted file mode 100644
index b1959e0a..00000000
--- a/app/src/hooks/useIngredientReset.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { useDispatch } from 'react-redux';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { clearAllPolicies } from '@/reducers/policyReducer';
-import { clearAllPopulations } from '@/reducers/populationReducer';
-import { clearReport, setActiveSimulationPosition, setMode } from '@/reducers/reportReducer';
-import { clearAllSimulations } from '@/reducers/simulationsReducer';
-import { AppDispatch } from '@/store';
-
-export const ingredients = ['policy', 'simulation', 'population', 'report'];
-
-export const useIngredientReset = () => {
- const dispatch = useDispatch();
- const countryId = useCurrentCountry();
-
- const resetIngredient = (ingredientName: (typeof ingredients)[number]) => {
- console.log('[useIngredientReset] ========== RESET INGREDIENT ==========');
- console.log('[useIngredientReset] Ingredient:', ingredientName);
- console.log('[useIngredientReset] Country:', countryId);
- switch (ingredientName) {
- case 'policy':
- dispatch(clearAllPolicies());
- // Reset to standalone mode when clearing any ingredient
- dispatch(setMode('standalone'));
- dispatch(setActiveSimulationPosition(0));
- console.log('[useIngredientReset] Cleared policies');
- break;
- case 'simulation':
- dispatch(clearAllSimulations());
- dispatch(clearAllPolicies());
- dispatch(clearAllPopulations());
- // Reset to standalone mode when clearing simulations
- dispatch(setMode('standalone'));
- dispatch(setActiveSimulationPosition(0));
- console.log('[useIngredientReset] Cleared simulations, policies, populations');
- break;
- case 'population':
- dispatch(clearAllPopulations());
- // Reset to standalone mode when clearing any ingredient
- dispatch(setMode('standalone'));
- dispatch(setActiveSimulationPosition(0));
- console.log('[useIngredientReset] Cleared populations');
- break;
- case 'report':
- console.log('[useIngredientReset] Clearing report and all ingredients...');
- dispatch(clearReport(countryId));
- dispatch(clearAllSimulations());
- dispatch(clearAllPolicies());
- dispatch(clearAllPopulations());
- // clearReport already resets mode and position, but let's be explicit
- // This ensures consistency even if clearReport changes in the future
- dispatch(setMode('standalone'));
- dispatch(setActiveSimulationPosition(0));
- console.log('[useIngredientReset] Cleared report, simulations, policies, populations');
- break;
- default:
- console.error(`Unknown ingredient: ${ingredientName}`);
- }
- console.log('[useIngredientReset] ========== RESET COMPLETE ==========');
- };
-
- const resetIngredients = (ingredientNames: (typeof ingredients)[number][]) => {
- // Sort by dependency order (most dependent first) to avoid redundant clears
- const dependencyOrder = ['report', 'simulation', 'policy', 'population'];
- const sortedIngredients = ingredientNames.sort(
- (a, b) => dependencyOrder.indexOf(b) - dependencyOrder.indexOf(a)
- );
-
- sortedIngredients.forEach((ingredient) => resetIngredient(ingredient));
- };
-
- return { resetIngredient, resetIngredients };
-};
diff --git a/app/src/hooks/usePathwayNavigation.ts b/app/src/hooks/usePathwayNavigation.ts
new file mode 100644
index 00000000..0811cb89
--- /dev/null
+++ b/app/src/hooks/usePathwayNavigation.ts
@@ -0,0 +1,49 @@
+import { useCallback, useState } from 'react';
+
+/**
+ * Custom hook for managing pathway navigation state
+ * Provides navigation with history tracking for back navigation
+ *
+ * @param initialMode - The starting mode for the pathway
+ * @returns Navigation state and control functions
+ */
+export function usePathwayNavigation(initialMode: TMode) {
+ const [currentMode, setCurrentMode] = useState(initialMode);
+ const [history, setHistory] = useState([]);
+
+ const navigateToMode = useCallback(
+ (mode: TMode) => {
+ console.log('[usePathwayNavigation] Navigating to mode:', mode);
+ setHistory((prev) => [...prev, currentMode]);
+ setCurrentMode(mode);
+ },
+ [currentMode]
+ );
+
+ const goBack = useCallback(() => {
+ if (history.length > 0) {
+ const previousMode = history[history.length - 1];
+ console.log('[usePathwayNavigation] Going back to mode:', previousMode);
+ setHistory((prev) => prev.slice(0, -1));
+ setCurrentMode(previousMode);
+ } else {
+ console.warn('[usePathwayNavigation] No history to go back to');
+ }
+ }, [history]);
+
+ const resetNavigation = useCallback((mode: TMode) => {
+ console.log('[usePathwayNavigation] Resetting navigation to mode:', mode);
+ setHistory([]);
+ setCurrentMode(mode);
+ }, []);
+
+ return {
+ currentMode,
+ setCurrentMode,
+ navigateToMode,
+ goBack,
+ resetNavigation,
+ history,
+ canGoBack: history.length > 0,
+ };
+}
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/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts
index c606fba0..e237125e 100644
--- a/app/src/hooks/useUserHousehold.ts
+++ b/app/src/hooks/useUserHousehold.ts
@@ -206,13 +206,17 @@ export const useUserHouseholds = (userId: string) => {
const householdsWithAssociations: UserHouseholdMetadataWithAssociation[] | undefined =
associations
?.filter((association) => association.householdId)
- .map((association, index) => ({
- association,
- household: householdQueries[index]?.data,
- isLoading: householdQueries[index]?.isLoading ?? false,
- error: householdQueries[index]?.error ?? null,
- isError: !!householdQueries[index]?.error,
- }));
+ .map((association, index) => {
+ const queryResult = householdQueries[index];
+
+ return {
+ association,
+ household: queryResult?.data,
+ isLoading: queryResult?.isLoading ?? false,
+ error: queryResult?.error ?? null,
+ isError: !!queryResult?.error,
+ };
+ });
return {
data: householdsWithAssociations,
diff --git a/app/src/libs/policyParameterTransform.ts b/app/src/libs/policyParameterTransform.ts
deleted file mode 100644
index 67e834ad..00000000
--- a/app/src/libs/policyParameterTransform.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Dispatch } from '@reduxjs/toolkit';
-import { convertPolicyJsonToParameters } from '@/adapters';
-import { addPolicyParamAtPosition } from '@/reducers/policyReducer';
-import { PolicyMetadata } from '@/types/metadata/policyMetadata';
-
-/**
- * Bulk loads policy parameters from PolicyMetadata.policy_json into the Redux store
- * @param policyJson - The policy_json object from PolicyMetadata
- * @param dispatch - Redux dispatch function
- * @param position - The position (0 or 1) to load the parameters into
- */
-export function loadPolicyParametersToStore(
- policyJson: PolicyMetadata['policy_json'],
- dispatch: Dispatch,
- position: 0 | 1
-): void {
- const parameters = convertPolicyJsonToParameters(policyJson);
-
- parameters.forEach((param) => {
- param.values.forEach((valueInterval) => {
- dispatch(
- addPolicyParamAtPosition({
- position,
- name: param.name,
- valueInterval,
- })
- );
- });
- });
-}
diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx
index eb67eac0..0703fc70 100644
--- a/app/src/pages/Policies.page.tsx
+++ b/app/src/pages/Policies.page.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import { Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns';
import { RenameIngredientModal } from '@/components/common/RenameIngredientModal';
@@ -104,7 +105,7 @@ export default function PoliciesPage() {
const transformedData: IngredientRecord[] =
data?.map((item) => ({
- id: item.association.id || item.association.policyId.toString(),
+ id: item.association.id?.toString() || item.association.policyId.toString(), // Use user association ID, not base policy ID
policyName: {
text: item.association.label || `Policy #${item.association.policyId}`,
} as TextValue,
@@ -125,22 +126,24 @@ export default function PoliciesPage() {
return (
<>
-
+
+
+
-
+
+
+
+
- >
+
);
}
diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx
index 3898d8c7..a42918a9 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 { Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
BulletsValue,
@@ -200,22 +201,24 @@ export default function ReportsPage() {
return (
<>
-
+
+
+
({
- id: item.userSimulation.id || item.userSimulation.simulationId.toString(),
+ id: item.userSimulation.id?.toString() || item.userSimulation.simulationId.toString(), // Use user association ID, not base simulation ID
simulation: {
text: item.userSimulation.label || `Simulation #${item.userSimulation.simulationId}`,
} as TextValue,
@@ -135,22 +136,24 @@ export default function SimulationsPage() {
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/SHARED_UTILITIES.md b/app/src/pathways/SHARED_UTILITIES.md
new file mode 100644
index 00000000..8c1561e1
--- /dev/null
+++ b/app/src/pathways/SHARED_UTILITIES.md
@@ -0,0 +1,469 @@
+# Shared Pathway Utilities
+
+This document describes the shared utilities created to enable code reuse across Report, Simulation, Policy, and Population pathways.
+
+## Overview
+
+The pathway system uses a component-based architecture where pathways orchestrate state management and navigation, while views handle presentation and user interaction. These utilities eliminate duplication by providing reusable building blocks.
+
+## 📁 Directory Structure
+
+```
+app/src/
+├── hooks/
+│ └── usePathwayNavigation.ts # Navigation state management
+├── types/pathwayModes/
+│ ├── SharedViewModes.ts # Shared view mode enums
+│ └── ReportViewMode.ts # Composes shared modes
+├── utils/
+│ ├── pathwayCallbacks/ # Callback factories
+│ │ ├── index.ts
+│ │ ├── policyCallbacks.ts
+│ │ ├── populationCallbacks.ts
+│ │ ├── simulationCallbacks.ts
+│ │ └── reportCallbacks.ts
+│ ├── ingredientReconstruction/ # API data reconstruction
+│ │ ├── index.ts
+│ │ ├── reconstructSimulation.ts
+│ │ ├── reconstructPolicy.ts
+│ │ └── reconstructPopulation.ts
+│ └── validation/ # Ingredient validation utilities
+│ └── ingredientValidation.ts # Configuration state validation
+└── pathways/
+ └── report/
+ └── views/ # Fully reusable view components
+ ├── simulation/
+ ├── policy/
+ └── population/
+```
+
+## 🎯 1. Shared View Components
+
+**Location**: `app/src/pathways/report/views/`
+
+All view components are already fully reusable across pathways:
+
+### Simulation Views
+- `SimulationLabelView` - Label entry
+- `SimulationSetupView` - Policy/population setup coordination
+- `SimulationSubmitView` - API submission
+- `SimulationPolicySetupView` - Policy selection coordinator
+- `SimulationPopulationSetupView` - Population selection coordinator
+
+### Policy Views
+- `PolicyLabelView` - Label entry
+- `PolicyParameterSelectorView` - Parameter modification
+- `PolicySubmitView` - API submission
+- `PolicyExistingView` - Load existing policy
+
+### Population Views
+- `PopulationScopeView` - Household vs geography selection
+- `PopulationLabelView` - Label entry
+- `HouseholdBuilderView` - Custom household creation
+- `GeographicConfirmationView` - Geography confirmation
+- `PopulationExistingView` - Load existing population
+
+**Usage**: Import directly into any pathway wrapper.
+
+## ✅ 2. Ingredient Validation Utilities
+
+**Location**: `app/src/utils/validation/ingredientValidation.ts`
+
+**Purpose**: Provides validation functions to determine if ingredients are fully configured and ready for use. Replaces the deprecated `isCreated` flag pattern with ID-based validation.
+
+### Key Functions
+
+#### `isPolicyConfigured(policy: PolicyStateProps | null | undefined): boolean`
+
+Checks if a policy is configured by verifying it has an ID. A policy gets an ID when:
+- User creates custom policy and submits to API
+- User selects current law (ID = currentLawId)
+- User loads existing policy from database
+
+```typescript
+import { isPolicyConfigured } from '@/utils/validation/ingredientValidation';
+
+if (isPolicyConfigured(policy)) {
+ // Policy is ready to use
+}
+```
+
+#### `isPopulationConfigured(population: PopulationStateProps | null | undefined): boolean`
+
+Checks if a population is configured by verifying it has either a household ID or geography ID.
+
+```typescript
+import { isPopulationConfigured } from '@/utils/validation/ingredientValidation';
+
+if (isPopulationConfigured(population)) {
+ // Population is ready to use
+}
+```
+
+#### `isSimulationConfigured(simulation: SimulationStateProps | null | undefined): boolean`
+
+Checks if a simulation is configured by checking:
+1. If simulation has an ID (fully persisted), OR
+2. If both policy and population are configured (ready to submit)
+
+```typescript
+import { isSimulationConfigured } from '@/utils/validation/ingredientValidation';
+
+if (isSimulationConfigured(simulation)) {
+ // Simulation is either persisted or ready to submit
+}
+```
+
+#### Additional Utilities
+
+- `isSimulationReadyToSubmit(simulation)` - Specifically checks if ingredients are ready for submission
+- `isSimulationPersisted(simulation)` - Specifically checks if simulation has database ID
+
+### Benefits
+
+- **Single Source of Truth**: Configuration state is determined by ID presence, not a separate flag
+- **No Stale State**: Copy/prefill operations automatically work correctly (IDs are copied with data)
+- **Clear Semantics**: Function names explicitly state what they check
+- **Type Safe**: Handles null/undefined gracefully
+
+**Note**: The `isCreated` flag has been removed from all StateProps interfaces. Use these validation functions instead.
+
+## 🔧 3. Callback Factories
+
+**Location**: `app/src/utils/pathwayCallbacks/`
+
+Factory functions that generate reusable callbacks for state management.
+
+### `createPolicyCallbacks`
+
+**Parameters**:
+- `setState`: State setter function
+- `policySelector`: Extract policy from state
+- `policyUpdater`: Update policy in state
+- `navigateToMode`: Navigation function
+- `returnMode`: Mode to return to after completion
+
+**Returns**:
+```typescript
+{
+ updateLabel: (label: string) => void
+ updatePolicy: (policy: PolicyStateProps) => void
+ handleSelectCurrentLaw: (lawId: number, label?: string) => void
+ handleSelectExisting: (id: string, label: string, params: Parameter[]) => void
+ handleSubmitSuccess: (policyId: string) => void
+}
+```
+
+**Example**:
+```typescript
+const policyCallbacks = createPolicyCallbacks(
+ setState,
+ (state) => state.policy,
+ (state, policy) => ({ ...state, policy }),
+ navigateToMode,
+ SimulationViewMode.SIMULATION_SETUP
+);
+```
+
+### `createPopulationCallbacks`
+
+**Parameters**:
+- `setState`: State setter function
+- `populationSelector`: Extract population from state
+- `populationUpdater`: Update population in state
+- `navigateToMode`: Navigation function
+- `returnMode`: Mode to return to after completion
+- `labelMode`: Mode to navigate to for labeling
+
+**Returns**:
+```typescript
+{
+ updateLabel: (label: string) => void
+ handleScopeSelected: (geography: Geography | null, scopeType: string) => void
+ handleSelectExistingHousehold: (id: string, household: Household, label: string) => void
+ handleSelectExistingGeography: (id: string, geography: Geography, label: string) => void
+ handleHouseholdSubmitSuccess: (id: string, household: Household) => void
+ handleGeographicSubmitSuccess: (id: string, label: string) => void
+}
+```
+
+### `createSimulationCallbacks`
+
+**Parameters**:
+- `setState`: State setter function
+- `simulationSelector`: Extract simulation from state
+- `simulationUpdater`: Update simulation in state
+- `navigateToMode`: Navigation function
+- `returnMode`: Mode to return to after completion
+
+**Returns**:
+```typescript
+{
+ updateLabel: (label: string) => void
+ handleSubmitSuccess: (simulationId: string) => void
+ handleSelectExisting: (enhancedSimulation: EnhancedUserSimulation) => void
+}
+```
+
+### `createReportCallbacks`
+
+**Parameters**:
+- `setState`: State setter function for report state
+- `navigateToMode`: Navigation function
+- `activeSimulationIndex`: Currently active simulation (0 or 1)
+- `simulationSelectionMode`: Mode to navigate to for simulation selection
+- `setupMode`: Mode to return to after operations (typically REPORT_SETUP)
+
+**Returns**:
+```typescript
+{
+ updateLabel: (label: string) => void
+ navigateToSimulationSelection: (simulationIndex: 0 | 1) => void
+ handleSelectExistingSimulation: (enhancedSimulation: EnhancedUserSimulation) => void
+ copyPopulationFromOtherSimulation: () => void
+ prefillPopulation2FromSimulation1: () => void
+}
+```
+
+**Example**:
+```typescript
+const reportCallbacks = createReportCallbacks(
+ setReportState,
+ navigateToMode,
+ activeSimulationIndex,
+ ReportViewMode.REPORT_SELECT_SIMULATION,
+ ReportViewMode.REPORT_SETUP
+);
+```
+
+## 🧭 3. Navigation Hook
+
+**Location**: `app/src/hooks/usePathwayNavigation.ts`
+
+**Purpose**: Manages pathway navigation state with history tracking.
+
+**Type Parameters**: `` - The enum type for view modes
+
+**Returns**:
+```typescript
+{
+ currentMode: TMode
+ setCurrentMode: (mode: TMode) => void
+ navigateToMode: (mode: TMode) => void
+ goBack: () => void
+ resetNavigation: (mode: TMode) => void
+ history: TMode[]
+ canGoBack: boolean
+}
+```
+
+**Example**:
+```typescript
+const { currentMode, navigateToMode, goBack } = usePathwayNavigation(
+ SimulationViewMode.SIMULATION_LABEL
+);
+```
+
+## 🔄 4. Ingredient Reconstruction
+
+**Location**: `app/src/utils/ingredientReconstruction/`
+
+Utilities to convert API/enhanced data into StateProps format.
+
+### `reconstructSimulationFromEnhanced(enhancedSimulation)`
+
+Converts `EnhancedUserSimulation` → `SimulationStateProps`
+
+**Features**:
+- Reconstructs nested policy from `enhancedSimulation.policy`
+- Reconstructs nested population from household or geography
+- Handles populationType detection
+- Sets `isCreated: true` for all nested ingredients
+
+### `reconstructPolicyFromJson(policyId, label, policyJson)`
+
+Converts `policy_json` format → `PolicyStateProps`
+
+**Features**:
+- Converts object notation to `Parameter[]` array
+- Handles value interval formatting
+- Normalizes date fields (start/startDate, end/endDate)
+
+### `reconstructPolicyFromParameters(policyId, label, parameters)`
+
+Direct conversion when parameters are already in correct format.
+
+### `reconstructPopulationFromHousehold(id, household, label)`
+
+Converts household data → `PopulationStateProps`
+
+### `reconstructPopulationFromGeography(id, geography, label)`
+
+Converts geography data → `PopulationStateProps`
+
+## 🎨 5. Shared View Modes
+
+**Location**: `app/src/types/pathwayModes/SharedViewModes.ts`
+
+Defines common view modes used across multiple pathways.
+
+### Enums
+
+```typescript
+enum PolicyViewMode {
+ POLICY_LABEL
+ POLICY_PARAMETER_SELECTOR
+ POLICY_SUBMIT
+ SELECT_EXISTING_POLICY
+ SETUP_POLICY
+}
+
+enum PopulationViewMode {
+ POPULATION_SCOPE
+ POPULATION_LABEL
+ POPULATION_HOUSEHOLD_BUILDER
+ POPULATION_GEOGRAPHIC_CONFIRM
+ SELECT_EXISTING_POPULATION
+ SETUP_POPULATION
+}
+
+enum SimulationViewMode {
+ SIMULATION_LABEL
+ SIMULATION_SETUP
+ SIMULATION_SUBMIT
+}
+```
+
+### Type Guards
+
+- `isPolicyMode(mode: string): mode is PolicyViewMode`
+- `isPopulationMode(mode: string): mode is PopulationViewMode`
+- `isSimulationMode(mode: string): mode is SimulationViewMode`
+
+### Usage in Pathway Modes
+
+Compose pathway-specific enums using shared modes:
+
+```typescript
+export enum SimulationViewMode {
+ // Simulation-specific
+ SIMULATION_LABEL = SharedViewMode.SIMULATION_LABEL,
+ SIMULATION_SETUP = SharedViewMode.SIMULATION_SETUP,
+ SIMULATION_SUBMIT = SharedViewMode.SIMULATION_SUBMIT,
+
+ // Compose policy modes
+ POLICY_LABEL = PolicyViewMode.POLICY_LABEL,
+ // ... etc
+
+ // Compose population modes
+ POPULATION_SCOPE = PopulationViewMode.POPULATION_SCOPE,
+ // ... etc
+}
+```
+
+## 📋 Creating a New Pathway
+
+To create a new pathway (e.g., `SimulationPathwayWrapper`):
+
+### 1. Import Shared Utilities
+
+```typescript
+import { usePathwayNavigation } from '@/hooks/usePathwayNavigation';
+import {
+ createPolicyCallbacks,
+ createPopulationCallbacks,
+ createSimulationCallbacks,
+ createReportCallbacks // Only for report pathways
+} from '@/utils/pathwayCallbacks';
+import { PolicyViewMode, PopulationViewMode, SimulationViewMode } from '@/types/pathwayModes/SharedViewModes';
+
+// Import reusable views
+import SimulationLabelView from '@/pathways/report/views/simulation/SimulationLabelView';
+import SimulationSetupView from '@/pathways/report/views/simulation/SimulationSetupView';
+// ... etc
+```
+
+### 2. Define State Management
+
+```typescript
+const [simulationState, setSimulationState] = useState(
+ () => initializeSimulationState(countryId)
+);
+
+const { currentMode, navigateToMode } = usePathwayNavigation(
+ SimulationViewMode.SIMULATION_LABEL
+);
+```
+
+### 3. Create Callbacks Using Factories
+
+```typescript
+const policyCallbacks = createPolicyCallbacks(
+ setSimulationState,
+ (state) => state.policy,
+ (state, policy) => ({ ...state, policy }),
+ navigateToMode,
+ SimulationViewMode.SIMULATION_SETUP
+);
+
+const populationCallbacks = createPopulationCallbacks(
+ setSimulationState,
+ (state) => state.population,
+ (state, population) => ({ ...state, population }),
+ navigateToMode,
+ SimulationViewMode.SIMULATION_SETUP,
+ SimulationViewMode.POPULATION_LABEL
+);
+```
+
+### 4. Implement Switch Statement
+
+```typescript
+switch (currentMode) {
+ case SimulationViewMode.SIMULATION_LABEL:
+ return setSimulationState(prev => ({ ...prev, label }))}
+ onNext={() => navigateToMode(SimulationViewMode.SIMULATION_SETUP)}
+ />;
+
+ case SimulationViewMode.POLICY_LABEL:
+ return navigateToMode(SimulationViewMode.POLICY_PARAMETER_SELECTOR)}
+ />;
+
+ // ... etc
+}
+```
+
+## 💡 Benefits
+
+1. **~70-80% Code Reduction**: Eliminate duplication across pathways
+2. **Type Safety**: Generic type parameters ensure correctness
+3. **Maintainability**: Update once, benefit everywhere
+4. **Consistency**: Same UX patterns across all pathways
+5. **Testability**: Test utilities once, reuse everywhere
+6. **Flexibility**: Each pathway maintains independent state management
+
+## 🔍 Best Practices
+
+1. **Use Callback Factories**: Don't duplicate state update logic
+2. **Compose View Modes**: Use shared enums where possible
+3. **Use Reconstruction Utilities**: Don't manually map API data
+4. **Leverage Navigation Hook**: Don't manually manage mode state
+5. **Import Views Directly**: Views are already reusable, no need to wrap them
+
+## 🚀 Next Steps
+
+With these utilities in place, creating new pathways becomes straightforward:
+
+1. Define pathway-specific state type (or use existing)
+2. Compose view mode enum using shared modes
+3. Initialize state with appropriate initializer
+4. Create callbacks using factories
+5. Wire up views in switch statement
+6. Handle pathway-specific logic only
+
+The heavy lifting is done by shared utilities.
diff --git a/app/src/pathways/policy/PolicyPathwayWrapper.tsx b/app/src/pathways/policy/PolicyPathwayWrapper.tsx
new file mode 100644
index 00000000..07558188
--- /dev/null
+++ b/app/src/pathways/policy/PolicyPathwayWrapper.tsx
@@ -0,0 +1,112 @@
+/**
+ * PolicyPathwayWrapper - Pathway orchestrator for standalone policy creation
+ *
+ * Manages local state for a single policy with parameter modifications.
+ * Reuses shared views from the report pathway with mode="standalone".
+ */
+
+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 { StandalonePolicyViewMode } 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';
+import PolicyParameterSelectorView from '../report/views/policy/PolicyParameterSelectorView';
+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([StandalonePolicyViewMode.PARAMETER_SELECTOR]);
+
+interface PolicyPathwayWrapperProps {
+ onComplete?: () => void;
+}
+
+export default function PolicyPathwayWrapper({ onComplete }: PolicyPathwayWrapperProps) {
+ console.log('[PolicyPathwayWrapper] ========== RENDER ==========');
+
+ const countryId = useCurrentCountry();
+ const navigate = useNavigate();
+
+ // Initialize policy state
+ const [policyState, setPolicyState] = useState(() => {
+ return initializePolicyState();
+ });
+
+ // ========== NAVIGATION ==========
+ const { currentMode, navigateToMode, goBack, canGoBack } = usePathwayNavigation(
+ StandalonePolicyViewMode.LABEL
+ );
+
+ // ========== CALLBACKS ==========
+ // 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,
+ StandalonePolicyViewMode.SUBMIT, // returnMode (not used in standalone mode)
+ (policyId: string) => {
+ // onPolicyComplete: custom navigation for standalone pathway
+ console.log('[PolicyPathwayWrapper] Policy created with ID:', policyId);
+ navigate(`/${countryId}/policies`);
+ onComplete?.();
+ }
+ );
+
+ // ========== VIEW RENDERING ==========
+ let currentView: React.ReactElement;
+
+ switch (currentMode) {
+ case StandalonePolicyViewMode.LABEL:
+ currentView = (
+ navigateToMode(StandalonePolicyViewMode.PARAMETER_SELECTOR)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/policies`)}
+ />
+ );
+ break;
+
+ case StandalonePolicyViewMode.PARAMETER_SELECTOR:
+ currentView = (
+ navigateToMode(StandalonePolicyViewMode.SUBMIT)}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case StandalonePolicyViewMode.SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/policies`)}
+ />
+ );
+ break;
+
+ default:
+ currentView = Unknown view mode: {currentMode}
;
+ }
+
+ // Conditionally wrap with StandardLayout
+ // PolicyParameterSelectorView manages its own AppShell
+ if (MODES_WITH_OWN_LAYOUT.has(currentMode as StandalonePolicyViewMode)) {
+ return currentView;
+ }
+
+ return {currentView};
+}
diff --git a/app/src/pathways/population/PopulationPathwayWrapper.tsx b/app/src/pathways/population/PopulationPathwayWrapper.tsx
new file mode 100644
index 00000000..a02bd987
--- /dev/null
+++ b/app/src/pathways/population/PopulationPathwayWrapper.tsx
@@ -0,0 +1,138 @@
+/**
+ * PopulationPathwayWrapper - Pathway orchestrator for standalone population creation
+ *
+ * Manages local state for a single population (household or geographic).
+ * Reuses shared views from the report pathway with mode="standalone".
+ */
+
+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 { Household } from '@/types/ingredients/Household';
+import { StandalonePopulationViewMode } 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';
+import PopulationLabelView from '../report/views/population/PopulationLabelView';
+// Population views (reusing from report pathway)
+import PopulationScopeView from '../report/views/population/PopulationScopeView';
+
+interface PopulationPathwayWrapperProps {
+ onComplete?: () => void;
+}
+
+export default function PopulationPathwayWrapper({ onComplete }: PopulationPathwayWrapperProps) {
+ console.log('[PopulationPathwayWrapper] ========== RENDER ==========');
+
+ const countryId = useCurrentCountry();
+ const navigate = useNavigate();
+
+ // Initialize population state
+ const [populationState, setPopulationState] = useState(() => {
+ return initializePopulationState();
+ });
+
+ // Get metadata for views
+ const metadata = useSelector((state: RootState) => state.metadata);
+
+ // ========== NAVIGATION ==========
+ const { currentMode, navigateToMode, goBack, canGoBack } = usePathwayNavigation(
+ StandalonePopulationViewMode.SCOPE
+ );
+
+ // ========== CALLBACKS ==========
+ // 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,
+ 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) => {
+ 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 ==========
+ let currentView: React.ReactElement;
+
+ switch (currentMode) {
+ case StandalonePopulationViewMode.SCOPE:
+ currentView = (
+ navigate(`/${countryId}/households`)}
+ />
+ );
+ break;
+
+ case StandalonePopulationViewMode.LABEL:
+ currentView = (
+ {
+ // Navigate based on population type
+ if (populationState.type === 'household') {
+ navigateToMode(StandalonePopulationViewMode.HOUSEHOLD_BUILDER);
+ } else {
+ navigateToMode(StandalonePopulationViewMode.GEOGRAPHIC_CONFIRM);
+ }
+ }}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case StandalonePopulationViewMode.HOUSEHOLD_BUILDER:
+ currentView = (
+
+ );
+ break;
+
+ case StandalonePopulationViewMode.GEOGRAPHIC_CONFIRM:
+ currentView = (
+
+ );
+ break;
+
+ default:
+ currentView = Unknown view mode: {currentMode}
;
+ }
+
+ return {currentView};
+}
diff --git a/app/src/pathways/report/ReportPathwayWrapper.tsx b/app/src/pathways/report/ReportPathwayWrapper.tsx
new file mode 100644
index 00000000..ce09f6a7
--- /dev/null
+++ b/app/src/pathways/report/ReportPathwayWrapper.tsx
@@ -0,0 +1,609 @@
+/**
+ * ReportPathwayWrapper - Pathway orchestrator for report creation
+ *
+ * Replaces ReportCreationFlow with local state management.
+ * Manages all state for report, simulations, policies, and populations.
+ *
+ * Phase 3 - Complete implementation with nested simulation/policy/population flows
+ */
+
+import { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+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';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+import { useUserSimulations } from '@/hooks/useUserSimulations';
+import { countryIds } from '@/libs/countries';
+import { RootState } from '@/store';
+import { Report } from '@/types/ingredients/Report';
+import { ReportViewMode } from '@/types/pathwayModes/ReportViewMode';
+import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState';
+import { ReportCreationPayload } from '@/types/payloads';
+import { convertSimulationStateToApi } from '@/utils/ingredientReconstruction';
+import {
+ createPolicyCallbacks,
+ createPopulationCallbacks,
+ createReportCallbacks,
+ createSimulationCallbacks,
+} from '@/utils/pathwayCallbacks';
+import { initializeReportState } from '@/utils/pathwayState/initializeReportState';
+import { getReportOutputPath } from '@/utils/reportRouting';
+import PolicyExistingView from './views/policy/PolicyExistingView';
+// Policy views
+import PolicyLabelView from './views/policy/PolicyLabelView';
+import PolicyParameterSelectorView from './views/policy/PolicyParameterSelectorView';
+import PolicySubmitView from './views/policy/PolicySubmitView';
+import GeographicConfirmationView from './views/population/GeographicConfirmationView';
+import HouseholdBuilderView from './views/population/HouseholdBuilderView';
+import PopulationExistingView from './views/population/PopulationExistingView';
+import PopulationLabelView from './views/population/PopulationLabelView';
+// Population views
+import PopulationScopeView from './views/population/PopulationScopeView';
+// Report-level views
+import ReportLabelView from './views/ReportLabelView';
+import ReportSetupView from './views/ReportSetupView';
+import ReportSimulationExistingView from './views/ReportSimulationExistingView';
+import ReportSimulationSelectionView from './views/ReportSimulationSelectionView';
+import ReportSubmitView from './views/ReportSubmitView';
+// Simulation views
+import SimulationLabelView from './views/simulation/SimulationLabelView';
+import SimulationPolicySetupView from './views/simulation/SimulationPolicySetupView';
+import SimulationPopulationSetupView from './views/simulation/SimulationPopulationSetupView';
+import SimulationSetupView from './views/simulation/SimulationSetupView';
+import SimulationSubmitView from './views/simulation/SimulationSubmitView';
+
+// View modes that manage their own AppShell (don't need StandardLayout wrapper)
+const MODES_WITH_OWN_LAYOUT = new Set([ReportViewMode.POLICY_PARAMETER_SELECTOR]);
+
+interface ReportPathwayWrapperProps {
+ onComplete?: () => void;
+}
+
+export default function ReportPathwayWrapper({ onComplete }: ReportPathwayWrapperProps) {
+ const { countryId: countryIdParam } = useParams<{ countryId: string }>();
+ const navigate = useNavigate();
+
+ // Validate countryId from URL params
+ if (!countryIdParam) {
+ return Error: Country ID not found
;
+ }
+
+ if (!countryIds.includes(countryIdParam as any)) {
+ return Error: Invalid country ID
;
+ }
+
+ const countryId = countryIdParam as (typeof countryIds)[number];
+
+ console.log('[ReportPathwayWrapper] ========== RENDER ==========');
+ console.log('[ReportPathwayWrapper] countryId:', countryId);
+
+ // Initialize report state
+ const [reportState, setReportState] = useState(() =>
+ initializeReportState(countryId)
+ );
+ const [activeSimulationIndex, setActiveSimulationIndex] = useState<0 | 1>(0);
+
+ const { createReport, isPending: isSubmitting } = useCreateReport(reportState.label || undefined);
+
+ // Get metadata for population views
+ const metadata = useSelector((state: RootState) => state.metadata);
+ const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId);
+
+ // ========== NAVIGATION ==========
+ const { currentMode, navigateToMode, goBack, canGoBack } = usePathwayNavigation(
+ ReportViewMode.REPORT_LABEL
+ );
+
+ // ========== FETCH USER DATA FOR CONDITIONAL NAVIGATION ==========
+ const userId = MOCK_USER_ID.toString();
+ const { data: userSimulations } = useUserSimulations(userId);
+ const { data: userPolicies } = useUserPolicies(userId);
+ const { data: userHouseholds } = useUserHouseholds(userId);
+ const { data: userGeographics } = useUserGeographics(userId);
+
+ const hasExistingSimulations = (userSimulations?.length ?? 0) > 0;
+ const hasExistingPolicies = (userPolicies?.length ?? 0) > 0;
+ const hasExistingPopulations = (userHouseholds?.length ?? 0) + (userGeographics?.length ?? 0) > 0;
+
+ // ========== HELPER: Get active simulation ==========
+ const activeSimulation = reportState.simulations[activeSimulationIndex];
+ const otherSimulation = reportState.simulations[activeSimulationIndex === 0 ? 1 : 0];
+
+ // ========== SHARED CALLBACK FACTORIES ==========
+ // Report-level callbacks
+ const reportCallbacks = createReportCallbacks(
+ setReportState,
+ navigateToMode,
+ activeSimulationIndex,
+ ReportViewMode.REPORT_SELECT_SIMULATION,
+ ReportViewMode.REPORT_SETUP
+ );
+
+ // Policy callbacks for active simulation
+ const policyCallbacks = createPolicyCallbacks(
+ setReportState,
+ (state) => state.simulations[activeSimulationIndex].policy,
+ (state, policy) => {
+ const newSimulations = [...state.simulations] as [
+ (typeof state.simulations)[0],
+ (typeof state.simulations)[1],
+ ];
+ newSimulations[activeSimulationIndex].policy = policy;
+ return { ...state, simulations: newSimulations };
+ },
+ navigateToMode,
+ ReportViewMode.SIMULATION_SETUP,
+ undefined // No onPolicyComplete - stays within report pathway
+ );
+
+ // Population callbacks for active simulation
+ const populationCallbacks = createPopulationCallbacks(
+ setReportState,
+ (state) => state.simulations[activeSimulationIndex].population,
+ (state, population) => {
+ const newSimulations = [...state.simulations] as [
+ (typeof state.simulations)[0],
+ (typeof state.simulations)[1],
+ ];
+ newSimulations[activeSimulationIndex].population = population;
+ return { ...state, simulations: newSimulations };
+ },
+ navigateToMode,
+ ReportViewMode.SIMULATION_SETUP,
+ ReportViewMode.POPULATION_LABEL,
+ undefined // No onPopulationComplete - stays within report pathway
+ );
+
+ // Simulation callbacks for active simulation
+ const simulationCallbacks = createSimulationCallbacks(
+ setReportState,
+ (state) => state.simulations[activeSimulationIndex],
+ (state, simulation) => {
+ const newSimulations = [...state.simulations] as [
+ (typeof state.simulations)[0],
+ (typeof state.simulations)[1],
+ ];
+ newSimulations[activeSimulationIndex] = simulation;
+ return { ...state, simulations: newSimulations };
+ },
+ navigateToMode,
+ ReportViewMode.REPORT_SETUP,
+ undefined // No onSimulationComplete - stays within report pathway
+ );
+
+ // ========== 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 (except for baseline, which has DefaultBaselineOption)
+ const handleNavigateToSimulationSelection = useCallback(
+ (simulationIndex: 0 | 1) => {
+ console.log('[ReportPathwayWrapper] Setting active simulation index:', simulationIndex);
+ setActiveSimulationIndex(simulationIndex);
+ // 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 (reform simulation only)
+ navigateToMode(ReportViewMode.SIMULATION_LABEL);
+ }
+ },
+ [reportCallbacks, hasExistingSimulations, navigateToMode]
+ );
+
+ // Conditional navigation to policy setup - skip if no existing policies
+ const handleNavigateToPolicy = useCallback(() => {
+ if (hasExistingPolicies) {
+ navigateToMode(ReportViewMode.SETUP_POLICY);
+ } else {
+ // Skip selection view, go directly to create new
+ navigateToMode(ReportViewMode.POLICY_LABEL);
+ }
+ }, [hasExistingPolicies, navigateToMode]);
+
+ // Conditional navigation to population setup - skip if no existing populations
+ const handleNavigateToPopulation = useCallback(() => {
+ if (hasExistingPopulations) {
+ navigateToMode(ReportViewMode.SETUP_POPULATION);
+ } else {
+ // Skip selection view, go directly to create new
+ navigateToMode(ReportViewMode.POPULATION_SCOPE);
+ }
+ }, [hasExistingPopulations, navigateToMode]);
+
+ // Wrapper for current law selection with custom logging
+ const handleSelectCurrentLaw = useCallback(() => {
+ console.log('[ReportPathwayWrapper] Selecting current law');
+ policyCallbacks.handleSelectCurrentLaw(currentLawId, 'Current law');
+ }, [currentLawId, policyCallbacks]);
+
+ // Handler for selecting default baseline simulation
+ // This is called after the simulation has been created by DefaultBaselineOption
+ const handleSelectDefaultBaseline = useCallback(
+ (simulationState: SimulationStateProps, simulationId: string) => {
+ console.log('[ReportPathwayWrapper] Default baseline simulation created');
+ console.log('[ReportPathwayWrapper] Simulation state:', simulationState);
+ console.log('[ReportPathwayWrapper] Simulation ID:', simulationId);
+
+ // Update the active simulation with the created simulation
+ setReportState((prev) => {
+ const newSimulations = [...prev.simulations] as [
+ (typeof prev.simulations)[0],
+ (typeof prev.simulations)[1],
+ ];
+ newSimulations[activeSimulationIndex] = simulationState;
+ return { ...prev, simulations: newSimulations };
+ });
+
+ // Navigate back to report setup
+ navigateToMode(ReportViewMode.REPORT_SETUP);
+ },
+ [activeSimulationIndex, navigateToMode]
+ );
+
+ // ========== REPORT SUBMISSION ==========
+ 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,
+ year: reportState.year,
+ simulationIds: [sim1Id, sim2Id].filter(Boolean) as string[],
+ apiVersion: reportState.apiVersion,
+ };
+
+ const serializedPayload: ReportCreationPayload = ReportAdapter.toCreationPayload(
+ reportData as Report
+ );
+
+ // Convert SimulationStateProps to Simulation format for CalcOrchestrator
+ const simulation1Api = convertSimulationStateToApi(reportState.simulations[0]);
+ const simulation2Api = convertSimulationStateToApi(reportState.simulations[1]);
+
+ if (!simulation1Api) {
+ console.error('[ReportPathwayWrapper] Failed to convert simulation1 to API format');
+ return;
+ }
+
+ console.log('[ReportPathwayWrapper] Converted simulations to API format:', {
+ simulation1: {
+ id: simulation1Api.id,
+ policyId: simulation1Api.policyId,
+ populationId: simulation1Api.populationId,
+ },
+ simulation2: simulation2Api
+ ? {
+ id: simulation2Api.id,
+ policyId: simulation2Api.policyId,
+ populationId: simulation2Api.populationId,
+ }
+ : null,
+ });
+
+ // Submit report
+ createReport(
+ {
+ countryId: reportState.countryId,
+ payload: serializedPayload,
+ simulations: {
+ simulation1: simulation1Api,
+ simulation2: simulation2Api,
+ },
+ 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);
+
+ // Determine which view to render based on current mode
+ let currentView: React.ReactNode;
+
+ switch (currentMode) {
+ // ========== REPORT-LEVEL VIEWS ==========
+ case ReportViewMode.REPORT_LABEL:
+ currentView = (
+ navigateToMode(ReportViewMode.REPORT_SETUP)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.REPORT_SETUP:
+ currentView = (
+ navigateToMode(ReportViewMode.REPORT_SUBMIT)}
+ onPrefillPopulation2={reportCallbacks.prefillPopulation2FromSimulation1}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.REPORT_SELECT_SIMULATION:
+ currentView = (
+ navigateToMode(ReportViewMode.SIMULATION_LABEL)}
+ onLoadExisting={() => navigateToMode(ReportViewMode.REPORT_SELECT_EXISTING_SIMULATION)}
+ onSelectDefaultBaseline={handleSelectDefaultBaseline}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.REPORT_SELECT_EXISTING_SIMULATION:
+ currentView = (
+ navigateToMode(ReportViewMode.REPORT_SETUP)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.REPORT_SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ // ========== SIMULATION-LEVEL VIEWS ==========
+ case ReportViewMode.SIMULATION_LABEL:
+ currentView = (
+ navigateToMode(ReportViewMode.SIMULATION_SETUP)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.SIMULATION_SETUP:
+ currentView = (
+ navigateToMode(ReportViewMode.SIMULATION_SUBMIT)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.SIMULATION_SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ // ========== POLICY SETUP COORDINATION ==========
+ case ReportViewMode.SETUP_POLICY:
+ currentView = (
+ navigateToMode(ReportViewMode.POLICY_LABEL)}
+ onLoadExisting={() => navigateToMode(ReportViewMode.SELECT_EXISTING_POLICY)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ // ========== POPULATION SETUP COORDINATION ==========
+ case ReportViewMode.SETUP_POPULATION:
+ currentView = (
+ navigateToMode(ReportViewMode.POPULATION_SCOPE)}
+ onLoadExisting={() => navigateToMode(ReportViewMode.SELECT_EXISTING_POPULATION)}
+ onCopyExisting={reportCallbacks.copyPopulationFromOtherSimulation}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ // ========== POLICY CREATION VIEWS ==========
+ case ReportViewMode.POLICY_LABEL:
+ currentView = (
+ navigateToMode(ReportViewMode.POLICY_PARAMETER_SELECTOR)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.POLICY_PARAMETER_SELECTOR:
+ currentView = (
+ navigateToMode(ReportViewMode.POLICY_SUBMIT)}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case ReportViewMode.POLICY_SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.SELECT_EXISTING_POLICY:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ // ========== POPULATION CREATION VIEWS ==========
+ case ReportViewMode.POPULATION_SCOPE:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ case ReportViewMode.POPULATION_LABEL:
+ currentView = (
+ {
+ // Navigate based on population type
+ if (activeSimulation.population.type === 'household') {
+ navigateToMode(ReportViewMode.POPULATION_HOUSEHOLD_BUILDER);
+ } else {
+ navigateToMode(ReportViewMode.POPULATION_GEOGRAPHIC_CONFIRM);
+ }
+ }}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case ReportViewMode.POPULATION_HOUSEHOLD_BUILDER:
+ currentView = (
+
+ );
+ break;
+
+ case ReportViewMode.POPULATION_GEOGRAPHIC_CONFIRM:
+ currentView = (
+
+ );
+ break;
+
+ case ReportViewMode.SELECT_EXISTING_POPULATION:
+ currentView = (
+ navigate(`/${countryId}/reports`)}
+ />
+ );
+ break;
+
+ default:
+ currentView = Unknown view mode: {currentMode}
;
+ }
+
+ // Conditionally wrap with StandardLayout
+ // 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 ? {wrappedView} : wrappedView;
+}
diff --git a/app/src/pathways/report/components/DefaultBaselineOption.tsx b/app/src/pathways/report/components/DefaultBaselineOption.tsx
new file mode 100644
index 00000000..fa40a43f
--- /dev/null
+++ b/app/src/pathways/report/components/DefaultBaselineOption.tsx
@@ -0,0 +1,57 @@
+/**
+ * DefaultBaselineOption - Option card for selecting default baseline simulation
+ *
+ * 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.
+ *
+ * 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 { IconChevronRight } from '@tabler/icons-react';
+import { Card, Group, Stack, Text } from '@mantine/core';
+import { spacing } from '@/designTokens';
+import { getDefaultBaselineLabel } from '@/utils/isDefaultBaselineSimulation';
+
+interface DefaultBaselineOptionProps {
+ countryId: string;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+export default function DefaultBaselineOption({
+ countryId,
+ isSelected,
+ onClick,
+}: DefaultBaselineOptionProps) {
+ const simulationLabel = getDefaultBaselineLabel(countryId);
+
+ return (
+
+
+
+ {simulationLabel}
+
+ Use current law with all households nationwide as baseline
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/policyParameterSelectorFrame/Main.tsx b/app/src/pathways/report/components/PolicyParameterSelectorMain.tsx
similarity index 68%
rename from app/src/components/policyParameterSelectorFrame/Main.tsx
rename to app/src/pathways/report/components/PolicyParameterSelectorMain.tsx
index 0546e036..ca6b5090 100644
--- a/app/src/components/policyParameterSelectorFrame/Main.tsx
+++ b/app/src/pathways/report/components/PolicyParameterSelectorMain.tsx
@@ -1,27 +1,29 @@
-import { useSelector } from 'react-redux';
+/**
+ * PolicyParameterSelectorMain - Props-based version of Main component
+ * Duplicated from components/policyParameterSelectorFrame/Main.tsx
+ * Manages parameter display and modification without Redux
+ */
+
import { Container, Text, Title } from '@mantine/core';
-import HistoricalValues from '@/components/policyParameterSelectorFrame/HistoricalValues';
-import ValueSetter from '@/components/policyParameterSelectorFrame/ValueSetter';
-import { selectActivePolicy } from '@/reducers/activeSelectors';
import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
+import { PolicyStateProps } from '@/types/pathwayState';
import { getParameterByName } from '@/types/subIngredients/parameter';
import { ValueIntervalCollection, ValuesList } from '@/types/subIngredients/valueInterval';
import { capitalize } from '@/utils/stringUtils';
-
-/* TODO:
-- Implement reset functionality
-- Implement a dropdown for selecting predefined values
-- Implement search feature
-*/
+import HistoricalValues from './policyParameterSelector/HistoricalValues';
+import PolicyParameterSelectorValueSetter from './PolicyParameterSelectorValueSetter';
interface PolicyParameterSelectorMainProps {
param: ParameterMetadata;
+ policy: PolicyStateProps;
+ onPolicyUpdate: (updatedPolicy: PolicyStateProps) => void;
}
-export default function PolicyParameterSelectorMain(props: PolicyParameterSelectorMainProps) {
- const { param } = props;
- const activePolicy = useSelector(selectActivePolicy);
-
+export default function PolicyParameterSelectorMain({
+ param,
+ policy,
+ onPolicyUpdate,
+}: PolicyParameterSelectorMainProps) {
const baseValues = new ValueIntervalCollection(param.values as ValuesList);
// Always start reform with a copy of base values (reform line matches current law initially)
@@ -30,12 +32,12 @@ export default function PolicyParameterSelectorMain(props: PolicyParameterSelect
let policyId = null;
// If a policy exists, get metadata and check for user-defined parameter values
- if (activePolicy) {
- policyLabel = activePolicy.label;
- policyId = activePolicy.id;
+ if (policy) {
+ policyLabel = policy.label;
+ policyId = policy.id;
// Check if this specific parameter has been modified by the user
- const paramToChart = getParameterByName(activePolicy, param.parameter);
+ const paramToChart = getParameterByName(policy, param.parameter);
if (paramToChart && paramToChart.values && paramToChart.values.length > 0) {
// Don't replace - instead, overlay user intervals on top of base values
const userIntervals = new ValueIntervalCollection(paramToChart.values as ValuesList);
@@ -62,7 +64,11 @@ export default function PolicyParameterSelectorMain(props: PolicyParameterSelect
{param.description}
>
)}
-
+
void;
+}
+
+export default function PolicyParameterSelectorValueSetter({
+ param,
+ policy,
+ onPolicyUpdate,
+}: PolicyParameterSelectorValueSetterProps) {
+ const [mode, setMode] = useState(ValueSetterMode.DEFAULT);
+
+ // Get date ranges from metadata using utility selector
+ const { minDate, maxDate } = useSelector(getDateRange);
+
+ const [intervals, setIntervals] = useState([]);
+
+ // Hoisted date state for all non-multi-year selectors
+ const [startDate, setStartDate] = useState('2025-01-01');
+ const [endDate, setEndDate] = useState('2025-12-31');
+
+ function resetValueSettingState() {
+ setIntervals([]);
+ }
+
+ function handleModeChange(newMode: ValueSetterMode) {
+ resetValueSettingState();
+ setMode(newMode);
+ }
+
+ function handleSubmit() {
+ // This mimics the Redux reducer's addPolicyParamAtPosition logic
+ // We need to update the policy's parameters array with new intervals
+
+ const updatedPolicy = { ...policy };
+
+ // Ensure parameters array exists
+ if (!updatedPolicy.parameters) {
+ updatedPolicy.parameters = [];
+ }
+
+ // Find existing parameter or create new one
+ let existingParam = getParameterByName(updatedPolicy, param.parameter);
+
+ if (!existingParam) {
+ // Create new parameter entry
+ existingParam = { name: param.parameter, values: [] };
+ updatedPolicy.parameters.push(existingParam);
+ }
+
+ // Use ValueIntervalCollection to properly merge intervals
+ const paramCollection = new ValueIntervalCollection(existingParam.values);
+
+ // Add each interval (collection handles overlaps/merging)
+ intervals.forEach((interval) => {
+ paramCollection.addInterval(interval);
+ });
+
+ // Get the final intervals and update the parameter
+ const newValues = paramCollection.getIntervals();
+ existingParam.values = newValues;
+
+ console.log('[PolicyParameterSelectorValueSetter] Updated policy:', updatedPolicy);
+ console.log('[PolicyParameterSelectorValueSetter] Parameter:', param.parameter);
+ console.log('[PolicyParameterSelectorValueSetter] New intervals:', newValues);
+
+ // Notify parent of policy update
+ onPolicyUpdate(updatedPolicy);
+
+ // Reset state after submission
+ resetValueSettingState();
+ }
+
+ const ValueSetterToRender = ValueSetterComponents[mode];
+
+ const valueSetterProps = {
+ minDate,
+ maxDate,
+ param,
+ policy,
+ intervals,
+ setIntervals,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+ };
+
+ return (
+
+
+ Current value
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/frames/population/UKGeographicOptions.tsx b/app/src/pathways/report/components/geographicOptions/UKGeographicOptions.tsx
similarity index 100%
rename from app/src/frames/population/UKGeographicOptions.tsx
rename to app/src/pathways/report/components/geographicOptions/UKGeographicOptions.tsx
diff --git a/app/src/frames/population/USGeographicOptions.tsx b/app/src/pathways/report/components/geographicOptions/USGeographicOptions.tsx
similarity index 100%
rename from app/src/frames/population/USGeographicOptions.tsx
rename to app/src/pathways/report/components/geographicOptions/USGeographicOptions.tsx
diff --git a/app/src/components/policyParameterSelectorFrame/HistoricalValues.tsx b/app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx
similarity index 100%
rename from app/src/components/policyParameterSelectorFrame/HistoricalValues.tsx
rename to app/src/pathways/report/components/policyParameterSelector/HistoricalValues.tsx
diff --git a/app/src/components/policyParameterSelectorFrame/MainEmpty.tsx b/app/src/pathways/report/components/policyParameterSelector/MainEmpty.tsx
similarity index 100%
rename from app/src/components/policyParameterSelectorFrame/MainEmpty.tsx
rename to app/src/pathways/report/components/policyParameterSelector/MainEmpty.tsx
diff --git a/app/src/components/policyParameterSelectorFrame/Menu.tsx b/app/src/pathways/report/components/policyParameterSelector/Menu.tsx
similarity index 94%
rename from app/src/components/policyParameterSelectorFrame/Menu.tsx
rename to app/src/pathways/report/components/policyParameterSelector/Menu.tsx
index d6da8062..9854837a 100644
--- a/app/src/components/policyParameterSelectorFrame/Menu.tsx
+++ b/app/src/pathways/report/components/policyParameterSelector/Menu.tsx
@@ -1,6 +1,6 @@
import { Box, Divider, ScrollArea, Stack, Text } from '@mantine/core';
+import NestedMenu from '@/components/common/NestedMenu';
import { ParameterTreeNode } from '@/types/metadata';
-import NestedMenu from '../common/NestedMenu';
interface PolicyParameterSelectorMenuProps {
setSelectedParamLabel: (param: string) => void;
diff --git a/app/src/pathways/report/components/valueSetters/DateValueSelector.tsx b/app/src/pathways/report/components/valueSetters/DateValueSelector.tsx
new file mode 100644
index 00000000..405d4bd5
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/DateValueSelector.tsx
@@ -0,0 +1,90 @@
+import dayjs from 'dayjs';
+import { useEffect, useState } from 'react';
+import { Group } from '@mantine/core';
+import { DatePickerInput } from '@mantine/dates';
+import { ValueInterval } from '@/types/subIngredients/valueInterval';
+import { fromISODateString, toISODateString } from '@/utils/dateUtils';
+import { getDefaultValueForParam } from './getDefaultValueForParam';
+import { ValueInputBox } from './ValueInputBox';
+import { ValueSetterProps } from './ValueSetterProps';
+
+export function DateValueSelector(props: ValueSetterProps) {
+ const {
+ param,
+ policy,
+ setIntervals,
+ minDate,
+ maxDate,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+ } = props;
+
+ // Local state for param value
+ const [paramValue, setParamValue] = useState(
+ getDefaultValueForParam(param, policy, startDate)
+ );
+
+ // Set endDate to end of year of startDate
+ useEffect(() => {
+ if (startDate) {
+ const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD');
+ setEndDate(endOfYearDate);
+ }
+ }, [startDate, setEndDate]);
+
+ // Update param value when startDate changes
+ useEffect(() => {
+ if (startDate) {
+ const newValue = getDefaultValueForParam(param, policy, startDate);
+ setParamValue(newValue);
+ }
+ }, [startDate, param, policy]);
+
+ // Update intervals whenever local state changes
+ useEffect(() => {
+ if (startDate && endDate) {
+ const newInterval: ValueInterval = {
+ startDate,
+ endDate,
+ value: paramValue,
+ };
+ setIntervals([newInterval]);
+ } else {
+ setIntervals([]);
+ }
+ }, [startDate, endDate, paramValue, setIntervals]);
+
+ function handleStartDateChange(value: Date | string | null) {
+ setStartDate(toISODateString(value));
+ }
+
+ function handleEndDateChange(value: Date | string | null) {
+ setEndDate(toISODateString(value));
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/DefaultValueSelector.tsx b/app/src/pathways/report/components/valueSetters/DefaultValueSelector.tsx
new file mode 100644
index 00000000..c01a459f
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/DefaultValueSelector.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+import { Box, Group, Text } from '@mantine/core';
+import { YearPickerInput } from '@mantine/dates';
+import { FOREVER } from '@/constants';
+import { ValueInterval } from '@/types/subIngredients/valueInterval';
+import { fromISODateString, toISODateString } from '@/utils/dateUtils';
+import { getDefaultValueForParam } from './getDefaultValueForParam';
+import { ValueInputBox } from './ValueInputBox';
+import { ValueSetterProps } from './ValueSetterProps';
+
+export function DefaultValueSelector(props: ValueSetterProps) {
+ const {
+ param,
+ policy,
+ setIntervals,
+ minDate,
+ maxDate,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+ } = props;
+
+ // Local state for param value
+ const [paramValue, setParamValue] = useState(
+ getDefaultValueForParam(param, policy, startDate)
+ );
+
+ // Set endDate to 2100-12-31 for default mode
+ useEffect(() => {
+ setEndDate(FOREVER);
+ }, [setEndDate]);
+
+ // Update param value when startDate changes
+ useEffect(() => {
+ if (startDate) {
+ const newValue = getDefaultValueForParam(param, policy, startDate);
+ setParamValue(newValue);
+ }
+ }, [startDate, param, policy]);
+
+ // Update intervals whenever local state changes
+ useEffect(() => {
+ if (startDate && endDate) {
+ const newInterval: ValueInterval = {
+ startDate,
+ endDate,
+ value: paramValue,
+ };
+ setIntervals([newInterval]);
+ } else {
+ setIntervals([]);
+ }
+ }, [startDate, endDate, paramValue, setIntervals]);
+
+ function handleStartDateChange(value: Date | string | null) {
+ setStartDate(toISODateString(value));
+ }
+
+ return (
+
+
+
+
+ onward:
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/ModeSelectorButton.tsx b/app/src/pathways/report/components/valueSetters/ModeSelectorButton.tsx
new file mode 100644
index 00000000..c16407e7
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/ModeSelectorButton.tsx
@@ -0,0 +1,30 @@
+import { IconSettings } from '@tabler/icons-react';
+import { ActionIcon, Menu } from '@mantine/core';
+
+enum ValueSetterMode {
+ DEFAULT = 'default',
+ YEARLY = 'yearly',
+ DATE = 'date',
+ MULTI_YEAR = 'multi-year',
+}
+
+export { ValueSetterMode };
+
+export function ModeSelectorButton(props: { setMode: (mode: ValueSetterMode) => void }) {
+ const { setMode } = props;
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/MultiYearValueSelector.tsx b/app/src/pathways/report/components/valueSetters/MultiYearValueSelector.tsx
new file mode 100644
index 00000000..3ea2af81
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/MultiYearValueSelector.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { Box, Group, SimpleGrid, Stack, Text } from '@mantine/core';
+import { getTaxYears } from '@/libs/metadataUtils';
+import { RootState } from '@/store';
+import { ValueInterval } from '@/types/subIngredients/valueInterval';
+import { getDefaultValueForParam } from './getDefaultValueForParam';
+import { ValueInputBox } from './ValueInputBox';
+import { ValueSetterProps } from './ValueSetterProps';
+
+export function MultiYearValueSelector(props: ValueSetterProps) {
+ const { param, policy, setIntervals } = props;
+
+ // Get available years from metadata
+ const availableYears = useSelector(getTaxYears);
+ const countryId = useSelector((state: RootState) => state.metadata.currentCountry);
+
+ // Country-specific max years configuration
+ const MAX_YEARS_BY_COUNTRY: Record = {
+ us: 10,
+ uk: 5,
+ };
+
+ // Generate years from metadata, starting from current year
+ const generateYears = () => {
+ const currentYear = new Date().getFullYear();
+ const maxYears = MAX_YEARS_BY_COUNTRY[countryId || 'us'] || 10;
+
+ // Filter available years from metadata to only include current year onwards
+ const futureYears = availableYears
+ .map((option) => parseInt(option.value, 10))
+ .filter((year) => year >= currentYear)
+ .sort((a, b) => a - b);
+
+ // Take only the configured max years for this country
+ return futureYears.slice(0, maxYears);
+ };
+
+ const years = generateYears();
+
+ // Get values for each year - check reform first, then baseline
+ const getInitialYearValues = useMemo(() => {
+ const initialValues: Record = {};
+ years.forEach((year) => {
+ initialValues[year] = getDefaultValueForParam(param, policy, `${year}-01-01`);
+ });
+ return initialValues;
+ }, [param, policy]);
+
+ const [yearValues, setYearValues] = useState>(getInitialYearValues);
+
+ // Update intervals whenever yearValues changes
+ useEffect(() => {
+ const newIntervals: ValueInterval[] = Object.keys(yearValues).map((year: string) => ({
+ startDate: `${year}-01-01`,
+ endDate: `${year}-12-31`,
+ value: yearValues[year],
+ }));
+
+ setIntervals(newIntervals);
+ }, [yearValues, setIntervals]);
+
+ const handleYearValueChange = (year: number, value: any) => {
+ setYearValues((prev) => ({
+ ...prev,
+ [year]: value,
+ }));
+ };
+
+ // Split years into two columns
+ const midpoint = Math.ceil(years.length / 2);
+ const leftColumn = years.slice(0, midpoint);
+ const rightColumn = years.slice(midpoint);
+
+ return (
+
+
+
+ {leftColumn.map((year) => (
+
+
+ {year}
+
+ handleYearValueChange(year, value)}
+ />
+
+ ))}
+
+
+ {rightColumn.map((year) => (
+
+
+ {year}
+
+ handleYearValueChange(year, value)}
+ />
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/ValueInputBox.tsx b/app/src/pathways/report/components/valueSetters/ValueInputBox.tsx
new file mode 100644
index 00000000..40bcad0e
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/ValueInputBox.tsx
@@ -0,0 +1,99 @@
+import { Group, NumberInput, Stack, Switch, Text } from '@mantine/core';
+import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
+
+interface ValueInputBoxProps {
+ label?: string;
+ param: ParameterMetadata;
+ value?: any;
+ onChange?: (value: any) => void;
+}
+
+export function ValueInputBox(props: ValueInputBoxProps) {
+ const { param, value, onChange, label } = props;
+
+ // US and UK packages use these type designations inconsistently
+ const USD_UNITS = ['currency-USD', 'currency_USD', 'USD'];
+ const GBP_UNITS = ['currency-GBP', 'currency_GBP', 'GBP'];
+
+ const prefix = USD_UNITS.includes(String(param.unit))
+ ? '$'
+ : GBP_UNITS.includes(String(param.unit))
+ ? '£'
+ : '';
+
+ const isPercentage = param.unit === '/1';
+ const isBool = param.unit === 'bool';
+
+ if (param.type !== 'parameter') {
+ console.error("ValueInputBox expects a parameter type of 'parameter', got:", param.type);
+ return ;
+ }
+
+ const handleChange = (newValue: any) => {
+ if (onChange) {
+ // Convert percentage display value (0-100) to decimal (0-1) for storage
+ const valueToStore = isPercentage ? newValue / 100 : newValue;
+ onChange(valueToStore);
+ }
+ };
+
+ const handleBoolChange = (checked: boolean) => {
+ if (onChange) {
+ onChange(checked);
+ }
+ };
+
+ // Convert decimal value (0-1) to percentage display value (0-100)
+ // Defensive: ensure value is a number, not an object/array/string
+ const numericValue = typeof value === 'number' ? value : 0;
+ const displayValue = isPercentage ? numericValue * 100 : numericValue;
+
+ if (isBool) {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ False
+
+ handleBoolChange(event.currentTarget.checked)}
+ size="md"
+ />
+
+ True
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/ValueSetterProps.ts b/app/src/pathways/report/components/valueSetters/ValueSetterProps.ts
new file mode 100644
index 00000000..50d96a96
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/ValueSetterProps.ts
@@ -0,0 +1,17 @@
+import { Dispatch, SetStateAction } from 'react';
+import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
+import { PolicyStateProps } from '@/types/pathwayState';
+import { ValueInterval } from '@/types/subIngredients/valueInterval';
+
+export interface ValueSetterProps {
+ minDate: string;
+ maxDate: string;
+ param: ParameterMetadata;
+ policy: PolicyStateProps;
+ intervals: ValueInterval[];
+ setIntervals: Dispatch>;
+ startDate: string;
+ setStartDate: Dispatch>;
+ endDate: string;
+ setEndDate: Dispatch>;
+}
diff --git a/app/src/pathways/report/components/valueSetters/YearlyValueSelector.tsx b/app/src/pathways/report/components/valueSetters/YearlyValueSelector.tsx
new file mode 100644
index 00000000..e800a622
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/YearlyValueSelector.tsx
@@ -0,0 +1,96 @@
+import dayjs from 'dayjs';
+import { useEffect, useState } from 'react';
+import { Group } from '@mantine/core';
+import { YearPickerInput } from '@mantine/dates';
+import { ValueInterval } from '@/types/subIngredients/valueInterval';
+import { fromISODateString, toISODateString } from '@/utils/dateUtils';
+import { getDefaultValueForParam } from './getDefaultValueForParam';
+import { ValueInputBox } from './ValueInputBox';
+import { ValueSetterProps } from './ValueSetterProps';
+
+export function YearlyValueSelector(props: ValueSetterProps) {
+ const {
+ param,
+ policy,
+ setIntervals,
+ minDate,
+ maxDate,
+ startDate,
+ setStartDate,
+ endDate,
+ setEndDate,
+ } = props;
+
+ // Local state for param value
+ const [paramValue, setParamValue] = useState(
+ getDefaultValueForParam(param, policy, startDate)
+ );
+
+ // Set endDate to end of year of startDate
+ useEffect(() => {
+ if (startDate) {
+ const endOfYearDate = dayjs(startDate).endOf('year').format('YYYY-MM-DD');
+ setEndDate(endOfYearDate);
+ }
+ }, [startDate, setEndDate]);
+
+ // Update param value when startDate changes
+ useEffect(() => {
+ if (startDate) {
+ const newValue = getDefaultValueForParam(param, policy, startDate);
+ setParamValue(newValue);
+ }
+ }, [startDate, param, policy]);
+
+ // Update intervals whenever local state changes
+ useEffect(() => {
+ if (startDate && endDate) {
+ const newInterval: ValueInterval = {
+ startDate,
+ endDate,
+ value: paramValue,
+ };
+ setIntervals([newInterval]);
+ } else {
+ setIntervals([]);
+ }
+ }, [startDate, endDate, paramValue, setIntervals]);
+
+ function handleStartDateChange(value: Date | string | null) {
+ setStartDate(toISODateString(value));
+ }
+
+ function handleEndDateChange(value: Date | string | null) {
+ const isoString = toISODateString(value);
+ if (isoString) {
+ const endOfYearDate = dayjs(isoString).endOf('year').format('YYYY-MM-DD');
+ setEndDate(endOfYearDate);
+ } else {
+ setEndDate('');
+ }
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/src/pathways/report/components/valueSetters/getDefaultValueForParam.ts b/app/src/pathways/report/components/valueSetters/getDefaultValueForParam.ts
new file mode 100644
index 00000000..fadf0632
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/getDefaultValueForParam.ts
@@ -0,0 +1,38 @@
+import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
+import { PolicyStateProps } from '@/types/pathwayState';
+import { getParameterByName } from '@/types/subIngredients/parameter';
+import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval';
+
+/**
+ * Helper function to get default value for a parameter at a specific date
+ * Priority: 1) User's reform value, 2) Baseline current law value
+ */
+export function getDefaultValueForParam(
+ param: ParameterMetadata,
+ policy: PolicyStateProps | null,
+ date: string
+): any {
+ // First check if user has set a reform value for this parameter
+ if (policy) {
+ const userParam = getParameterByName(policy, param.parameter);
+ if (userParam && userParam.values && userParam.values.length > 0) {
+ const userCollection = new ValueIntervalCollection(userParam.values);
+ const userValue = userCollection.getValueAtDate(date);
+ if (userValue !== undefined) {
+ return userValue;
+ }
+ }
+ }
+
+ // Fall back to baseline current law value from metadata
+ if (param.values) {
+ const collection = new ValueIntervalCollection(param.values as any);
+ const value = collection.getValueAtDate(date);
+ if (value !== undefined) {
+ return value;
+ }
+ }
+
+ // Last resort: default based on unit type
+ return param.unit === 'bool' ? false : 0;
+}
diff --git a/app/src/pathways/report/components/valueSetters/index.ts b/app/src/pathways/report/components/valueSetters/index.ts
new file mode 100644
index 00000000..054e28ff
--- /dev/null
+++ b/app/src/pathways/report/components/valueSetters/index.ts
@@ -0,0 +1,21 @@
+import { DateValueSelector } from './DateValueSelector';
+import { DefaultValueSelector } from './DefaultValueSelector';
+import { ValueSetterMode } from './ModeSelectorButton';
+import { MultiYearValueSelector } from './MultiYearValueSelector';
+import { YearlyValueSelector } from './YearlyValueSelector';
+
+export { ModeSelectorButton, ValueSetterMode } from './ModeSelectorButton';
+export { getDefaultValueForParam } from './getDefaultValueForParam';
+export { ValueInputBox } from './ValueInputBox';
+export { DefaultValueSelector } from './DefaultValueSelector';
+export { YearlyValueSelector } from './YearlyValueSelector';
+export { DateValueSelector } from './DateValueSelector';
+export { MultiYearValueSelector } from './MultiYearValueSelector';
+export type { ValueSetterProps } from './ValueSetterProps';
+
+export const ValueSetterComponents = {
+ [ValueSetterMode.DEFAULT]: DefaultValueSelector,
+ [ValueSetterMode.YEARLY]: YearlyValueSelector,
+ [ValueSetterMode.DATE]: DateValueSelector,
+ [ValueSetterMode.MULTI_YEAR]: MultiYearValueSelector,
+} as const;
diff --git a/app/src/pathways/report/views/ReportLabelView.tsx b/app/src/pathways/report/views/ReportLabelView.tsx
new file mode 100644
index 00000000..02ece3fb
--- /dev/null
+++ b/app/src/pathways/report/views/ReportLabelView.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { Select, TextInput } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { CURRENT_YEAR } from '@/constants';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { getTaxYears } from '@/libs/metadataUtils';
+
+interface ReportLabelViewProps {
+ label: string | null;
+ year: string | null;
+ onUpdateLabel: (label: string) => void;
+ onUpdateYear: (year: string) => void;
+ onNext: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function ReportLabelView({
+ label,
+ year,
+ onUpdateLabel,
+ onUpdateYear,
+ onNext,
+ onBack,
+ onCancel,
+}: ReportLabelViewProps) {
+ console.log('[ReportLabelView] ========== COMPONENT RENDER ==========');
+ const countryId = useCurrentCountry();
+ const [localLabel, setLocalLabel] = useState(label || '');
+ const [localYear, setLocalYear] = useState(year || CURRENT_YEAR);
+
+ // Get available years from metadata
+ const availableYears = useSelector(getTaxYears);
+
+ // Use British spelling for UK
+ const initializeText = countryId === 'uk' ? 'Initialise' : 'Initialize';
+
+ function handleLocalLabelChange(value: string) {
+ setLocalLabel(value);
+ }
+
+ function handleYearChange(value: string | null) {
+ const newYear = value || CURRENT_YEAR;
+ console.log('[ReportLabelView] Year changed to:', newYear);
+ setLocalYear(newYear);
+ }
+
+ function submissionHandler() {
+ console.log('[ReportLabelView] Submit clicked - label:', localLabel, 'year:', localYear);
+ onUpdateLabel(localLabel);
+ onUpdateYear(localYear);
+ console.log('[ReportLabelView] Navigating to next');
+ onNext();
+ }
+
+ const formInputs = (
+ <>
+ handleLocalLabelChange(e.currentTarget.value)}
+ />
+
+ >
+ );
+
+ const primaryAction = {
+ label: `${initializeText} report`,
+ onClick: submissionHandler,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/ReportSetupView.tsx b/app/src/pathways/report/views/ReportSetupView.tsx
new file mode 100644
index 00000000..f8f53084
--- /dev/null
+++ b/app/src/pathways/report/views/ReportSetupView.tsx
@@ -0,0 +1,247 @@
+import { useState } from 'react';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState';
+import { isSimulationConfigured } from '@/utils/validation/ingredientValidation';
+
+type SimulationCard = 'simulation1' | 'simulation2';
+
+interface ReportSetupViewProps {
+ reportState: ReportStateProps;
+ onNavigateToSimulationSelection: (simulationIndex: 0 | 1) => void;
+ onNext: () => void;
+ onPrefillPopulation2: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function ReportSetupView({
+ reportState,
+ onNavigateToSimulationSelection,
+ onNext,
+ onPrefillPopulation2,
+ onBack,
+ onCancel,
+}: ReportSetupViewProps) {
+ const [selectedCard, setSelectedCard] = useState(null);
+
+ // Get simulation state from report
+ const simulation1 = reportState.simulations[0];
+ const simulation2 = reportState.simulations[1];
+
+ // Fetch population data for pre-filling simulation 2
+ const userId = MOCK_USER_ID.toString();
+ const { data: householdData } = useUserHouseholds(userId);
+ const { data: geographicData } = useUserGeographics(userId);
+
+ // Check if simulations are fully configured
+ const simulation1Configured = isSimulationConfigured(simulation1);
+ const simulation2Configured = isSimulationConfigured(simulation2);
+
+ // Check if population data is loaded (needed for simulation2 prefill)
+ const isPopulationDataLoaded = householdData !== undefined && geographicData !== undefined;
+
+ // Determine if simulation2 is optional based on population type of simulation1
+ const isHouseholdReport = simulation1?.population.type === 'household';
+ const isSimulation2Optional = simulation1Configured && isHouseholdReport;
+
+ const handleSimulation1Select = () => {
+ setSelectedCard('simulation1');
+ console.log('Adding simulation 1');
+ };
+
+ const handleSimulation2Select = () => {
+ setSelectedCard('simulation2');
+ console.log('Adding simulation 2');
+ };
+
+ const handleNext = () => {
+ if (selectedCard === 'simulation1') {
+ console.log('Setting up simulation 1');
+ onNavigateToSimulationSelection(0);
+ } else if (selectedCard === 'simulation2') {
+ console.log('Setting up simulation 2');
+ // PRE-FILL POPULATION FROM SIMULATION 1
+ onPrefillPopulation2();
+ onNavigateToSimulationSelection(1);
+ } else if (canProceed) {
+ console.log('Both simulations configured, proceeding to next step');
+ onNext();
+ }
+ };
+
+ const setupConditionCards = [
+ {
+ title: getBaselineCardTitle(simulation1, simulation1Configured),
+ description: getBaselineCardDescription(simulation1, simulation1Configured),
+ onClick: handleSimulation1Select,
+ isSelected: selectedCard === 'simulation1',
+ isFulfilled: simulation1Configured,
+ isDisabled: false,
+ },
+ {
+ title: getComparisonCardTitle(
+ simulation2,
+ simulation2Configured,
+ simulation1Configured,
+ isSimulation2Optional
+ ),
+ description: getComparisonCardDescription(
+ simulation2,
+ simulation2Configured,
+ simulation1Configured,
+ isSimulation2Optional,
+ !isPopulationDataLoaded
+ ),
+ onClick: handleSimulation2Select,
+ isSelected: selectedCard === 'simulation2',
+ isFulfilled: simulation2Configured,
+ isDisabled: !simulation1Configured, // Disable until simulation1 is configured
+ },
+ ];
+
+ // Determine if we can proceed to submission
+ const canProceed: boolean =
+ simulation1Configured && (isSimulation2Optional || simulation2Configured);
+
+ // Determine the primary action label and state
+ const getPrimaryAction = () => {
+ // Allow setting up simulation1 if selected and not configured
+ if (selectedCard === 'simulation1' && !simulation1Configured) {
+ return {
+ label: 'Configure baseline simulation ',
+ onClick: handleNext,
+ isDisabled: false,
+ };
+ }
+ // Allow setting up simulation2 if selected and not configured
+ else if (selectedCard === 'simulation2' && !simulation2Configured) {
+ return {
+ label: 'Configure comparison simulation ',
+ onClick: handleNext,
+ isDisabled: !isPopulationDataLoaded, // Disable if data not loaded
+ };
+ }
+ // Allow proceeding if requirements met
+ else if (canProceed) {
+ return {
+ label: 'Review report ',
+ onClick: handleNext,
+ isDisabled: false,
+ };
+ }
+ // Disable if requirements not met - show uppermost option (baseline)
+ return {
+ label: 'Configure baseline simulation ',
+ onClick: handleNext,
+ isDisabled: true,
+ };
+ };
+
+ const primaryAction = getPrimaryAction();
+
+ return (
+
+ );
+}
+
+/**
+ * Get title for baseline simulation card
+ */
+function getBaselineCardTitle(
+ simulation: SimulationStateProps | null,
+ isConfigured: boolean
+): string {
+ if (isConfigured) {
+ const label = simulation?.label || simulation?.id || 'Configured';
+ return `Baseline: ${label}`;
+ }
+ return 'Baseline simulation';
+}
+
+/**
+ * Get description for baseline simulation card
+ */
+function getBaselineCardDescription(
+ simulation: SimulationStateProps | null,
+ isConfigured: boolean
+): string {
+ if (isConfigured) {
+ const policyId = simulation?.policy.id || 'N/A';
+ const populationId =
+ simulation?.population.household?.id || simulation?.population.geography?.id || 'N/A';
+ return `Policy #${policyId} • Household(s) #${populationId}`;
+ }
+ return 'Select your baseline simulation';
+}
+
+/**
+ * Get title for comparison simulation card
+ */
+function getComparisonCardTitle(
+ simulation: SimulationStateProps | null,
+ isConfigured: boolean,
+ baselineConfigured: boolean,
+ isOptional: boolean
+): string {
+ // If configured, show simulation name
+ if (isConfigured) {
+ const label = simulation?.label || simulation?.id || 'Configured';
+ return `Comparison: ${label}`;
+ }
+
+ // If baseline not configured yet, show waiting message
+ if (!baselineConfigured) {
+ return 'Comparison simulation · Waiting for baseline';
+ }
+
+ // Baseline configured: show optional or required
+ if (isOptional) {
+ return 'Comparison simulation (optional)';
+ }
+ return 'Comparison simulation';
+}
+
+/**
+ * Get description for comparison simulation card
+ */
+function getComparisonCardDescription(
+ simulation: SimulationStateProps | null,
+ isConfigured: boolean,
+ baselineConfigured: boolean,
+ isOptional: boolean,
+ dataLoading: boolean
+): string {
+ // If configured, show simulation details
+ if (isConfigured) {
+ const policyId = simulation?.policy.id || 'N/A';
+ const populationId =
+ simulation?.population.household?.id || simulation?.population.geography?.id || 'N/A';
+ return `Policy #${policyId} • Household(s) #${populationId}`;
+ }
+
+ // If baseline not configured yet, show waiting message
+ if (!baselineConfigured) {
+ return 'Set up your baseline simulation first';
+ }
+
+ // If baseline configured but data still loading, show loading message
+ if (dataLoading && baselineConfigured && !isConfigured) {
+ return 'Loading household data...';
+ }
+
+ // Baseline configured: show optional or required message
+ if (isOptional) {
+ return 'Optional: add a second simulation to compare';
+ }
+ return 'Required: add a second simulation to compare';
+}
diff --git a/app/src/pathways/report/views/ReportSimulationExistingView.tsx b/app/src/pathways/report/views/ReportSimulationExistingView.tsx
new file mode 100644
index 00000000..55b77e2e
--- /dev/null
+++ b/app/src/pathways/report/views/ReportSimulationExistingView.tsx
@@ -0,0 +1,197 @@
+import { useState } from 'react';
+import { Text } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import { EnhancedUserSimulation, useUserSimulations } from '@/hooks/useUserSimulations';
+import { SimulationStateProps } from '@/types/pathwayState';
+import { arePopulationsCompatible } from '@/utils/populationCompatibility';
+
+interface ReportSimulationExistingViewProps {
+ activeSimulationIndex: 0 | 1;
+ otherSimulation: SimulationStateProps | null;
+ onSelectSimulation: (enhancedSimulation: EnhancedUserSimulation) => void;
+ onNext: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function ReportSimulationExistingView({
+ activeSimulationIndex: _activeSimulationIndex,
+ otherSimulation,
+ onSelectSimulation,
+ onNext,
+ onBack,
+ onCancel,
+}: ReportSimulationExistingViewProps) {
+ const userId = MOCK_USER_ID.toString();
+
+ const { data, isLoading, isError, error } = useUserSimulations(userId);
+ const [localSimulation, setLocalSimulation] = useState(null);
+
+ console.log('[ReportSimulationExistingView] ========== DATA FETCH ==========');
+ console.log('[ReportSimulationExistingView] Raw data:', data);
+ console.log('[ReportSimulationExistingView] Raw data length:', data?.length);
+ console.log('[ReportSimulationExistingView] isLoading:', isLoading);
+ console.log('[ReportSimulationExistingView] isError:', isError);
+ console.log('[ReportSimulationExistingView] Error:', error);
+
+ function canProceed() {
+ if (!localSimulation) {
+ return false;
+ }
+ return localSimulation.simulation?.id !== null && localSimulation.simulation?.id !== undefined;
+ }
+
+ function handleSimulationSelect(enhancedSimulation: EnhancedUserSimulation) {
+ if (!enhancedSimulation) {
+ return;
+ }
+
+ setLocalSimulation(enhancedSimulation);
+ }
+
+ function handleSubmit() {
+ if (!localSimulation || !localSimulation.simulation) {
+ return;
+ }
+
+ console.log('Submitting Simulation in handleSubmit:', localSimulation);
+
+ onSelectSimulation(localSimulation);
+ onNext();
+ }
+
+ const userSimulations = data || [];
+
+ console.log('[ReportSimulationExistingView] ========== BEFORE FILTERING ==========');
+ console.log('[ReportSimulationExistingView] User simulations count:', userSimulations.length);
+ console.log('[ReportSimulationExistingView] User simulations:', userSimulations);
+
+ if (isLoading) {
+ return (
+ Loading simulations...}
+ buttonPreset="none"
+ />
+ );
+ }
+
+ if (isError) {
+ return (
+ Error: {(error as Error)?.message || 'Something went wrong.'}}
+ buttonPreset="none"
+ />
+ );
+ }
+
+ if (userSimulations.length === 0) {
+ return (
+ No simulations available. Please create a new simulation.}
+ primaryAction={{
+ label: 'Next',
+ onClick: () => {},
+ isDisabled: true,
+ }}
+ backAction={onBack ? { onClick: onBack } : undefined}
+ cancelAction={onCancel ? { onClick: onCancel } : undefined}
+ />
+ );
+ }
+
+ // Filter simulations with loaded data
+ const filteredSimulations = userSimulations.filter((enhancedSim) => enhancedSim.simulation?.id);
+
+ console.log('[ReportSimulationExistingView] ========== AFTER FILTERING ==========');
+ console.log(
+ '[ReportSimulationExistingView] Filtered simulations count:',
+ filteredSimulations.length
+ );
+
+ // Get other simulation's population ID (base ingredient ID) for compatibility check
+ // For household populations, use household.id
+ // For geography populations, use geography.geographyId (the base geography identifier)
+ const otherPopulationId =
+ otherSimulation?.population.household?.id ||
+ otherSimulation?.population.geography?.geographyId ||
+ otherSimulation?.population.geography?.id;
+
+ // Sort simulations to show compatible first, then incompatible
+ const sortedSimulations = [...filteredSimulations].sort((a, b) => {
+ const aCompatible = arePopulationsCompatible(otherPopulationId, a.simulation!.populationId);
+ const bCompatible = arePopulationsCompatible(otherPopulationId, b.simulation!.populationId);
+
+ return bCompatible === aCompatible ? 0 : aCompatible ? -1 : 1;
+ });
+
+ console.log('[ReportSimulationExistingView] ========== AFTER SORTING ==========');
+ console.log('[ReportSimulationExistingView] Sorted simulations count:', sortedSimulations.length);
+
+ // Build card list items from sorted simulations
+ const simulationCardItems = sortedSimulations.map((enhancedSim) => {
+ const simulation = enhancedSim.simulation!;
+
+ // Check compatibility with other simulation
+ const isCompatible = arePopulationsCompatible(otherPopulationId, simulation.populationId);
+
+ let title = '';
+ let subtitle = '';
+
+ if (enhancedSim.userSimulation?.label) {
+ title = enhancedSim.userSimulation.label;
+ subtitle = `Simulation #${simulation.id}`;
+ } else {
+ title = `Simulation #${simulation.id}`;
+ }
+
+ // Add policy and population info to subtitle if available
+ const policyLabel =
+ enhancedSim.userPolicy?.label || enhancedSim.policy?.label || enhancedSim.policy?.id;
+ const populationLabel =
+ enhancedSim.userHousehold?.label || enhancedSim.geography?.name || simulation.populationId;
+
+ if (policyLabel && populationLabel) {
+ subtitle = subtitle
+ ? `${subtitle} • Policy: ${policyLabel} • Population: ${populationLabel}`
+ : `Policy: ${policyLabel} • Population: ${populationLabel}`;
+ }
+
+ // If incompatible, add explanation to subtitle
+ if (!isCompatible) {
+ subtitle = subtitle
+ ? `${subtitle} • Incompatible: different population than configured simulation`
+ : 'Incompatible: different population than configured simulation';
+ }
+
+ return {
+ id: enhancedSim.userSimulation?.id?.toString() || simulation.id, // Use user simulation association ID for unique key
+ title,
+ subtitle,
+ onClick: () => handleSimulationSelect(enhancedSim),
+ isSelected: localSimulation?.simulation?.id === simulation.id,
+ isDisabled: !isCompatible,
+ };
+ });
+
+ const primaryAction = {
+ label: 'Next ',
+ onClick: handleSubmit,
+ isDisabled: !canProceed(),
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/ReportSimulationSelectionView.tsx b/app/src/pathways/report/views/ReportSimulationSelectionView.tsx
new file mode 100644
index 00000000..4623746f
--- /dev/null
+++ b/app/src/pathways/report/views/ReportSimulationSelectionView.tsx
@@ -0,0 +1,308 @@
+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 { 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 {
+ simulationIndex: 0 | 1;
+ countryId: string;
+ currentLawId: number;
+ onCreateNew: () => void;
+ onLoadExisting: () => void;
+ onSelectDefaultBaseline?: (simulationState: SimulationStateProps, simulationId: string) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function ReportSimulationSelectionView({
+ simulationIndex,
+ countryId,
+ currentLawId,
+ onCreateNew,
+ onLoadExisting,
+ onSelectDefaultBaseline,
+ onBack,
+ onCancel,
+}: ReportSimulationSelectionViewProps) {
+ const userId = MOCK_USER_ID.toString();
+ const { data: userSimulations } = useUserSimulations(userId);
+ 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;
+
+ function handleClickCreateNew() {
+ setSelectedAction('createNew');
+ }
+
+ function handleClickExisting() {
+ if (hasExistingSimulations) {
+ setSelectedAction('loadExisting');
+ }
+ }
+
+ 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);
+ }
+ }
+
+ 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();
+ }
+ }
+ }
+
+ const buttonPanelCards = [
+ // Only show "Load existing" if user has existing simulations
+ ...(hasExistingSimulations
+ ? [
+ {
+ title: 'Load existing simulation',
+ description: 'Use a simulation you have already created',
+ onClick: handleClickExisting,
+ isSelected: selectedAction === 'loadExisting',
+ },
+ ]
+ : []),
+ {
+ title: 'Create new simulation',
+ description: 'Build a new simulation',
+ onClick: handleClickCreateNew,
+ isSelected: selectedAction === 'createNew',
+ },
+ ];
+
+ const hasExistingBaselineText = existingBaseline && existingSimulationId;
+
+ const primaryAction = {
+ label: isCreatingBaseline
+ ? hasExistingBaselineText
+ ? 'Applying simulation...'
+ : 'Creating simulation...'
+ : 'Next',
+ onClick: handleClickSubmit,
+ isLoading: isCreatingBaseline,
+ isDisabled: !selectedAction || isCreatingBaseline,
+ };
+
+ // For baseline simulation, combine default baseline option with other cards
+ if (isBaseline) {
+ return (
+
+
+
+
+ }
+ primaryAction={primaryAction}
+ backAction={onBack ? { onClick: onBack } : undefined}
+ cancelAction={onCancel ? { onClick: onCancel } : undefined}
+ />
+ );
+ }
+
+ // For reform simulation, just show the standard button panel
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/ReportSubmitView.tsx b/app/src/pathways/report/views/ReportSubmitView.tsx
new file mode 100644
index 00000000..f40d7b32
--- /dev/null
+++ b/app/src/pathways/report/views/ReportSubmitView.tsx
@@ -0,0 +1,82 @@
+import IngredientSubmissionView, { SummaryBoxItem } from '@/components/IngredientSubmissionView';
+import { ReportStateProps } from '@/types/pathwayState';
+
+interface ReportSubmitViewProps {
+ reportState: ReportStateProps;
+ onSubmit: () => void;
+ isSubmitting: boolean;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function ReportSubmitView({
+ reportState,
+ onSubmit,
+ isSubmitting,
+ onBack,
+ onCancel,
+}: ReportSubmitViewProps) {
+ console.log('[ReportSubmitView] ========== COMPONENT RENDER ==========');
+
+ const simulation1 = reportState.simulations[0];
+ const simulation2 = reportState.simulations[1];
+
+ // Helper to get badge text for a simulation
+ const getSimulationBadge = (simulation: typeof simulation1) => {
+ if (!simulation) {
+ return undefined;
+ }
+
+ // Get policy label - use label if available, otherwise fall back to ID
+ const policyLabel = simulation.policy.label || `Policy #${simulation.policy.id}`;
+
+ // Get population label - use label if available, otherwise fall back to ID
+ const populationLabel =
+ simulation.population.label ||
+ `Population #${simulation.population.household?.id || simulation.population.geography?.id}`;
+
+ return `${policyLabel} • ${populationLabel}`;
+ };
+
+ // Check if simulation is configured (has either ID or configured ingredients)
+ const isSimulation1Configured =
+ !!simulation1?.id ||
+ (!!simulation1?.policy?.id &&
+ !!(simulation1?.population?.household?.id || simulation1?.population?.geography?.id));
+ const isSimulation2Configured =
+ !!simulation2?.id ||
+ (!!simulation2?.policy?.id &&
+ !!(simulation2?.population?.household?.id || simulation2?.population?.geography?.id));
+
+ // Create summary boxes based on the simulations
+ const summaryBoxes: SummaryBoxItem[] = [
+ {
+ title: 'Baseline simulation',
+ description:
+ simulation1?.label || (simulation1?.id ? `Simulation #${simulation1.id}` : 'No simulation'),
+ isFulfilled: isSimulation1Configured,
+ badge: isSimulation1Configured ? getSimulationBadge(simulation1) : undefined,
+ },
+ {
+ title: 'Comparison simulation',
+ description:
+ simulation2?.label || (simulation2?.id ? `Simulation #${simulation2.id}` : 'No simulation'),
+ isFulfilled: isSimulation2Configured,
+ isDisabled: !isSimulation2Configured,
+ badge: isSimulation2Configured ? getSimulationBadge(simulation2) : undefined,
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/policy/PolicyExistingView.tsx b/app/src/pathways/report/views/policy/PolicyExistingView.tsx
new file mode 100644
index 00000000..95a593e7
--- /dev/null
+++ b/app/src/pathways/report/views/policy/PolicyExistingView.tsx
@@ -0,0 +1,214 @@
+/**
+ * PolicyExistingView - View for selecting existing policy
+ * Duplicated from SimulationSelectExistingPolicyFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import { Text } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import {
+ isPolicyMetadataWithAssociation,
+ UserPolicyMetadataWithAssociation,
+ useUserPolicies,
+} from '@/hooks/useUserPolicy';
+import { Parameter } from '@/types/subIngredients/parameter';
+
+interface PolicyExistingViewProps {
+ onSelectPolicy: (policyId: string, label: string, parameters: Parameter[]) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function PolicyExistingView({
+ onSelectPolicy,
+ onBack,
+ onCancel,
+}: PolicyExistingViewProps) {
+ const userId = MOCK_USER_ID.toString();
+
+ const { data, isLoading, isError, error } = useUserPolicies(userId);
+ const [localPolicy, setLocalPolicy] = useState(null);
+
+ console.log('[PolicyExistingView] ========== DATA FETCH ==========');
+ console.log('[PolicyExistingView] Raw data:', data);
+ console.log('[PolicyExistingView] Raw data length:', data?.length);
+ console.log('[PolicyExistingView] isLoading:', isLoading);
+ console.log('[PolicyExistingView] isError:', isError);
+ console.log('[PolicyExistingView] Error:', error);
+
+ function canProceed() {
+ if (!localPolicy) {
+ return false;
+ }
+ if (isPolicyMetadataWithAssociation(localPolicy)) {
+ return localPolicy.policy?.id !== null && localPolicy.policy?.id !== undefined;
+ }
+ return false;
+ }
+
+ function handlePolicySelect(association: UserPolicyMetadataWithAssociation) {
+ if (!association) {
+ return;
+ }
+
+ setLocalPolicy(association);
+ }
+
+ function handleSubmit() {
+ if (!localPolicy) {
+ return;
+ }
+
+ console.log('[PolicyExistingView] Submitting Policy in handleSubmit:', localPolicy);
+
+ if (isPolicyMetadataWithAssociation(localPolicy)) {
+ console.log('[PolicyExistingView] Use policy handler');
+ handleSubmitPolicy();
+ }
+ }
+
+ function handleSubmitPolicy() {
+ if (!localPolicy || !isPolicyMetadataWithAssociation(localPolicy)) {
+ return;
+ }
+
+ console.log('[PolicyExistingView] === SUBMIT START ===');
+ console.log('[PolicyExistingView] Local Policy on Submit:', localPolicy);
+ console.log('[PolicyExistingView] Association:', localPolicy.association);
+ console.log('[PolicyExistingView] Association countryId:', localPolicy.association?.countryId);
+ console.log('[PolicyExistingView] Policy metadata:', localPolicy.policy);
+ console.log('[PolicyExistingView] Policy ID:', localPolicy.policy?.id);
+
+ const policyId = localPolicy.policy?.id?.toString();
+ const label = localPolicy.association?.label || '';
+
+ // Convert policy_json to Parameter[] format
+ const parameters: Parameter[] = [];
+
+ if (localPolicy.policy?.policy_json) {
+ const policyJson = localPolicy.policy.policy_json;
+ console.log(
+ '[PolicyExistingView] Converting parameters from policy_json:',
+ Object.keys(policyJson)
+ );
+
+ Object.entries(policyJson).forEach(([paramName, valueIntervals]) => {
+ if (Array.isArray(valueIntervals) && valueIntervals.length > 0) {
+ // Convert each value interval to the proper format
+ const values = valueIntervals.map((vi: any) => ({
+ startDate: vi.start || vi.startDate,
+ endDate: vi.end || vi.endDate,
+ value: vi.value,
+ }));
+
+ parameters.push({
+ name: paramName,
+ values,
+ });
+ }
+ });
+ }
+
+ console.log('[PolicyExistingView] Converted parameters:', parameters);
+ console.log('[PolicyExistingView] === SUBMIT END ===');
+
+ // Call parent callback instead of dispatching to Redux
+ if (policyId) {
+ onSelectPolicy(policyId, label, parameters);
+ }
+ }
+
+ const userPolicies = data || [];
+
+ console.log('[PolicyExistingView] ========== BEFORE FILTERING ==========');
+ console.log('[PolicyExistingView] User policies count:', userPolicies.length);
+ console.log('[PolicyExistingView] User policies:', userPolicies);
+
+ if (isLoading) {
+ return (
+ Loading policies...}
+ buttonPreset="none"
+ />
+ );
+ }
+
+ if (isError) {
+ return (
+ Error: {(error as Error)?.message || 'Something went wrong.'}}
+ buttonPreset="none"
+ />
+ );
+ }
+
+ if (userPolicies.length === 0) {
+ return (
+ No policies available. Please create a new policy.}
+ primaryAction={{
+ label: 'Next',
+ onClick: () => {},
+ isDisabled: true,
+ }}
+ backAction={onBack ? { onClick: onBack } : undefined}
+ cancelAction={onCancel ? { onClick: onCancel } : undefined}
+ />
+ );
+ }
+
+ // Filter policies with loaded data
+ const filteredPolicies = userPolicies.filter((association) =>
+ isPolicyMetadataWithAssociation(association)
+ );
+
+ console.log('[PolicyExistingView] ========== AFTER FILTERING ==========');
+ console.log('[PolicyExistingView] Filtered policies count:', filteredPolicies.length);
+ console.log('[PolicyExistingView] Filter criteria: isPolicyMetadataWithAssociation(association)');
+ console.log('[PolicyExistingView] Filtered policies:', filteredPolicies);
+
+ // Build card list items from ALL filtered policies (pagination handled by PathwayView)
+ const policyCardItems = filteredPolicies.map((association) => {
+ let title = '';
+ let subtitle = '';
+ if ('label' in association.association && association.association.label) {
+ title = association.association.label;
+ subtitle = `Policy #${association.policy!.id}`;
+ } else {
+ title = `Policy #${association.policy!.id}`;
+ }
+
+ return {
+ id: association.association.id?.toString() || association.policy!.id?.toString(), // Use association ID for unique key
+ title,
+ subtitle,
+ onClick: () => handlePolicySelect(association),
+ isSelected:
+ isPolicyMetadataWithAssociation(localPolicy) &&
+ localPolicy.policy?.id === association.policy!.id,
+ };
+ });
+
+ const primaryAction = {
+ label: 'Next ',
+ onClick: handleSubmit,
+ isDisabled: !canProceed(),
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/policy/PolicyLabelView.tsx b/app/src/pathways/report/views/policy/PolicyLabelView.tsx
new file mode 100644
index 00000000..d3ae64d5
--- /dev/null
+++ b/app/src/pathways/report/views/policy/PolicyLabelView.tsx
@@ -0,0 +1,88 @@
+/**
+ * PolicyLabelView - View for setting policy label
+ * Duplicated from PolicyCreationFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import { TextInput } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { PathwayMode } from '@/types/pathwayModes/PathwayMode';
+
+interface PolicyLabelViewProps {
+ label: string | null;
+ mode: PathwayMode;
+ simulationIndex?: 0 | 1; // Required if mode='report', ignored if mode='standalone'
+ reportLabel?: string | null; // Optional for report context
+ onUpdateLabel: (label: string) => void;
+ onNext: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function PolicyLabelView({
+ label,
+ mode,
+ simulationIndex,
+ reportLabel = null,
+ onUpdateLabel,
+ onNext,
+ onBack,
+ onCancel,
+}: PolicyLabelViewProps) {
+ // Validate that required props are present in report mode
+ if (mode === 'report' && simulationIndex === undefined) {
+ throw new Error('[PolicyLabelView] simulationIndex is required when mode is "report"');
+ }
+
+ const countryId = useCurrentCountry();
+ const initializeText = countryId === 'uk' ? 'Initialise' : 'Initialize';
+
+ // Generate default label based on context
+ const getDefaultLabel = () => {
+ if (mode === 'standalone') {
+ return 'My policy';
+ }
+ // mode === 'report'
+ const baseName = simulationIndex === 0 ? 'baseline policy' : 'reform policy';
+ return reportLabel
+ ? `${reportLabel} ${baseName}`
+ : `${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}`;
+ };
+
+ const [localLabel, setLocalLabel] = useState(label || getDefaultLabel());
+
+ function handleLocalLabelChange(value: string) {
+ setLocalLabel(value);
+ }
+
+ function submissionHandler() {
+ onUpdateLabel(localLabel);
+ onNext();
+ }
+
+ const formInputs = (
+ handleLocalLabelChange(e.currentTarget.value)}
+ />
+ );
+
+ const primaryAction = {
+ label: `${initializeText} policy`,
+ onClick: submissionHandler,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/frames/policy/PolicyParameterSelectorFrame.tsx b/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx
similarity index 50%
rename from app/src/frames/policy/PolicyParameterSelectorFrame.tsx
rename to app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx
index bf0a32dc..9fec60dd 100644
--- a/app/src/frames/policy/PolicyParameterSelectorFrame.tsx
+++ b/app/src/pathways/report/views/policy/PolicyParameterSelectorView.tsx
@@ -1,25 +1,39 @@
+/**
+ * PolicyParameterSelectorView - View for selecting policy parameters
+ * Duplicated from PolicyParameterSelectorFrame
+ * Props-based instead of Redux-based
+ */
+
import { useState } from 'react';
+import { IconChevronRight } from '@tabler/icons-react';
import { useSelector } from 'react-redux';
-import { AppShell, Text } from '@mantine/core';
+import { AppShell, Box, Button, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
-import Footer from '@/components/policyParameterSelectorFrame/Footer';
-import Main from '@/components/policyParameterSelectorFrame/Main';
-import MainEmpty from '@/components/policyParameterSelectorFrame/MainEmpty';
-import Menu from '@/components/policyParameterSelectorFrame/Menu';
import HeaderNavigation from '@/components/shared/HomeHeader';
import LegacyBanner from '@/components/shared/LegacyBanner';
import { spacing } from '@/designTokens';
+import { colors } from '@/designTokens/colors';
import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
+import { PolicyStateProps } from '@/types/pathwayState';
+import { countPolicyModifications } from '@/utils/countParameterChanges';
+import MainEmpty from '../../components/policyParameterSelector/MainEmpty';
+import Menu from '../../components/policyParameterSelector/Menu';
+import PolicyParameterSelectorMain from '../../components/PolicyParameterSelectorMain';
+
+interface PolicyParameterSelectorViewProps {
+ policy: PolicyStateProps;
+ onPolicyUpdate: (updatedPolicy: PolicyStateProps) => void;
+ onNext: () => void;
+ onBack?: () => void;
+}
-export default function PolicyParameterSelectorFrame({
- onNavigate,
- onReturn,
- flowConfig,
- isInSubflow,
- flowDepth,
-}: FlowComponentProps) {
+export default function PolicyParameterSelectorView({
+ policy,
+ onPolicyUpdate,
+ onNext,
+ onBack,
+}: PolicyParameterSelectorViewProps) {
const [selectedLeafParam, setSelectedLeafParam] = useState(null);
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
@@ -28,6 +42,9 @@ export default function PolicyParameterSelectorFrame({
(state: RootState) => state.metadata
);
+ // Count modifications from policy prop
+ const modificationCount = countPolicyModifications(policy);
+
// Show error if metadata failed to load
if (error) {
return (
@@ -39,7 +56,6 @@ export default function PolicyParameterSelectorFrame({
}
function handleMenuItemClick(paramLabel: string) {
- // Use real parameters instead of mock data
const param: ParameterMetadata | null = parameters[paramLabel] || null;
if (param && param.type === 'parameter') {
setSelectedLeafParam(param);
@@ -50,6 +66,35 @@ export default function PolicyParameterSelectorFrame({
}
}
+ // Custom footer component for this view
+ const PolicyParameterFooter = () => (
+
+ {onBack && (
+
+ )}
+ {modificationCount > 0 && (
+
+
+
+ {modificationCount} parameter modification{modificationCount !== 1 ? 's' : ''}
+
+
+ )}
+ }>
+ Review my policy
+
+
+ );
+
return (
) : selectedLeafParam ? (
-
+
) : (
)}
-
+
);
diff --git a/app/src/pathways/report/views/policy/PolicySubmitView.tsx b/app/src/pathways/report/views/policy/PolicySubmitView.tsx
new file mode 100644
index 00000000..954cdc3e
--- /dev/null
+++ b/app/src/pathways/report/views/policy/PolicySubmitView.tsx
@@ -0,0 +1,99 @@
+/**
+ * PolicySubmitView - View for reviewing and submitting policy
+ * Duplicated from PolicySubmitFrame
+ * Props-based instead of Redux-based
+ */
+
+import { PolicyAdapter } from '@/adapters';
+import IngredientSubmissionView, {
+ DateIntervalValue,
+ TextListItem,
+ TextListSubItem,
+} from '@/components/IngredientSubmissionView';
+import { useCreatePolicy } from '@/hooks/useCreatePolicy';
+import { countryIds } from '@/libs/countries';
+import { Policy } from '@/types/ingredients/Policy';
+import { PolicyStateProps } from '@/types/pathwayState';
+import { PolicyCreationPayload } from '@/types/payloads';
+import { formatDate } from '@/utils/dateUtils';
+
+interface PolicySubmitViewProps {
+ policy: PolicyStateProps;
+ countryId: (typeof countryIds)[number];
+ onSubmitSuccess: (policyId: string) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function PolicySubmitView({
+ policy,
+ countryId,
+ onSubmitSuccess,
+ onBack,
+ onCancel,
+}: PolicySubmitViewProps) {
+ const { createPolicy, isPending } = useCreatePolicy(policy?.label || undefined);
+
+ // Convert state to Policy type structure
+ const policyData: Partial = {
+ parameters: policy?.parameters,
+ };
+
+ function handleSubmit() {
+ if (!policy) {
+ console.error('No policy found');
+ return;
+ }
+
+ const serializedPolicyCreationPayload: PolicyCreationPayload = PolicyAdapter.toCreationPayload(
+ policyData as Policy
+ );
+ console.log('serializedPolicyCreationPayload', serializedPolicyCreationPayload);
+ createPolicy(serializedPolicyCreationPayload, {
+ onSuccess: (data) => {
+ console.log('Policy created successfully:', data);
+ onSubmitSuccess(data.result.policy_id);
+ },
+ });
+ }
+
+ // Helper function to format date range string (UTC timezone-agnostic)
+ const formatDateRange = (startDate: string, endDate: string): string => {
+ const start = formatDate(startDate, 'short-month-day-year', countryId);
+ const end =
+ endDate === '9999-12-31' ? 'Ongoing' : formatDate(endDate, 'short-month-day-year', countryId);
+ return `${start} - ${end}`;
+ };
+
+ // Create hierarchical provisions list with header and date intervals
+ const provisions: TextListItem[] = [
+ {
+ text: 'Provision',
+ isHeader: true,
+ subItems: policy.parameters.map((param) => {
+ const dateIntervals: DateIntervalValue[] = param.values.map((valueInterval) => ({
+ dateRange: formatDateRange(valueInterval.startDate, valueInterval.endDate),
+ value: valueInterval.value,
+ }));
+
+ return {
+ label: param.name,
+ dateIntervals,
+ } as TextListSubItem;
+ }),
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/population/GeographicConfirmationView.tsx b/app/src/pathways/report/views/population/GeographicConfirmationView.tsx
new file mode 100644
index 00000000..a71e4983
--- /dev/null
+++ b/app/src/pathways/report/views/population/GeographicConfirmationView.tsx
@@ -0,0 +1,124 @@
+/**
+ * GeographicConfirmationView - View for confirming geographic population
+ * Duplicated from GeographicConfirmationFrame
+ * Props-based instead of Redux-based
+ */
+
+import { Stack, Text } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import { useCreateGeographicAssociation } from '@/hooks/useUserGeographic';
+import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation';
+import { PopulationStateProps } from '@/types/pathwayState';
+import { getCountryLabel, getRegionLabel, getRegionTypeLabel } from '@/utils/geographyUtils';
+
+interface GeographicConfirmationViewProps {
+ population: PopulationStateProps;
+ metadata: any;
+ onSubmitSuccess: (geographyId: string, label: string) => void;
+ onBack?: () => void;
+}
+
+export default function GeographicConfirmationView({
+ population,
+ metadata,
+ onSubmitSuccess,
+ onBack,
+}: GeographicConfirmationViewProps) {
+ const { mutateAsync: createGeographicAssociation, isPending } = useCreateGeographicAssociation();
+ const currentUserId = MOCK_USER_ID;
+
+ // Build geographic population data from existing geography
+ const buildGeographicPopulation = (): Omit => {
+ if (!population?.geography) {
+ throw new Error('No geography found in population state');
+ }
+
+ const basePopulation = {
+ id: `${currentUserId}-${Date.now()}`,
+ userId: currentUserId,
+ countryId: population.geography.countryId,
+ geographyId: population.geography.geographyId,
+ scope: population.geography.scope,
+ label: population.label || population.geography.name || undefined,
+ };
+
+ return basePopulation;
+ };
+
+ const handleSubmit = async () => {
+ const populationData = buildGeographicPopulation();
+ console.log('Creating geographic population:', populationData);
+
+ try {
+ const result = await createGeographicAssociation(populationData);
+ console.log('Geographic population created successfully:', result);
+ onSubmitSuccess(result.geographyId, result.label || '');
+ } catch (err) {
+ console.error('Failed to create geographic association:', err);
+ }
+ };
+
+ // Build display content based on geographic scope
+ const buildDisplayContent = () => {
+ if (!population?.geography) {
+ return (
+
+ No geography selected
+
+ );
+ }
+
+ const geographyCountryId = population.geography.countryId;
+
+ if (population.geography.scope === 'national') {
+ return (
+
+
+ Confirm household collection
+
+
+ Scope: National
+
+
+ Country: {getCountryLabel(geographyCountryId)}
+
+
+ );
+ }
+
+ // Subnational
+ const regionCode = population.geography.geographyId;
+ const regionLabel = getRegionLabel(regionCode, metadata);
+ const regionTypeName = getRegionTypeLabel(geographyCountryId, regionCode, metadata);
+
+ return (
+
+
+ Confirm household collection
+
+
+ Scope: {regionTypeName}
+
+
+ {regionTypeName}: {regionLabel}
+
+
+ );
+ };
+
+ const primaryAction = {
+ label: 'Create household collection ',
+ onClick: handleSubmit,
+ isLoading: isPending,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/frames/population/HouseholdBuilderFrame.tsx b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx
similarity index 88%
rename from app/src/frames/population/HouseholdBuilderFrame.tsx
rename to app/src/pathways/report/views/population/HouseholdBuilderView.tsx
index 9bce5346..8028a582 100644
--- a/app/src/frames/population/HouseholdBuilderFrame.tsx
+++ b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx
@@ -1,5 +1,11 @@
+/**
+ * HouseholdBuilderView - View for building custom household
+ * Duplicated from HouseholdBuilderFrame
+ * Props-based instead of Redux-based
+ */
+
import { useEffect, useState } from 'react';
-import { shallowEqual, useDispatch, useSelector } from 'react-redux';
+import { shallowEqual, useSelector } from 'react-redux';
import {
Divider,
Group,
@@ -11,10 +17,8 @@ import {
TextInput,
} from '@mantine/core';
import { HouseholdAdapter } from '@/adapters/HouseholdAdapter';
-import FlowView from '@/components/common/FlowView';
+import PathwayView from '@/components/common/PathwayView';
import { useCreateHousehold } from '@/hooks/useCreateHousehold';
-import { useCurrentCountry } from '@/hooks/useCurrentCountry';
-import { useIngredientReset } from '@/hooks/useIngredientReset';
import { useReportYear } from '@/hooks/useReportYear';
import {
getBasicInputFields,
@@ -22,44 +26,39 @@ import {
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 { PopulationStateProps } from '@/types/pathwayState';
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();
+interface HouseholdBuilderViewProps {
+ population: PopulationStateProps;
+ countryId: string;
+ onSubmitSuccess: (householdId: string, household: Household) => void;
+ onBack?: () => void;
+}
+
+export default function HouseholdBuilderView({
+ population,
+ countryId,
+ onSubmitSuccess,
+ 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 (
-
@@ -74,7 +73,7 @@ export default function HouseholdBuilderFrame({
}
buttonPreset="cancel-only"
cancelAction={{
- onClick: onReturn,
+ onClick: onBack,
}}
/>
);
@@ -82,8 +81,8 @@ export default function HouseholdBuilderFrame({
// Initialize with empty household if none exists
const [household, setLocalHousehold] = useState(() => {
- if (populationState?.household) {
- return populationState.household;
+ if (population?.household) {
+ return population.household;
}
const builder = new HouseholdBuilder(countryId as any, reportYear);
return builder.build();
@@ -100,19 +99,6 @@ export default function HouseholdBuilderFrame({
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);
@@ -299,8 +285,8 @@ export default function HouseholdBuilderFrame({
// Show error state if metadata failed to load
if (error) {
return (
-
@@ -328,14 +314,6 @@ export default function HouseholdBuilderFrame({
}, 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) {
@@ -353,36 +331,7 @@ export default function HouseholdBuilderFrame({
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');
- }
+ onSubmitSuccess(householdId, household);
} catch (err) {
console.error('Failed to create household:', err);
}
@@ -598,7 +547,7 @@ export default function HouseholdBuilderFrame({
const canProceed = validation.isValid;
const primaryAction = {
- label: 'Create household',
+ label: 'Create household ',
onClick: handleSubmit,
isLoading: isPending,
isDisabled: !canProceed,
@@ -651,14 +600,11 @@ export default function HouseholdBuilderFrame({
);
return (
-
);
}
diff --git a/app/src/frames/simulation/SimulationSelectExistingPopulationFrame.tsx b/app/src/pathways/report/views/population/PopulationExistingView.tsx
similarity index 52%
rename from app/src/frames/simulation/SimulationSelectExistingPopulationFrame.tsx
rename to app/src/pathways/report/views/population/PopulationExistingView.tsx
index 5df366f0..44ee13f8 100644
--- a/app/src/frames/simulation/SimulationSelectExistingPopulationFrame.tsx
+++ b/app/src/pathways/report/views/population/PopulationExistingView.tsx
@@ -1,8 +1,14 @@
+/**
+ * PopulationExistingView - View for selecting existing population
+ * Duplicated from SimulationSelectExistingPopulationFrame
+ * Props-based instead of Redux-based
+ */
+
import { useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+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,
@@ -14,25 +20,29 @@ import {
UserHouseholdMetadataWithAssociation,
useUserHouseholds,
} from '@/hooks/useUserHousehold';
-import { selectCurrentPosition } from '@/reducers/activeSelectors';
-import {
- createPopulationAtPosition,
- setGeographyAtPosition,
- setHouseholdAtPosition,
-} from '@/reducers/populationReducer';
import { RootState } from '@/store';
-import { FlowComponentProps } from '@/types/flow';
import { Geography } from '@/types/ingredients/Geography';
+import { Household } from '@/types/ingredients/Household';
import { getCountryLabel, getRegionLabel } from '@/utils/geographyUtils';
+import {
+ isGeographicAssociationReady,
+ isHouseholdAssociationReady,
+} from '@/utils/validation/ingredientValidation';
+
+interface PopulationExistingViewProps {
+ onSelectHousehold: (householdId: string, household: Household, label: string) => void;
+ onSelectGeography: (geographyId: string, geography: Geography, label: string) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
-export default function SimulationSelectExistingPopulationFrame({
- onNavigate,
-}: FlowComponentProps) {
- const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic
- const dispatch = useDispatch();
-
- // Read position from report reducer via cross-cutting selector
- const currentPosition = useSelector((state: RootState) => selectCurrentPosition(state));
+export default function PopulationExistingView({
+ onSelectHousehold,
+ onSelectGeography,
+ onBack,
+ onCancel,
+}: PopulationExistingViewProps) {
+ const userId = MOCK_USER_ID.toString();
const metadata = useSelector((state: RootState) => state.metadata);
// Fetch household populations
@@ -43,17 +53,12 @@ export default function SimulationSelectExistingPopulationFrame({
error: householdError,
} = useUserHouseholds(userId);
- console.log(
- '[SimulationSelectExistingPopulationFrame] ========== HOUSEHOLD DATA FETCH =========='
- );
- console.log('[SimulationSelectExistingPopulationFrame] Household raw data:', householdData);
- console.log(
- '[SimulationSelectExistingPopulationFrame] Household raw data length:',
- householdData?.length
- );
- console.log('[SimulationSelectExistingPopulationFrame] Household isLoading:', isHouseholdLoading);
- console.log('[SimulationSelectExistingPopulationFrame] Household isError:', isHouseholdError);
- console.log('[SimulationSelectExistingPopulationFrame] Household error:', householdError);
+ console.log('[PopulationExistingView] ========== HOUSEHOLD DATA FETCH ==========');
+ console.log('[PopulationExistingView] Household raw data:', householdData);
+ console.log('[PopulationExistingView] Household raw data length:', householdData?.length);
+ console.log('[PopulationExistingView] Household isLoading:', isHouseholdLoading);
+ console.log('[PopulationExistingView] Household isError:', isHouseholdError);
+ console.log('[PopulationExistingView] Household error:', householdError);
// Fetch geographic populations
const {
@@ -63,20 +68,12 @@ export default function SimulationSelectExistingPopulationFrame({
error: geographicError,
} = useUserGeographics(userId);
- console.log(
- '[SimulationSelectExistingPopulationFrame] ========== GEOGRAPHIC DATA FETCH =========='
- );
- console.log('[SimulationSelectExistingPopulationFrame] Geographic raw data:', geographicData);
- console.log(
- '[SimulationSelectExistingPopulationFrame] Geographic raw data length:',
- geographicData?.length
- );
- console.log(
- '[SimulationSelectExistingPopulationFrame] Geographic isLoading:',
- isGeographicLoading
- );
- console.log('[SimulationSelectExistingPopulationFrame] Geographic isError:', isGeographicError);
- console.log('[SimulationSelectExistingPopulationFrame] Geographic error:', geographicError);
+ console.log('[PopulationExistingView] ========== GEOGRAPHIC DATA FETCH ==========');
+ console.log('[PopulationExistingView] Geographic raw data:', geographicData);
+ console.log('[PopulationExistingView] Geographic raw data length:', geographicData?.length);
+ console.log('[PopulationExistingView] Geographic isLoading:', isGeographicLoading);
+ console.log('[PopulationExistingView] Geographic isError:', isGeographicError);
+ console.log('[PopulationExistingView] Geographic error:', geographicError);
const [localPopulation, setLocalPopulation] = useState<
UserHouseholdMetadataWithAssociation | UserGeographicMetadataWithAssociation | null
@@ -91,12 +88,15 @@ export default function SimulationSelectExistingPopulationFrame({
if (!localPopulation) {
return false;
}
+
if (isHouseholdMetadataWithAssociation(localPopulation)) {
- return localPopulation.household?.id !== null;
+ return isHouseholdAssociationReady(localPopulation);
}
+
if (isGeographicMetadataWithAssociation(localPopulation)) {
- return localPopulation.geography?.id !== null;
+ return isGeographicAssociationReady(localPopulation);
}
+
return false;
}
@@ -121,17 +121,11 @@ export default function SimulationSelectExistingPopulationFrame({
return;
}
- console.log('Submitting Population in handleSubmit:', localPopulation);
-
if (isHouseholdMetadataWithAssociation(localPopulation)) {
- console.log('Use household handler');
handleSubmitHouseholdPopulation();
} else if (isGeographicMetadataWithAssociation(localPopulation)) {
- console.log('Use geographic handler');
handleSubmitGeographicPopulation();
}
-
- onNavigate('next');
}
function handleSubmitHouseholdPopulation() {
@@ -139,49 +133,28 @@ export default function SimulationSelectExistingPopulationFrame({
return;
}
- console.log('[POPULATION SELECT] === SUBMIT START ===');
- console.log('[POPULATION SELECT] Local Population on Submit:', localPopulation);
- console.log('[POPULATION SELECT] Association:', localPopulation.association);
- console.log(
- '[POPULATION SELECT] Association countryId:',
- localPopulation.association?.countryId
- );
- console.log('[POPULATION SELECT] Household metadata:', localPopulation.household);
+ // Guard: ensure household data is fully loaded before calling adapter
+ if (!localPopulation.household) {
+ console.error('[PopulationExistingView] Household metadata is undefined');
+ return;
+ }
- const householdToSet = HouseholdAdapter.fromMetadata(localPopulation.household!);
- console.log('[POPULATION SELECT] Converted household:', householdToSet);
- console.log('[POPULATION SELECT] Household ID:', householdToSet.id);
+ // Handle both API format (household_json) and transformed format (householdData)
+ // The cache might contain transformed data from useUserSimulations
+ let householdToSet;
+ if ('household_json' in localPopulation.household) {
+ // API format - needs transformation
+ householdToSet = HouseholdAdapter.fromMetadata(localPopulation.household);
+ } else {
+ // Already transformed format from cache
+ householdToSet = localPopulation.household as any;
+ }
- // Create a new population at the current position
- console.log('[POPULATION SELECT] Dispatching createPopulationAtPosition with:', {
- position: currentPosition,
- label: localPopulation.association?.label || '',
- isCreated: true,
- });
- dispatch(
- createPopulationAtPosition({
- position: currentPosition,
- population: {
- label: localPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
- );
+ const label = localPopulation.association?.label || '';
+ const householdId = householdToSet.id!;
- // Update with household data
- console.log(
- '[POPULATION SELECT] Dispatching setHouseholdAtPosition with household ID:',
- householdToSet.id
- );
- dispatch(
- setHouseholdAtPosition({
- position: currentPosition,
- household: householdToSet,
- })
- );
- console.log('[POPULATION SELECT] === SUBMIT END ===');
+ // Call parent callback instead of dispatching to Redux
+ onSelectHousehold(householdId, householdToSet, label);
}
function handleSubmitGeographicPopulation() {
@@ -189,57 +162,36 @@ export default function SimulationSelectExistingPopulationFrame({
return;
}
- console.log('Local Geographic Population on Submit:', localPopulation);
- console.log('Setting geography in population:', localPopulation.geography);
-
- // Create a new population at the current position
- dispatch(
- createPopulationAtPosition({
- position: currentPosition,
- population: {
- label: localPopulation.association?.label || '',
- isCreated: true,
- household: null,
- geography: null,
- },
- })
+ console.log('[PopulationExistingView] Local Geographic Population on Submit:', localPopulation);
+ console.log(
+ '[PopulationExistingView] Setting geography in population:',
+ localPopulation.geography
);
- // Update with geography data
- dispatch(
- setGeographyAtPosition({
- position: currentPosition,
- geography: localPopulation.geography!,
- })
- );
+ const label = localPopulation.association?.label || '';
+ const geography = localPopulation.geography!;
+ const geographyId = geography.id!;
+
+ // Call parent callback instead of dispatching to Redux
+ onSelectGeography(geographyId, geography, label);
}
const householdPopulations = householdData || [];
const geographicPopulations = geographicData || [];
- console.log('[SimulationSelectExistingPopulationFrame] ========== BEFORE FILTERING ==========');
- console.log(
- '[SimulationSelectExistingPopulationFrame] Household populations count:',
- householdPopulations.length
- );
- console.log(
- '[SimulationSelectExistingPopulationFrame] Household populations:',
- householdPopulations
- );
+ console.log('[PopulationExistingView] ========== BEFORE FILTERING ==========');
+ console.log('[PopulationExistingView] Household populations count:', householdPopulations.length);
+ console.log('[PopulationExistingView] Household populations:', householdPopulations);
console.log(
- '[SimulationSelectExistingPopulationFrame] Geographic populations count:',
+ '[PopulationExistingView] Geographic populations count:',
geographicPopulations.length
);
- console.log(
- '[SimulationSelectExistingPopulationFrame] Geographic populations:',
- geographicPopulations
- );
+ console.log('[PopulationExistingView] Geographic populations:', geographicPopulations);
- // TODO: For all of these, refactor into something more reusable
if (isLoading) {
return (
- Loading households...}
buttonPreset="none"
/>
@@ -248,8 +200,8 @@ export default function SimulationSelectExistingPopulationFrame({
if (isError) {
return (
- Error: {(error as Error)?.message || 'Something went wrong.'}}
buttonPreset="none"
/>
@@ -258,10 +210,16 @@ export default function SimulationSelectExistingPopulationFrame({
if (householdPopulations.length === 0 && geographicPopulations.length === 0) {
return (
- No households available. Please create new household(s).}
- buttonPreset="cancel-only"
+ primaryAction={{
+ label: 'Next',
+ onClick: () => {},
+ isDisabled: true,
+ }}
+ backAction={onBack ? { onClick: onBack } : undefined}
+ cancelAction={onCancel ? { onClick: onCancel } : undefined}
/>
);
}
@@ -271,39 +229,45 @@ export default function SimulationSelectExistingPopulationFrame({
isHouseholdMetadataWithAssociation(association)
);
- console.log('[SimulationSelectExistingPopulationFrame] ========== AFTER FILTERING ==========');
+ console.log('[PopulationExistingView] ========== AFTER FILTERING ==========');
+ console.log('[PopulationExistingView] Filtered households count:', filteredHouseholds.length);
console.log(
- '[SimulationSelectExistingPopulationFrame] Filtered households count:',
- filteredHouseholds.length
+ '[PopulationExistingView] Filter criteria: isHouseholdMetadataWithAssociation(association)'
);
- console.log(
- '[SimulationSelectExistingPopulationFrame] Filter criteria: isHouseholdMetadataWithAssociation(association)'
- );
- console.log('[SimulationSelectExistingPopulationFrame] Filtered households:', filteredHouseholds);
+ 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
const householdCardItems = allPopulations
.filter((association) => isHouseholdMetadataWithAssociation(association))
.map((association) => {
+ const isReady = isHouseholdAssociationReady(association);
+
let title = '';
let subtitle = '';
- if ('label' in association.association && association.association.label) {
+
+ if (!isReady) {
+ // NOT LOADED YET - show loading indicator
+ title = '⏳ Loading...';
+ subtitle = 'Household data not loaded yet';
+ } else if ('label' in association.association && association.association.label) {
title = association.association.label;
subtitle = `Population #${association.household!.id}`;
} else {
title = `Population #${association.household!.id}`;
+ subtitle = '';
}
return {
+ id: association.association.id?.toString() || association.household?.id?.toString(), // Use association ID for unique key
title,
subtitle,
onClick: () => handleHouseholdPopulationSelect(association!),
isSelected:
isHouseholdMetadataWithAssociation(localPopulation) &&
- localPopulation.household?.id === association.household!.id,
+ localPopulation.household?.id === association.household?.id,
};
});
@@ -348,6 +312,7 @@ export default function SimulationSelectExistingPopulationFrame({
}
return {
+ id: association.association.id?.toString() || association.geography?.id?.toString(), // Use association ID for unique key
title,
subtitle,
onClick: () => handleGeographicPopulationSelect(association!),
@@ -361,17 +326,19 @@ export default function SimulationSelectExistingPopulationFrame({
const cardListItems = [...householdCardItems, ...geographicCardItems];
const primaryAction = {
- label: 'Next',
+ label: 'Next ',
onClick: handleSubmit,
isDisabled: !canProceed(),
};
return (
-
);
diff --git a/app/src/pathways/report/views/population/PopulationLabelView.tsx b/app/src/pathways/report/views/population/PopulationLabelView.tsx
new file mode 100644
index 00000000..dfad4fbe
--- /dev/null
+++ b/app/src/pathways/report/views/population/PopulationLabelView.tsx
@@ -0,0 +1,120 @@
+/**
+ * PopulationLabelView - View for setting population label
+ * Duplicated from SetPopulationLabelFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import { Stack, Text, TextInput } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { PathwayMode } from '@/types/pathwayModes/PathwayMode';
+import { PopulationStateProps } from '@/types/pathwayState';
+import { extractRegionDisplayValue } from '@/utils/regionStrategies';
+
+interface PopulationLabelViewProps {
+ population: PopulationStateProps;
+ mode: PathwayMode;
+ simulationIndex?: 0 | 1; // Required if mode='report', ignored if mode='standalone'
+ reportLabel?: string | null; // Optional for report context
+ onUpdateLabel: (label: string) => void;
+ onNext: () => void;
+ onBack?: () => void;
+}
+
+export default function PopulationLabelView({
+ population,
+ mode,
+ simulationIndex,
+ reportLabel: _reportLabel = null,
+ onUpdateLabel,
+ onNext,
+ onBack,
+}: PopulationLabelViewProps) {
+ // Validate that required props are present in report mode
+ if (mode === 'report' && simulationIndex === undefined) {
+ throw new Error('[PopulationLabelView] simulationIndex is required when mode is "report"');
+ }
+
+ const countryId = useCurrentCountry();
+ const initializeText = countryId === 'uk' ? 'Initialise' : 'Initialize';
+
+ // Initialize with existing label or generate a default based on population type
+ const getDefaultLabel = () => {
+ if (population?.label) {
+ return population.label;
+ }
+
+ if (population?.geography) {
+ // Geographic population
+ if (population.geography.scope === 'national') {
+ return 'National Households';
+ } else if (population.geography.geographyId) {
+ // Use display value (strip prefix for UK regions)
+ const displayValue = extractRegionDisplayValue(population.geography.geographyId);
+ return `${displayValue} Households`;
+ }
+ return 'Regional Households';
+ }
+ // Household population
+ return 'Custom Household';
+ };
+
+ const [label, setLabel] = useState(getDefaultLabel());
+ const [error, setError] = useState('');
+
+ const handleSubmit = () => {
+ // Validate label
+ if (!label.trim()) {
+ setError('Please enter a label for your household(s)');
+ return;
+ }
+
+ if (label.length > 100) {
+ setError('Label must be less than 100 characters');
+ return;
+ }
+
+ onUpdateLabel(label.trim());
+ onNext();
+ };
+
+ const formInputs = (
+
+
+ Give your household(s) a descriptive name.
+
+
+ {
+ setLabel(event.currentTarget.value);
+ setError(''); // Clear error when user types
+ }}
+ error={error}
+ required
+ maxLength={100}
+ />
+
+
+ This label will help you identify this household(s) when creating simulations.
+
+
+ );
+
+ const primaryAction = {
+ label: `${initializeText} household(s)`,
+ onClick: handleSubmit,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/population/PopulationScopeView.tsx b/app/src/pathways/report/views/population/PopulationScopeView.tsx
new file mode 100644
index 00000000..c0fcd432
--- /dev/null
+++ b/app/src/pathways/report/views/population/PopulationScopeView.tsx
@@ -0,0 +1,105 @@
+/**
+ * PopulationScopeView - View for selecting population geographic scope
+ * Duplicated from SelectGeographicScopeFrame
+ * Props-based instead of Redux-based
+ *
+ * NOTE: This is a simplified version for Phase 3. Full implementation with
+ * all geographic options will be added in later phases.
+ */
+
+import { useState } from 'react';
+import { Stack } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { countryIds } from '@/libs/countries';
+import { Geography } from '@/types/ingredients/Geography';
+import {
+ createGeographyFromScope,
+ getUKConstituencies,
+ getUKCountries,
+ getUSStates,
+} from '@/utils/regionStrategies';
+import UKGeographicOptions from '../../components/geographicOptions/UKGeographicOptions';
+import USGeographicOptions from '../../components/geographicOptions/USGeographicOptions';
+
+type ScopeType = 'national' | 'country' | 'constituency' | 'state' | 'household';
+
+interface PopulationScopeViewProps {
+ countryId: (typeof countryIds)[number];
+ regionData: any[];
+ onScopeSelected: (geography: Geography | null, scopeType: ScopeType) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function PopulationScopeView({
+ countryId,
+ regionData,
+ onScopeSelected,
+ onBack,
+ onCancel,
+}: PopulationScopeViewProps) {
+ const [scope, setScope] = useState('national');
+ const [selectedRegion, setSelectedRegion] = useState('');
+
+ // Get region options based on country
+ const usStates = countryId === 'us' ? getUSStates(regionData) : [];
+ const ukCountries = countryId === 'uk' ? getUKCountries(regionData) : [];
+ const ukConstituencies = countryId === 'uk' ? getUKConstituencies(regionData) : [];
+
+ const handleScopeChange = (value: ScopeType) => {
+ setScope(value);
+ setSelectedRegion(''); // Clear selection when scope changes
+ };
+
+ function submissionHandler() {
+ // Validate that if a regional scope is selected, a region must be chosen
+ const needsRegion = ['state', 'country', 'constituency'].includes(scope);
+ if (needsRegion && !selectedRegion) {
+ console.warn(`${scope} selected but no region chosen`);
+ return;
+ }
+
+ // Create geography from scope selection
+ const geography = createGeographyFromScope(scope, countryId, selectedRegion);
+
+ onScopeSelected(geography as Geography | null, scope);
+ }
+
+ const formInputs = (
+
+ {countryId === 'uk' ? (
+ handleScopeChange(newScope)}
+ onRegionChange={setSelectedRegion}
+ />
+ ) : (
+ handleScopeChange(newScope)}
+ onRegionChange={setSelectedRegion}
+ />
+ )}
+
+ );
+
+ const primaryAction = {
+ label: 'Select scope ',
+ onClick: submissionHandler,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/simulation/SimulationLabelView.tsx b/app/src/pathways/report/views/simulation/SimulationLabelView.tsx
new file mode 100644
index 00000000..3cc497e5
--- /dev/null
+++ b/app/src/pathways/report/views/simulation/SimulationLabelView.tsx
@@ -0,0 +1,88 @@
+/**
+ * SimulationLabelView - View for setting simulation label
+ * Duplicated from SimulationCreationFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import { TextInput } from '@mantine/core';
+import PathwayView from '@/components/common/PathwayView';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { PathwayMode } from '@/types/pathwayModes/PathwayMode';
+
+interface SimulationLabelViewProps {
+ label: string | null;
+ mode: PathwayMode;
+ simulationIndex?: 0 | 1; // Required if mode='report', ignored if mode='standalone'
+ reportLabel?: string | null; // Optional for report context
+ onUpdateLabel: (label: string) => void;
+ onNext: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function SimulationLabelView({
+ label,
+ mode,
+ simulationIndex,
+ reportLabel = null,
+ onUpdateLabel,
+ onNext,
+ onBack,
+ onCancel,
+}: SimulationLabelViewProps) {
+ // Validate that required props are present in report mode
+ if (mode === 'report' && simulationIndex === undefined) {
+ throw new Error('[SimulationLabelView] simulationIndex is required when mode is "report"');
+ }
+
+ const countryId = useCurrentCountry();
+ const initializeText = countryId === 'uk' ? 'Initialise' : 'Initialize';
+
+ // Generate default label based on context
+ const getDefaultLabel = () => {
+ if (mode === 'standalone') {
+ return 'My simulation';
+ }
+ // mode === 'report'
+ const baseName = simulationIndex === 0 ? 'baseline simulation' : 'reform simulation';
+ return reportLabel
+ ? `${reportLabel} ${baseName}`
+ : `${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}`;
+ };
+
+ const [localLabel, setLocalLabel] = useState(label || getDefaultLabel());
+
+ function handleLocalLabelChange(value: string) {
+ setLocalLabel(value);
+ }
+
+ function submissionHandler() {
+ onUpdateLabel(localLabel);
+ onNext();
+ }
+
+ const formInputs = (
+ handleLocalLabelChange(e.currentTarget.value)}
+ />
+ );
+
+ const primaryAction = {
+ label: `${initializeText} simulation`,
+ onClick: submissionHandler,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx b/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx
new file mode 100644
index 00000000..9b8371b4
--- /dev/null
+++ b/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx
@@ -0,0 +1,105 @@
+/**
+ * SimulationPolicySetupView - View for choosing how to setup policy
+ * Duplicated from SimulationSetupPolicyFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+
+type SetupAction = 'createNew' | 'loadExisting' | 'selectCurrentLaw';
+
+interface SimulationPolicySetupViewProps {
+ currentLawId: number;
+ countryId: string;
+ onSelectCurrentLaw: () => void;
+ onCreateNew: () => void;
+ onLoadExisting: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function SimulationPolicySetupView({
+ currentLawId: _currentLawId,
+ countryId: _countryId,
+ onSelectCurrentLaw,
+ onCreateNew,
+ onLoadExisting,
+ onBack,
+ onCancel,
+}: SimulationPolicySetupViewProps) {
+ const userId = MOCK_USER_ID.toString();
+ const { data: userPolicies } = useUserPolicies(userId);
+ const hasExistingPolicies = (userPolicies?.length ?? 0) > 0;
+
+ const [selectedAction, setSelectedAction] = useState(null);
+
+ function handleClickCreateNew() {
+ setSelectedAction('createNew');
+ }
+
+ function handleClickExisting() {
+ if (hasExistingPolicies) {
+ setSelectedAction('loadExisting');
+ }
+ }
+
+ function handleClickCurrentLaw() {
+ setSelectedAction('selectCurrentLaw');
+ }
+
+ function handleClickSubmit() {
+ if (selectedAction === 'selectCurrentLaw') {
+ onSelectCurrentLaw();
+ } else if (selectedAction === 'createNew') {
+ onCreateNew();
+ } else if (selectedAction === 'loadExisting') {
+ onLoadExisting();
+ }
+ }
+
+ const buttonPanelCards = [
+ {
+ title: 'Current law',
+ description: 'Use the baseline tax-benefit system with no reforms',
+ onClick: handleClickCurrentLaw,
+ isSelected: selectedAction === 'selectCurrentLaw',
+ },
+ // Only show "Load existing" if user has existing policies
+ ...(hasExistingPolicies
+ ? [
+ {
+ title: 'Load existing policy',
+ description: 'Use a policy you have already created',
+ onClick: handleClickExisting,
+ isSelected: selectedAction === 'loadExisting',
+ },
+ ]
+ : []),
+ {
+ title: 'Create new policy',
+ description: 'Build a new policy',
+ onClick: handleClickCreateNew,
+ isSelected: selectedAction === 'createNew',
+ },
+ ];
+
+ const primaryAction = {
+ label: 'Next ',
+ onClick: handleClickSubmit,
+ isDisabled: !selectedAction,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx b/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx
new file mode 100644
index 00000000..857e1dbc
--- /dev/null
+++ b/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx
@@ -0,0 +1,152 @@
+/**
+ * SimulationPopulationSetupView - View for choosing how to setup population
+ * Duplicated from SimulationSetupPopulationFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import PathwayView from '@/components/common/PathwayView';
+import { MOCK_USER_ID } from '@/constants';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState';
+import { getPopulationLabel, getSimulationLabel } from '@/utils/populationCompatibility';
+import {
+ getPopulationLockConfig,
+ getPopulationSelectionSubtitle,
+ getPopulationSelectionTitle,
+} from '@/utils/reportPopulationLock';
+
+type SetupAction = 'createNew' | 'loadExisting' | 'copyExisting';
+
+interface SimulationPopulationSetupViewProps {
+ isReportMode: boolean;
+ otherSimulation: SimulationStateProps | null;
+ otherPopulation: PopulationStateProps | null;
+ onCreateNew: () => void;
+ onLoadExisting: () => void;
+ onCopyExisting: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function SimulationPopulationSetupView({
+ isReportMode,
+ otherSimulation,
+ otherPopulation,
+ onCreateNew,
+ onLoadExisting,
+ onCopyExisting,
+ onBack,
+ onCancel,
+}: SimulationPopulationSetupViewProps) {
+ const userId = MOCK_USER_ID.toString();
+ const { data: userHouseholds } = useUserHouseholds(userId);
+ const { data: userGeographics } = useUserGeographics(userId);
+ const hasExistingPopulations = (userHouseholds?.length ?? 0) + (userGeographics?.length ?? 0) > 0;
+
+ const [selectedAction, setSelectedAction] = useState(null);
+
+ // Determine if population selection should be locked
+ const mode = isReportMode ? 'report' : 'standalone';
+ const { shouldLock: shouldLockToOtherPopulation } = getPopulationLockConfig(
+ mode,
+ otherSimulation as any, // TODO: Type compatibility
+ otherPopulation as any
+ );
+
+ function handleClickCreateNew() {
+ setSelectedAction('createNew');
+ }
+
+ function handleClickExisting() {
+ if (hasExistingPopulations) {
+ setSelectedAction('loadExisting');
+ }
+ }
+
+ function handleClickCopyExisting() {
+ setSelectedAction('copyExisting');
+ }
+
+ function handleClickSubmit() {
+ if (selectedAction === 'createNew') {
+ onCreateNew();
+ } else if (selectedAction === 'loadExisting') {
+ onLoadExisting();
+ } else if (selectedAction === 'copyExisting') {
+ onCopyExisting();
+ }
+ }
+
+ // Define card arrays separately for clarity
+ const lockedCards = [
+ // Card 1: Load Existing Population (disabled)
+ {
+ title: 'Load existing household(s)',
+ description:
+ 'Cannot load different household(s) when another simulation is already configured',
+ onClick: handleClickExisting,
+ isSelected: false,
+ isDisabled: true,
+ },
+ // Card 2: Create New Population (disabled)
+ {
+ title: 'Create new household(s)',
+ description: 'Cannot create new household(s) when another simulation is already configured',
+ onClick: handleClickCreateNew,
+ isSelected: false,
+ isDisabled: true,
+ },
+ // Card 3: Use Population from Other Simulation (enabled)
+ {
+ title: `Use household(s) from ${getSimulationLabel(otherSimulation as any)}`,
+ description: `Household(s): ${getPopulationLabel(otherPopulation as any)}`,
+ onClick: handleClickCopyExisting,
+ isSelected: selectedAction === 'copyExisting',
+ isDisabled: false,
+ },
+ ];
+
+ const normalCards = [
+ {
+ title: 'Load existing household(s)',
+ description: hasExistingPopulations
+ ? 'Use household(s) you have already created'
+ : 'No existing household(s) available',
+ onClick: handleClickExisting,
+ isSelected: selectedAction === 'loadExisting',
+ isDisabled: !hasExistingPopulations,
+ },
+ {
+ title: 'Create new household(s)',
+ description: 'Build new household(s)',
+ onClick: handleClickCreateNew,
+ isSelected: selectedAction === 'createNew',
+ },
+ ];
+
+ // Select appropriate cards based on lock state
+ const buttonPanelCards = shouldLockToOtherPopulation ? lockedCards : normalCards;
+
+ const viewTitle = getPopulationSelectionTitle(shouldLockToOtherPopulation);
+ const viewSubtitle = getPopulationSelectionSubtitle(shouldLockToOtherPopulation);
+
+ const primaryAction = {
+ label: 'Next ',
+ onClick: handleClickSubmit,
+ isDisabled: shouldLockToOtherPopulation ? false : !selectedAction,
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/simulation/SimulationSetupView.tsx b/app/src/pathways/report/views/simulation/SimulationSetupView.tsx
new file mode 100644
index 00000000..48f8b541
--- /dev/null
+++ b/app/src/pathways/report/views/simulation/SimulationSetupView.tsx
@@ -0,0 +1,193 @@
+/**
+ * SimulationSetupView - View for configuring simulation policy and population
+ * Duplicated from SimulationSetupFrame
+ * Props-based instead of Redux-based
+ */
+
+import { useState } from 'react';
+import PathwayView from '@/components/common/PathwayView';
+import { SimulationStateProps } from '@/types/pathwayState';
+import {
+ isPolicyConfigured,
+ isPopulationConfigured,
+} from '@/utils/validation/ingredientValidation';
+
+type SetupCard = 'population' | 'policy';
+
+interface SimulationSetupViewProps {
+ simulation: SimulationStateProps;
+ simulationIndex: 0 | 1;
+ isReportMode: boolean;
+ onNavigateToPolicy: () => void;
+ onNavigateToPopulation: () => void;
+ onNext: () => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function SimulationSetupView({
+ simulation,
+ simulationIndex,
+ isReportMode,
+ onNavigateToPolicy,
+ onNavigateToPopulation,
+ onNext,
+ onBack,
+ onCancel,
+}: SimulationSetupViewProps) {
+ const [selectedCard, setSelectedCard] = useState(null);
+
+ const policy = simulation.policy;
+ const population = simulation.population;
+
+ // Detect if we're in report mode for simulation 2 (population will be inherited)
+ const isSimulation2InReport = isReportMode && simulationIndex === 1;
+
+ const handlePopulationSelect = () => {
+ setSelectedCard('population');
+ };
+
+ const handlePolicySelect = () => {
+ setSelectedCard('policy');
+ };
+
+ const handleNext = () => {
+ if (selectedCard === 'population' && !isPopulationConfigured(population)) {
+ onNavigateToPopulation();
+ } else if (selectedCard === 'policy' && !isPolicyConfigured(policy)) {
+ onNavigateToPolicy();
+ } else if (isPolicyConfigured(policy) && isPopulationConfigured(population)) {
+ // Both are fulfilled, proceed to next step
+ onNext();
+ }
+ };
+
+ const canProceed: boolean = isPolicyConfigured(policy) && isPopulationConfigured(population);
+
+ function generatePopulationCardTitle() {
+ if (!isPopulationConfigured(population)) {
+ return 'Add household(s)';
+ }
+
+ // In simulation 2 of a report, indicate population is inherited from baseline
+ if (isSimulation2InReport) {
+ return `${population.label || 'Household(s)'} (from baseline)`;
+ }
+
+ if (population.label) {
+ return population.label;
+ }
+ if (population.household) {
+ return `Household #${population.household.id}`;
+ }
+ if (population.geography) {
+ return `Household(s) #${population.geography.id}`;
+ }
+ return '';
+ }
+
+ function generatePopulationCardDescription() {
+ if (!isPopulationConfigured(population)) {
+ return 'Select a household collection or custom household';
+ }
+
+ // In simulation 2 of a report, indicate population is inherited from baseline
+ if (isSimulation2InReport) {
+ const popId = population.household?.id || population.geography?.id;
+ const popType = population.household ? 'Household' : 'Household collection';
+ return `${popType} #${popId} • Inherited from baseline simulation`;
+ }
+
+ if (population.label && population.household) {
+ return `Household #${population.household.id}`;
+ }
+ if (population.label && population.geography) {
+ return `Household collection #${population.geography.id}`;
+ }
+ return '';
+ }
+
+ function generatePolicyCardTitle() {
+ if (!isPolicyConfigured(policy)) {
+ return 'Add policy';
+ }
+ if (policy.label) {
+ return policy.label;
+ }
+ if (policy.id) {
+ return `Policy #${policy.id}`;
+ }
+ return '';
+ }
+
+ function generatePolicyCardDescription() {
+ if (!isPolicyConfigured(policy)) {
+ return 'Select a policy to apply to the simulation';
+ }
+ if (policy.label && policy.id) {
+ return `Policy #${policy.id}`;
+ }
+ return '';
+ }
+
+ const setupConditionCards = [
+ {
+ title: generatePopulationCardTitle(),
+ description: generatePopulationCardDescription(),
+ onClick: handlePopulationSelect,
+ isSelected: selectedCard === 'population',
+ isFulfilled: isPopulationConfigured(population),
+ isDisabled: false,
+ },
+ {
+ title: generatePolicyCardTitle(),
+ description: generatePolicyCardDescription(),
+ onClick: handlePolicySelect,
+ isSelected: selectedCard === 'policy',
+ isFulfilled: isPolicyConfigured(policy),
+ isDisabled: false,
+ },
+ ];
+
+ // Determine the primary action label and state
+ const getPrimaryAction = () => {
+ if (selectedCard === 'population' && !isPopulationConfigured(population)) {
+ return {
+ label: 'Configure household(s) ',
+ onClick: handleNext,
+ isDisabled: false,
+ };
+ } else if (selectedCard === 'policy' && !isPolicyConfigured(policy)) {
+ return {
+ label: 'Configure policy ',
+ onClick: handleNext,
+ isDisabled: false,
+ };
+ } else if (canProceed) {
+ return {
+ label: 'Next ',
+ onClick: handleNext,
+ isDisabled: false,
+ };
+ }
+ // Default disabled state - show uppermost option (household(s))
+ return {
+ label: 'Configure household(s) ',
+ onClick: handleNext,
+ isDisabled: true,
+ };
+ };
+
+ const primaryAction = getPrimaryAction();
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/report/views/simulation/SimulationSubmitView.tsx b/app/src/pathways/report/views/simulation/SimulationSubmitView.tsx
new file mode 100644
index 00000000..db87f318
--- /dev/null
+++ b/app/src/pathways/report/views/simulation/SimulationSubmitView.tsx
@@ -0,0 +1,93 @@
+/**
+ * SimulationSubmitView - View for reviewing and submitting simulation
+ * Duplicated from SimulationSubmitFrame
+ * Props-based instead of Redux-based
+ */
+
+import { SimulationAdapter } from '@/adapters';
+import IngredientSubmissionView, { SummaryBoxItem } from '@/components/IngredientSubmissionView';
+import { useCreateSimulation } from '@/hooks/useCreateSimulation';
+import { Simulation } from '@/types/ingredients/Simulation';
+import { SimulationStateProps } from '@/types/pathwayState';
+import { SimulationCreationPayload } from '@/types/payloads';
+
+interface SimulationSubmitViewProps {
+ simulation: SimulationStateProps;
+ onSubmitSuccess: (simulationId: string) => void;
+ onBack?: () => void;
+ onCancel?: () => void;
+}
+
+export default function SimulationSubmitView({
+ simulation,
+ onSubmitSuccess,
+ onBack,
+ onCancel,
+}: SimulationSubmitViewProps) {
+ const { createSimulation, isPending } = useCreateSimulation(simulation?.label || undefined);
+
+ function handleSubmit() {
+ // Determine population ID and type based on what's set
+ let populationId: string | undefined;
+ let populationType: 'household' | 'geography' | undefined;
+
+ if (simulation.population.household?.id) {
+ populationId = simulation.population.household.id;
+ populationType = 'household';
+ } else if (simulation.population.geography?.id) {
+ populationId = simulation.population.geography.id;
+ populationType = 'geography';
+ }
+
+ // Convert state to partial Simulation for adapter
+ const simulationData: Partial = {
+ populationId,
+ policyId: simulation.policy.id,
+ populationType,
+ };
+
+ const serializedSimulationCreationPayload: SimulationCreationPayload =
+ SimulationAdapter.toCreationPayload(simulationData);
+
+ console.log('Submitting simulation:', serializedSimulationCreationPayload);
+ createSimulation(serializedSimulationCreationPayload, {
+ onSuccess: (data) => {
+ console.log('Simulation created successfully:', data);
+ onSubmitSuccess(data.result.simulation_id);
+ },
+ });
+ }
+
+ // Create summary boxes based on the current simulation state
+ const summaryBoxes: SummaryBoxItem[] = [
+ {
+ title: 'Population added',
+ description:
+ simulation.population.label ||
+ `Household #${simulation.population.household?.id || simulation.population.geography?.id}`,
+ isFulfilled: !!(simulation.population.household?.id || simulation.population.geography?.id),
+ badge:
+ simulation.population.label ||
+ `Household #${simulation.population.household?.id || simulation.population.geography?.id}`,
+ },
+ {
+ title: 'Policy reform added',
+ description: simulation.policy.label || `Policy #${simulation.policy.id}`,
+ isFulfilled: !!simulation.policy.id,
+ badge: simulation.policy.label || `Policy #${simulation.policy.id}`,
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/app/src/pathways/simulation/SimulationPathwayWrapper.tsx b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx
new file mode 100644
index 00000000..1206659d
--- /dev/null
+++ b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx
@@ -0,0 +1,362 @@
+/**
+ * SimulationPathwayWrapper - Pathway orchestrator for standalone simulation creation
+ *
+ * Manages local state for a single simulation (policy + population).
+ * Reuses all shared views from the report pathway with mode="standalone".
+ */
+
+import { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import StandardLayout from '@/components/StandardLayout';
+import { MOCK_USER_ID } from '@/constants';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { usePathwayNavigation } from '@/hooks/usePathwayNavigation';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+import { RootState } from '@/store';
+import { SimulationViewMode } from '@/types/pathwayModes/SimulationViewMode';
+import { SimulationStateProps } from '@/types/pathwayState';
+import {
+ createPolicyCallbacks,
+ createPopulationCallbacks,
+ createSimulationCallbacks,
+} from '@/utils/pathwayCallbacks';
+import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState';
+import PolicyExistingView from '../report/views/policy/PolicyExistingView';
+// Policy views
+import PolicyLabelView from '../report/views/policy/PolicyLabelView';
+import PolicyParameterSelectorView from '../report/views/policy/PolicyParameterSelectorView';
+import PolicySubmitView from '../report/views/policy/PolicySubmitView';
+import GeographicConfirmationView from '../report/views/population/GeographicConfirmationView';
+import HouseholdBuilderView from '../report/views/population/HouseholdBuilderView';
+import PopulationExistingView from '../report/views/population/PopulationExistingView';
+import PopulationLabelView from '../report/views/population/PopulationLabelView';
+// Population views
+import PopulationScopeView from '../report/views/population/PopulationScopeView';
+// Simulation views
+import SimulationLabelView from '../report/views/simulation/SimulationLabelView';
+import SimulationPolicySetupView from '../report/views/simulation/SimulationPolicySetupView';
+import SimulationPopulationSetupView from '../report/views/simulation/SimulationPopulationSetupView';
+import SimulationSetupView from '../report/views/simulation/SimulationSetupView';
+import SimulationSubmitView from '../report/views/simulation/SimulationSubmitView';
+
+// View modes that manage their own AppShell (don't need StandardLayout wrapper)
+const MODES_WITH_OWN_LAYOUT = new Set([SimulationViewMode.POLICY_PARAMETER_SELECTOR]);
+
+interface SimulationPathwayWrapperProps {
+ onComplete?: () => void;
+}
+
+export default function SimulationPathwayWrapper({ onComplete }: SimulationPathwayWrapperProps) {
+ console.log('[SimulationPathwayWrapper] ========== RENDER ==========');
+
+ const countryId = useCurrentCountry();
+ const navigate = useNavigate();
+
+ // Initialize simulation state
+ const [simulationState, setSimulationState] = useState(() => {
+ const state = initializeSimulationState();
+ state.countryId = countryId;
+ return state;
+ });
+
+ // Get metadata for population views
+ const metadata = useSelector((state: RootState) => state.metadata);
+ const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId);
+
+ // ========== NAVIGATION ==========
+ const { currentMode, navigateToMode, goBack, canGoBack } = usePathwayNavigation(
+ SimulationViewMode.LABEL
+ );
+
+ // ========== FETCH USER DATA FOR CONDITIONAL NAVIGATION ==========
+ const userId = MOCK_USER_ID.toString();
+ const { data: userPolicies } = useUserPolicies(userId);
+ const { data: userHouseholds } = useUserHouseholds(userId);
+ const { data: userGeographics } = useUserGeographics(userId);
+
+ const hasExistingPolicies = (userPolicies?.length ?? 0) > 0;
+ const hasExistingPopulations = (userHouseholds?.length ?? 0) + (userGeographics?.length ?? 0) > 0;
+
+ // ========== CONDITIONAL NAVIGATION HANDLERS ==========
+ // Skip selection view if user has no existing items
+ const handleNavigateToPolicy = useCallback(() => {
+ if (hasExistingPolicies) {
+ navigateToMode(SimulationViewMode.SETUP_POLICY);
+ } else {
+ navigateToMode(SimulationViewMode.POLICY_LABEL);
+ }
+ }, [hasExistingPolicies, navigateToMode]);
+
+ const handleNavigateToPopulation = useCallback(() => {
+ if (hasExistingPopulations) {
+ navigateToMode(SimulationViewMode.SETUP_POPULATION);
+ } else {
+ navigateToMode(SimulationViewMode.POPULATION_SCOPE);
+ }
+ }, [hasExistingPopulations, navigateToMode]);
+
+ // ========== CALLBACK FACTORIES ==========
+ // Simulation-level callbacks with custom completion handler
+ const simulationCallbacks = createSimulationCallbacks(
+ setSimulationState,
+ (state) => state,
+ (_state, simulation) => simulation,
+ navigateToMode,
+ SimulationViewMode.SETUP,
+ (simulationId: string) => {
+ // onSimulationComplete: custom navigation for standalone pathway
+ console.log('[SimulationPathwayWrapper] Simulation created with ID:', simulationId);
+ navigate(`/${countryId}/simulations`);
+ onComplete?.();
+ }
+ );
+
+ // Policy callbacks - no custom completion (stays within simulation pathway)
+ const policyCallbacks = createPolicyCallbacks(
+ setSimulationState,
+ (state) => state.policy,
+ (state, policy) => ({ ...state, policy }),
+ navigateToMode,
+ SimulationViewMode.SETUP,
+ undefined // No onPolicyComplete - stays within simulation pathway
+ );
+
+ // 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,
+ undefined // No onPopulationComplete - stays within simulation pathway
+ );
+
+ // ========== SPECIAL HANDLERS ==========
+ // Handle "Use Current Law" selection for policy
+ const handleSelectCurrentLaw = useCallback(() => {
+ if (!currentLawId) {
+ console.error('[SimulationPathwayWrapper] No current law ID available');
+ return;
+ }
+
+ setSimulationState((prev) => ({
+ ...prev,
+ policy: {
+ ...prev.policy,
+ id: currentLawId.toString(),
+ label: 'Current law',
+ parameters: [],
+ },
+ }));
+
+ navigateToMode(SimulationViewMode.SETUP);
+ }, [currentLawId, navigateToMode]);
+
+ // ========== VIEW RENDERING ==========
+ let currentView: React.ReactElement;
+
+ switch (currentMode) {
+ // ========== SIMULATION-LEVEL VIEWS ==========
+ case SimulationViewMode.LABEL:
+ currentView = (
+ navigateToMode(SimulationViewMode.SETUP)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ case SimulationViewMode.SETUP:
+ currentView = (
+ navigateToMode(SimulationViewMode.SUBMIT)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ case SimulationViewMode.SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ // ========== POLICY SETUP COORDINATION ==========
+ case SimulationViewMode.SETUP_POLICY:
+ currentView = (
+ navigateToMode(SimulationViewMode.POLICY_LABEL)}
+ onLoadExisting={() => navigateToMode(SimulationViewMode.SELECT_EXISTING_POLICY)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ // ========== POPULATION SETUP COORDINATION ==========
+ case SimulationViewMode.SETUP_POPULATION:
+ currentView = (
+ navigateToMode(SimulationViewMode.POPULATION_SCOPE)}
+ onLoadExisting={() => navigateToMode(SimulationViewMode.SELECT_EXISTING_POPULATION)}
+ onCopyExisting={() => {
+ // Not applicable in standalone mode
+ console.warn(
+ '[SimulationPathwayWrapper] Copy existing not applicable in standalone mode'
+ );
+ }}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ // ========== POLICY CREATION VIEWS ==========
+ case SimulationViewMode.POLICY_LABEL:
+ currentView = (
+ navigateToMode(SimulationViewMode.POLICY_PARAMETER_SELECTOR)}
+ onBack={canGoBack ? goBack : undefined}
+ onCancel={() => navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ case SimulationViewMode.POLICY_PARAMETER_SELECTOR:
+ currentView = (
+ navigateToMode(SimulationViewMode.POLICY_SUBMIT)}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case SimulationViewMode.POLICY_SUBMIT:
+ currentView = (
+ navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ case SimulationViewMode.SELECT_EXISTING_POLICY:
+ currentView = (
+ navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ // ========== POPULATION CREATION VIEWS ==========
+ case SimulationViewMode.POPULATION_SCOPE:
+ currentView = (
+ navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ case SimulationViewMode.POPULATION_LABEL:
+ currentView = (
+ {
+ // Navigate based on population type
+ if (simulationState.population.type === 'household') {
+ navigateToMode(SimulationViewMode.POPULATION_HOUSEHOLD_BUILDER);
+ } else {
+ navigateToMode(SimulationViewMode.POPULATION_GEOGRAPHIC_CONFIRM);
+ }
+ }}
+ onBack={canGoBack ? goBack : undefined}
+ />
+ );
+ break;
+
+ case SimulationViewMode.POPULATION_HOUSEHOLD_BUILDER:
+ currentView = (
+
+ );
+ break;
+
+ case SimulationViewMode.POPULATION_GEOGRAPHIC_CONFIRM:
+ currentView = (
+
+ );
+ break;
+
+ case SimulationViewMode.SELECT_EXISTING_POPULATION:
+ currentView = (
+ navigate(`/${countryId}/simulations`)}
+ />
+ );
+ break;
+
+ default:
+ currentView = Unknown view mode: {currentMode}
;
+ }
+
+ // Conditionally wrap with StandardLayout
+ // Views in MODES_WITH_OWN_LAYOUT manage their own AppShell
+ if (MODES_WITH_OWN_LAYOUT.has(currentMode as SimulationViewMode)) {
+ return currentView;
+ }
+
+ return {currentView};
+}
diff --git a/app/src/reducers/activeSelectors.ts b/app/src/reducers/activeSelectors.ts
deleted file mode 100644
index d58ebb23..00000000
--- a/app/src/reducers/activeSelectors.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { RootState } from '@/store';
-
-// Helper to detect Immer Proxy objects
-function isProxy(obj: any): boolean {
- return obj != null && typeof obj === 'object' && obj.constructor?.name === 'DraftObject';
-}
-
-/**
- * Cross-cutting selectors that combine report position with other reducers
- * These selectors provide a unified way to access the "active" item based on the current mode
- */
-
-/**
- * Select the currently active simulation based on mode and position
- * In report mode: uses activeSimulationPosition from report
- * In standalone mode: defaults to position 0
- */
-export const selectActiveSimulation = (state: RootState) => {
- 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/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/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/styles/components.ts b/app/src/styles/components.ts
index e6230535..a63ab2fb 100644
--- a/app/src/styles/components.ts
+++ b/app/src/styles/components.ts
@@ -171,7 +171,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.secondary[100],
border: `1px solid ${colors.primary[500]}`,
cursor: 'pointer',
@@ -188,7 +187,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.white,
border: `1px solid ${colors.border.light}`,
cursor: 'pointer',
@@ -205,7 +203,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.white,
border: `1px solid ${colors.border.light}`,
cursor: 'pointer',
@@ -222,7 +219,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.gray[50],
border: `1px solid ${colors.border.light}`,
cursor: 'not-allowed',
@@ -237,7 +233,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.secondary[100],
border: `1px solid ${colors.primary[500]}`,
cursor: 'pointer',
@@ -254,7 +249,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.white,
border: `1px solid ${colors.border.light}`,
cursor: 'pointer',
@@ -271,7 +265,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.gray[50],
border: `1px solid ${colors.border.light}`,
cursor: 'not-allowed',
@@ -285,7 +278,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.secondary[100],
border: `1px solid ${colors.primary[500]}`,
cursor: 'pointer',
@@ -302,7 +294,6 @@ export const themeComponents = {
return {
root: {
padding: spacing.md,
- marginBottom: spacing.md,
backgroundColor: colors.white,
border: `1px solid ${colors.border.light}`,
cursor: 'pointer',
diff --git a/app/src/tests/fixtures/components/FlowContainerMocks.tsx b/app/src/tests/fixtures/components/FlowContainerMocks.tsx
deleted file mode 100644
index 6edb3d7d..00000000
--- a/app/src/tests/fixtures/components/FlowContainerMocks.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { vi } from 'vitest';
-
-// Test constants
-export const TEST_STRINGS = {
- NO_FLOW_MESSAGE: 'No flow available',
- TEST_COMPONENT_TEXT: 'Test Component',
- ANOTHER_COMPONENT_TEXT: 'Another Test Component',
- IN_SUBFLOW_TEXT: 'In Subflow',
- FLOW_DEPTH_PREFIX: 'Flow Depth:',
- PARENT_PREFIX: 'Parent:',
- COMPONENT_NOT_FOUND_PREFIX: 'Component not found:',
- AVAILABLE_COMPONENTS_PREFIX: 'Available components:',
-} as const;
-
-export const TEST_FLOW_NAMES = {
- TEST_FLOW: 'testFlow',
- ANOTHER_FLOW: 'anotherFlow',
- PARENT_FLOW: 'parentFlow',
- FLOW_WITHOUT_EVENTS: 'flowWithoutEvents',
-} as const;
-
-export const TEST_FRAME_NAMES = {
- TEST_FRAME: 'testFrame',
- NEXT_FRAME: 'nextFrame',
- START_FRAME: 'startFrame',
- PARENT_FRAME: 'parentFrame',
- FRAME_WITH_NO_EVENTS: 'frameWithNoEvents',
- NON_EXISTENT_COMPONENT: 'nonExistentComponent',
- RETURN_FRAME: 'returnFrame',
- UNKNOWN_TARGET: 'unknownTargetValue',
-} as const;
-
-export const TEST_EVENTS = {
- NEXT: 'next',
- SUBMIT: 'submit',
- GO_TO_FLOW: 'goToFlow',
- BACK: 'back',
- INVALID_EVENT: 'invalidEvent',
- NON_EXISTENT_EVENT: 'nonExistentEvent',
- DIRECT_FLOW: 'directFlow',
- UNKNOWN_TARGET: 'unknownTarget',
-} as const;
-
-export const NAVIGATION_TARGETS = {
- RETURN_KEYWORD: '__return__',
-} as const;
-
-export const mockFlow = {
- initialFrame: TEST_FRAME_NAMES.TEST_FRAME as any,
- frames: {
- [TEST_FRAME_NAMES.TEST_FRAME]: {
- component: TEST_FRAME_NAMES.TEST_FRAME as any,
- 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,
- },
- },
- [TEST_FRAME_NAMES.NEXT_FRAME]: {
- component: TEST_FRAME_NAMES.NEXT_FRAME as any,
- on: {
- [TEST_EVENTS.BACK]: TEST_FRAME_NAMES.TEST_FRAME,
- },
- },
- },
-};
-
-export const mockFlowWithoutEvents = {
- initialFrame: TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS as any,
- frames: {
- [TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS]: {
- component: TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS as any,
- on: {},
- },
- },
-};
-
-export const mockSubflowStack = [
- {
- flow: {
- initialFrame: TEST_FRAME_NAMES.PARENT_FRAME as any,
- frames: {},
- },
- frame: TEST_FRAME_NAMES.PARENT_FRAME,
- },
-];
-
-export const TestComponent = vi.fn(
- ({ onNavigate, onReturn, isInSubflow, flowDepth, parentFlowContext }: any) => {
- 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/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/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/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/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/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/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/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/pathways/report/ReportPathwayWrapperMocks.ts b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts
new file mode 100644
index 00000000..63a6f50e
--- /dev/null
+++ b/app/src/tests/fixtures/pathways/report/ReportPathwayWrapperMocks.ts
@@ -0,0 +1,99 @@
+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.REPORT_LABEL,
+ SETUP: ReportViewMode.REPORT_SETUP,
+ SIMULATION_SELECTION: ReportViewMode.REPORT_SELECT_SIMULATION,
+ 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/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts
new file mode 100644
index 00000000..70774cac
--- /dev/null
+++ b/app/src/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks.ts
@@ -0,0 +1,146 @@
+import { vi } from 'vitest';
+
+// 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();
+export const mockOnClick = 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();
+ mockOnClick.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/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..d269f8aa
--- /dev/null
+++ b/app/src/tests/fixtures/pathways/report/views/PopulationViewMocks.ts
@@ -0,0 +1,56 @@
+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',
+ countryId: 'us',
+ householdData: {
+ people: {},
+ },
+ },
+ geography: null,
+};
+
+export const mockPopulationStateWithGeography: PopulationStateProps = {
+ label: 'National Households',
+ type: 'geography',
+ household: null,
+ geography: {
+ id: 'us-us',
+ countryId: 'us',
+ 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..038f103a
--- /dev/null
+++ b/app/src/tests/fixtures/pathways/report/views/ReportViewMocks.ts
@@ -0,0 +1,165 @@
+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 mockOnUpdateYear = 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,
+ parameters: [],
+ },
+ population: {
+ label: null,
+ type: null,
+ household: null,
+ geography: null,
+ },
+ apiVersion: undefined,
+ status: 'pending',
+};
+
+export const mockConfiguredSimulation: SimulationStateProps = {
+ id: '123',
+ label: 'Baseline Simulation',
+ countryId: TEST_COUNTRY_ID,
+ policy: {
+ id: '456',
+ label: 'Current Law',
+ parameters: [],
+ },
+ population: {
+ label: 'My Household',
+ type: 'household',
+ household: {
+ id: '789',
+ countryId: 'us',
+ householdData: {
+ people: {},
+ },
+ },
+ geography: null,
+ },
+ apiVersion: '0.1.0',
+ status: 'complete',
+};
+
+export const mockReportState: ReportStateProps = {
+ id: undefined,
+ label: null,
+ year: '2025',
+ 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,
+ isError: false,
+ error: null,
+ associations: [],
+};
+
+export const mockUseUserGeographicsEmpty = {
+ data: [],
+ isLoading: false,
+ isError: false,
+ error: null,
+ associations: [],
+};
+
+export function resetAllMocks() {
+ mockOnUpdateLabel.mockClear();
+ mockOnUpdateYear.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..5d22f54a
--- /dev/null
+++ b/app/src/tests/fixtures/pathways/report/views/SimulationViewMocks.ts
@@ -0,0 +1,91 @@
+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,
+ parameters: [],
+ },
+ population: {
+ label: null,
+ type: null,
+ household: null,
+ geography: null,
+ },
+ apiVersion: undefined,
+ status: 'pending',
+};
+
+export const mockSimulationStateConfigured: SimulationStateProps = {
+ id: '123',
+ label: 'Test Simulation',
+ countryId: TEST_COUNTRY_ID,
+ policy: {
+ id: '456',
+ label: 'Current Law',
+ parameters: [],
+ },
+ population: {
+ label: 'My Household',
+ type: 'household',
+ household: {
+ id: '789',
+ countryId: 'us',
+ householdData: {
+ people: {},
+ },
+ },
+ geography: null,
+ },
+ apiVersion: '0.1.0',
+ status: 'complete',
+};
+
+export const mockSimulationStateWithPolicy: SimulationStateProps = {
+ ...mockSimulationStateEmpty,
+ policy: {
+ id: '456',
+ label: 'Current Law',
+ parameters: [],
+ },
+};
+
+export const mockSimulationStateWithPopulation: SimulationStateProps = {
+ ...mockSimulationStateEmpty,
+ population: {
+ label: 'My Household',
+ type: 'household',
+ household: {
+ id: '789',
+ countryId: 'us',
+ householdData: {
+ 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/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/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/fixtures/utils/isDefaultBaselineSimulationMocks.ts b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts
new file mode 100644
index 00000000..0d37300b
--- /dev/null
+++ b/app/src/tests/fixtures/utils/isDefaultBaselineSimulationMocks.ts
@@ -0,0 +1,186 @@
+// Test constants
+export const TEST_CURRENT_LAW_ID = 1;
+
+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: '999',
+ 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/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/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/integration/report/SingleSimulationReportFlow.test.tsx b/app/src/tests/integration/report/SingleSimulationReportFlow.test.tsx
deleted file mode 100644
index 4c57a425..00000000
--- a/app/src/tests/integration/report/SingleSimulationReportFlow.test.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { configureStore } from '@reduxjs/toolkit';
-import { render, screen, userEvent, waitFor } from '@test-utils';
-import { Provider } from 'react-redux';
-import { beforeEach, describe, expect, test, vi } from 'vitest';
-import ReportSetupFrame from '@/frames/report/ReportSetupFrame';
-import ReportSubmitFrame from '@/frames/report/ReportSubmitFrame';
-import reportReducer from '@/reducers/reportReducer';
-import simulationsReducer from '@/reducers/simulationsReducer';
-import {
- BASELINE_SIMULATION_TITLE,
- COMPARISON_SIMULATION_OPTIONAL_TITLE,
- MOCK_HOUSEHOLD_SIMULATION,
- REVIEW_REPORT_LABEL,
-} from '@/tests/fixtures/frames/ReportSetupFrame';
-
-// Mock Plotly
-vi.mock('react-plotly.js', () => ({ default: vi.fn(() => null) }));
-
-// Mock hooks
-vi.mock('@/hooks/useCreateReport', () => ({
- useCreateReport: () => ({
- createReport: vi.fn(),
- isPending: false,
- }),
-}));
-
-vi.mock('@/hooks/useIngredientReset', () => ({
- useIngredientReset: () => ({
- resetIngredient: vi.fn(),
- }),
-}));
-
-vi.mock('@/hooks/useUserHousehold', () => ({
- useUserHouseholds: () => ({
- data: [],
- isLoading: false,
- isError: false,
- error: null,
- associations: [],
- }),
- isHouseholdMetadataWithAssociation: vi.fn(() => false),
-}));
-
-vi.mock('@/hooks/useUserGeographic', () => ({
- useUserGeographics: () => ({
- data: [],
- isLoading: false,
- isError: false,
- error: null,
- associations: [],
- }),
- isGeographicMetadataWithAssociation: vi.fn(() => false),
-}));
-
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom');
- return {
- ...actual,
- useNavigate: () => vi.fn(),
- };
-});
-
-describe('Single Simulation Report Flow Integration', () => {
- let store: any;
-
- beforeEach(() => {
- store = configureStore({
- reducer: {
- report: reportReducer,
- simulations: simulationsReducer,
- },
- preloadedState: {
- report: {
- id: '',
- countryId: 'us' as const,
- year: '2024',
- apiVersion: 'v1',
- label: null,
- simulationIds: [],
- status: 'pending' as const,
- output: null,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- activeSimulationPosition: 0 as const,
- mode: 'standalone' as const,
- },
- simulations: {
- simulations: [null, null] as [null, null],
- },
- },
- });
- });
-
- test('given user configures household simulation then can proceed without comparison simulation', async () => {
- // Given
- const user = userEvent.setup();
- const mockOnNavigate = vi.fn();
- const flowProps = {
- onNavigate: mockOnNavigate,
- onReturn: vi.fn(),
- flowConfig: { component: 'ReportSetupFrame' as any, on: {} },
- isInSubflow: false,
- flowDepth: 0,
- };
-
- render(
-
-
-
- );
-
- // When - Select baseline simulation card
- await user.click(screen.getByText(BASELINE_SIMULATION_TITLE));
-
- // Then - Setup baseline button should appear
- expect(screen.getByRole('button', { name: /setup baseline simulation/i })).toBeInTheDocument();
-
- // When - Configure baseline simulation in store
- await waitFor(() => {
- store.dispatch({
- type: 'simulations/createSimulationAtPosition',
- payload: { position: 0, simulation: MOCK_HOUSEHOLD_SIMULATION },
- });
- });
-
- // Then - Review report button should be enabled (component re-renders automatically)
- await waitFor(() => {
- const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL });
- expect(reviewButton).toBeEnabled();
- });
-
- // Then - Comparison simulation should show as optional
- expect(screen.getByText(COMPARISON_SIMULATION_OPTIONAL_TITLE)).toBeInTheDocument();
- });
-
- test('given user configures geography simulation then cannot proceed without comparison simulation', async () => {
- // Given
- const mockOnNavigate = vi.fn();
- const flowProps = {
- onNavigate: mockOnNavigate,
- onReturn: vi.fn(),
- flowConfig: { component: 'ReportSetupFrame' as any, on: {} },
- isInSubflow: false,
- flowDepth: 0,
- };
-
- // Configure geography simulation in store
- const geographySim = {
- ...MOCK_HOUSEHOLD_SIMULATION,
- populationType: 'geography' as const,
- populationId: 'geography_1',
- };
-
- store.dispatch({
- type: 'simulations/createSimulationAtPosition',
- payload: { position: 0, simulation: geographySim },
- });
-
- // When
- render(
-
-
-
- );
-
- // Then - Review report button should be disabled
- const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL });
- expect(reviewButton).toBeDisabled();
-
- // Then - Comparison simulation should show as required
- expect(screen.getByText(/comparison simulation$/i)).toBeInTheDocument();
- expect(screen.getByText(/required/i)).toBeInTheDocument();
- });
-
- test('given single household simulation configured then submit frame allows submission', () => {
- // Given
- store.dispatch({
- type: 'simulations/createSimulationAtPosition',
- payload: { position: 0, simulation: MOCK_HOUSEHOLD_SIMULATION },
- });
-
- const flowProps = {
- onNavigate: vi.fn(),
- onReturn: vi.fn(),
- flowConfig: { component: 'ReportSubmitFrame' as any, on: {} },
- isInSubflow: false,
- flowDepth: 0,
- };
-
- // When
- render(
-
-
-
- );
-
- // Then - Submit button should be enabled
- const generateButton = screen.getByRole('button', { name: /generate report/i });
- expect(generateButton).toBeEnabled();
-
- // Then - Should show baseline simulation summary
- expect(screen.getByText('Baseline simulation')).toBeInTheDocument();
- expect(screen.getByText(MOCK_HOUSEHOLD_SIMULATION.label!)).toBeInTheDocument();
- });
-});
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/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/common/FlowView.test.tsx b/app/src/tests/unit/components/common/FlowView.test.tsx
deleted file mode 100644
index c2c168ad..00000000
--- a/app/src/tests/unit/components/common/FlowView.test.tsx
+++ /dev/null
@@ -1,418 +0,0 @@
-import { render, screen, userEvent } from '@test-utils';
-import { beforeEach, describe, expect, test, vi } from 'vitest';
-import FlowView from '@/components/common/FlowView';
-import {
- BUTTON_PRESETS,
- createButtonPanelCard,
- createCardListItem,
- createSetupConditionCard,
- FLOW_VIEW_STRINGS,
- FLOW_VIEW_VARIANTS,
- mockButtonPanelCards,
- mockCancelAction,
- mockCardClick,
- mockCardListItems,
- MockCustomContent,
- mockExplicitButtons,
- mockItemClick,
- mockPrimaryAction,
- mockPrimaryActionDisabled,
- mockPrimaryActionLoading,
- mockPrimaryClick,
- mockSetupConditionCards,
- resetAllMocks,
-} from '@/tests/fixtures/components/common/FlowViewMocks';
-
-describe('FlowView', () => {
- beforeEach(() => {
- resetAllMocks();
- vi.spyOn(console, 'log').mockImplementation(() => {});
- });
-
- 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();
- });
-
- test('given only title then renders without subtitle', () => {
- render();
-
- expect(screen.getByText(FLOW_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument();
- expect(screen.queryByText(FLOW_VIEW_STRINGS.SUBTITLE)).not.toBeInTheDocument();
- });
-
- test('given custom content then renders content', () => {
- render(} />);
-
- expect(screen.getByTestId('custom-content')).toBeInTheDocument();
- expect(screen.getByText(FLOW_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();
- });
-
- 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),
- });
- expect(card).toBeInTheDocument();
- });
-
- test('given selected condition then applies active variant', () => {
- const selectedCard = createSetupConditionCard({ isSelected: true });
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
- });
- expect(card).toHaveAttribute('data-variant', 'setupCondition--active');
- });
-
- test('given disabled condition then disables card', () => {
- const disabledCard = createSetupConditionCard({ isDisabled: true });
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
- });
- expect(card).toBeDisabled();
- });
-
- test('given user clicks setup card then calls onClick handler', async () => {
- const user = userEvent.setup();
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
- });
- await user.click(card);
-
- expect(mockCardClick).toHaveBeenCalledTimes(1);
- });
- });
-
- 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();
- });
-
- 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),
- });
- expect(card).toHaveAttribute('data-variant', 'buttonPanel--active');
- });
-
- test('given user clicks panel card then calls onClick handler', async () => {
- const user = userEvent.setup();
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE),
- });
- await user.click(card);
-
- expect(mockCardClick).toHaveBeenCalledTimes(1);
- });
- });
-
- 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();
- });
-
- 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();
- });
-
- 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),
- });
- expect(card).toHaveAttribute('data-variant', 'cardList--active');
- });
-
- test('given disabled item then disables card', () => {
- const disabledItem = createCardListItem({ isDisabled: true });
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE),
- });
- expect(card).toBeDisabled();
- });
-
- test('given user clicks list item then calls onClick handler', async () => {
- const user = userEvent.setup();
-
- render(
-
- );
-
- const card = screen.getByRole('button', {
- name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE),
- });
- await user.click(card);
-
- expect(mockItemClick).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('Button Configuration', () => {
- test('given explicit buttons then renders them', () => {
- render();
-
- expect(
- screen.getByRole('button', { name: FLOW_VIEW_STRINGS.BACK_BUTTON })
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: FLOW_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 })
- ).toBeInTheDocument();
- expect(
- screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON })
- ).not.toBeInTheDocument();
- });
-
- test('given none preset then renders no buttons', () => {
- 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 })
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: FLOW_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 });
- expect(submitButton).toBeDisabled();
- });
-
- test('given loading primary action then passes loading state', () => {
- render(
-
- );
-
- const submitButton = screen.getByRole('button', { name: FLOW_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 });
- expect(cancelButton).toBeDisabled();
- });
-
- test('given cancel action then renders disabled cancel button', () => {
- render();
-
- const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON });
- expect(cancelButton).toBeDisabled();
- });
-
- test('given user clicks primary button then calls primary handler', async () => {
- const user = userEvent.setup();
-
- render();
-
- const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON });
- await user.click(submitButton);
-
- expect(mockPrimaryClick).toHaveBeenCalledTimes(1);
- });
- });
-
- 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 })
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CONTINUE_BUTTON })
- ).toBeInTheDocument();
- expect(
- screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON })
- ).not.toBeInTheDocument();
- expect(
- screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
- ).not.toBeInTheDocument();
- });
-
- test('given no actions and no preset then renders default cancel button', () => {
- render();
-
- expect(
- screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
- ).toBeInTheDocument();
- });
- });
-});
diff --git a/app/src/tests/unit/components/common/PathwayView.test.tsx b/app/src/tests/unit/components/common/PathwayView.test.tsx
new file mode 100644
index 00000000..ddacf471
--- /dev/null
+++ b/app/src/tests/unit/components/common/PathwayView.test.tsx
@@ -0,0 +1,432 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import PathwayView from '@/components/common/PathwayView';
+import {
+ BUTTON_PRESETS,
+ createButtonPanelCard,
+ createCardListItem,
+ createSetupConditionCard,
+ mockButtonPanelCards,
+ mockCancelAction,
+ mockCardClick,
+ mockCardListItems,
+ MockCustomContent,
+ mockExplicitButtons,
+ mockItemClick,
+ mockPrimaryAction,
+ mockPrimaryActionDisabled,
+ mockPrimaryActionLoading,
+ mockPrimaryClick,
+ mockSetupConditionCards,
+ PATHWAY_VIEW_STRINGS,
+ PATHWAY_VIEW_VARIANTS,
+ resetAllMocks,
+} from '@/tests/fixtures/components/common/PathwayViewMocks';
+
+describe('PathwayView', () => {
+ beforeEach(() => {
+ resetAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ describe('Basic Rendering', () => {
+ test('given title and subtitle then renders both correctly', () => {
+ render(
+
+ );
+
+ 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();
+
+ 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(
+ } />
+ );
+
+ expect(screen.getByTestId('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(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(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toBeInTheDocument();
+ });
+
+ test('given selected condition then applies active variant', () => {
+ const selectedCard = createSetupConditionCard({ isSelected: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'setupCondition--active');
+ });
+
+ test('given disabled condition then disables card', () => {
+ const disabledCard = createSetupConditionCard({ isDisabled: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toBeDisabled();
+ });
+
+ test('given user clicks setup card then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockCardClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Panel Variant', () => {
+ test('given button panel cards then renders all cards', () => {
+ render(
+
+ );
+
+ 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(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'buttonPanel--active');
+ });
+
+ test('given user clicks panel card then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.PANEL_CARD_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockCardClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Card List Variant', () => {
+ test('given card list items then renders all items', () => {
+ render(
+
+ );
+
+ 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(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(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'cardList--active');
+ });
+
+ test('given disabled item then disables card', () => {
+ const disabledItem = createCardListItem({ isDisabled: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ expect(card).toBeDisabled();
+ });
+
+ test('given user clicks list item then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(PATHWAY_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockItemClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Configuration', () => {
+ test('given explicit buttons then renders them', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.BACK_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ 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: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON })
+ ).not.toBeInTheDocument();
+ });
+
+ test('given none preset then renders no buttons', () => {
+ 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: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ 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: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON });
+ expect(submitButton).toBeDisabled();
+ });
+
+ test('given loading primary action then passes loading state', () => {
+ render(
+
+ );
+
+ 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: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON });
+ expect(cancelButton).toBeDisabled();
+ });
+
+ 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).not.toBeDisabled();
+ });
+
+ test('given user clicks primary button then calls primary handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const submitButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON });
+ await user.click(submitButton);
+
+ expect(mockPrimaryClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Precedence', () => {
+ test('given explicit buttons and convenience props then uses new layout with actions', () => {
+ render(
+
+ );
+
+ // When convenience props are provided, they take precedence over explicit buttons
+ expect(
+ screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ });
+
+ test('given no actions and no preset then renders no buttons', () => {
+ render();
+
+ // Without any button configuration, no buttons are rendered
+ const buttons = screen.queryAllByRole('button');
+ expect(buttons).toHaveLength(0);
+ });
+ });
+});
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/HouseholdBuilderFrame.test.tsx b/app/src/tests/unit/frames/population/HouseholdBuilderFrame.test.tsx
deleted file mode 100644
index 2438dd9e..00000000
--- a/app/src/tests/unit/frames/population/HouseholdBuilderFrame.test.tsx
+++ /dev/null
@@ -1,457 +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 HouseholdBuilderFrame from '@/frames/population/HouseholdBuilderFrame';
-import metadataReducer from '@/reducers/metadataReducer';
-import populationReducer from '@/reducers/populationReducer';
-import reportReducer from '@/reducers/reportReducer';
-import {
- getMockHousehold,
- mockCreateHouseholdResponse,
- mockFlowProps,
- mockTaxYears,
-} from '@/tests/fixtures/frames/populationMocks';
-
-// Mock household utilities
-vi.mock('@/utils/HouseholdBuilder', () => ({
- HouseholdBuilder: 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(),
- })),
-}));
-
-vi.mock('@/utils/HouseholdQueries', () => ({
- getChildCount: vi.fn(() => 0),
- getChildren: vi.fn(() => []),
- getPersonVariable: vi.fn((_household, _person, variable, _year) => {
- if (variable === 'age') {
- return 30;
- }
- if (variable === 'employment_income') {
- return 50000;
- }
- return 0;
- }),
-}));
-
-vi.mock('@/utils/HouseholdValidation', () => ({
- HouseholdValidation: {
- isReadyForSimulation: vi.fn(() => ({ isValid: true, errors: [] })),
- },
-}));
-
-// Mock adapter
-vi.mock('@/adapters/HouseholdAdapter', () => ({
- HouseholdAdapter: {
- toCreationPayload: vi.fn(() => ({
- country_id: 'us',
- data: getMockHousehold().householdData,
- })),
- },
-}));
-
-// Mock hooks - hoisted to ensure they're available before module load
-const { mockCreateHousehold, mockResetIngredient } = vi.hoisted(() => ({
- mockCreateHousehold: vi.fn(),
- mockResetIngredient: vi.fn(),
-}));
-
-vi.mock('@/hooks/useCreateHousehold', () => ({
- useCreateHousehold: () => ({
- createHousehold: mockCreateHousehold,
- isPending: false,
- }),
-}));
-
-vi.mock('@/hooks/useIngredientReset', () => ({
- useIngredientReset: () => ({
- resetIngredient: mockResetIngredient,
- }),
-}));
-
-// Mock metadata selectors
-const mockBasicInputFields = {
- person: ['age', 'employment_income'],
- household: ['state_code'],
-};
-
-const mockFieldOptions = [
- { value: 'CA', label: 'California' },
- { value: 'NY', label: 'New York' },
-];
-
-vi.mock('@/libs/metadataUtils', () => ({
- getTaxYears: () => mockTaxYears,
- getBasicInputFields: () => mockBasicInputFields,
- getFieldLabel: (field: string) => {
- const labels: Record = {
- state_code: 'State',
- age: 'Age',
- employment_income: 'Employment Income',
- };
- return labels[field] || field;
- },
- isDropdownField: (_state: any, field: string) => field === 'state_code',
- getFieldOptions: (_state: any, _field: string) => mockFieldOptions,
-}));
-
-describe('HouseholdBuilderFrame', () => {
- let store: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
- mockCreateHousehold.mockReset();
- mockResetIngredient.mockReset();
- mockCreateHousehold.mockResolvedValue(mockCreateHouseholdResponse);
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- const renderComponent = (
- populationState: any = {},
- metadataState: Partial = {
- currentCountry: 'us',
- variables: {
- age: { defaultValue: 30 },
- employment_income: { defaultValue: 0 },
- },
- basic_inputs: {
- person: ['age', 'employment_income'],
- household: ['state_code'],
- },
- loading: false,
- error: null,
- },
- props = mockFlowProps
- ) => {
- const basePopulationState = {
- populations: [null, null],
- ...populationState,
- };
- const fullMetadataState = {
- loading: false,
- error: null,
- currentCountry: 'us',
- variables: {
- age: { defaultValue: 30 },
- employment_income: { defaultValue: 0 },
- state_code: {
- defaultValue: '',
- possibleValues: mockFieldOptions,
- },
- },
- parameters: {},
- entities: {},
- variableModules: {},
- economyOptions: { region: [], time_period: [], datasets: [] },
- currentLawId: 0,
- basicInputs: ['age', 'employment_income'],
- basic_inputs: {
- person: ['age', 'employment_income'],
- household: ['state_code'],
- },
- modelledPolicies: { core: {}, filtered: {} },
- version: null,
- parameterTree: null,
- ...metadataState,
- };
-
- store = configureStore({
- reducer: {
- population: populationReducer,
- report: reportReducer,
- metadata: metadataReducer,
- },
- preloadedState: {
- population: basePopulationState,
- metadata: fullMetadataState,
- },
- });
-
- return render(
-
-
-
-
- } />
-
-
-
-
- );
- };
-
- describe('Component rendering', () => {
- test('given component loads then displays household builder form', () => {
- // When
- renderComponent();
-
- // Then
- expect(screen.getByText('Build Your Household')).toBeInTheDocument();
- expect(screen.getByText('Marital Status')).toBeInTheDocument();
- expect(screen.getByText('Number of Children')).toBeInTheDocument();
- });
-
- test('given metadata error then displays error state', () => {
- // Given
- const metadataState = {
- loading: false,
- error: 'Failed to load metadata',
- };
-
- // When
- renderComponent({}, metadataState);
-
- // Then
- expect(screen.getByText('Failed to Load Required Data')).toBeInTheDocument();
- expect(screen.getByText(/Unable to load household configuration data/)).toBeInTheDocument();
- });
-
- test('given loading state then shows loading overlay', () => {
- // Given
- const metadataState = {
- loading: true,
- error: null,
- currentCountry: 'us',
- variables: {},
- basic_inputs: { person: [], household: [] },
- };
-
- // When
- renderComponent({}, metadataState);
-
- // Then
- const loadingOverlay = document.querySelector('.mantine-LoadingOverlay-root');
- expect(loadingOverlay).toBeInTheDocument();
- });
- });
-
- describe('Household configuration', () => {
- test('given marital status changed to married then shows partner fields', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When
- const maritalLabel = screen.getByText('Marital Status');
- const maritalSelect = maritalLabel.parentElement?.querySelector('input') as HTMLElement;
- await user.click(maritalSelect);
- const marriedOption = await screen.findByText('Married');
- await user.click(marriedOption);
-
- // Then
- await waitFor(() => {
- expect(screen.getByText('Your Partner')).toBeInTheDocument();
- });
- });
-
- test('given number of children changed then shows child fields', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When
- const childrenLabel = screen.getByText('Number of Children');
- const childrenSelect = childrenLabel.parentElement?.querySelector('input') as HTMLElement;
- await user.click(childrenSelect);
- const twoChildren = await screen.findByText('2');
- await user.click(twoChildren);
-
- // Then
- await waitFor(() => {
- expect(screen.getByText('Child 1')).toBeInTheDocument();
- expect(screen.getByText('Child 2')).toBeInTheDocument();
- });
- });
-
- test.skip('given tax year changed then updates household data', async () => {
- // Note: Tax year selection has been removed from HouseholdBuilderFrame
- // Year is now set at report level and passed via useReportYear hook
- // This test is skipped as the feature is no longer in this component
- });
- });
-
- describe('Field value changes', () => {
- test('given adult age changed then updates household data', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When
- const ageInputs = screen.getAllByPlaceholderText('Age');
- const primaryAdultAge = ageInputs[0];
-
- await user.clear(primaryAdultAge);
- await user.type(primaryAdultAge, '35');
-
- // Then
- await waitFor(() => {
- expect(primaryAdultAge).toHaveValue('35');
- });
- });
-
- test('given employment income changed then updates household data', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When
- const incomeInputs = screen.getAllByPlaceholderText('Employment Income');
- const primaryIncome = incomeInputs[0];
-
- await user.clear(primaryIncome);
- await user.type(primaryIncome, '75000');
-
- // Then
- await waitFor(() => {
- const value = (primaryIncome as HTMLInputElement).value;
- expect(value).toContain('75'); // Check that the value contains 75
- });
- });
-
- test.skip('given household field changed then updates household data', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When - Check if State field is rendered
- const stateLabels = screen.queryAllByText('State');
- if (stateLabels.length === 0) {
- // State field not rendered, skip test as the component structure has changed
- console.warn('State field not found - skipping test');
- return;
- }
-
- const stateLabel = stateLabels[0];
- const stateSelect = stateLabel.parentElement?.querySelector('input') as HTMLElement;
- await user.click(stateSelect);
- const california = await screen.findByText('California');
- await user.click(california);
-
- // Then
- await waitFor(() => {
- const stateLabel2 = screen.getByText('State');
- const stateInput = stateLabel2.parentElement?.querySelector('input') as HTMLInputElement;
- expect(stateInput.value).toBe('California');
- });
- });
- });
-
- describe('Form submission', () => {
- test('given valid household when submitted then creates household', async () => {
- // Given
- const user = userEvent.setup();
- const mockHouseholdData = getMockHousehold();
- const populationState = {
- label: 'Test Household',
- household: mockHouseholdData,
- };
- const props = { ...mockFlowProps };
- renderComponent(populationState, undefined, props);
-
- // When
- const submitButton = screen.getByRole('button', { name: /Create household/i });
- await user.click(submitButton);
-
- // Then
- await waitFor(() => {
- expect(mockCreateHousehold).toHaveBeenCalledWith(
- expect.objectContaining({
- country_id: 'us',
- data: mockHouseholdData.householdData,
- })
- );
- });
-
- await waitFor(() => {
- expect(props.onReturn).toHaveBeenCalled();
- });
- });
-
- test('given invalid household when submitted then does not create', async () => {
- // Given
- const { HouseholdValidation } = await import('@/utils/HouseholdValidation');
- (HouseholdValidation.isReadyForSimulation as any).mockReturnValue({
- isValid: false,
- errors: ['Missing required fields'],
- });
-
- renderComponent();
-
- // When
- const submitButton = screen.getByRole('button', { name: /Create household/i });
-
- // Then
- expect(submitButton).toBeDisabled();
- });
- });
-
- describe('Complex household scenarios', () => {
- test('given married with children configuration then creates complete household', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When - Configure married with 2 children
- const maritalLabel2 = screen.getByText('Marital Status');
- const maritalSelect2 = maritalLabel2.parentElement?.querySelector('input') as HTMLElement;
- await user.click(maritalSelect2);
- const marriedOption = await screen.findByText('Married');
- await user.click(marriedOption);
-
- const childrenLabel2 = screen.getByText('Number of Children');
- const childrenSelect2 = childrenLabel2.parentElement?.querySelector('input') as HTMLElement;
- await user.click(childrenSelect2);
- const twoChildren = await screen.findByText('2');
- await user.click(twoChildren);
-
- // Then - Verify all family members are displayed
- await waitFor(() => {
- expect(screen.getByText('You')).toBeInTheDocument();
- expect(screen.getByText('Your Partner')).toBeInTheDocument();
- expect(screen.getByText('Child 1')).toBeInTheDocument();
- expect(screen.getByText('Child 2')).toBeInTheDocument();
- });
- });
-
- test('given switching from married to single then removes partner', async () => {
- // Given
- const user = userEvent.setup();
- renderComponent();
-
- // When - Set to married first
- const maritalLabel = screen.getByText('Marital Status');
- const maritalSelect = maritalLabel.parentElement?.querySelector('input') as HTMLElement;
- await user.click(maritalSelect);
- const marriedOption = await screen.findByText('Married');
- await user.click(marriedOption);
-
- // Verify partner appears
- await waitFor(() => {
- expect(screen.getByText('Your Partner')).toBeInTheDocument();
- });
-
- // Then switch back to single
- await user.click(maritalSelect);
- const singleOption = await screen.findByText('Single');
- await user.click(singleOption);
-
- // Then - Partner should be removed
- await waitFor(() => {
- expect(screen.queryByText('Your Partner')).not.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/ReportCreationFrame.test.tsx b/app/src/tests/unit/frames/report/ReportCreationFrame.test.tsx
deleted file mode 100644
index 3b5453c9..00000000
--- a/app/src/tests/unit/frames/report/ReportCreationFrame.test.tsx
+++ /dev/null
@@ -1,250 +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 ReportCreationFrame from '@/frames/report/ReportCreationFrame';
-import flowReducer from '@/reducers/flowReducer';
-import metadataReducer from '@/reducers/metadataReducer';
-import policyReducer from '@/reducers/policyReducer';
-import populationReducer from '@/reducers/populationReducer';
-import reportReducer, * as reportActions from '@/reducers/reportReducer';
-import simulationsReducer from '@/reducers/simulationsReducer';
-import {
- CREATE_REPORT_BUTTON_LABEL,
- EMPTY_REPORT_LABEL,
- REPORT_CREATION_FRAME_TITLE,
- REPORT_NAME_INPUT_LABEL,
- TEST_REPORT_LABEL,
- YEAR_INPUT_LABEL,
-} from '@/tests/fixtures/frames/ReportCreationFrame';
-
-describe('ReportCreationFrame', () => {
- let store: any;
- let mockOnNavigate: ReturnType;
- let mockOnReturn: ReturnType;
- let defaultFlowProps: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // 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: 'ReportCreationFrame',
- on: {
- next: '__return__',
- },
- },
- isInSubflow: false,
- flowDepth: 0,
- };
-
- // Spy on the action creators
- vi.spyOn(reportActions, 'clearReport');
- vi.spyOn(reportActions, 'updateLabel');
- });
-
- // Helper to render with router context
- const renderWithRouter = (component: React.ReactElement) => {
- return render(
-
-
-
-
-
-
-
-
-
- );
- };
-
- test('given component mounts then clears report state', () => {
- // Given/When
- renderWithRouter();
-
- // Then - should have cleared the report
- expect(reportActions.clearReport).toHaveBeenCalled();
- });
-
- test('given component renders then displays correct UI elements', () => {
- // Given/When
- renderWithRouter(
-
-
-
- );
-
- // Then - should display title, inputs and button
- expect(screen.getByRole('heading', { name: REPORT_CREATION_FRAME_TITLE })).toBeInTheDocument();
- expect(screen.getByLabelText(REPORT_NAME_INPUT_LABEL)).toBeInTheDocument();
- expect(screen.getByText(YEAR_INPUT_LABEL)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: CREATE_REPORT_BUTTON_LABEL })).toBeInTheDocument();
- });
-
- test('given year dropdown renders then shows year label', () => {
- // Given/When
- renderWithRouter(
-
-
-
- );
-
- // Then - year dropdown should show the year label
- expect(screen.getByText(YEAR_INPUT_LABEL)).toBeInTheDocument();
-
- // And - the Select component should be rendered (verified by presence of input)
- const yearLabel = screen.getByText(YEAR_INPUT_LABEL);
- const yearInput = yearLabel.parentElement?.querySelector('input');
- expect(yearInput).toBeInTheDocument();
- });
-
- test('given user enters label then input value updates', async () => {
- // Given
- const user = userEvent.setup();
- renderWithRouter(
-
-
-
- );
-
- const input = screen.getByLabelText(REPORT_NAME_INPUT_LABEL) as HTMLInputElement;
-
- // When
- await user.type(input, TEST_REPORT_LABEL);
-
- // Then
- expect(input.value).toBe(TEST_REPORT_LABEL);
- });
-
- test('given user submits label then dispatches updateLabel action', async () => {
- // Given
- const user = userEvent.setup();
- renderWithRouter(
-
-
-
- );
-
- const input = screen.getByLabelText(REPORT_NAME_INPUT_LABEL);
- const submitButton = screen.getByRole('button', { name: CREATE_REPORT_BUTTON_LABEL });
-
- // When
- await user.type(input, TEST_REPORT_LABEL);
- await user.click(submitButton);
-
- // Then - should dispatch updateLabel to report reducer
- expect(reportActions.updateLabel).toHaveBeenCalledWith(TEST_REPORT_LABEL);
-
- // And - should navigate to next
- expect(mockOnNavigate).toHaveBeenCalledWith('next');
- });
-
- test('given user submits label then reducer state is updated', async () => {
- // Given
- const user = userEvent.setup();
- renderWithRouter(
-
-
-
- );
-
- const input = screen.getByLabelText(REPORT_NAME_INPUT_LABEL);
- const submitButton = screen.getByRole('button', { name: CREATE_REPORT_BUTTON_LABEL });
-
- // When
- await user.type(input, TEST_REPORT_LABEL);
- await user.click(submitButton);
-
- // Then - check reducer state
- const state = store.getState();
- expect(state.report.label).toBe(TEST_REPORT_LABEL);
- });
-
- test('given empty label then still dispatches to reducer', async () => {
- // Given
- const user = userEvent.setup();
- renderWithRouter(
-
-
-
- );
-
- const submitButton = screen.getByRole('button', { name: CREATE_REPORT_BUTTON_LABEL });
-
- // When - submit without entering a label
- await user.click(submitButton);
-
- // Then - should dispatch empty string to reducer
- expect(reportActions.updateLabel).toHaveBeenCalledWith(EMPTY_REPORT_LABEL);
-
- // And - should still navigate to next
- expect(mockOnNavigate).toHaveBeenCalledWith('next');
- });
-
- test('given component mounts multiple times then clears report each time', () => {
- // Given
- const { unmount } = renderWithRouter(
-
-
-
- );
-
- // Reset spy count after first mount
- vi.clearAllMocks();
-
- // When - unmount and mount a new instance
- unmount();
- renderWithRouter(
-
-
-
- );
-
- // Then - should clear report again on new mount
- expect(reportActions.clearReport).toHaveBeenCalledTimes(1);
- });
-
- test('given pre-existing report data then clears on mount', async () => {
- // Given - populate report with existing data
- store.dispatch(reportActions.updateLabel('Existing Report'));
- store.dispatch(reportActions.addSimulationId('123'));
-
- // Verify pre-existing data
- let state = store.getState();
- expect(state.report.label).toBe('Existing Report');
- expect(state.report.simulationIds).toContain('123');
-
- // When
- renderWithRouter();
-
- // Then - report should be cleared (wait for async thunk)
- expect(reportActions.clearReport).toHaveBeenCalled();
- await waitFor(() => {
- state = store.getState();
- expect(state.report.label).toBeNull();
- expect(state.report.simulationIds).toHaveLength(0);
- });
- });
-});
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/pathways/policy/PolicyPathwayWrapper.test.tsx b/app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx
new file mode 100644
index 00000000..27deed9d
--- /dev/null
+++ b/app/src/tests/unit/pathways/policy/PolicyPathwayWrapper.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from '@test-utils';
+import { useParams } from 'react-router-dom';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+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(),
+ };
+});
+
+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..776e5922
--- /dev/null
+++ b/app/src/tests/unit/pathways/population/PopulationPathwayWrapper.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@test-utils';
+import { useParams } from 'react-router-dom';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+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(),
+ };
+});
+
+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..44410cb8
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/ReportPathwayWrapper.test.tsx
@@ -0,0 +1,166 @@
+import { render, screen } from '@test-utils';
+import { useParams } from 'react-router-dom';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCreateReport } from '@/hooks/useCreateReport';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+import { useUserSimulations } from '@/hooks/useUserSimulations';
+import ReportPathwayWrapper from '@/pathways/report/ReportPathwayWrapper';
+import {
+ mockMetadata,
+ mockNavigate,
+ mockOnComplete,
+ mockUseCreateReport,
+ mockUseParams,
+ mockUseParamsInvalid,
+ mockUseParamsMissing,
+ mockUseUserGeographics,
+ mockUseUserHouseholds,
+ mockUseUserPolicies,
+ mockUseUserSimulations,
+ 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(),
+ })),
+}));
+
+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/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);
+ });
+ });
+});
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..936e0b57
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/components/DefaultBaselineOption.test.tsx
@@ -0,0 +1,180 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import DefaultBaselineOption from '@/pathways/report/components/DefaultBaselineOption';
+import {
+ DEFAULT_BASELINE_LABELS,
+ mockOnClick,
+ resetAllMocks,
+ TEST_COUNTRIES,
+} from '@/tests/fixtures/pathways/report/components/DefaultBaselineOptionMocks';
+
+describe('DefaultBaselineOption', () => {
+ beforeEach(() => {
+ resetAllMocks();
+ vi.clearAllMocks();
+ });
+
+ 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('Selection state', () => {
+ test('given isSelected is false then shows inactive variant', () => {
+ // When
+ const { container } = render(
+
+ );
+
+ // Then
+ const button = container.querySelector('[data-variant="buttonPanel--inactive"]');
+ expect(button).toBeInTheDocument();
+ });
+
+ test('given isSelected is true then shows active variant', () => {
+ // When
+ const { container } = render(
+
+ );
+
+ // Then
+ const button = container.querySelector('[data-variant="buttonPanel--active"]');
+ expect(button).toBeInTheDocument();
+ });
+ });
+
+ describe('User interactions', () => {
+ test('given button is clicked then onClick callback is invoked', async () => {
+ // Given
+ const user = userEvent.setup();
+ const mockCallback = vi.fn();
+
+ render(
+
+ );
+
+ const button = screen.getByRole('button');
+
+ // When
+ await user.click(button);
+
+ // Then
+ expect(mockCallback).toHaveBeenCalledOnce();
+ });
+
+ test('given button is clicked multiple times then onClick is called each time', async () => {
+ // Given
+ const user = userEvent.setup();
+ const mockCallback = vi.fn();
+
+ render(
+
+ );
+
+ const button = screen.getByRole('button');
+
+ // When
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+
+ // Then
+ expect(mockCallback).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ 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();
+ });
+ });
+});
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..8d5dc3b4
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/ReportLabelView.test.tsx
@@ -0,0 +1,357 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import ReportLabelView from '@/pathways/report/views/ReportLabelView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnNext,
+ mockOnUpdateLabel,
+ mockOnUpdateYear,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+ TEST_REPORT_LABEL,
+} from '@/tests/fixtures/pathways/report/views/ReportViewMocks';
+
+vi.mock('@/hooks/useCurrentCountry', () => ({
+ useCurrentCountry: vi.fn(),
+}));
+
+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 searchable input
+ const yearInput = container.querySelector('input[aria-haspopup="listbox"]');
+ expect(yearInput).toBeInTheDocument();
+ });
+ });
+
+ 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 onUpdateYear with year value', async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+
+ );
+ const submitButton = screen.getByRole('button', { name: /initialize report/i });
+
+ // When
+ await user.click(submitButton);
+
+ // Then
+ expect(mockOnUpdateYear).toHaveBeenCalledWith('2025');
+ });
+
+ 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..b5667c30
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/ReportSetupView.test.tsx
@@ -0,0 +1,336 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import ReportSetupView from '@/pathways/report/views/ReportSetupView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnNavigateToSimulationSelection,
+ mockOnNext,
+ mockOnPrefillPopulation2,
+ mockReportState,
+ mockReportStateWithBothConfigured,
+ mockReportStateWithConfiguredBaseline,
+ mockUseUserGeographicsEmpty,
+ mockUseUserHouseholdsEmpty,
+ 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(),
+}));
+
+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..5eb366cc
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/ReportSimulationExistingView.test.tsx
@@ -0,0 +1,262 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useUserSimulations } from '@/hooks/useUserSimulations';
+import ReportSimulationExistingView from '@/pathways/report/views/ReportSimulationExistingView';
+import {
+ mockEnhancedUserSimulation,
+ mockOnBack,
+ mockOnCancel,
+ mockOnNext,
+ mockOnSelectSimulation,
+ mockSimulationState,
+ mockUseUserSimulationsEmpty,
+ mockUseUserSimulationsError,
+ mockUseUserSimulationsLoading,
+ mockUseUserSimulationsWithData,
+ resetAllMocks,
+} from '@/tests/fixtures/pathways/report/views/ReportViewMocks';
+
+vi.mock('@/hooks/useUserSimulations', () => ({
+ useUserSimulations: vi.fn(),
+}));
+
+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',
+ countryId: 'us' as const,
+ householdData: { 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..10e6ee09
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/ReportSimulationSelectionView.test.tsx
@@ -0,0 +1,288 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useUserSimulations } from '@/hooks/useUserSimulations';
+import ReportSimulationSelectionView from '@/pathways/report/views/ReportSimulationSelectionView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnCreateNew,
+ mockOnLoadExisting,
+ mockOnSelectDefaultBaseline,
+ mockUseUserSimulationsEmpty,
+ mockUseUserSimulationsWithData,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+ TEST_CURRENT_LAW_ID,
+} 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) }));
+
+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..12786b54
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/ReportSubmitView.test.tsx
@@ -0,0 +1,276 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import ReportSubmitView from '@/pathways/report/views/ReportSubmitView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnSubmit,
+ mockReportState,
+ mockReportStateWithBothConfigured,
+ mockReportStateWithConfiguredBaseline,
+ 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..c75f5e9d
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/policy/PolicyExistingView.test.tsx
@@ -0,0 +1,156 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+import PolicyExistingView from '@/pathways/report/views/policy/PolicyExistingView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnSelectPolicy,
+ mockUseUserPoliciesEmpty,
+ mockUseUserPoliciesError,
+ mockUseUserPoliciesLoading,
+ mockUseUserPoliciesWithData,
+ resetAllMocks,
+} from '@/tests/fixtures/pathways/report/views/PolicyViewMocks';
+
+vi.mock('@/hooks/useUserPolicy', () => ({
+ useUserPolicies: vi.fn(),
+ isPolicyMetadataWithAssociation: vi.fn((val) => val && val.policy && val.association),
+}));
+
+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..bf5d2b5a
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/policy/PolicyLabelView.test.tsx
@@ -0,0 +1,236 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import PolicyLabelView from '@/pathways/report/views/policy/PolicyLabelView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnNext,
+ mockOnUpdateLabel,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+ TEST_POLICY_LABEL,
+} from '@/tests/fixtures/pathways/report/views/PolicyViewMocks';
+
+vi.mock('@/hooks/useCurrentCountry', () => ({
+ useCurrentCountry: vi.fn(),
+}));
+
+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..16b61f48
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/population/PopulationLabelView.test.tsx
@@ -0,0 +1,280 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import PopulationLabelView from '@/pathways/report/views/population/PopulationLabelView';
+import {
+ mockOnBack,
+ mockOnNext,
+ mockOnUpdateLabel,
+ mockPopulationStateEmpty,
+ mockPopulationStateWithGeography,
+ mockPopulationStateWithHousehold,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+ TEST_POPULATION_LABEL,
+} from '@/tests/fixtures/pathways/report/views/PopulationViewMocks';
+
+vi.mock('@/hooks/useCurrentCountry', () => ({
+ useCurrentCountry: vi.fn(),
+}));
+
+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..0b2e50fa
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/population/PopulationScopeView.test.tsx
@@ -0,0 +1,112 @@
+import { render, screen } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import PopulationScopeView from '@/pathways/report/views/population/PopulationScopeView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnScopeSelected,
+ mockRegionData,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+} 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..3a6dffb4
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/simulation/SimulationLabelView.test.tsx
@@ -0,0 +1,258 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import SimulationLabelView from '@/pathways/report/views/simulation/SimulationLabelView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnNext,
+ mockOnUpdateLabel,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+ TEST_SIMULATION_LABEL,
+} from '@/tests/fixtures/pathways/report/views/SimulationViewMocks';
+
+vi.mock('@/hooks/useCurrentCountry', () => ({
+ useCurrentCountry: vi.fn(),
+}));
+
+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..771eb509
--- /dev/null
+++ b/app/src/tests/unit/pathways/report/views/simulation/SimulationSetupView.test.tsx
@@ -0,0 +1,317 @@
+import { render, screen, userEvent } from '@test-utils';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import SimulationSetupView from '@/pathways/report/views/simulation/SimulationSetupView';
+import {
+ mockOnBack,
+ mockOnCancel,
+ mockOnNavigateToPolicy,
+ mockOnNavigateToPopulation,
+ mockOnNext,
+ mockSimulationStateConfigured,
+ mockSimulationStateEmpty,
+ mockSimulationStateWithPolicy,
+ mockSimulationStateWithPopulation,
+ 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();
+ });
+ });
+});
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..bc88d64c
--- /dev/null
+++ b/app/src/tests/unit/pathways/simulation/SimulationPathwayWrapper.test.tsx
@@ -0,0 +1,143 @@
+import { render, screen } from '@test-utils';
+import { useParams } from 'react-router-dom';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCreateSimulation } from '@/hooks/useCreateSimulation';
+import { useCurrentCountry } from '@/hooks/useCurrentCountry';
+import { useUserGeographics } from '@/hooks/useUserGeographic';
+import { useUserHouseholds } from '@/hooks/useUserHousehold';
+import { useUserPolicies } from '@/hooks/useUserPolicy';
+import SimulationPathwayWrapper from '@/pathways/simulation/SimulationPathwayWrapper';
+import {
+ mockMetadata,
+ mockNavigate,
+ mockOnComplete,
+ mockUseCreateSimulation,
+ mockUseParams,
+ mockUseUserGeographics,
+ mockUseUserHouseholds,
+ mockUseUserPolicies,
+ resetAllMocks,
+ TEST_COUNTRY_ID,
+} 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(),
+}));
+
+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/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/isDefaultBaselineSimulation.test.ts b/app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts
new file mode 100644
index 00000000..c725cc2e
--- /dev/null
+++ b/app/src/tests/unit/utils/isDefaultBaselineSimulation.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, test } from 'vitest';
+import {
+ EXPECTED_LABELS,
+ mockCustomPolicySimulation,
+ mockDefaultBaselineSimulation,
+ mockHouseholdSimulation,
+ mockIncompleteSimulation,
+ mockSubnationalSimulation,
+ mockUKDefaultBaselineSimulation,
+ mockWrongLabelSimulation,
+ TEST_COUNTRIES,
+ TEST_CURRENT_LAW_ID,
+} from '@/tests/fixtures/utils/isDefaultBaselineSimulationMocks';
+import {
+ countryNames,
+ getDefaultBaselineLabel,
+ isDefaultBaselineSimulation,
+} from '@/utils/isDefaultBaselineSimulation';
+
+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();
+ });
+});
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..2f27dcd7
--- /dev/null
+++ b/app/src/tests/unit/utils/pathwayState/initializeReportState.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, test } from 'vitest';
+import {
+ EXPECTED_REPORT_STATE_STRUCTURE,
+ TEST_COUNTRIES,
+} from '@/tests/fixtures/utils/pathwayState/initializeStateMocks';
+import { initializeReportState } from '@/utils/pathwayState/initializeReportState';
+
+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);
+ });
+ });
+});
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/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)');
});
});
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/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/PathwayMode.ts b/app/src/types/pathwayModes/PathwayMode.ts
new file mode 100644
index 00000000..b57a7c89
--- /dev/null
+++ b/app/src/types/pathwayModes/PathwayMode.ts
@@ -0,0 +1,7 @@
+/**
+ * PathwayMode - Indicates whether a view is being used in report or standalone context
+ *
+ * - 'report': View is part of a report pathway (baseline/reform simulations)
+ * - 'standalone': View is part of a standalone simulation pathway
+ */
+export type PathwayMode = 'report' | 'standalone';
diff --git a/app/src/types/pathwayModes/PolicyViewMode.ts b/app/src/types/pathwayModes/PolicyViewMode.ts
new file mode 100644
index 00000000..d7df9919
--- /dev/null
+++ b/app/src/types/pathwayModes/PolicyViewMode.ts
@@ -0,0 +1,17 @@
+/**
+ * StandalonePolicyViewMode - Enum for standalone policy creation pathway view states
+ *
+ * This is used by the standalone PolicyPathwayWrapper.
+ * For policy modes used within composite pathways (Report, Simulation),
+ * see PolicyViewMode in SharedViewModes.ts
+ *
+ * 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 StandalonePolicyViewMode {
+ 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..83652419
--- /dev/null
+++ b/app/src/types/pathwayModes/PopulationViewMode.ts
@@ -0,0 +1,19 @@
+/**
+ * StandalonePopulationViewMode - Enum for standalone population creation pathway view states
+ *
+ * This is used by the standalone PopulationPathwayWrapper.
+ * For population modes used within composite pathways (Report, Simulation),
+ * see PopulationViewMode in SharedViewModes.ts
+ *
+ * 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 StandalonePopulationViewMode {
+ 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..ec0e0c80
--- /dev/null
+++ b/app/src/types/pathwayModes/ReportViewMode.ts
@@ -0,0 +1,49 @@
+import { PolicyViewMode, PopulationViewMode, SimulationViewMode } from './SharedViewModes';
+
+/**
+ * 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 (REPORT_*)
+ * - Simulation setup views (inline, from SimulationViewMode)
+ * - Policy setup views (inline, from PolicyViewMode)
+ * - Population setup views (inline, from PopulationViewMode)
+ * - Selection views (for loading existing ingredients)
+ *
+ * Note: This enum is large (~20+ modes) which is acceptable per the plan's Option A.
+ * It composes shared view modes to maximize reusability across pathways.
+ */
+export enum ReportViewMode {
+ // ========== Report-level views (report-specific) ==========
+ REPORT_LABEL = 'REPORT_LABEL', // ReportCreationFrame
+ REPORT_SETUP = 'REPORT_SETUP', // ReportSetupFrame (shows two simulation cards)
+ REPORT_SUBMIT = 'REPORT_SUBMIT', // ReportSubmitFrame
+
+ // ========== Simulation selection/creation views (report-specific) ==========
+ REPORT_SELECT_SIMULATION = 'REPORT_SELECT_SIMULATION', // ReportSelectSimulationFrame (create new vs load existing)
+ REPORT_SELECT_EXISTING_SIMULATION = 'REPORT_SELECT_EXISTING_SIMULATION', // ReportSelectExistingSimulationFrame
+
+ // ========== Simulation setup views (shared) ==========
+ SIMULATION_LABEL = SimulationViewMode.SIMULATION_LABEL,
+ SIMULATION_SETUP = SimulationViewMode.SIMULATION_SETUP,
+ SIMULATION_SUBMIT = SimulationViewMode.SIMULATION_SUBMIT,
+
+ // ========== Policy setup views (shared) ==========
+ POLICY_LABEL = PolicyViewMode.POLICY_LABEL,
+ POLICY_PARAMETER_SELECTOR = PolicyViewMode.POLICY_PARAMETER_SELECTOR,
+ POLICY_SUBMIT = PolicyViewMode.POLICY_SUBMIT,
+ SELECT_EXISTING_POLICY = PolicyViewMode.SELECT_EXISTING_POLICY,
+ SETUP_POLICY = PolicyViewMode.SETUP_POLICY,
+
+ // ========== Population setup views (shared) ==========
+ POPULATION_SCOPE = PopulationViewMode.POPULATION_SCOPE,
+ POPULATION_LABEL = PopulationViewMode.POPULATION_LABEL,
+ POPULATION_HOUSEHOLD_BUILDER = PopulationViewMode.POPULATION_HOUSEHOLD_BUILDER,
+ POPULATION_GEOGRAPHIC_CONFIRM = PopulationViewMode.POPULATION_GEOGRAPHIC_CONFIRM,
+ SELECT_EXISTING_POPULATION = PopulationViewMode.SELECT_EXISTING_POPULATION,
+ SETUP_POPULATION = PopulationViewMode.SETUP_POPULATION,
+}
diff --git a/app/src/types/pathwayModes/SharedViewModes.ts b/app/src/types/pathwayModes/SharedViewModes.ts
new file mode 100644
index 00000000..939e10c1
--- /dev/null
+++ b/app/src/types/pathwayModes/SharedViewModes.ts
@@ -0,0 +1,74 @@
+/**
+ * SharedViewModes - Common view modes used across multiple pathways
+ *
+ * These modes are shared between Report, Simulation, Policy, and Population pathways.
+ * Each pathway can compose their own view mode enum using these shared modes.
+ *
+ * This approach:
+ * - Reduces duplication
+ * - Makes it clear which views are reusable
+ * - Allows type-safe navigation across pathways
+ * - Maintains independent pathway mode enums for flexibility
+ */
+
+/**
+ * Policy-related modes used in multiple pathways
+ * Used in: Report, Simulation pathways
+ */
+export enum PolicyViewMode {
+ POLICY_LABEL = 'POLICY_LABEL',
+ POLICY_PARAMETER_SELECTOR = 'POLICY_PARAMETER_SELECTOR',
+ POLICY_SUBMIT = 'POLICY_SUBMIT',
+ SELECT_EXISTING_POLICY = 'SELECT_EXISTING_POLICY',
+ SETUP_POLICY = 'SETUP_POLICY',
+}
+
+/**
+ * Population-related modes used in multiple pathways
+ * Used in: Report, Simulation pathways
+ */
+export enum PopulationViewMode {
+ POPULATION_SCOPE = 'POPULATION_SCOPE',
+ POPULATION_LABEL = 'POPULATION_LABEL',
+ POPULATION_HOUSEHOLD_BUILDER = 'POPULATION_HOUSEHOLD_BUILDER',
+ POPULATION_GEOGRAPHIC_CONFIRM = 'POPULATION_GEOGRAPHIC_CONFIRM',
+ SELECT_EXISTING_POPULATION = 'SELECT_EXISTING_POPULATION',
+ SETUP_POPULATION = 'SETUP_POPULATION',
+}
+
+/**
+ * Simulation-related modes used in multiple pathways
+ * Used in: Report pathway (and future standalone Simulation pathway)
+ */
+export enum SimulationViewMode {
+ SIMULATION_LABEL = 'SIMULATION_LABEL',
+ SIMULATION_SETUP = 'SIMULATION_SETUP',
+ SIMULATION_SUBMIT = 'SIMULATION_SUBMIT',
+}
+
+/**
+ * Helper type to get all shared view mode values
+ * Useful for type narrowing and validation
+ */
+export type SharedViewModeValue = PolicyViewMode | PopulationViewMode | SimulationViewMode;
+
+/**
+ * Helper to check if a mode is a policy mode
+ */
+export function isPolicyMode(mode: string): mode is PolicyViewMode {
+ return Object.values(PolicyViewMode).includes(mode as PolicyViewMode);
+}
+
+/**
+ * Helper to check if a mode is a population mode
+ */
+export function isPopulationMode(mode: string): mode is PopulationViewMode {
+ return Object.values(PopulationViewMode).includes(mode as PopulationViewMode);
+}
+
+/**
+ * Helper to check if a mode is a simulation mode
+ */
+export function isSimulationMode(mode: string): mode is SimulationViewMode {
+ return Object.values(SimulationViewMode).includes(mode as SimulationViewMode);
+}
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..af80c387
--- /dev/null
+++ b/app/src/types/pathwayState/PolicyStateProps.ts
@@ -0,0 +1,17 @@
+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.
+ *
+ * Configuration state is determined by presence of `id` field.
+ * Use `isPolicyConfigured()` utility to check if policy is ready for use.
+ */
+export interface PolicyStateProps {
+ id?: string; // Populated after API creation, current law selection, or loading existing
+ label: string | null; // Required field, can be null
+ parameters: Parameter[]; // Always present, empty array if no params
+}
diff --git a/app/src/types/pathwayState/PopulationStateProps.ts b/app/src/types/pathwayState/PopulationStateProps.ts
new file mode 100644
index 00000000..2f44596a
--- /dev/null
+++ b/app/src/types/pathwayState/PopulationStateProps.ts
@@ -0,0 +1,22 @@
+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.
+ *
+ * Configuration state is determined by presence of `household.id` or `geography.id`.
+ * Use `isPopulationConfigured()` utility to check if population is ready for use.
+ */
+export interface PopulationStateProps {
+ label: string | null; // Required field, can be null
+ 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..df5d5d4c
--- /dev/null
+++ b/app/src/types/pathwayState/ReportStateProps.ts
@@ -0,0 +1,28 @@
+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
+ 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
+ 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..31f73423
--- /dev/null
+++ b/app/src/types/pathwayState/SimulationStateProps.ts
@@ -0,0 +1,28 @@
+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.
+ *
+ * Configuration state is determined by presence of `id` field OR by checking
+ * if both nested ingredients are configured.
+ * Use `isSimulationConfigured()` utility to check if simulation is ready.
+ */
+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
+ 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/ingredientReconstruction/convertSimulationStateToApi.ts b/app/src/utils/ingredientReconstruction/convertSimulationStateToApi.ts
new file mode 100644
index 00000000..9c85f394
--- /dev/null
+++ b/app/src/utils/ingredientReconstruction/convertSimulationStateToApi.ts
@@ -0,0 +1,61 @@
+import { Simulation } from '@/types/ingredients/Simulation';
+import { SimulationStateProps } from '@/types/pathwayState/SimulationStateProps';
+
+/**
+ * Converts SimulationStateProps (pathway local state) to Simulation (API format)
+ *
+ * SimulationStateProps has nested policy/population objects with IDs buried in them.
+ * Simulation has flat policyId/populationId fields that CalcOrchestrator expects.
+ *
+ * This conversion is critical for report creation flow where pathways pass their
+ * local state to useCreateReport, which then passes to CalcOrchestrator.
+ *
+ * @param stateProps - SimulationStateProps from pathway local state
+ * @returns Simulation object with flat structure expected by calculation system
+ */
+export function convertSimulationStateToApi(
+ stateProps: SimulationStateProps | null | undefined
+): Simulation | null {
+ if (!stateProps) {
+ return null;
+ }
+
+ // Extract policyId from nested policy object
+ const policyId = stateProps.policy?.id;
+ if (!policyId) {
+ console.warn('[convertSimulationStateToApi] Simulation missing policy.id:', stateProps);
+ return null;
+ }
+
+ // Extract populationId and populationType from nested population object
+ const population = stateProps.population;
+ let populationId: string | undefined;
+ let populationType: 'household' | 'geography' | undefined;
+
+ if (population?.household?.id) {
+ populationId = population.household.id;
+ populationType = 'household';
+ } else if (population?.geography?.id) {
+ populationId = population.geography.id;
+ populationType = 'geography';
+ }
+
+ if (!populationId || !populationType) {
+ console.warn('[convertSimulationStateToApi] Simulation missing population ID:', stateProps);
+ return null;
+ }
+
+ // Convert to flat Simulation structure
+ return {
+ id: stateProps.id,
+ countryId: stateProps.countryId as any,
+ apiVersion: stateProps.apiVersion,
+ policyId, // ← Flattened from stateProps.policy.id
+ populationId, // ← Flattened from stateProps.population.household/geography.id
+ populationType, // ← Derived from which population type is present
+ label: stateProps.label,
+ isCreated: !!stateProps.id, // Has ID = created
+ output: stateProps.output,
+ status: stateProps.status,
+ };
+}
diff --git a/app/src/utils/ingredientReconstruction/index.ts b/app/src/utils/ingredientReconstruction/index.ts
new file mode 100644
index 00000000..c4df12cb
--- /dev/null
+++ b/app/src/utils/ingredientReconstruction/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Barrel export for ingredient reconstruction utilities
+ * Used to reconstruct StateProps from API/enhanced data
+ */
+
+export { reconstructSimulationFromEnhanced } from './reconstructSimulation';
+export { reconstructPolicyFromJson, reconstructPolicyFromParameters } from './reconstructPolicy';
+export {
+ reconstructPopulationFromHousehold,
+ reconstructPopulationFromGeography,
+} from './reconstructPopulation';
+export { convertSimulationStateToApi } from './convertSimulationStateToApi';
diff --git a/app/src/utils/ingredientReconstruction/reconstructPolicy.ts b/app/src/utils/ingredientReconstruction/reconstructPolicy.ts
new file mode 100644
index 00000000..b48d0dd2
--- /dev/null
+++ b/app/src/utils/ingredientReconstruction/reconstructPolicy.ts
@@ -0,0 +1,58 @@
+import { PolicyStateProps } from '@/types/pathwayState';
+import { Parameter } from '@/types/subIngredients/parameter';
+
+/**
+ * Reconstructs a PolicyStateProps object from policy_json format
+ * Used when loading existing policies in pathways
+ *
+ * @param policyId - The policy ID
+ * @param label - The policy label (from user association or policy metadata)
+ * @param policyJson - The policy_json object with parameter definitions
+ * @returns A fully-formed PolicyStateProps object
+ */
+export function reconstructPolicyFromJson(
+ policyId: string,
+ label: string | null,
+ policyJson: Record
+): PolicyStateProps {
+ const parameters: Parameter[] = [];
+
+ // Convert policy_json to Parameter[] format
+ Object.entries(policyJson).forEach(([paramName, valueIntervals]) => {
+ if (Array.isArray(valueIntervals) && valueIntervals.length > 0) {
+ const values = valueIntervals.map((vi: any) => ({
+ startDate: vi.start || vi.startDate,
+ endDate: vi.end || vi.endDate,
+ value: vi.value,
+ }));
+ parameters.push({ name: paramName, values });
+ }
+ });
+
+ return {
+ id: policyId,
+ label,
+ parameters,
+ };
+}
+
+/**
+ * Reconstructs a PolicyStateProps object from a Policy ingredient
+ * Used when loading existing policies that are already in Parameter[] format
+ *
+ * @param policyId - The policy ID
+ * @param label - The policy label
+ * @param parameters - The parameters array
+ * @returns A fully-formed PolicyStateProps object
+ */
+export function reconstructPolicyFromParameters(
+ policyId: string,
+ label: string | null,
+ parameters: Parameter[]
+): PolicyStateProps {
+ return {
+ id: policyId,
+ label,
+ parameters,
+ };
+}
diff --git a/app/src/utils/ingredientReconstruction/reconstructPopulation.ts b/app/src/utils/ingredientReconstruction/reconstructPopulation.ts
new file mode 100644
index 00000000..c4207eca
--- /dev/null
+++ b/app/src/utils/ingredientReconstruction/reconstructPopulation.ts
@@ -0,0 +1,47 @@
+import { Geography } from '@/types/ingredients/Geography';
+import { Household } from '@/types/ingredients/Household';
+import { PopulationStateProps } from '@/types/pathwayState';
+
+/**
+ * Reconstructs a PopulationStateProps object from a household
+ * Used when loading existing household populations in pathways
+ *
+ * @param householdId - The household ID
+ * @param household - The household data
+ * @param label - The population label
+ * @returns A fully-formed PopulationStateProps object
+ */
+export function reconstructPopulationFromHousehold(
+ householdId: string,
+ household: Household,
+ label: string | null
+): PopulationStateProps {
+ return {
+ household: { ...household, id: householdId },
+ geography: null,
+ label,
+ type: 'household',
+ };
+}
+
+/**
+ * Reconstructs a PopulationStateProps object from a geography
+ * Used when loading existing geographic populations in pathways
+ *
+ * @param geographyId - The geography ID
+ * @param geography - The geography data
+ * @param label - The population label
+ * @returns A fully-formed PopulationStateProps object
+ */
+export function reconstructPopulationFromGeography(
+ geographyId: string,
+ geography: Geography,
+ label: string | null
+): PopulationStateProps {
+ return {
+ household: null,
+ geography: { ...geography, id: geographyId },
+ label,
+ type: 'geography',
+ };
+}
diff --git a/app/src/utils/ingredientReconstruction/reconstructSimulation.ts b/app/src/utils/ingredientReconstruction/reconstructSimulation.ts
new file mode 100644
index 00000000..e4ec394f
--- /dev/null
+++ b/app/src/utils/ingredientReconstruction/reconstructSimulation.ts
@@ -0,0 +1,62 @@
+import { EnhancedUserSimulation } from '@/hooks/useUserSimulations';
+import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState';
+
+/**
+ * Reconstructs a SimulationStateProps object from an EnhancedUserSimulation
+ * Used when loading existing simulations in pathways
+ *
+ * @param enhancedSimulation - The enhanced simulation data from useUserSimulations
+ * @returns A fully-formed SimulationStateProps object
+ * @throws Error if simulation data is missing or invalid
+ */
+export function reconstructSimulationFromEnhanced(
+ enhancedSimulation: EnhancedUserSimulation
+): SimulationStateProps {
+ if (!enhancedSimulation.simulation) {
+ throw new Error('[reconstructSimulation] No simulation data in enhancedSimulation');
+ }
+
+ const simulation = enhancedSimulation.simulation;
+ const label = enhancedSimulation.userSimulation?.label || simulation.label || '';
+
+ // Reconstruct PolicyStateProps from enhanced data
+ const policy: PolicyStateProps = {
+ id: enhancedSimulation.policy?.id || simulation.policyId,
+ label: enhancedSimulation.userPolicy?.label || enhancedSimulation.policy?.label || null,
+ parameters: enhancedSimulation.policy?.parameters || [],
+ };
+
+ // Reconstruct PopulationStateProps from enhanced data
+ let population: PopulationStateProps;
+
+ if (simulation.populationType === 'household' && enhancedSimulation.household) {
+ population = {
+ household: enhancedSimulation.household,
+ geography: null,
+ label: enhancedSimulation.userHousehold?.label || null,
+ type: 'household',
+ };
+ } else if (simulation.populationType === 'geography' && enhancedSimulation.geography) {
+ population = {
+ household: null,
+ geography: enhancedSimulation.geography,
+ label: enhancedSimulation.userHousehold?.label || null,
+ type: 'geography',
+ };
+ } else {
+ throw new Error(
+ '[reconstructSimulation] Unable to determine population type or missing population data'
+ );
+ }
+
+ return {
+ id: simulation.id,
+ label,
+ countryId: simulation.countryId,
+ apiVersion: simulation.apiVersion,
+ status: simulation.status,
+ output: simulation.output,
+ policy,
+ population,
+ };
+}
diff --git a/app/src/utils/isDefaultBaselineSimulation.ts b/app/src/utils/isDefaultBaselineSimulation.ts
new file mode 100644
index 00000000..ac232340
--- /dev/null
+++ b/app/src/utils/isDefaultBaselineSimulation.ts
@@ -0,0 +1,46 @@
+import { EnhancedUserSimulation } from '@/hooks/useUserSimulations';
+
+/**
+ * Checks if a simulation matches the default baseline criteria:
+ * - Uses current law (no policy modifications)
+ * - Uses nationwide geographic population
+ * - Has the expected default baseline label
+ */
+export function isDefaultBaselineSimulation(
+ simulation: EnhancedUserSimulation,
+ countryId: string,
+ currentLawId: number
+): boolean {
+ // Check policy is current law
+ const isCurrentLaw = simulation.simulation?.policyId === currentLawId.toString();
+
+ // Check population is nationwide geography (populationId === countryId for national)
+ const isNationwideGeography =
+ simulation.simulation?.populationType === 'geography' &&
+ simulation.simulation?.populationId === countryId;
+
+ // Check label matches expected default baseline label
+ const expectedLabel = getDefaultBaselineLabel(countryId);
+ const hasMatchingLabel = simulation.userSimulation?.label === expectedLabel;
+
+ return isCurrentLaw && isNationwideGeography && hasMatchingLabel;
+}
+
+/**
+ * Country name mapping for display purposes
+ */
+export const countryNames: Record = {
+ us: 'United States',
+ uk: 'United Kingdom',
+ ca: 'Canada',
+ ng: 'Nigeria',
+ il: 'Israel',
+};
+
+/**
+ * Get the label for a default baseline simulation
+ */
+export function getDefaultBaselineLabel(countryId: string): string {
+ const countryName = countryNames[countryId] || countryId.toUpperCase();
+ return `${countryName} current law for all households nationwide`;
+}
diff --git a/app/src/utils/pathwayCallbacks/index.ts b/app/src/utils/pathwayCallbacks/index.ts
new file mode 100644
index 00000000..65f9564e
--- /dev/null
+++ b/app/src/utils/pathwayCallbacks/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Barrel export for pathway callback factories
+ * Used to create reusable callbacks across pathways
+ */
+
+export { createPolicyCallbacks } from './policyCallbacks';
+export { createPopulationCallbacks } from './populationCallbacks';
+export { createSimulationCallbacks } from './simulationCallbacks';
+export { createReportCallbacks } from './reportCallbacks';
diff --git a/app/src/utils/pathwayCallbacks/policyCallbacks.ts b/app/src/utils/pathwayCallbacks/policyCallbacks.ts
new file mode 100644
index 00000000..46c37802
--- /dev/null
+++ b/app/src/utils/pathwayCallbacks/policyCallbacks.ts
@@ -0,0 +1,96 @@
+import { useCallback } from 'react';
+import { PolicyStateProps } from '@/types/pathwayState';
+import { Parameter } from '@/types/subIngredients/parameter';
+
+/**
+ * Factory for creating reusable policy-related callbacks
+ * Can be used across Report, Simulation, and Policy pathways
+ *
+ * @param setState - State setter function
+ * @param policySelector - Function to extract policy from state
+ * @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,
+ onPolicyComplete?: (policyId: string) => void
+) {
+ const updateLabel = useCallback(
+ (label: string) => {
+ setState((prev) => {
+ const policy = policySelector(prev);
+ return policyUpdater(prev, { ...policy, label });
+ });
+ },
+ [setState, policySelector, policyUpdater]
+ );
+
+ const updatePolicy = useCallback(
+ (updatedPolicy: PolicyStateProps) => {
+ setState((prev) => policyUpdater(prev, updatedPolicy));
+ },
+ [setState, policyUpdater]
+ );
+
+ const handleSelectCurrentLaw = useCallback(
+ (currentLawId: number, label: string = 'Current law') => {
+ setState((prev) =>
+ policyUpdater(prev, {
+ id: currentLawId.toString(),
+ label,
+ parameters: [],
+ })
+ );
+ navigateToMode(returnMode);
+ },
+ [setState, policyUpdater, navigateToMode, returnMode]
+ );
+
+ const handleSelectExisting = useCallback(
+ (policyId: string, label: string, parameters: Parameter[]) => {
+ setState((prev) =>
+ policyUpdater(prev, {
+ id: policyId,
+ label,
+ parameters,
+ })
+ );
+ navigateToMode(returnMode);
+ },
+ [setState, policyUpdater, navigateToMode, returnMode]
+ );
+
+ const handleSubmitSuccess = useCallback(
+ (policyId: string) => {
+ setState((prev) => {
+ const policy = policySelector(prev);
+ return policyUpdater(prev, {
+ ...policy,
+ id: policyId,
+ });
+ });
+
+ // Use custom navigation if provided, otherwise use default
+ if (onPolicyComplete) {
+ onPolicyComplete(policyId);
+ } else {
+ navigateToMode(returnMode);
+ }
+ },
+ [setState, policySelector, policyUpdater, navigateToMode, returnMode, onPolicyComplete]
+ );
+
+ return {
+ updateLabel,
+ updatePolicy,
+ handleSelectCurrentLaw,
+ handleSelectExisting,
+ handleSubmitSuccess,
+ };
+}
diff --git a/app/src/utils/pathwayCallbacks/populationCallbacks.ts b/app/src/utils/pathwayCallbacks/populationCallbacks.ts
new file mode 100644
index 00000000..107bcc15
--- /dev/null
+++ b/app/src/utils/pathwayCallbacks/populationCallbacks.ts
@@ -0,0 +1,149 @@
+import { useCallback } from 'react';
+import { Geography } from '@/types/ingredients/Geography';
+import { Household } from '@/types/ingredients/Household';
+import { PopulationStateProps } from '@/types/pathwayState';
+
+/**
+ * Factory for creating reusable population-related callbacks
+ * Can be used across Report, Simulation, and Population pathways
+ *
+ * @param setState - State setter function
+ * @param populationSelector - Function to extract population from state
+ * @param populationUpdater - Function to update population in state
+ * @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>,
+ populationSelector: (state: TState) => PopulationStateProps,
+ populationUpdater: (state: TState, population: PopulationStateProps) => TState,
+ navigateToMode: (mode: TMode) => void,
+ returnMode: TMode,
+ labelMode: TMode,
+ onPopulationComplete?: {
+ onHouseholdComplete?: (householdId: string, household: Household) => void;
+ onGeographyComplete?: (geographyId: string, label: string) => void;
+ }
+) {
+ const updateLabel = useCallback(
+ (label: string) => {
+ setState((prev) => {
+ const population = populationSelector(prev);
+ return populationUpdater(prev, { ...population, label });
+ });
+ },
+ [setState, populationSelector, populationUpdater]
+ );
+
+ const handleScopeSelected = useCallback(
+ (geography: Geography | null, _scopeType: string) => {
+ setState((prev) => {
+ const population = populationSelector(prev);
+ return populationUpdater(prev, {
+ ...population,
+ geography: geography || null,
+ type: geography ? 'geography' : 'household',
+ });
+ });
+ navigateToMode(labelMode);
+ },
+ [setState, populationSelector, populationUpdater, navigateToMode, labelMode]
+ );
+
+ const handleSelectExistingHousehold = useCallback(
+ (householdId: string, household: Household, label: string) => {
+ setState((prev) =>
+ populationUpdater(prev, {
+ household: { ...household, id: householdId },
+ geography: null,
+ label,
+ type: 'household',
+ })
+ );
+ navigateToMode(returnMode);
+ },
+ [setState, populationUpdater, navigateToMode, returnMode]
+ );
+
+ const handleSelectExistingGeography = useCallback(
+ (geographyId: string, geography: Geography, label: string) => {
+ setState((prev) =>
+ populationUpdater(prev, {
+ household: null,
+ geography: { ...geography, id: geographyId },
+ label,
+ type: 'geography',
+ })
+ );
+ navigateToMode(returnMode);
+ },
+ [setState, populationUpdater, navigateToMode, returnMode]
+ );
+
+ const handleHouseholdSubmitSuccess = useCallback(
+ (householdId: string, household: Household) => {
+ setState((prev) => {
+ const population = populationSelector(prev);
+ return populationUpdater(prev, {
+ ...population,
+ household: { ...household, id: householdId },
+ });
+ });
+
+ // Use custom navigation if provided, otherwise use default
+ if (onPopulationComplete?.onHouseholdComplete) {
+ onPopulationComplete.onHouseholdComplete(householdId, household);
+ } else {
+ navigateToMode(returnMode);
+ }
+ },
+ [
+ setState,
+ populationSelector,
+ populationUpdater,
+ navigateToMode,
+ returnMode,
+ onPopulationComplete,
+ ]
+ );
+
+ const handleGeographicSubmitSuccess = useCallback(
+ (geographyId: string, label: string) => {
+ setState((prev) => {
+ const population = populationSelector(prev);
+ const updatedPopulation = { ...population };
+ if (updatedPopulation.geography) {
+ updatedPopulation.geography.id = geographyId;
+ }
+ updatedPopulation.label = label;
+ return populationUpdater(prev, updatedPopulation);
+ });
+
+ // Use custom navigation if provided, otherwise use default
+ if (onPopulationComplete?.onGeographyComplete) {
+ onPopulationComplete.onGeographyComplete(geographyId, label);
+ } else {
+ navigateToMode(returnMode);
+ }
+ },
+ [
+ setState,
+ populationSelector,
+ populationUpdater,
+ navigateToMode,
+ returnMode,
+ onPopulationComplete,
+ ]
+ );
+
+ return {
+ updateLabel,
+ handleScopeSelected,
+ handleSelectExistingHousehold,
+ handleSelectExistingGeography,
+ handleHouseholdSubmitSuccess,
+ handleGeographicSubmitSuccess,
+ };
+}
diff --git a/app/src/utils/pathwayCallbacks/reportCallbacks.ts b/app/src/utils/pathwayCallbacks/reportCallbacks.ts
new file mode 100644
index 00000000..3cc1ae08
--- /dev/null
+++ b/app/src/utils/pathwayCallbacks/reportCallbacks.ts
@@ -0,0 +1,127 @@
+import { useCallback } from 'react';
+import { EnhancedUserSimulation } from '@/hooks/useUserSimulations';
+import { ReportStateProps, SimulationStateProps } from '@/types/pathwayState';
+import { reconstructSimulationFromEnhanced } from '@/utils/ingredientReconstruction';
+
+/**
+ * Factory for creating reusable report-related callbacks
+ * Handles report-level operations including label updates, simulation selection,
+ * and simulation management
+ *
+ * @param setState - State setter function for report state
+ * @param navigateToMode - Navigation function
+ * @param activeSimulationIndex - Currently active simulation (0 or 1)
+ * @param simulationSelectionMode - Mode to navigate to for simulation selection
+ * @param setupMode - Mode to return to after operations (typically REPORT_SETUP)
+ */
+export function createReportCallbacks(
+ setState: React.Dispatch>,
+ navigateToMode: (mode: TMode) => void,
+ activeSimulationIndex: 0 | 1,
+ simulationSelectionMode: TMode,
+ setupMode: TMode
+) {
+ /**
+ * Updates the report label
+ */
+ const updateLabel = useCallback(
+ (label: string) => {
+ 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
+ */
+ const navigateToSimulationSelection = useCallback(
+ (_simulationIndex: 0 | 1) => {
+ // Note: activeSimulationIndex must be updated by caller before navigation
+ navigateToMode(simulationSelectionMode);
+ },
+ [navigateToMode, simulationSelectionMode]
+ );
+
+ /**
+ * Handles selecting an existing simulation
+ * Reconstructs the simulation from enhanced format and updates state
+ */
+ const handleSelectExistingSimulation = useCallback(
+ (enhancedSimulation: EnhancedUserSimulation) => {
+ try {
+ const reconstructedSimulation = reconstructSimulationFromEnhanced(enhancedSimulation);
+
+ setState((prev) => {
+ const newSimulations = [...prev.simulations] as [
+ SimulationStateProps,
+ SimulationStateProps,
+ ];
+ newSimulations[activeSimulationIndex] = reconstructedSimulation;
+ return { ...prev, simulations: newSimulations };
+ });
+
+ navigateToMode(setupMode);
+ } catch (error) {
+ console.error('[ReportCallbacks] Error reconstructing simulation:', error);
+ throw error;
+ }
+ },
+ [setState, activeSimulationIndex, navigateToMode, setupMode]
+ );
+
+ /**
+ * Copies population from the other simulation to the active simulation
+ * Report-specific feature for maintaining population consistency
+ */
+ const copyPopulationFromOtherSimulation = useCallback(() => {
+ const otherIndex = activeSimulationIndex === 0 ? 1 : 0;
+
+ setState((prev) => {
+ const newSimulations = [...prev.simulations] as [SimulationStateProps, SimulationStateProps];
+ newSimulations[activeSimulationIndex].population = {
+ ...prev.simulations[otherIndex].population,
+ };
+ return { ...prev, simulations: newSimulations };
+ });
+
+ navigateToMode(setupMode);
+ }, [setState, activeSimulationIndex, navigateToMode, setupMode]);
+
+ /**
+ * Pre-fills simulation 2's population from simulation 1
+ * Used when creating second simulation to maintain population consistency
+ */
+ const prefillPopulation2FromSimulation1 = useCallback(() => {
+ setState((prev) => {
+ const sim1Population = prev.simulations[0].population;
+ const newSimulations = [...prev.simulations] as [SimulationStateProps, SimulationStateProps];
+ newSimulations[1] = {
+ ...newSimulations[1],
+ population: { ...sim1Population },
+ };
+ return {
+ ...prev,
+ simulations: newSimulations,
+ };
+ });
+ }, [setState]);
+
+ return {
+ updateLabel,
+ updateYear,
+ navigateToSimulationSelection,
+ handleSelectExistingSimulation,
+ copyPopulationFromOtherSimulation,
+ prefillPopulation2FromSimulation1,
+ };
+}
diff --git a/app/src/utils/pathwayCallbacks/simulationCallbacks.ts b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts
new file mode 100644
index 00000000..01f34fd0
--- /dev/null
+++ b/app/src/utils/pathwayCallbacks/simulationCallbacks.ts
@@ -0,0 +1,123 @@
+import { useCallback } from 'react';
+import { EnhancedUserSimulation } from '@/hooks/useUserSimulations';
+import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState';
+
+/**
+ * Factory for creating reusable simulation-related callbacks
+ * Can be used across Report and Simulation pathways
+ *
+ * @param setState - State setter function
+ * @param simulationSelector - Function to extract simulation from state
+ * @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,
+ onSimulationComplete?: (simulationId: string) => void
+) {
+ const updateLabel = useCallback(
+ (label: string) => {
+ setState((prev) => {
+ const simulation = simulationSelector(prev);
+ return simulationUpdater(prev, { ...simulation, label });
+ });
+ },
+ [setState, simulationSelector, simulationUpdater]
+ );
+
+ const handleSubmitSuccess = useCallback(
+ (simulationId: string) => {
+ setState((prev) => {
+ const simulation = simulationSelector(prev);
+ return simulationUpdater(prev, {
+ ...simulation,
+ id: simulationId,
+ });
+ });
+
+ // Use custom navigation if provided, otherwise use default
+ if (onSimulationComplete) {
+ onSimulationComplete(simulationId);
+ } else {
+ navigateToMode(returnMode);
+ }
+ },
+ [
+ setState,
+ simulationSelector,
+ simulationUpdater,
+ navigateToMode,
+ returnMode,
+ onSimulationComplete,
+ ]
+ );
+
+ const handleSelectExisting = useCallback(
+ (enhancedSimulation: EnhancedUserSimulation) => {
+ if (!enhancedSimulation.simulation) {
+ console.error('[simulationCallbacks] No simulation data in enhancedSimulation');
+ return;
+ }
+
+ const simulation = enhancedSimulation.simulation;
+ const label = enhancedSimulation.userSimulation?.label || simulation.label || '';
+
+ // Reconstruct PolicyStateProps from enhanced data
+ const policy: PolicyStateProps = {
+ id: enhancedSimulation.policy?.id || simulation.policyId,
+ label: enhancedSimulation.userPolicy?.label || enhancedSimulation.policy?.label || null,
+ parameters: enhancedSimulation.policy?.parameters || [],
+ };
+
+ // Reconstruct PopulationStateProps from enhanced data
+ let population: PopulationStateProps;
+
+ if (simulation.populationType === 'household' && enhancedSimulation.household) {
+ population = {
+ household: enhancedSimulation.household,
+ geography: null,
+ label: enhancedSimulation.userHousehold?.label || null,
+ type: 'household',
+ };
+ } else if (simulation.populationType === 'geography' && enhancedSimulation.geography) {
+ population = {
+ household: null,
+ geography: enhancedSimulation.geography,
+ label: enhancedSimulation.userHousehold?.label || null,
+ type: 'geography',
+ };
+ } else {
+ console.error(
+ '[simulationCallbacks] Unable to determine population type or missing population data'
+ );
+ return;
+ }
+
+ setState((prev) =>
+ simulationUpdater(prev, {
+ ...simulationSelector(prev),
+ id: simulation.id,
+ label,
+ countryId: simulation.countryId,
+ apiVersion: simulation.apiVersion,
+ policy,
+ population,
+ })
+ );
+ navigateToMode(returnMode);
+ },
+ [setState, simulationSelector, simulationUpdater, navigateToMode, returnMode]
+ );
+
+ return {
+ updateLabel,
+ handleSubmitSuccess,
+ handleSelectExisting,
+ };
+}
diff --git a/app/src/utils/pathwayState/initializePolicyState.ts b/app/src/utils/pathwayState/initializePolicyState.ts
new file mode 100644
index 00000000..de6199f6
--- /dev/null
+++ b/app/src/utils/pathwayState/initializePolicyState.ts
@@ -0,0 +1,15 @@
+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: [],
+ };
+}
diff --git a/app/src/utils/pathwayState/initializePopulationState.ts b/app/src/utils/pathwayState/initializePopulationState.ts
new file mode 100644
index 00000000..82c0e233
--- /dev/null
+++ b/app/src/utils/pathwayState/initializePopulationState.ts
@@ -0,0 +1,16 @@
+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,
+ 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..1451a0d4
--- /dev/null
+++ b/app/src/utils/pathwayState/initializeReportState.ts
@@ -0,0 +1,28 @@
+import { CURRENT_YEAR } from '@/constants';
+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,
+ year: CURRENT_YEAR,
+ 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..e43cf329
--- /dev/null
+++ b/app/src/utils/pathwayState/initializeSimulationState.ts
@@ -0,0 +1,24 @@
+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,
+ status: undefined,
+ output: null,
+ policy: initializePolicyState(),
+ population: initializePopulationState(),
+ };
+}
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,
- })
- );
-}
diff --git a/app/src/utils/reportPopulationLock.ts b/app/src/utils/reportPopulationLock.ts
index b7adc41c..0edef3d1 100644
--- a/app/src/utils/reportPopulationLock.ts
+++ b/app/src/utils/reportPopulationLock.ts
@@ -41,7 +41,7 @@ export function getPopulationLockConfig(
* @returns The title text
*/
export function getPopulationSelectionTitle(shouldLock: boolean): string {
- return shouldLock ? 'Apply Household(s)' : 'Select Household(s)';
+ return shouldLock ? 'Apply household(s)' : 'Select household(s)';
}
/**
diff --git a/app/src/utils/validation/ingredientValidation.ts b/app/src/utils/validation/ingredientValidation.ts
new file mode 100644
index 00000000..d845237c
--- /dev/null
+++ b/app/src/utils/validation/ingredientValidation.ts
@@ -0,0 +1,163 @@
+import { UserGeographicMetadataWithAssociation } from '@/hooks/useUserGeographic';
+import { UserHouseholdMetadataWithAssociation } from '@/hooks/useUserHousehold';
+import { PolicyStateProps, PopulationStateProps, SimulationStateProps } from '@/types/pathwayState';
+
+/**
+ * Validation utilities for ingredient configuration state
+ *
+ * These functions replace the `isCreated` flag pattern with validation based on
+ * actual data presence (primarily ID fields). This provides a single source of
+ * truth and eliminates the possibility of stale flags.
+ */
+
+/**
+ * Checks if a policy is fully configured and ready for use in a simulation
+ *
+ * A policy is considered configured if it has an ID, which happens when:
+ * - User creates custom policy and submits to API
+ * - User selects current law (ID = currentLawId)
+ * - User loads existing policy from database
+ */
+export function isPolicyConfigured(policy: PolicyStateProps | null | undefined): boolean {
+ return !!policy?.id;
+}
+
+/**
+ * Checks if a population is fully configured and ready for use in a simulation
+ *
+ * A population is considered configured if it has either:
+ * - A household with an ID (from API creation)
+ * - A geography with an ID (from scope selection via createGeographyFromScope)
+ */
+export function isPopulationConfigured(
+ population: PopulationStateProps | null | undefined
+): boolean {
+ if (!population) {
+ return false;
+ }
+ return !!(population.household?.id || population.geography?.id);
+}
+
+/**
+ * Checks if a simulation is fully configured
+ *
+ * A simulation is considered configured if:
+ * - It has a simulation ID from API (fully persisted), OR
+ * - Both its policy and population are configured (ready to submit)
+ */
+export function isSimulationConfigured(
+ simulation: SimulationStateProps | null | undefined
+): boolean {
+ if (!simulation) {
+ return false;
+ }
+
+ // Fully persisted simulation
+ if (simulation.id) {
+ return true;
+ }
+
+ // Pre-submission: check if ingredients are ready
+ return isPolicyConfigured(simulation.policy) && isPopulationConfigured(simulation.population);
+}
+
+/**
+ * Checks if a simulation is ready to be submitted to the API
+ *
+ * Different from isSimulationConfigured in that it specifically checks
+ * if the ingredients are ready, regardless of whether simulation ID exists.
+ * Useful for enabling "Submit" buttons.
+ */
+export function isSimulationReadyToSubmit(
+ simulation: SimulationStateProps | null | undefined
+): boolean {
+ if (!simulation) {
+ return false;
+ }
+ return isPolicyConfigured(simulation.policy) && isPopulationConfigured(simulation.population);
+}
+
+/**
+ * Checks if a simulation has been persisted to the database
+ *
+ * Different from isSimulationConfigured in that it only checks for
+ * simulation ID existence, not ingredient readiness.
+ */
+export function isSimulationPersisted(
+ simulation: SimulationStateProps | null | undefined
+): boolean {
+ return !!simulation?.id;
+}
+
+/**
+ * Checks if a UserHouseholdMetadataWithAssociation has fully loaded household data
+ *
+ * A household association is considered "ready" when:
+ * 1. The household metadata exists (not undefined)
+ * 2. The household metadata has household_json populated
+ * 3. The query is not still loading
+ *
+ * @param association - The household association to check
+ * @returns true if household data is fully loaded and ready to use
+ */
+export function isHouseholdAssociationReady(
+ association: UserHouseholdMetadataWithAssociation | null | undefined
+): boolean {
+ if (!association) {
+ return false;
+ }
+
+ // Still loading individual household data
+ if (association.isLoading) {
+ return false;
+ }
+
+ // Household metadata not loaded
+ if (!association.household) {
+ return false;
+ }
+
+ // Check for household data in EITHER format:
+ // - API format: household_json (from direct API fetch)
+ // - Transformed format: householdData (from HouseholdAdapter.fromMetadata, which may be in cache)
+ // This handles the case where React Query cache contains transformed data from useUserSimulations
+ const hasHouseholdData = !!(
+ association.household.household_json || (association.household as any).householdData
+ );
+
+ if (!hasHouseholdData) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Checks if a UserGeographicMetadataWithAssociation has fully loaded geography data
+ *
+ * A geographic association is considered "ready" when:
+ * 1. The geography metadata exists (not undefined)
+ * 2. The query is not still loading
+ *
+ * @param association - The geographic association to check
+ * @returns true if geography data is fully loaded and ready to use
+ */
+export function isGeographicAssociationReady(
+ association: UserGeographicMetadataWithAssociation | null | undefined
+): boolean {
+ if (!association) {
+ return false;
+ }
+
+ // Still loading individual geography data
+ if (association.isLoading) {
+ return false;
+ }
+
+ // Geography data not loaded
+ if (!association.geography) {
+ return false;
+ }
+
+ return true;
+}