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 && ( + + )} + {primaryAction && ( + + )} + + + + ); + } - 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()} </Stack> - {finalButtons.length > 0 && <MultiButtonFooter buttons={finalButtons} />} + {hasFooter && <MultiButtonFooter {...footerProps} />} </> ); return <Container variant="guttered">{containerContent}</Container>; } -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 ( - <Stack gap={spacing.md}> - {/* Card list */} - <Stack gap={spacing.sm}> - {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 ( - <Card - key={index} - withBorder - component="button" - onClick={item.isDisabled ? undefined : item.onClick} - disabled={item.isDisabled} - variant={variant} - > - <Stack gap={spacing.xs}> - <Text fw={600}>{item.title}</Text> - {item.subtitle && ( - <Text size="sm" c="dimmed"> - {item.subtitle} - </Text> - )} - </Stack> - </Card> - ); - })} - </Stack> + <Stack> + {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 && ( - <Group justify="space-between" align="center"> - <Text size="sm" c="dimmed"> - Showing {startIndex + 1}-{Math.min(endIndex, allItems.length)} of {allItems.length} - </Text> - <Group gap="xs"> - <ActionIcon - variant="default" - onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} - disabled={currentPage === 1} - aria-label="Previous page" - > - <IconChevronLeft size={18} /> - </ActionIcon> - <Text size="sm"> - Page {currentPage} of {totalPages} - </Text> - <ActionIcon - variant="default" - onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} - disabled={currentPage === totalPages} - aria-label="Next page" - > - <IconChevronRight size={18} /> - </ActionIcon> - </Group> - </Group> - )} + return ( + <Card + key={item.id || index} + withBorder + component="button" + onClick={item.isDisabled ? undefined : item.onClick} + disabled={item.isDisabled} + variant={variant} + > + <Stack gap={spacing.xs}> + <Text fw={600}>{item.title}</Text> + {item.subtitle && ( + <Text size="sm" c="dimmed"> + {item.subtitle} + </Text> + )} + </Stack> + </Card> + ); + })} </Stack> ); } 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 ( - <Group justify="space-between" align="center"> - <Button variant="default" disabled> - Cancel - </Button> - {modificationCount > 0 && ( - <Group gap="xs"> - <Box - style={{ - width: '8px', - height: '8px', - borderRadius: '50%', - backgroundColor: colors.primary[600], - }} - /> - <Text size="sm" c="gray.5"> - {modificationCount} parameter modification{modificationCount !== 1 ? 's' : ''} - </Text> - </Group> - )} - <Button variant="filled" onClick={handleNext} rightSection={<IconChevronRight size={16} />}> - Review my policy - </Button> - </Group> - ); -} 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<SetStateAction<ValueInterval[]>>; - startDate: string; - setStartDate: Dispatch<SetStateAction<string>>; - endDate: string; - setEndDate: Dispatch<SetStateAction<string>>; -} - -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>(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<ValueInterval[]>([]); - - // Hoisted date state for all non-multi-year selectors - const currentYear = new Date().getFullYear(); - const [startDate, setStartDate] = useState<string>(`${currentYear}-01-01`); - const [endDate, setEndDate] = useState<string>(`${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 ( - <Container bg="gray.0" bd="1px solid gray.2" m="0" p="lg"> - <Stack> - <Text fw={700}>Current value</Text> - <Divider style={{ padding: 0 }} /> - <Group align="flex-end" w="100%"> - <ValueSetterToRender {...valueSetterProps} /> - <ModeSelectorButton setMode={handleModeChange} /> - <Button onClick={handleSubmit}>Add parameter</Button> - </Group> - </Stack> - </Container> - ); -} - -export function ModeSelectorButton(props: { setMode: (mode: ValueSetterMode) => void }) { - const { setMode } = props; - return ( - <Menu> - <Menu.Target> - <ActionIcon aria-label="Select value setter mode" variant="default"> - <IconSettings /> - </ActionIcon> - </Menu.Target> - <Menu.Dropdown> - <Menu.Item onClick={() => setMode(ValueSetterMode.DEFAULT)}>Default</Menu.Item> - <Menu.Item onClick={() => setMode(ValueSetterMode.YEARLY)}>Yearly</Menu.Item> - <Menu.Item onClick={() => setMode(ValueSetterMode.DATE)}>Advanced</Menu.Item> - <Menu.Item onClick={() => setMode(ValueSetterMode.MULTI_YEAR)}>Multi-year</Menu.Item> - </Menu.Dropdown> - </Menu> - ); -} - -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<any>( - 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 ( - <Group align="flex-end" style={{ flex: 1 }}> - <YearPickerInput - placeholder="Pick a year" - label="From" - minDate={fromISODateString(minDate)} - maxDate={fromISODateString(maxDate)} - value={fromISODateString(startDate)} - onChange={handleStartDateChange} - style={{ flex: 1 }} - /> - <Box style={{ flex: 1, display: 'flex', alignItems: 'center', height: '36px' }}> - <Text size="sm" fw={500}> - onward: - </Text> - </Box> - <Box style={{ flex: 1 }}> - <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> - </Box> - </Group> - ); -} - -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<any>( - 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 ( - <Group align="flex-end" style={{ flex: 1 }}> - <YearPickerInput - placeholder="Pick a year" - label="From" - minDate={fromISODateString(minDate)} - maxDate={fromISODateString(maxDate)} - value={fromISODateString(startDate)} - onChange={handleStartDateChange} - style={{ flex: 1 }} - /> - <YearPickerInput - placeholder="Pick a year" - label="To" - minDate={fromISODateString(minDate)} - maxDate={fromISODateString(maxDate)} - value={fromISODateString(endDate)} - onChange={handleEndDateChange} - style={{ flex: 1 }} - /> - <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> - </Group> - ); -} - -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<any>( - 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 ( - <Group align="flex-end" style={{ flex: 1 }}> - <DatePickerInput - placeholder="Pick a start date" - label="From" - minDate={fromISODateString(minDate)} - maxDate={fromISODateString(maxDate)} - value={fromISODateString(startDate)} - onChange={handleStartDateChange} - style={{ flex: 1 }} - /> - <DatePickerInput - placeholder="Pick an end date" - label="To" - minDate={fromISODateString(minDate)} - maxDate={fromISODateString(maxDate)} - value={fromISODateString(endDate)} - onChange={handleEndDateChange} - style={{ flex: 1 }} - /> - <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> - </Group> - ); -} - -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<string, number> = { - 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<string, any> = {}; - years.forEach((year) => { - initialValues[year] = getDefaultValueForParam(param, activePolicy, `${year}-01-01`); - }); - return initialValues; - }, [param, activePolicy, years]); - - const [yearValues, setYearValues] = useState<Record<string, any>>(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 ( - <Box> - <SimpleGrid cols={2} spacing="md"> - <Stack> - {leftColumn.map((year) => ( - <Group key={year}> - <Text fw={500} style={{ minWidth: '50px' }}> - {year} - </Text> - <ValueInputBox - param={param} - value={yearValues[year]} - onChange={(value) => handleYearValueChange(year, value)} - /> - </Group> - ))} - </Stack> - <Stack> - {rightColumn.map((year) => ( - <Group key={year}> - <Text fw={500} style={{ minWidth: '50px' }}> - {year} - </Text> - <ValueInputBox - param={param} - value={yearValues[year]} - onChange={(value) => handleYearValueChange(year, value)} - /> - </Group> - ))} - </Stack> - </SimpleGrid> - </Box> - ); -} - -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 <NumberInput disabled value={0} />; - } - - 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 ( - <Stack gap="xs" style={{ flex: 1 }}> - {label && ( - <Text size="sm" fw={500}> - {label} - </Text> - )} - <Group - justify="space-between" - align="center" - style={{ - border: '1px solid #ced4da', - borderRadius: '4px', - padding: '6px 12px', - height: '36px', - backgroundColor: 'white', - }} - > - <Text size="sm" c={value ? 'dimmed' : 'dark'} fw={value ? 400 : 600}> - False - </Text> - <Switch - checked={value || false} - onChange={(event) => handleBoolChange(event.currentTarget.checked)} - size="md" - /> - <Text size="sm" c={value ? 'dark' : 'dimmed'} fw={value ? 600 : 400}> - True - </Text> - </Group> - </Stack> - ); - } - - return ( - <NumberInput - label={label} - placeholder="Enter value" - min={0} - prefix={prefix} - suffix={isPercentage ? '%' : ''} - value={displayValue} - onChange={handleChange} - thousandSeparator="," - style={{ flex: 1 }} - /> - ); -} 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<ReportYearContextValue | undefined>(undefined); + +interface ReportYearProviderProps { + year: string | null; + children: ReactNode; +} + +export function ReportYearProvider({ year, children }: ReportYearProviderProps) { + return <ReportYearContext.Provider value={{ year }}>{children}</ReportYearContext.Provider>; +} + +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 ( - <div> - <Button onClick={handleNext}>Next</Button> - <Button onClick={handleBack}>Back</Button> - </div> - ); -} -``` - -**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 = ( - <TextInput - label="Policy title" - placeholder="Policy name" - value={localLabel} - onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} - /> - ); - - const primaryAction = { - label: 'Create a policy', - onClick: submissionHandler, - }; - - return <FlowView title="Create a policy" content={formInputs} primaryAction={primaryAction} />; -} 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<Policy> = { - 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 ( - <IngredientSubmissionView - title="Review Policy" - subtitle="Review your policy configurations before submitting." - textList={provisions} - submitButtonText="Submit Policy" - submissionHandler={handleSubmit} - submitButtonLoading={isPending} - /> - ); -} 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<UserGeographyPopulation, 'createdAt' | 'type'> => { - 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 ( - <Stack gap="md"> - <Text c="red">No geography selected</Text> - </Stack> - ); - } - - const geographyCountryId = populationState.geography.countryId; - - if (populationState.geography.scope === 'national') { - return ( - <Stack gap="md"> - <Text fw={600} fz="lg"> - Confirm household collection - </Text> - <Text> - <strong>Scope:</strong> National - </Text> - <Text> - <strong>Country:</strong> {getCountryLabel(geographyCountryId)} - </Text> - </Stack> - ); - } - - // 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 ( - <Stack gap="md"> - <Text fw={600} fz="lg"> - Confirm household collection - </Text> - <Text> - <strong>Scope:</strong> {regionTypeName} - </Text> - <Text> - <strong>{regionTypeName}:</strong> {regionLabel} - </Text> - </Stack> - ); - }; - - const primaryAction = { - label: 'Create household collection', - onClick: handleSubmit, - isLoading: isPending, - }; - - return ( - <FlowView - title="Confirm household collection" - content={buildDisplayContent()} - primaryAction={primaryAction} - /> - ); -} 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<ScopeType>('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 = ( - <Stack> - {currentCountry === 'uk' ? ( - <UKGeographicOptions - scope={scope as 'national' | 'country' | 'constituency' | 'household'} - selectedRegion={selectedRegion} - countryOptions={ukCountries} - constituencyOptions={ukConstituencies} - onScopeChange={(newScope) => handleScopeChange(newScope)} - onRegionChange={setSelectedRegion} - /> - ) : ( - <USGeographicOptions - scope={scope as 'national' | 'state' | 'household'} - selectedRegion={selectedRegion} - stateOptions={usStates} - onScopeChange={(newScope) => handleScopeChange(newScope)} - onRegionChange={setSelectedRegion} - /> - )} - </Stack> - ); - - const primaryAction = { - label: 'Select Scope', - onClick: submissionHandler, - }; - - return ( - <FlowView title="Select Household Scope" content={formInputs} primaryAction={primaryAction} /> - ); -} 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<string>(getDefaultLabel()); - const [error, setError] = useState<string>(''); - - 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 = ( - <Stack> - <Text size="sm" c="dimmed"> - Give your household(s) a descriptive name. - </Text> - - <TextInput - label="Household Label" - placeholder="e.g., My Family 2025, All California Households, UK National Households" - value={label} - onChange={(event) => { - setLabel(event.currentTarget.value); - setError(''); // Clear error when user types - }} - error={error} - required - maxLength={100} - /> - - <Text size="xs" c="dimmed"> - This label will help you identify this household(s) when creating simulations. - </Text> - </Stack> - ); - - const primaryAction = { - label: 'Continue', - onClick: handleSubmit, - }; - - const cancelAction = { - label: 'Back', - onClick: () => onNavigate('back'), - }; - - return ( - <FlowView - title="Name Your Household(s)" - content={formInputs} - primaryAction={primaryAction} - cancelAction={cancelAction} - /> - ); -} 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<AppDispatch>(); - const countryId = useCurrentCountry(); - const [localLabel, setLocalLabel] = useState(''); - - // Get available years from metadata - const availableYears = useSelector(getTaxYears); - const [localYear, setLocalYear] = useState<string>(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 = ( - <> - <TextInput - label="Report name" - placeholder="Enter report name" - value={localLabel} - onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} - /> - <Select - label="Year" - placeholder="Select year" - data={availableYears} - value={localYear} - onChange={handleYearChange} - searchable - /> - </> - ); - - const primaryAction = { - label: 'Create report', - onClick: submissionHandler, - }; - - return <FlowView title="Create report" content={formInputs} primaryAction={primaryAction} />; -} 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<EnhancedUserSimulation | null>(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 ( - <FlowView - title="Select an Existing Simulation" - content={<Text>Loading simulations...</Text>} - buttonPreset="none" - /> - ); - } - - if (isError) { - return ( - <FlowView - title="Select an Existing Simulation" - content={<Text c="red">Error: {(error as Error)?.message || 'Something went wrong.'}</Text>} - buttonPreset="none" - /> - ); - } - - if (userSimulations.length === 0) { - return ( - <FlowView - title="Select an Existing Simulation" - content={<Text>No simulations available. Please create a new simulation.</Text>} - 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 ( - <FlowView - title="Select an Existing Simulation" - variant="cardList" - cardListItems={simulationCardItems} - primaryAction={primaryAction} - itemsPerPage={5} - /> - ); -} 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<SetupAction | null>(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 ( - <FlowView - title="Select Simulation" - variant="buttonPanel" - buttonPanelCards={buttonPanelCards} - primaryAction={primaryAction} - /> - ); -} 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<SimulationCard | null>(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 ( - <FlowView - title="Setup Report" - variant="setupConditions" - setupConditionCards={setupConditionCards} - primaryAction={primaryAction} - /> - ); -} - -/** - * 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<Report> = { - 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 ( - <IngredientSubmissionView - title="Review Report Configuration" - subtitle="Review your selected simulations before generating the report." - summaryBoxes={summaryBoxes} - submitButtonText="Generate Report" - submissionHandler={handleSubmit} - submitButtonLoading={isPending} - /> - ); -} 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 = ( - <TextInput - label="Simulation name" - placeholder="Enter simulation name" - value={localLabel} - onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} - /> - ); - - const primaryAction = { - label: 'Create simulation', - onClick: submissionHandler, - }; - - return <FlowView title="Create simulation" content={formInputs} primaryAction={primaryAction} />; -} 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<UserPolicyMetadataWithAssociation | null>(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 ( - <FlowView - title="Select an Existing Policy" - content={<Text>Loading policies...</Text>} - buttonPreset="none" - /> - ); - } - - if (isError) { - return ( - <FlowView - title="Select an Existing Policy" - content={<Text c="red">Error: {(error as Error)?.message || 'Something went wrong.'}</Text>} - buttonPreset="none" - /> - ); - } - - if (userPolicies.length === 0) { - return ( - <FlowView - title="Select an Existing Policy" - content={<Text>No policies available. Please create a new policy.</Text>} - 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 ( - <FlowView - title="Select an Existing Policy" - variant="cardList" - cardListItems={policyCardItems} - primaryAction={primaryAction} - itemsPerPage={5} - /> - ); -} 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<SetupCard | null>(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 ( - <FlowView - title="Setup Simulation" - variant="setupConditions" - setupConditionCards={setupConditionCards} - primaryAction={primaryAction} - /> - ); -} 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<SetupAction | null>(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 ( - <FlowView - title="Select Policy" - variant="buttonPanel" - buttonPanelCards={buttonPanelCards} - primaryAction={primaryAction} - /> - ); -} 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<SetupAction | null>(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 ( - <FlowView - title={viewTitle} - subtitle={viewSubtitle} - variant="buttonPanel" - buttonPanelCards={buttonPanelCards} - primaryAction={primaryAction} - /> - ); -} 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<Simulation> = { - 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 ( - <IngredientSubmissionView - title="Summary of Selections" - subtitle="Review your configurations and add additional criteria before running your simulation." - summaryBoxes={summaryBoxes} - submitButtonText="Save Simulation" - submissionHandler={handleSubmit} - submitButtonLoading={isPending} - /> - ); -} 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<AppDispatch>(); - 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<TMode>(initialMode: TMode) { + const [currentMode, setCurrentMode] = useState<TMode>(initialMode); + const [history, setHistory] = useState<TMode[]>([]); + + 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 <div>No report year available</div>; + * } * 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 ( <> - <IngredientReadView - ingredient="policy" - title="Your saved policies" - subtitle="Create a policy reform or find and save existing policies to use in your simulation configurations." - onBuild={handleBuildPolicy} - isLoading={isLoading} - isError={isError} - error={error} - data={transformedData} - columns={policyColumns} - searchValue={searchValue} - onSearchChange={setSearchValue} - enableSelection - isSelected={isSelected} - onSelectionChange={handleSelectionChange} - /> + <Stack gap="md"> + <IngredientReadView + ingredient="policy" + title="Your saved policies" + subtitle="Create a policy reform or find and save existing policies to use in your simulation configurations." + onBuild={handleBuildPolicy} + isLoading={isLoading} + isError={isError} + error={error} + data={transformedData} + columns={policyColumns} + searchValue={searchValue} + onSearchChange={setSearchValue} + enableSelection + isSelected={isSelected} + onSelectionChange={handleSelectionChange} + /> + </Stack> <RenameIngredientModal opened={renameOpened} diff --git a/app/src/pages/Populations.page.tsx b/app/src/pages/Populations.page.tsx index 4145bc50..311193c5 100644 --- a/app/src/pages/Populations.page.tsx +++ b/app/src/pages/Populations.page.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { Stack } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { BulletsValue, ColumnConfig, IngredientRecord, TextValue } from '@/components/columns'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; @@ -312,23 +313,25 @@ export default function PopulationsPage() { return ( <> - <IngredientReadView - ingredient="household" - title="Your saved households" - subtitle="Configure one or a collection of households to use in your simulation configurations." - buttonLabel="New household(s)" - onBuild={handleBuildPopulation} - isLoading={isLoading} - isError={isError} - error={error} - data={transformedData} - columns={populationColumns} - searchValue={searchValue} - onSearchChange={setSearchValue} - enableSelection - isSelected={isSelected} - onSelectionChange={handleSelectionChange} - /> + <Stack gap="md"> + <IngredientReadView + ingredient="household" + title="Your saved households" + subtitle="Configure one or a collection of households to use in your simulation configurations." + buttonLabel="New household(s)" + onBuild={handleBuildPopulation} + isLoading={isLoading} + isError={isError} + error={error} + data={transformedData} + columns={populationColumns} + searchValue={searchValue} + onSearchChange={setSearchValue} + enableSelection + isSelected={isSelected} + onSelectionChange={handleSelectionChange} + /> + </Stack> <RenameIngredientModal opened={renameOpened} diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index f3176e93..9a9d80da 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -4,6 +4,7 @@ import { Container, Stack, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUpdateReportAssociation } from '@/hooks/useUserReportAssociations'; @@ -219,7 +220,7 @@ export default function ReportOutputPage() { }; return ( - <> + <ReportYearProvider year={report?.year ?? null}> <ReportOutputLayout reportId={userReportId} reportLabel={userReport?.label} @@ -245,7 +246,7 @@ export default function ReportOutputPage() { isLoading={updateAssociation.isPending} ingredientType="report" /> - </> + </ReportYearProvider> ); } 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 ( <> - <IngredientReadView - ingredient="report" - title="Your saved reports" - subtitle="Generate comprehensive impact analyses comparing tax policy scenarios. Reports show distributional effects, budget impacts, and poverty outcomes across demographics" - onBuild={handleBuildReport} - isLoading={isLoading} - isError={isError} - error={error} - data={transformedData} - columns={reportColumns} - searchValue={searchValue} - onSearchChange={setSearchValue} - enableSelection - isSelected={isSelected} - onSelectionChange={handleSelectionChange} - /> + <Stack gap="md"> + <IngredientReadView + ingredient="report" + title="Your saved reports" + subtitle="Generate comprehensive impact analyses comparing tax policy scenarios. Reports show distributional effects, budget impacts, and poverty outcomes across demographics" + onBuild={handleBuildReport} + isLoading={isLoading} + isError={isError} + error={error} + data={transformedData} + columns={reportColumns} + searchValue={searchValue} + onSearchChange={setSearchValue} + enableSelection + isSelected={isSelected} + onSelectionChange={handleSelectionChange} + /> + </Stack> <RenameIngredientModal opened={renameOpened} diff --git a/app/src/pages/Simulations.page.tsx b/app/src/pages/Simulations.page.tsx index f6323948..82abb64a 100644 --- a/app/src/pages/Simulations.page.tsx +++ b/app/src/pages/Simulations.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'; @@ -108,7 +109,7 @@ export default function SimulationsPage() { // Transform the data to match the new structure const transformedData: IngredientRecord[] = data?.map((item) => ({ - 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 ( <> - <IngredientReadView - ingredient="simulation" - title="Your saved simulations" - subtitle="Build and save tax policy scenarios for quick access when creating impact reports. Pre-configured simulations accelerate report generation by up to X%" - onBuild={handleBuildSimulation} - isLoading={isLoading} - isError={isError} - error={error} - data={transformedData} - columns={simulationColumns} - searchValue={searchValue} - onSearchChange={setSearchValue} - enableSelection - isSelected={isSelected} - onSelectionChange={handleSelectionChange} - /> + <Stack gap="md"> + <IngredientReadView + ingredient="simulation" + title="Your saved simulations" + subtitle="Build and save tax policy scenarios for quick access when creating impact reports. Pre-configured simulations accelerate report generation by up to X%" + onBuild={handleBuildSimulation} + isLoading={isLoading} + isError={isError} + error={error} + data={transformedData} + columns={simulationColumns} + searchValue={searchValue} + onSearchChange={setSearchValue} + enableSelection + isSelected={isSelected} + onSelectionChange={handleSelectionChange} + /> + </Stack> <RenameIngredientModal opened={renameOpened} diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index a46ee1cc..80ab49c8 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -43,6 +43,15 @@ export default function EarningsVariationSubPage({ const reportYear = useReportYear(); const metadata = useSelector((state: RootState) => state.metadata); + // Early return if no report year available (shouldn't happen in report output context) + if (!reportYear) { + return ( + <Stack gap={spacing.md}> + <Text c="red">Error: Report year not available</Text> + </Stack> + ); + } + // 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 ( + <Stack gap={spacing.md}> + <Text c="red">Error: Report year not available</Text> + </Stack> + ); + } + // 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<TState, TMode>` + +**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<TState, TMode>` + +**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<TState, TMode>` + +**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<TMode>` + +**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**: `<TMode>` - 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<SimulationStateProps>( + () => 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 <SimulationLabelView + label={simulationState.label} + onUpdateLabel={(label) => setSimulationState(prev => ({ ...prev, label }))} + onNext={() => navigateToMode(SimulationViewMode.SIMULATION_SETUP)} + />; + + case SimulationViewMode.POLICY_LABEL: + return <PolicyLabelView + label={simulationState.policy.label} + onUpdateLabel={policyCallbacks.updateLabel} + onNext={() => 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<PolicyStateProps>(() => { + 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 = ( + <PolicyLabelView + label={policyState.label} + mode="standalone" + onUpdateLabel={policyCallbacks.updateLabel} + onNext={() => navigateToMode(StandalonePolicyViewMode.PARAMETER_SELECTOR)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/policies`)} + /> + ); + break; + + case StandalonePolicyViewMode.PARAMETER_SELECTOR: + currentView = ( + <PolicyParameterSelectorView + policy={policyState} + onPolicyUpdate={policyCallbacks.updatePolicy} + onNext={() => navigateToMode(StandalonePolicyViewMode.SUBMIT)} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case StandalonePolicyViewMode.SUBMIT: + currentView = ( + <PolicySubmitView + policy={policyState} + countryId={countryId} + onSubmitSuccess={policyCallbacks.handleSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/policies`)} + /> + ); + break; + + default: + currentView = <div>Unknown view mode: {currentMode}</div>; + } + + // Conditionally wrap with StandardLayout + // PolicyParameterSelectorView manages its own AppShell + if (MODES_WITH_OWN_LAYOUT.has(currentMode as StandalonePolicyViewMode)) { + return currentView; + } + + return <StandardLayout>{currentView}</StandardLayout>; +} 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<PopulationStateProps>(() => { + 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 = ( + <PopulationScopeView + countryId={countryId} + regionData={metadata.economyOptions?.region || []} + onScopeSelected={populationCallbacks.handleScopeSelected} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/households`)} + /> + ); + break; + + case StandalonePopulationViewMode.LABEL: + currentView = ( + <PopulationLabelView + population={populationState} + mode="standalone" + onUpdateLabel={populationCallbacks.updateLabel} + onNext={() => { + // 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 = ( + <HouseholdBuilderView + population={populationState} + countryId={countryId} + onSubmitSuccess={populationCallbacks.handleHouseholdSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case StandalonePopulationViewMode.GEOGRAPHIC_CONFIRM: + currentView = ( + <GeographicConfirmationView + population={populationState} + metadata={metadata} + onSubmitSuccess={populationCallbacks.handleGeographicSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + default: + currentView = <div>Unknown view mode: {currentMode}</div>; + } + + return <StandardLayout>{currentView}</StandardLayout>; +} 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 <div>Error: Country ID not found</div>; + } + + if (!countryIds.includes(countryIdParam as any)) { + return <div>Error: Invalid country ID</div>; + } + + const countryId = countryIdParam as (typeof countryIds)[number]; + + console.log('[ReportPathwayWrapper] ========== RENDER =========='); + console.log('[ReportPathwayWrapper] countryId:', countryId); + + // Initialize report state + const [reportState, setReportState] = useState<ReportStateProps>(() => + 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<Report> = { + 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 = ( + <ReportLabelView + label={reportState.label} + year={reportState.year} + onUpdateLabel={reportCallbacks.updateLabel} + onUpdateYear={reportCallbacks.updateYear} + onNext={() => navigateToMode(ReportViewMode.REPORT_SETUP)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.REPORT_SETUP: + currentView = ( + <ReportSetupView + reportState={reportState} + onNavigateToSimulationSelection={handleNavigateToSimulationSelection} + onNext={() => navigateToMode(ReportViewMode.REPORT_SUBMIT)} + onPrefillPopulation2={reportCallbacks.prefillPopulation2FromSimulation1} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.REPORT_SELECT_SIMULATION: + currentView = ( + <ReportSimulationSelectionView + simulationIndex={activeSimulationIndex} + countryId={countryId} + currentLawId={currentLawId} + onCreateNew={() => 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 = ( + <ReportSimulationExistingView + activeSimulationIndex={activeSimulationIndex} + otherSimulation={otherSimulation} + onSelectSimulation={reportCallbacks.handleSelectExistingSimulation} + onNext={() => navigateToMode(ReportViewMode.REPORT_SETUP)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.REPORT_SUBMIT: + currentView = ( + <ReportSubmitView + reportState={reportState} + onSubmit={handleSubmitReport} + isSubmitting={isSubmitting} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + // ========== SIMULATION-LEVEL VIEWS ========== + case ReportViewMode.SIMULATION_LABEL: + currentView = ( + <SimulationLabelView + label={activeSimulation.label} + mode="report" + simulationIndex={activeSimulationIndex} + reportLabel={reportState.label} + onUpdateLabel={simulationCallbacks.updateLabel} + onNext={() => navigateToMode(ReportViewMode.SIMULATION_SETUP)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.SIMULATION_SETUP: + currentView = ( + <SimulationSetupView + simulation={activeSimulation} + simulationIndex={activeSimulationIndex} + isReportMode + onNavigateToPolicy={handleNavigateToPolicy} + onNavigateToPopulation={handleNavigateToPopulation} + onNext={() => navigateToMode(ReportViewMode.SIMULATION_SUBMIT)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.SIMULATION_SUBMIT: + currentView = ( + <SimulationSubmitView + simulation={activeSimulation} + onSubmitSuccess={simulationCallbacks.handleSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + // ========== POLICY SETUP COORDINATION ========== + case ReportViewMode.SETUP_POLICY: + currentView = ( + <SimulationPolicySetupView + currentLawId={currentLawId} + countryId={countryId} + onSelectCurrentLaw={handleSelectCurrentLaw} + onCreateNew={() => 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 = ( + <SimulationPopulationSetupView + isReportMode + otherSimulation={otherSimulation} + otherPopulation={otherSimulation.population} + onCreateNew={() => 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 = ( + <PolicyLabelView + label={activeSimulation.policy.label} + mode="report" + simulationIndex={activeSimulationIndex} + reportLabel={reportState.label} + onUpdateLabel={policyCallbacks.updateLabel} + onNext={() => navigateToMode(ReportViewMode.POLICY_PARAMETER_SELECTOR)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.POLICY_PARAMETER_SELECTOR: + currentView = ( + <PolicyParameterSelectorView + policy={activeSimulation.policy} + onPolicyUpdate={policyCallbacks.updatePolicy} + onNext={() => navigateToMode(ReportViewMode.POLICY_SUBMIT)} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case ReportViewMode.POLICY_SUBMIT: + currentView = ( + <PolicySubmitView + policy={activeSimulation.policy} + countryId={countryId} + onSubmitSuccess={policyCallbacks.handleSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.SELECT_EXISTING_POLICY: + currentView = ( + <PolicyExistingView + onSelectPolicy={policyCallbacks.handleSelectExisting} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + // ========== POPULATION CREATION VIEWS ========== + case ReportViewMode.POPULATION_SCOPE: + currentView = ( + <PopulationScopeView + countryId={countryId} + regionData={metadata.economyOptions?.region || []} + onScopeSelected={populationCallbacks.handleScopeSelected} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + case ReportViewMode.POPULATION_LABEL: + currentView = ( + <PopulationLabelView + population={activeSimulation.population} + mode="report" + simulationIndex={activeSimulationIndex} + reportLabel={reportState.label} + onUpdateLabel={populationCallbacks.updateLabel} + onNext={() => { + // 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 = ( + <HouseholdBuilderView + population={activeSimulation.population} + countryId={countryId} + onSubmitSuccess={populationCallbacks.handleHouseholdSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case ReportViewMode.POPULATION_GEOGRAPHIC_CONFIRM: + currentView = ( + <GeographicConfirmationView + population={activeSimulation.population} + metadata={metadata} + onSubmitSuccess={populationCallbacks.handleGeographicSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case ReportViewMode.SELECT_EXISTING_POPULATION: + currentView = ( + <PopulationExistingView + onSelectHousehold={populationCallbacks.handleSelectExistingHousehold} + onSelectGeography={populationCallbacks.handleSelectExistingGeography} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/reports`)} + /> + ); + break; + + default: + currentView = <div>Unknown view mode: {currentMode}</div>; + } + + // 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 = ( + <ReportYearProvider year={reportState.year}>{currentView}</ReportYearProvider> + ); + + // This is a workaround to allow the param setter to manage its own AppShell + return needsStandardLayout ? <StandardLayout>{wrappedView}</StandardLayout> : 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 ( + <Card + withBorder + component="button" + onClick={onClick} + variant={isSelected ? 'buttonPanel--active' : 'buttonPanel--inactive'} + style={{ + cursor: 'pointer', + }} + > + <Group justify="space-between" align="center"> + <Stack gap={spacing.xs} style={{ flex: 1 }}> + <Text fw={700}>{simulationLabel}</Text> + <Text size="sm" c="dimmed"> + Use current law with all households nationwide as baseline + </Text> + </Stack> + <IconChevronRight + size={20} + style={{ + color: 'var(--mantine-color-gray-6)', + marginTop: '2px', + flexShrink: 0, + }} + /> + </Group> + </Card> + ); +} 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 <Text pb="sm">{param.description}</Text> </> )} - <ValueSetter param={param} /> + <PolicyParameterSelectorValueSetter + param={param} + policy={policy} + onPolicyUpdate={onPolicyUpdate} + /> <HistoricalValues param={param} baseValues={baseValues} diff --git a/app/src/pathways/report/components/PolicyParameterSelectorValueSetter.tsx b/app/src/pathways/report/components/PolicyParameterSelectorValueSetter.tsx new file mode 100644 index 00000000..1641ea04 --- /dev/null +++ b/app/src/pathways/report/components/PolicyParameterSelectorValueSetter.tsx @@ -0,0 +1,119 @@ +/** + * PolicyParameterSelectorValueSetter - Props-based version of ValueSetter container + * Duplicated from components/policyParameterSelectorFrame/ValueSetter.tsx + * Updates policy state via props instead of Redux dispatch + */ + +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Button, Container, Divider, Group, Stack, Text } from '@mantine/core'; +import { getDateRange } from '@/libs/metadataUtils'; +import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; +import { PolicyStateProps } from '@/types/pathwayState'; +import { getParameterByName } from '@/types/subIngredients/parameter'; +import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; +import { ModeSelectorButton, ValueSetterComponents, ValueSetterMode } from './valueSetters'; + +interface PolicyParameterSelectorValueSetterProps { + param: ParameterMetadata; + policy: PolicyStateProps; + onPolicyUpdate: (updatedPolicy: PolicyStateProps) => void; +} + +export default function PolicyParameterSelectorValueSetter({ + param, + policy, + onPolicyUpdate, +}: PolicyParameterSelectorValueSetterProps) { + const [mode, setMode] = useState<ValueSetterMode>(ValueSetterMode.DEFAULT); + + // Get date ranges from metadata using utility selector + const { minDate, maxDate } = useSelector(getDateRange); + + const [intervals, setIntervals] = useState<ValueInterval[]>([]); + + // Hoisted date state for all non-multi-year selectors + const [startDate, setStartDate] = useState<string>('2025-01-01'); + const [endDate, setEndDate] = useState<string>('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 ( + <Container bg="gray.0" bd="1px solid gray.2" m="0" p="lg"> + <Stack> + <Text fw={700}>Current value</Text> + <Divider style={{ padding: 0 }} /> + <Group align="flex-end" w="100%"> + <ValueSetterToRender {...valueSetterProps} /> + <ModeSelectorButton setMode={handleModeChange} /> + <Button onClick={handleSubmit}>Add parameter</Button> + </Group> + </Stack> + </Container> + ); +} 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<any>( + 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 ( + <Group align="flex-end" style={{ flex: 1 }}> + <DatePickerInput + placeholder="Pick a start date" + label="From" + minDate={fromISODateString(minDate)} + maxDate={fromISODateString(maxDate)} + value={fromISODateString(startDate)} + onChange={handleStartDateChange} + style={{ flex: 1 }} + /> + <DatePickerInput + placeholder="Pick an end date" + label="To" + minDate={fromISODateString(minDate)} + maxDate={fromISODateString(maxDate)} + value={fromISODateString(endDate)} + onChange={handleEndDateChange} + style={{ flex: 1 }} + /> + <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> + </Group> + ); +} 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<any>( + 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 ( + <Group align="flex-end" style={{ flex: 1 }}> + <YearPickerInput + placeholder="Pick a year" + label="From" + minDate={fromISODateString(minDate)} + maxDate={fromISODateString(maxDate)} + value={fromISODateString(startDate)} + onChange={handleStartDateChange} + style={{ flex: 1 }} + /> + <Box style={{ flex: 1, display: 'flex', alignItems: 'center', height: '36px' }}> + <Text size="sm" fw={500}> + onward: + </Text> + </Box> + <Box style={{ flex: 1 }}> + <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> + </Box> + </Group> + ); +} 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 ( + <Menu> + <Menu.Target> + <ActionIcon aria-label="Select value setter mode" variant="default"> + <IconSettings /> + </ActionIcon> + </Menu.Target> + <Menu.Dropdown> + <Menu.Item onClick={() => setMode(ValueSetterMode.DEFAULT)}>Default</Menu.Item> + <Menu.Item onClick={() => setMode(ValueSetterMode.YEARLY)}>Yearly</Menu.Item> + <Menu.Item onClick={() => setMode(ValueSetterMode.DATE)}>Advanced</Menu.Item> + <Menu.Item onClick={() => setMode(ValueSetterMode.MULTI_YEAR)}>Multi-year</Menu.Item> + </Menu.Dropdown> + </Menu> + ); +} 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<string, number> = { + 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<string, any> = {}; + years.forEach((year) => { + initialValues[year] = getDefaultValueForParam(param, policy, `${year}-01-01`); + }); + return initialValues; + }, [param, policy]); + + const [yearValues, setYearValues] = useState<Record<string, any>>(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 ( + <Box> + <SimpleGrid cols={2} spacing="md"> + <Stack> + {leftColumn.map((year) => ( + <Group key={year}> + <Text fw={500} style={{ minWidth: '50px' }}> + {year} + </Text> + <ValueInputBox + param={param} + value={yearValues[year]} + onChange={(value) => handleYearValueChange(year, value)} + /> + </Group> + ))} + </Stack> + <Stack> + {rightColumn.map((year) => ( + <Group key={year}> + <Text fw={500} style={{ minWidth: '50px' }}> + {year} + </Text> + <ValueInputBox + param={param} + value={yearValues[year]} + onChange={(value) => handleYearValueChange(year, value)} + /> + </Group> + ))} + </Stack> + </SimpleGrid> + </Box> + ); +} 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 <NumberInput disabled value={0} />; + } + + 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 ( + <Stack gap="xs" style={{ flex: 1 }}> + {label && ( + <Text size="sm" fw={500}> + {label} + </Text> + )} + <Group + justify="space-between" + align="center" + style={{ + border: '1px solid #ced4da', + borderRadius: '4px', + padding: '6px 12px', + height: '36px', + backgroundColor: 'white', + }} + > + <Text size="sm" c={value ? 'dimmed' : 'dark'} fw={value ? 400 : 600}> + False + </Text> + <Switch + checked={value || false} + onChange={(event) => handleBoolChange(event.currentTarget.checked)} + size="md" + /> + <Text size="sm" c={value ? 'dark' : 'dimmed'} fw={value ? 600 : 400}> + True + </Text> + </Group> + </Stack> + ); + } + + return ( + <NumberInput + label={label} + placeholder="Enter value" + min={0} + prefix={prefix} + suffix={isPercentage ? '%' : ''} + value={displayValue} + onChange={handleChange} + thousandSeparator="," + style={{ flex: 1 }} + /> + ); +} 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<SetStateAction<ValueInterval[]>>; + startDate: string; + setStartDate: Dispatch<SetStateAction<string>>; + endDate: string; + setEndDate: Dispatch<SetStateAction<string>>; +} 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<any>( + 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 ( + <Group align="flex-end" style={{ flex: 1 }}> + <YearPickerInput + placeholder="Pick a year" + label="From" + minDate={fromISODateString(minDate)} + maxDate={fromISODateString(maxDate)} + value={fromISODateString(startDate)} + onChange={handleStartDateChange} + style={{ flex: 1 }} + /> + <YearPickerInput + placeholder="Pick a year" + label="To" + minDate={fromISODateString(minDate)} + maxDate={fromISODateString(maxDate)} + value={fromISODateString(endDate)} + onChange={handleEndDateChange} + style={{ flex: 1 }} + /> + <ValueInputBox param={param} value={paramValue} onChange={setParamValue} /> + </Group> + ); +} 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<string>(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 = ( + <> + <TextInput + label="Report name" + placeholder="Enter report name" + value={localLabel} + onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} + /> + <Select + label="Year" + placeholder="Select year" + data={availableYears} + value={localYear} + onChange={handleYearChange} + searchable + /> + </> + ); + + const primaryAction = { + label: `${initializeText} report`, + onClick: submissionHandler, + }; + + return ( + <PathwayView + title="Create report" + content={formInputs} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<SimulationCard | null>(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 ( + <PathwayView + title="Configure report" + variant="setupConditions" + setupConditionCards={setupConditionCards} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} + +/** + * 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<EnhancedUserSimulation | null>(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 ( + <PathwayView + title="Select an existing simulation" + content={<Text>Loading simulations...</Text>} + buttonPreset="none" + /> + ); + } + + if (isError) { + return ( + <PathwayView + title="Select an existing simulation" + content={<Text c="red">Error: {(error as Error)?.message || 'Something went wrong.'}</Text>} + buttonPreset="none" + /> + ); + } + + if (userSimulations.length === 0) { + return ( + <PathwayView + title="Select an existing simulation" + content={<Text>No simulations available. Please create a new simulation.</Text>} + 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 ( + <PathwayView + title="Select an existing simulation" + variant="cardList" + cardListItems={simulationCardItems} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + itemsPerPage={5} + /> + ); +} 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<SetupAction | null>(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<Simulation> = { + 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 ( + <PathwayView + title="Select simulation" + content={ + <Stack> + <DefaultBaselineOption + countryId={countryId} + isSelected={selectedAction === 'defaultBaseline'} + onClick={handleClickDefaultBaseline} + /> + <ButtonPanelVariant cards={buttonPanelCards} /> + </Stack> + } + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); + } + + // For reform simulation, just show the standard button panel + return ( + <PathwayView + title="Select simulation" + variant="buttonPanel" + buttonPanelCards={buttonPanelCards} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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 ( + <IngredientSubmissionView + title="Review report configuration" + subtitle="Review your selected simulations before generating the report." + summaryBoxes={summaryBoxes} + submitButtonText="Create report" + submissionHandler={onSubmit} + submitButtonLoading={isSubmitting} + onBack={onBack} + onCancel={onCancel} + /> + ); +} 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<UserPolicyMetadataWithAssociation | null>(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 ( + <PathwayView + title="Select an existing policy" + content={<Text>Loading policies...</Text>} + buttonPreset="none" + /> + ); + } + + if (isError) { + return ( + <PathwayView + title="Select an existing policy" + content={<Text c="red">Error: {(error as Error)?.message || 'Something went wrong.'}</Text>} + buttonPreset="none" + /> + ); + } + + if (userPolicies.length === 0) { + return ( + <PathwayView + title="Select an existing policy" + content={<Text>No policies available. Please create a new policy.</Text>} + 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 ( + <PathwayView + title="Select an existing policy" + variant="cardList" + cardListItems={policyCardItems} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + itemsPerPage={5} + /> + ); +} 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 = ( + <TextInput + label="Policy title" + placeholder="Policy name" + value={localLabel} + onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} + /> + ); + + const primaryAction = { + label: `${initializeText} policy`, + onClick: submissionHandler, + }; + + return ( + <PathwayView + title="Create policy" + content={formInputs} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<ParameterMetadata | null>(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 = () => ( + <Group justify="space-between" align="center"> + {onBack && ( + <Button variant="default" onClick={onBack}> + Back + </Button> + )} + {modificationCount > 0 && ( + <Group gap="xs"> + <Box + style={{ + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: colors.primary[600], + }} + /> + <Text size="sm" c="gray.5"> + {modificationCount} parameter modification{modificationCount !== 1 ? 's' : ''} + </Text> + </Group> + )} + <Button variant="filled" onClick={onNext} rightSection={<IconChevronRight size={16} />}> + Review my policy + </Button> + </Group> + ); + return ( <AppShell layout="default" @@ -78,20 +123,19 @@ export default function PolicyParameterSelectorFrame({ {loading || !parameterTree ? ( <MainEmpty /> ) : selectedLeafParam ? ( - <Main key={selectedLeafParam.parameter} param={selectedLeafParam} /> + <PolicyParameterSelectorMain + key={selectedLeafParam.parameter} + param={selectedLeafParam} + policy={policy} + onPolicyUpdate={onPolicyUpdate} + /> ) : ( <MainEmpty /> )} </AppShell.Main> <AppShell.Footer p="md"> - <Footer - onNavigate={onNavigate} - onReturn={onReturn} - flowConfig={flowConfig} - isInSubflow={isInSubflow} - flowDepth={flowDepth} - /> + <PolicyParameterFooter /> </AppShell.Footer> </AppShell> ); 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<Policy> = { + 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 ( + <IngredientSubmissionView + title="Review policy" + subtitle="Review your policy configurations before submitting." + textList={provisions} + submitButtonText="Create policy" + submissionHandler={handleSubmit} + submitButtonLoading={isPending} + onBack={onBack} + onCancel={onCancel} + /> + ); +} 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<UserGeographyPopulation, 'createdAt' | 'type'> => { + 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 ( + <Stack gap="md"> + <Text c="red">No geography selected</Text> + </Stack> + ); + } + + const geographyCountryId = population.geography.countryId; + + if (population.geography.scope === 'national') { + return ( + <Stack gap="md"> + <Text fw={600} fz="lg"> + Confirm household collection + </Text> + <Text> + <strong>Scope:</strong> National + </Text> + <Text> + <strong>Country:</strong> {getCountryLabel(geographyCountryId)} + </Text> + </Stack> + ); + } + + // Subnational + const regionCode = population.geography.geographyId; + const regionLabel = getRegionLabel(regionCode, metadata); + const regionTypeName = getRegionTypeLabel(geographyCountryId, regionCode, metadata); + + return ( + <Stack gap="md"> + <Text fw={600} fz="lg"> + Confirm household collection + </Text> + <Text> + <strong>Scope:</strong> {regionTypeName} + </Text> + <Text> + <strong>{regionTypeName}:</strong> {regionLabel} + </Text> + </Stack> + ); + }; + + const primaryAction = { + label: 'Create household collection ', + onClick: handleSubmit, + isLoading: isPending, + }; + + return ( + <PathwayView + title="Confirm household collection" + content={buildDisplayContent()} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + /> + ); +} 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 ( - <FlowView + <PathwayView title="Create Household" content={ <Stack align="center" gap="md" p="xl"> @@ -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<Household>(() => { - 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<number>(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 ( - <FlowView - title="Create Household" + <PathwayView + title="Create household" content={ <Stack align="center" gap="md" p="xl"> <Text c="red" fw={600}> @@ -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 ( - <FlowView - title="Build Your Household" + <PathwayView + title="Build your household" content={content} primaryAction={primaryAction} - cancelAction={{ - onClick: onReturn, - }} - buttonPreset="cancel-primary" + backAction={onBack ? { onClick: onBack } : undefined} /> ); } 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 ( - <FlowView - title="Select Existing Household(s)" + <PathwayView + title="Select existing household(s)" content={<Text>Loading households...</Text>} buttonPreset="none" /> @@ -248,8 +200,8 @@ export default function SimulationSelectExistingPopulationFrame({ if (isError) { return ( - <FlowView - title="Select Existing Household(s)" + <PathwayView + title="Select existing household(s)" content={<Text c="red">Error: {(error as Error)?.message || 'Something went wrong.'}</Text>} buttonPreset="none" /> @@ -258,10 +210,16 @@ export default function SimulationSelectExistingPopulationFrame({ if (householdPopulations.length === 0 && geographicPopulations.length === 0) { return ( - <FlowView - title="Select Existing Household(s)" + <PathwayView + title="Select existing household(s)" content={<Text>No households available. Please create new household(s).</Text>} - 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 ( - <FlowView - title="Select Existing Household(s)" + <PathwayView + title="Select existing household(s)" variant="cardList" cardListItems={cardListItems} primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} itemsPerPage={5} /> ); 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<string>(getDefaultLabel()); + const [error, setError] = useState<string>(''); + + 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 = ( + <Stack> + <Text size="sm" c="dimmed"> + Give your household(s) a descriptive name. + </Text> + + <TextInput + label="Household Label" + placeholder="e.g., My Family 2025, All California Households, UK National Households" + value={label} + onChange={(event) => { + setLabel(event.currentTarget.value); + setError(''); // Clear error when user types + }} + error={error} + required + maxLength={100} + /> + + <Text size="xs" c="dimmed"> + This label will help you identify this household(s) when creating simulations. + </Text> + </Stack> + ); + + const primaryAction = { + label: `${initializeText} household(s)`, + onClick: handleSubmit, + }; + + return ( + <PathwayView + title="Name your household(s)" + content={formInputs} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + /> + ); +} 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<ScopeType>('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 = ( + <Stack> + {countryId === 'uk' ? ( + <UKGeographicOptions + scope={scope as 'national' | 'country' | 'constituency' | 'household'} + selectedRegion={selectedRegion} + countryOptions={ukCountries} + constituencyOptions={ukConstituencies} + onScopeChange={(newScope) => handleScopeChange(newScope)} + onRegionChange={setSelectedRegion} + /> + ) : ( + <USGeographicOptions + scope={scope as 'national' | 'state' | 'household'} + selectedRegion={selectedRegion} + stateOptions={usStates} + onScopeChange={(newScope) => handleScopeChange(newScope)} + onRegionChange={setSelectedRegion} + /> + )} + </Stack> + ); + + const primaryAction = { + label: 'Select scope ', + onClick: submissionHandler, + }; + + return ( + <PathwayView + title="Select household scope" + content={formInputs} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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 = ( + <TextInput + label="Simulation name" + placeholder="Enter simulation name" + value={localLabel} + onChange={(e) => handleLocalLabelChange(e.currentTarget.value)} + /> + ); + + const primaryAction = { + label: `${initializeText} simulation`, + onClick: submissionHandler, + }; + + return ( + <PathwayView + title="Create simulation" + content={formInputs} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<SetupAction | null>(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 ( + <PathwayView + title="Select policy" + variant="buttonPanel" + buttonPanelCards={buttonPanelCards} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<SetupAction | null>(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 ( + <PathwayView + title={viewTitle} + subtitle={viewSubtitle} + variant="buttonPanel" + buttonPanelCards={buttonPanelCards} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<SetupCard | null>(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 ( + <PathwayView + title="Configure simulation" + variant="setupConditions" + setupConditionCards={setupConditionCards} + primaryAction={primaryAction} + backAction={onBack ? { onClick: onBack } : undefined} + cancelAction={onCancel ? { onClick: onCancel } : undefined} + /> + ); +} 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<Simulation> = { + 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 ( + <IngredientSubmissionView + title="Summary of selections" + subtitle="Review your configurations and add additional criteria before running your simulation." + summaryBoxes={summaryBoxes} + submitButtonText="Create simulation" + submissionHandler={handleSubmit} + submitButtonLoading={isPending} + onBack={onBack} + onCancel={onCancel} + /> + ); +} 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<SimulationStateProps>(() => { + 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 = ( + <SimulationLabelView + label={simulationState.label} + mode="standalone" + onUpdateLabel={simulationCallbacks.updateLabel} + onNext={() => navigateToMode(SimulationViewMode.SETUP)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + case SimulationViewMode.SETUP: + currentView = ( + <SimulationSetupView + simulation={simulationState} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={handleNavigateToPolicy} + onNavigateToPopulation={handleNavigateToPopulation} + onNext={() => navigateToMode(SimulationViewMode.SUBMIT)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + case SimulationViewMode.SUBMIT: + currentView = ( + <SimulationSubmitView + simulation={simulationState} + onSubmitSuccess={simulationCallbacks.handleSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + // ========== POLICY SETUP COORDINATION ========== + case SimulationViewMode.SETUP_POLICY: + currentView = ( + <SimulationPolicySetupView + currentLawId={currentLawId} + countryId={countryId} + onSelectCurrentLaw={handleSelectCurrentLaw} + onCreateNew={() => 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 = ( + <SimulationPopulationSetupView + isReportMode={false} + otherSimulation={null} + otherPopulation={null} + onCreateNew={() => 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 = ( + <PolicyLabelView + label={simulationState.policy.label} + mode="standalone" + onUpdateLabel={policyCallbacks.updateLabel} + onNext={() => navigateToMode(SimulationViewMode.POLICY_PARAMETER_SELECTOR)} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + case SimulationViewMode.POLICY_PARAMETER_SELECTOR: + currentView = ( + <PolicyParameterSelectorView + policy={simulationState.policy} + onPolicyUpdate={policyCallbacks.updatePolicy} + onNext={() => navigateToMode(SimulationViewMode.POLICY_SUBMIT)} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case SimulationViewMode.POLICY_SUBMIT: + currentView = ( + <PolicySubmitView + policy={simulationState.policy} + countryId={countryId} + onSubmitSuccess={policyCallbacks.handleSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + case SimulationViewMode.SELECT_EXISTING_POLICY: + currentView = ( + <PolicyExistingView + onSelectPolicy={policyCallbacks.handleSelectExisting} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + // ========== POPULATION CREATION VIEWS ========== + case SimulationViewMode.POPULATION_SCOPE: + currentView = ( + <PopulationScopeView + countryId={countryId} + regionData={metadata.economyOptions?.region || []} + onScopeSelected={populationCallbacks.handleScopeSelected} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + case SimulationViewMode.POPULATION_LABEL: + currentView = ( + <PopulationLabelView + population={simulationState.population} + mode="standalone" + onUpdateLabel={populationCallbacks.updateLabel} + onNext={() => { + // 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 = ( + <HouseholdBuilderView + population={simulationState.population} + countryId={countryId} + onSubmitSuccess={populationCallbacks.handleHouseholdSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case SimulationViewMode.POPULATION_GEOGRAPHIC_CONFIRM: + currentView = ( + <GeographicConfirmationView + population={simulationState.population} + metadata={metadata} + onSubmitSuccess={populationCallbacks.handleGeographicSubmitSuccess} + onBack={canGoBack ? goBack : undefined} + /> + ); + break; + + case SimulationViewMode.SELECT_EXISTING_POPULATION: + currentView = ( + <PopulationExistingView + onSelectHousehold={populationCallbacks.handleSelectExistingHousehold} + onSelectGeography={populationCallbacks.handleSelectExistingGeography} + onBack={canGoBack ? goBack : undefined} + onCancel={() => navigate(`/${countryId}/simulations`)} + /> + ); + break; + + default: + currentView = <div>Unknown view mode: {currentMode}</div>; + } + + // 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 <StandardLayout>{currentView}</StandardLayout>; +} 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<ComponentKey>) => { - 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<Policy>; - }> - ) => { - 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<Policy>; - }> - ) => { - 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<PolicyParamAdditionPayload>) => { - 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<string>) => { - 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<string>) => { - state.simulationIds = state.simulationIds.filter((id) => id !== action.payload); - state.updatedAt = new Date().toISOString(); - }, - - // Update API version - updateApiVersion: (state, action: PayloadAction<string | null>) => { - state.apiVersion = action.payload; - }, - - // Update country ID (rarely used - clearReport thunk handles initialization) - updateCountryId: (state, action: PayloadAction<typeof initialState.countryId>) => { - state.countryId = action.payload; - }, - - // Update report year - updateYear: (state, action: PayloadAction<string>) => { - state.year = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report label - updateLabel: (state, action: PayloadAction<string | null>) => { - state.label = action.payload; - state.updatedAt = new Date().toISOString(); - }, - - // Update report ID - updateReportId: (state, action: PayloadAction<string>) => { - 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<ReportOutput | null>) => { - 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<string>) => { - state.policyId = action.payload; - }, - updateSimulationLabel: (state, action: PayloadAction<string>) => { - state.label = action.payload; - }, - updateSimulationId: (state, action: PayloadAction<string>) => { - 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<Simulation>; - }> - ) => { - 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<Simulation>; - }> - ) => { - 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 ( - <div> - <p>{TEST_STRINGS.TEST_COMPONENT_TEXT}</p> - <button type="button" onClick={() => onNavigate(TEST_EVENTS.NEXT)}> - Navigate Next - </button> - <button type="button" onClick={() => onNavigate(TEST_EVENTS.SUBMIT)}> - Submit - </button> - <button type="button" onClick={() => onNavigate(TEST_EVENTS.GO_TO_FLOW)}> - Go to Flow - </button> - <button type="button" onClick={() => onReturn()}> - Return - </button> - {isInSubflow && <p>{TEST_STRINGS.IN_SUBFLOW_TEXT}</p>} - {flowDepth > 0 && ( - <p> - {TEST_STRINGS.FLOW_DEPTH_PREFIX} {flowDepth} - </p> - )} - {parentFlowContext && ( - <p> - {TEST_STRINGS.PARENT_PREFIX} {parentFlowContext.parentFrame} - </p> - )} - </div> - ); - } -); - -export const AnotherTestComponent = vi.fn(() => { - return <div>{TEST_STRINGS.ANOTHER_COMPONENT_TEXT}</div>; -}); - -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 = () => ( - <div data-testid="custom-content">{FLOW_VIEW_STRINGS.CUSTOM_CONTENT}</div> + <div data-testid="custom-content">{PATHWAY_VIEW_STRINGS.CUSTOM_CONTENT}</div> ); // 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<RootState> => { - 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<any>) => ({ - 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<RootState> = { - 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<string, string> = { - 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<RootState>) => ({ - 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> = {}): 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 = <T>(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( - <Provider store={store}> - <MantineProvider theme={policyEngineTheme}> - <MemoryRouter initialEntries={[`/${country}/simulation/setup-policy`]}> - <Routes> - <Route path="/:countryId" element={<CountryGuard />}> - <Route - path="simulation/setup-policy" - element={<SimulationSetupPolicyFrame {...mockFlowProps} />} - /> - </Route> - </Routes> - </MemoryRouter> - </MantineProvider> - </Provider> - ); - }; - - 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( - <Provider store={store}> - <ReportSetupFrame {...flowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <ReportSetupFrame {...flowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <ReportSubmitFrame {...flowProps} /> - </Provider> - ); - - // 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowContainer />); - - 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(<FlowRouter flow={TEST_FLOW} returnPath={TEST_RETURN_PATH} />); - - // 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(<FlowRouter flow={TEST_FLOW} returnPath={TEST_RETURN_PATH} />); - - // 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(<FlowRouter flow={TEST_FLOW} returnPath="policies" />); - - // 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(<FlowRouter flow={TEST_FLOW} returnPath={TEST_RETURN_PATH} />); - - 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(<FlowRouter flow={TEST_FLOW} returnPath={TEST_RETURN_PATH} />); - - // 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( - <FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} subtitle={FLOW_VIEW_STRINGS.SUBTITLE} /> - ); - - 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} />); - - 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} content={<MockCustomContent />} />); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.SETUP_CONDITIONS} - setupConditionCards={mockSetupConditionCards} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.SETUP_CONDITIONS} - setupConditionCards={[fulfilledCard]} - /> - ); - - // 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.SETUP_CONDITIONS} - setupConditionCards={[selectedCard]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.SETUP_CONDITIONS} - setupConditionCards={[disabledCard]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.SETUP_CONDITIONS} - setupConditionCards={[mockSetupConditionCards[0]]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.BUTTON_PANEL} - buttonPanelCards={mockButtonPanelCards} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.BUTTON_PANEL} - buttonPanelCards={[selectedCard]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.BUTTON_PANEL} - buttonPanelCards={[mockButtonPanelCards[0]]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.CARD_LIST} - cardListItems={mockCardListItems} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.CARD_LIST} - cardListItems={[itemWithoutSubtitle]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.CARD_LIST} - cardListItems={[selectedItem]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.CARD_LIST} - cardListItems={[disabledItem]} - /> - ); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - variant={FLOW_VIEW_VARIANTS.CARD_LIST} - cardListItems={[mockCardListItems[0]]} - /> - ); - - 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} buttons={mockExplicitButtons} />); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - buttonPreset={BUTTON_PRESETS.CANCEL_ONLY} - cancelAction={mockCancelAction} - /> - ); - - 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} buttonPreset={BUTTON_PRESETS.NONE} />); - - expect(screen.queryByTestId('multi-button-footer')).not.toBeInTheDocument(); - }); - - test('given primary and cancel actions then renders both buttons', () => { - render( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - primaryAction={mockPrimaryAction} - cancelAction={mockCancelAction} - /> - ); - - 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( - <FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} primaryAction={mockPrimaryActionDisabled} /> - ); - - const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON }); - expect(submitButton).toBeDisabled(); - }); - - test('given loading primary action then passes loading state', () => { - render( - <FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} primaryAction={mockPrimaryActionLoading} /> - ); - - 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( - <FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} buttonPreset={BUTTON_PRESETS.CANCEL_ONLY} /> - ); - - const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON }); - expect(cancelButton).toBeDisabled(); - }); - - test('given cancel action then renders disabled cancel button', () => { - render(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} cancelAction={mockCancelAction} />); - - 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} primaryAction={mockPrimaryAction} />); - - 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( - <FlowView - title={FLOW_VIEW_STRINGS.MAIN_TITLE} - buttons={mockExplicitButtons} - primaryAction={mockPrimaryAction} - cancelAction={mockCancelAction} - /> - ); - - // 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(<FlowView title={FLOW_VIEW_STRINGS.MAIN_TITLE} />); - - 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + subtitle={PATHWAY_VIEW_STRINGS.SUBTITLE} + /> + ); + + 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(<PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} />); + + 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( + <PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} content={<MockCustomContent />} /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.SETUP_CONDITIONS} + setupConditionCards={mockSetupConditionCards} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.SETUP_CONDITIONS} + setupConditionCards={[fulfilledCard]} + /> + ); + + // 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.SETUP_CONDITIONS} + setupConditionCards={[selectedCard]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.SETUP_CONDITIONS} + setupConditionCards={[disabledCard]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.SETUP_CONDITIONS} + setupConditionCards={[mockSetupConditionCards[0]]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.BUTTON_PANEL} + buttonPanelCards={mockButtonPanelCards} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.BUTTON_PANEL} + buttonPanelCards={[selectedCard]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.BUTTON_PANEL} + buttonPanelCards={[mockButtonPanelCards[0]]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.CARD_LIST} + cardListItems={mockCardListItems} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.CARD_LIST} + cardListItems={[itemWithoutSubtitle]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.CARD_LIST} + cardListItems={[selectedItem]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.CARD_LIST} + cardListItems={[disabledItem]} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + variant={PATHWAY_VIEW_VARIANTS.CARD_LIST} + cardListItems={[mockCardListItems[0]]} + /> + ); + + 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(<PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} buttons={mockExplicitButtons} />); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + buttonPreset={BUTTON_PRESETS.CANCEL_ONLY} + cancelAction={mockCancelAction} + /> + ); + + 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( + <PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} buttonPreset={BUTTON_PRESETS.NONE} /> + ); + + expect(screen.queryByTestId('multi-button-footer')).not.toBeInTheDocument(); + }); + + test('given primary and cancel actions then renders both buttons', () => { + render( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + primaryAction={mockPrimaryAction} + cancelAction={mockCancelAction} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + primaryAction={mockPrimaryActionDisabled} + /> + ); + + const submitButton = screen.getByRole('button', { name: PATHWAY_VIEW_STRINGS.SUBMIT_BUTTON }); + expect(submitButton).toBeDisabled(); + }); + + test('given loading primary action then passes loading state', () => { + render( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + primaryAction={mockPrimaryActionLoading} + /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + buttonPreset={BUTTON_PRESETS.CANCEL_ONLY} + /> + ); + + 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( + <PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} cancelAction={mockCancelAction} /> + ); + + 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( + <PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} primaryAction={mockPrimaryAction} /> + ); + + 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( + <PathwayView + title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} + buttons={mockExplicitButtons} + primaryAction={mockPrimaryAction} + cancelAction={mockCancelAction} + /> + ); + + // 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(<PathwayView title={PATHWAY_VIEW_STRINGS.MAIN_TITLE} />); + + // 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 <div data-testid="plotly-chart" data-plotly-props={JSON.stringify(props)} />; - }), -})); - -describe('HistoricalValues', () => { - describe('PolicyParameterSelectorHistoricalValues wrapper', () => { - it('given component renders then displays historical values section', () => { - // Given - const { getByText } = render( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - expect(getByText('Historical values')).toBeInTheDocument(); - }); - - it('given parameter label then displays in component', () => { - // Given - const { getByText } = render( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - expect(getByText('Standard Deduction over time')).toBeInTheDocument(); - }); - - it('given base values only then renders chart without reform', () => { - // Given - const { getByTestId } = render( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - const chart = getByTestId('plotly-chart'); - expect(chart).toBeInTheDocument(); - }); - - it('given reform values then passes to chart component', () => { - // Given - const { getByTestId } = render( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - reformValues={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INVALID_DATES} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - - it('given percentage parameter then renders chart', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - /> - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - - it('given boolean parameter then renders chart', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={BOOLEAN_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - expect(getByTestId('plotly-chart')).toBeInTheDocument(); - }); - }); - - describe('ParameterOverTimeChart complex data scenarios', () => { - it('given multiple value changes then includes all dates', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - /> - ); - - // 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( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - reformValuesCollection={SAMPLE_REFORM_VALUES_COMPLEX} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - /> - ); - - // 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( - <ParameterOverTimeChart - param={BOOLEAN_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - const chartContainer = container.querySelector('div'); - expect(chartContainer).toBeInTheDocument(); - }); - - it('given chart then has margin configuration', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_CUSTOM} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_SHORT} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyId={SAMPLE_POLICY_ID_NUMERIC.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyId={SAMPLE_POLICY_ID_SMALL.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_CUSTOM} - policyId={SAMPLE_POLICY_ID_NUMERIC.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel="" - policyId={SAMPLE_POLICY_ID_NUMERIC.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={null} - policyId={SAMPLE_POLICY_ID_NUMERIC.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel="" - policyId={null} - /> - ); - - // 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( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - reformValues={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_CUSTOM} - /> - ); - - // 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( - <PolicyParameterSelectorHistoricalValues - param={CURRENCY_USD_PARAMETER} - baseValues={SAMPLE_BASE_VALUES_SIMPLE} - reformValues={SAMPLE_REFORM_VALUES_SIMPLE} - policyId={SAMPLE_POLICY_ID_NUMERIC.toString()} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={EMPTY_VALUES_COLLECTION} - /> - ); - - // Then - expect(screen.getByText(EXPECTED_NO_DATA_MESSAGE)).toBeInTheDocument(); - }); - - it('given empty values then does not render chart', () => { - // Given/When - const { queryByTestId } = render( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={EMPTY_VALUES_COLLECTION} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={mismatchedCollection} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={errorCollection} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={errorCollection} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={EMPTY_VALUES_COLLECTION} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={mismatchedCollection} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_CUSTOM} - /> - ); - - const firstChart = getByTestId('plotly-chart'); - const firstProps = JSON.parse(firstChart.getAttribute('data-plotly-props') || '{}'); - - // When - Re-render with same props - rerender( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - policyLabel={SAMPLE_POLICY_LABEL_CUSTOM} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INFINITY} - /> - ); - - // 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( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INFINITY} - /> - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given reform values with infinity then filters out infinite values', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_WITH_INFINITY} - /> - ); - - // 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( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - reformValuesCollection={SAMPLE_REFORM_VALUES_WITH_INFINITY} - /> - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given both base and reform with infinity then displays warning message', () => { - // Given/When - render( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INFINITY} - reformValuesCollection={SAMPLE_REFORM_VALUES_WITH_INFINITY} - /> - ); - - // Then - expect(screen.getByText(EXPECTED_INFINITY_WARNING_MESSAGE)).toBeInTheDocument(); - }); - - it('given all infinite base values then displays no data message', () => { - // Given/When - render( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_ALL_INFINITE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // Then - expect(screen.queryByText(EXPECTED_INFINITY_WARNING_MESSAGE)).not.toBeInTheDocument(); - }); - - it('given base with infinity then y-axis includes 0', () => { - // Given - const { getByTestId } = render( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INFINITY} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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( - <ParameterOverTimeChart - param={PERCENTAGE_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_COMPLEX} - /> - ); - - // 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( - <ParameterOverTimeChart - param={INTEGER_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_WITH_INFINITY} - /> - ); - - // 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( - <ParameterOverTimeChart - param={CURRENCY_USD_PARAMETER} - baseValuesCollection={SAMPLE_BASE_VALUES_SIMPLE} - /> - ); - - // 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: () => <div data-testid="value-setter">ValueSetter</div>, -})); - -vi.mock('@/components/policyParameterSelectorFrame/HistoricalValues', () => ({ - default: (props: any) => ( - <div data-testid="historical-values"> - <div data-testid="base-intervals">{JSON.stringify(props.baseValues?.getIntervals())}</div> - <div data-testid="reform-intervals">{JSON.stringify(props.reformValues?.getIntervals())}</div> - <div data-testid="policy-label">{props.policyLabel || 'null'}</div> - <div data-testid="policy-id">{props.policyId || 'null'}</div> - </div> - ), -})); - -// 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(<Provider store={store}>{component}</Provider>); -} - -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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyParameterSelectorMain param={SAMPLE_PARAM} />, 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(<PolicyCreationFrame {...flowProps} />); - - // 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(<PolicyCreationFrame {...flowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicyCreationFrame {...mockFlowProps} />); - - // 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(<PolicySubmitFrame {...mockFlowProps} />); - - // 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(<PolicySubmitFrame {...mockFlowProps} />); - - // 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(<PolicySubmitFrame {...mockFlowProps} />); - - // 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(<PolicySubmitFrame {...flowProps} />); - - // 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(<PolicySubmitFrame {...flowProps} />); - - // 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(<PolicySubmitFrame {...mockFlowProps} />); - - // 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(<PolicySubmitFrame {...mockFlowProps} />); - - // 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<any> = { 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( - <Provider store={store}> - <MantineProvider> - <GeographicConfirmationFrame {...props} /> - </MantineProvider> - </Provider> - ); - }; - - 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<string, string> = { - 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<any> = { - 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( - <Provider store={store}> - <MantineProvider> - <MemoryRouter initialEntries={['/us/populations']}> - <Routes> - <Route path="/:countryId/*" element={<HouseholdBuilderFrame {...props} />} /> - </Routes> - </MemoryRouter> - </MantineProvider> - </Provider> - ); - }; - - 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<any> = { 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( - <Provider store={store}> - <MantineProvider> - <MemoryRouter initialEntries={[`/${countryForRouter}/populations`]}> - <Routes> - <Route path="/:countryId/*" element={<SelectGeographicScopeFrame {...props} />} /> - </Routes> - </MemoryRouter> - </MantineProvider> - </Provider> - ); - }; - - 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( - <Provider store={store}> - <MantineProvider> - <SetPopulationLabelFrame {...props} /> - </MantineProvider> - </Provider> - ); - }; - - 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<typeof vi.fn>; - let mockOnReturn: ReturnType<typeof vi.fn>; - 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( - <Provider store={store}> - <MantineProvider> - <MemoryRouter initialEntries={['/us/reports']}> - <Routes> - <Route path="/:countryId/*" element={component} /> - </Routes> - </MemoryRouter> - </MantineProvider> - </Provider> - ); - }; - - test('given component mounts then clears report state', () => { - // Given/When - renderWithRouter(<ReportCreationFrame {...defaultFlowProps} />); - - // Then - should have cleared the report - expect(reportActions.clearReport).toHaveBeenCalled(); - }); - - test('given component renders then displays correct UI elements', () => { - // Given/When - renderWithRouter( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - 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( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - // Reset spy count after first mount - vi.clearAllMocks(); - - // When - unmount and mount a new instance - unmount(); - renderWithRouter( - <Provider store={store}> - <ReportCreationFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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(<ReportCreationFrame {...defaultFlowProps} />); - - // 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<typeof vi.fn>; - let mockOnReturn: ReturnType<typeof vi.fn>; - let defaultFlowProps: any; - let consoleLogSpy: ReturnType<typeof vi.spyOn>; - - 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( - <Provider store={store}> - <QueryClientProvider client={queryClient}> - <QueryNormalizerProvider queryClient={queryClient}> - <MantineProvider> - <MemoryRouter initialEntries={['/us/reports']}> - <Routes> - <Route path="/:countryId/*" element={component} /> - </Routes> - </MemoryRouter> - </MantineProvider> - </QueryNormalizerProvider> - </QueryClientProvider> - </Provider> - ); - }; - - test('given no simulations exist then displays empty state', () => { - // Given - mock hook returns empty array (default from beforeEach) - - // When - renderFrame(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectExistingSimulationFrame {...defaultFlowProps} />); - - // 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<typeof vi.fn>; - let mockOnReturn: ReturnType<typeof vi.fn>; - 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSelectSimulationFrame {...defaultFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSetupFrame {...mockFlowProps} />); - - // Then - const reviewButton = screen.getByRole('button', { name: REVIEW_REPORT_LABEL }); - expect(reviewButton).toBeEnabled(); - }); - }); - - describe('Population Pre-filling', () => { - let consoleLogSpy: ReturnType<typeof vi.spyOn>; - let consoleErrorSpy: ReturnType<typeof vi.spyOn>; - - 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(<ReportSetupFrame {...mockFlowProps} />); - 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(<ReportSetupFrame {...mockFlowProps} />); - 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(<ReportSetupFrame {...mockFlowProps} />); - 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(<ReportSetupFrame {...mockFlowProps} />); - 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(<ReportSetupFrame {...mockFlowProps} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} isInSubflow={false} />); - - // 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(<ReportSubmitFrame {...mockFlowProps} isInSubflow />); - - // 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(<ReportSubmitFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationCreationFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSelectExistingPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSetupFrame {...mockFlowProps} />); - - // 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(<SimulationSetupFrame {...mockFlowProps} />); - - // 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(<SimulationSetupFrame {...mockFlowProps} />); - - // 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(<SimulationSetupFrame {...mockFlowProps} />); - - // 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(<SimulationSetupFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPolicyFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPopulationFrame {...mockFlowProps} />); - - // 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(<SimulationSetupPopulationFrame {...mockFlowProps} />); - - // 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<typeof vi.fn>; - let mockOnReturn: ReturnType<typeof vi.fn>; - 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - // 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} /> - </Provider> - ); - - 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} isInSubflow={false} /> - </Provider> - ); - - 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( - <Provider store={store}> - <SimulationSubmitFrame {...defaultFlowProps} isInSubflow /> - </Provider> - ); - - 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<typeof createMockStore>; - let dispatch: ReturnType<typeof createMockDispatch>; - - beforeEach(() => { - vi.clearAllMocks(); - dispatch = createMockDispatch(); - store = createMockStore(dispatch); - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - <Provider store={store}>{children}</Provider> - ); - - 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(<PolicyPathwayWrapper />); + + // 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(<PolicyPathwayWrapper />)).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(<PopulationPathwayWrapper />); + + // 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(<PopulationPathwayWrapper />)).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(<ReportPathwayWrapper />); + + // 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(<ReportPathwayWrapper />); + + // Then + expect(screen.getByText(/Invalid country ID/i)).toBeInTheDocument(); + }); + }); + + describe('Basic rendering', () => { + test('given valid countryId then renders without error', () => { + // When + const { container } = render(<ReportPathwayWrapper />); + + // 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(<ReportPathwayWrapper />); + + // 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(<ReportPathwayWrapper onComplete={mockOnComplete} />); + + // Then - Component renders with callback + expect(container).toBeInTheDocument(); + }); + + test('given no onComplete callback then renders without error', () => { + // When + const { container } = render(<ReportPathwayWrapper />); + + // Then + expect(container).toBeInTheDocument(); + }); + }); + + describe('State initialization', () => { + test('given wrapper renders then initializes report state with country', () => { + // When + render(<ReportPathwayWrapper />); + + // 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockOnClick} + /> + ); + + // 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.UK} + isSelected={false} + onClick={mockOnClick} + /> + ); + + // Then + expect(screen.getByText(DEFAULT_BASELINE_LABELS.UK)).toBeInTheDocument(); + }); + + test('given component renders then displays card as button', () => { + // When + render( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockOnClick} + /> + ); + + // 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockOnClick} + /> + ); + + // 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockOnClick} + /> + ); + + // 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( + <DefaultBaselineOption countryId={TEST_COUNTRIES.US} isSelected onClick={mockOnClick} /> + ); + + // 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockCallback} + /> + ); + + 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockCallback} + /> + ); + + 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( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.US} + isSelected={false} + onClick={mockOnClick} + /> + ); + expect(screen.getByText(DEFAULT_BASELINE_LABELS.US)).toBeInTheDocument(); + + // Test UK + rerender( + <DefaultBaselineOption + countryId={TEST_COUNTRIES.UK} + isSelected={false} + onClick={mockOnClick} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /create report/i })).toBeInTheDocument(); + }); + + test('given component renders then displays report name input', () => { + // When + render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/report name/i)).toBeInTheDocument(); + }); + + test('given component renders then displays year select', () => { + // When + const { container } = render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportLabelView + label={TEST_REPORT_LABEL} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/report name/i)).toHaveValue(TEST_REPORT_LABEL); + }); + + test('given null label then input is empty', () => { + // When + render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onBack not provided then no back button', () => { + // When + render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + test('given user clicks back then calls onBack', async () => { + // Given + const user = userEvent.setup(); + render( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // 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( + <ReportLabelView + label={null} + year="2025" + onUpdateLabel={mockOnUpdateLabel} + onUpdateYear={mockOnUpdateYear} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /configure report/i })).toBeInTheDocument(); + }); + + test('given component renders then displays baseline simulation card', () => { + // When + render( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // Then + expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); + }); + }); + + describe('Unconfigured simulations', () => { + test('given no simulations configured then comparison card shows waiting message', () => { + // When + render( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // Then + expect(screen.getByText(/waiting for baseline/i)).toBeInTheDocument(); + }); + + test('given no simulations configured then comparison card is disabled', () => { + // When + const { container } = render( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportStateWithConfiguredBaseline} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // Then + expect(screen.getByText(/comparison simulation \(optional\)/i)).toBeInTheDocument(); + }); + + test('given baseline configured then comparison card is enabled', () => { + // When + render( + <ReportSetupView + reportState={mockReportStateWithConfiguredBaseline} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportStateWithConfiguredBaseline} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportStateWithBothConfigured} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /review report/i })).toBeInTheDocument(); + }); + + test('given both simulations configured then Review button is enabled', () => { + // When + render( + <ReportSetupView + reportState={mockReportStateWithBothConfigured} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + 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( + <ReportSetupView + reportState={mockReportStateWithConfiguredBaseline} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + 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( + <ReportSetupView + reportState={mockReportStateWithBothConfigured} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + /> + ); + + // 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( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <ReportSetupView + reportState={mockReportState} + onNavigateToSimulationSelection={mockOnNavigateToSimulationSelection} + onNext={mockOnNext} + onPrefillPopulation2={mockOnPrefillPopulation2} + onCancel={mockOnCancel} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + 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( + <ReportSimulationExistingView + activeSimulationIndex={1} + otherSimulation={otherSim} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // 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( + <ReportSimulationExistingView + activeSimulationIndex={0} + otherSimulation={null} + onSelectSimulation={mockOnSelectSimulation} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + onSelectDefaultBaseline={mockOnSelectDefaultBaseline} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={1} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={1} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + onBack={mockOnBack} + /> + ); + + // 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( + <ReportSimulationSelectionView + simulationIndex={0} + countryId={TEST_COUNTRY_ID} + currentLawId={TEST_CURRENT_LAW_ID} + onCreateNew={mockOnCreateNew} + onLoadExisting={mockOnLoadExisting} + onCancel={mockOnCancel} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect( + screen.getByRole('heading', { name: /review report configuration/i }) + ).toBeInTheDocument(); + }); + + test('given component renders then displays subtitle', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getByText(/review your selected simulations/i)).toBeInTheDocument(); + }); + + test('given component renders then displays baseline simulation box', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getByText(/baseline simulation/i)).toBeInTheDocument(); + }); + + test('given component renders then displays comparison simulation box', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getByText(/comparison simulation/i)).toBeInTheDocument(); + }); + + test('given component renders then displays create report button', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).toBeInTheDocument(); + }); + }); + + describe('Configured baseline simulation', () => { + test('given baseline configured then shows simulation label', () => { + // When + render( + <ReportSubmitView + reportState={mockReportStateWithConfiguredBaseline} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getAllByText(/baseline simulation/i).length).toBeGreaterThan(0); + }); + + test('given baseline configured then shows policy and population info', () => { + // When + render( + <ReportSubmitView + reportState={mockReportStateWithConfiguredBaseline} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportStateWithBothConfigured} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportStateWithBothConfigured} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportStateWithBothConfigured} + onSubmit={mockOnSubmit} + isSubmitting + /> + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).toBeDisabled(); + }); + + test('given isSubmitting false then button is enabled', () => { + // When + render( + <ReportSubmitView + reportState={mockReportStateWithBothConfigured} + onSubmit={mockOnSubmit} + isSubmitting={false} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /create report/i })).not.toBeDisabled(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + onCancel={mockOnCancel} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + test('given user clicks back then calls onBack', async () => { + // Given + const user = userEvent.setup(); + render( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + onBack={mockOnBack} + /> + ); + + // 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( + <ReportSubmitView + reportState={mockReportState} + onSubmit={mockOnSubmit} + isSubmitting={false} + onCancel={mockOnCancel} + /> + ); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} />); + 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} onBack={mockOnBack} />); + + // 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(<PolicyExistingView onSelectPolicy={mockOnSelectPolicy} onCancel={mockOnCancel} />); + + // 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( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /create policy/i })).toBeInTheDocument(); + }); + + test('given standalone mode then displays policy title input', () => { + // When + render( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toBeInTheDocument(); + }); + + test('given standalone mode and null label then shows default My policy', () => { + // When + render( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PolicyLabelView + label={null} + mode="report" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ) + ).toThrow('simulationIndex is required'); + }); + + test('given report mode baseline then shows baseline policy default label', () => { + // When + render( + <PolicyLabelView + label={null} + mode="report" + simulationIndex={0} + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/policy title/i)).toHaveValue('Baseline policy'); + }); + + test('given report mode reform then shows reform policy default label', () => { + // When + render( + <PolicyLabelView + label={null} + mode="report" + simulationIndex={1} + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /initialise policy/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <PolicyLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /name your household/i })).toBeInTheDocument(); + }); + + test('given component renders then displays household label input', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toBeInTheDocument(); + }); + }); + + describe('Default labels', () => { + test('given household population then shows Custom Household default', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toHaveValue('Custom Household'); + }); + + test('given geography population then shows geography-based label', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateWithGeography} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/household label/i)).toHaveValue('National Households'); + }); + + test('given existing label then shows that label', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateWithHousehold} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="report" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ) + ).toThrow('simulationIndex is required'); + }); + + test('given report mode with simulationIndex then renders without error', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="report" + simulationIndex={0} + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /initialise household/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given no onBack then no back button', () => { + // When + render( + <PopulationLabelView + population={mockPopulationStateEmpty} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <PopulationScopeView + countryId={TEST_COUNTRY_ID} + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /select household scope/i })).toBeInTheDocument(); + }); + + test('given component renders then displays select scope button', () => { + // When + render( + <PopulationScopeView + countryId={TEST_COUNTRY_ID} + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + /> + ); + + // 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( + <PopulationScopeView + countryId="us" + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + /> + ); + + // Then + expect(screen.getByText(/national/i)).toBeInTheDocument(); + }); + }); + + describe('UK country options', () => { + test('given UK country then renders without error', () => { + // When + const { container } = render( + <PopulationScopeView + countryId="uk" + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + /> + ); + + // Then + expect(container).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + <PopulationScopeView + countryId={TEST_COUNTRY_ID} + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <PopulationScopeView + countryId={TEST_COUNTRY_ID} + regionData={mockRegionData} + onScopeSelected={mockOnScopeSelected} + onCancel={mockOnCancel} + /> + ); + + // 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( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /create simulation/i })).toBeInTheDocument(); + }); + + test('given standalone mode then displays simulation name input', () => { + // When + render( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toBeInTheDocument(); + }); + + test('given standalone mode and null label then shows default My simulation', () => { + // When + render( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationLabelView + label={null} + mode="report" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ) + ).toThrow('simulationIndex is required'); + }); + + test('given report mode baseline then shows baseline simulation default label', () => { + // When + render( + <SimulationLabelView + label={null} + mode="report" + simulationIndex={0} + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('Baseline simulation'); + }); + + test('given report mode reform then shows reform simulation default label', () => { + // When + render( + <SimulationLabelView + label={null} + mode="report" + simulationIndex={1} + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByLabelText(/simulation name/i)).toHaveValue('Reform simulation'); + }); + + test('given report label then incorporates into default label', () => { + // When + render( + <SimulationLabelView + label={null} + mode="report" + simulationIndex={0} + reportLabel="My Report" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + 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( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /initialise simulation/i })).toBeInTheDocument(); + }); + }); + + describe('Navigation actions', () => { + test('given onBack provided then renders back button', () => { + // When + render( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <SimulationLabelView + label={null} + mode="standalone" + onUpdateLabel={mockOnUpdateLabel} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByRole('heading', { name: /configure simulation/i })).toBeInTheDocument(); + }); + + test('given empty simulation then displays add household card', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByText(/add household\(s\)/i)).toBeInTheDocument(); + }); + + test('given empty simulation then displays add policy card', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByText(/add policy/i)).toBeInTheDocument(); + }); + }); + + describe('Configured simulation', () => { + test('given fully configured simulation then shows policy label', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateConfigured} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByText(/current law/i)).toBeInTheDocument(); + }); + + test('given fully configured simulation then shows population label', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateConfigured} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getByText(/my household/i)).toBeInTheDocument(); + }); + + test('given fully configured simulation then Next button is enabled', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateConfigured} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateWithPolicy} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateWithPopulation} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateWithPopulation} + simulationIndex={1} + isReportMode + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // Then + expect(screen.getAllByText(/from baseline/i).length).toBeGreaterThan(0); + }); + + test('given report mode sim 2 with population then shows inherited message', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateWithPopulation} + simulationIndex={1} + isReportMode + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + 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( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + 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( + <SimulationSetupView + simulation={mockSimulationStateConfigured} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + /> + ); + + // 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( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + onBack={mockOnBack} + /> + ); + + // Then + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + + test('given onCancel provided then renders cancel button', () => { + // When + render( + <SimulationSetupView + simulation={mockSimulationStateEmpty} + simulationIndex={0} + isReportMode={false} + onNavigateToPolicy={mockOnNavigateToPolicy} + onNavigateToPopulation={mockOnNavigateToPopulation} + onNext={mockOnNext} + onCancel={mockOnCancel} + /> + ); + + // 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(<SimulationPathwayWrapper />)).toThrow( + 'useCurrentCountry must be used within country routes' + ); + }); + }); + + describe('Basic rendering', () => { + test('given valid countryId then renders without error', () => { + // When + const { container } = render(<SimulationPathwayWrapper />); + + // 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(<SimulationPathwayWrapper />); + + // Then + expect(useUserPolicies).toHaveBeenCalled(); + expect(useUserHouseholds).toHaveBeenCalled(); + expect(useUserGeographics).toHaveBeenCalled(); + }); + }); + + describe('Props handling', () => { + test('given onComplete callback then accepts prop', () => { + // When + const { container } = render(<SimulationPathwayWrapper onComplete={mockOnComplete} />); + + // 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<string, FlowFrame>; -} - -// 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<TState, TMode> { + state: TState; + currentMode: TMode; + onNavigate: (mode: TMode) => void; + onUpdateState: (updates: Partial<TState>) => 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<TState, TMode> { + 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<string, any> +): 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<string, string> = { + 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<TState, TMode>( + setState: React.Dispatch<React.SetStateAction<TState>>, + 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<TState, TMode>( + setState: React.Dispatch<React.SetStateAction<TState>>, + 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<TMode>( + setState: React.Dispatch<React.SetStateAction<ReportStateProps>>, + 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<TState, TMode>( + setState: React.Dispatch<React.SetStateAction<TState>>, + 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; +}