diff --git a/app/src/components/FlowContainer.tsx b/app/src/components/FlowContainer.tsx
index 93a8c2c3..3b485afe 100644
--- a/app/src/components/FlowContainer.tsx
+++ b/app/src/components/FlowContainer.tsx
@@ -15,7 +15,6 @@ export default function FlowContainer() {
const flowDepth = flowStack.length;
const parentFlowContext = isInSubflow
? {
- flowName: flowStack[flowStack.length - 1].flow.name,
parentFrame: flowStack[flowStack.length - 1].frame,
}
: undefined;
diff --git a/app/src/tests/components/FlowContainer.test.tsx b/app/src/tests/components/FlowContainer.test.tsx
new file mode 100644
index 00000000..5adad352
--- /dev/null
+++ b/app/src/tests/components/FlowContainer.test.tsx
@@ -0,0 +1,290 @@
+import { render, screen, userEvent } from '@test-utils';
+import { useSelector } from 'react-redux';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import FlowContainer from '@/components/FlowContainer';
+import { navigateToFlow, navigateToFrame, returnFromFlow } from '@/reducers/flowReducer';
+import {
+ addEventToMockFlow,
+ cleanupDynamicEvents,
+ createMockState,
+ mockFlow,
+ mockFlowRegistry,
+ mockSubflowStack,
+ TEST_EVENTS,
+ TEST_FLOW_NAMES,
+ TEST_FRAME_NAMES,
+ TEST_STRINGS,
+ TestComponent,
+} from '@/tests/fixtures/components/FlowContainerMocks';
+
+const mockDispatch = vi.fn();
+
+vi.mock('@/flows/registry', async () => {
+ const mocks = await import('@/tests/fixtures/components/FlowContainerMocks');
+ return {
+ componentRegistry: mocks.mockComponentRegistry,
+ flowRegistry: mocks.mockFlowRegistry,
+ };
+});
+
+vi.mock('@/reducers/flowReducer', () => ({
+ default: vi.fn((state = {}) => state),
+ navigateToFlow: vi.fn((payload) => ({ type: 'flow/navigateToFlow', payload })),
+ navigateToFrame: vi.fn((payload) => ({ type: 'flow/navigateToFrame', payload })),
+ returnFromFlow: vi.fn(() => ({ type: 'flow/returnFromFlow' })),
+}));
+
+vi.mock('react-redux', async () => {
+ const actual = await vi.importActual('react-redux');
+ return {
+ ...actual,
+ useDispatch: () => mockDispatch,
+ useSelector: vi.fn(),
+ };
+});
+
+vi.mock('@/types/flow', async () => {
+ const actual = await vi.importActual('@/types/flow');
+ const mocks = await import('@/tests/fixtures/components/FlowContainerMocks');
+ return {
+ ...actual,
+ isFlowKey: vi.fn((target: string) => {
+ return (
+ target === mocks.TEST_FLOW_NAMES.ANOTHER_FLOW || target === mocks.TEST_FLOW_NAMES.TEST_FLOW
+ );
+ }),
+ isComponentKey: vi.fn((target: string) => {
+ return (
+ target === mocks.TEST_FRAME_NAMES.TEST_FRAME ||
+ target === mocks.TEST_FRAME_NAMES.NEXT_FRAME ||
+ target === mocks.TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT
+ );
+ }),
+ };
+});
+
+describe('FlowContainer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ cleanupDynamicEvents();
+ });
+
+ describe('Error States', () => {
+ test('given no current flow then displays no flow message', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({
+ flow: {
+ currentFlow: null,
+ currentFrame: null,
+ flowStack: [],
+ },
+ })
+ );
+
+ render();
+
+ expect(screen.getByText(TEST_STRINGS.NO_FLOW_MESSAGE)).toBeInTheDocument();
+ });
+
+ test('given no current frame then displays no flow message', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({
+ flow: {
+ currentFlow: mockFlow,
+ currentFrame: null,
+ flowStack: [],
+ },
+ })
+ );
+
+ render();
+
+ expect(screen.getByText(TEST_STRINGS.NO_FLOW_MESSAGE)).toBeInTheDocument();
+ });
+
+ test('given component not in registry then displays error message', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({
+ flow: {
+ currentFlow: mockFlow,
+ currentFrame: TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT,
+ flowStack: [],
+ },
+ })
+ );
+
+ render();
+
+ expect(
+ screen.getByText(
+ `${TEST_STRINGS.COMPONENT_NOT_FOUND_PREFIX} ${TEST_FRAME_NAMES.NON_EXISTENT_COMPONENT}`
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(new RegExp(TEST_STRINGS.AVAILABLE_COMPONENTS_PREFIX))
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Component Rendering', () => {
+ test('given valid flow and frame then renders correct component', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ expect(screen.getByText(TEST_STRINGS.TEST_COMPONENT_TEXT)).toBeInTheDocument();
+ expect(TestComponent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onNavigate: expect.any(Function),
+ onReturn: expect.any(Function),
+ flowConfig: mockFlow,
+ isInSubflow: false,
+ flowDepth: 0,
+ parentFlowContext: undefined,
+ }),
+ undefined
+ );
+ });
+
+ test('given subflow context then passes correct props to component', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState({ flowStack: mockSubflowStack }).flow })
+ );
+
+ render();
+
+ expect(screen.getByText(TEST_STRINGS.IN_SUBFLOW_TEXT)).toBeInTheDocument();
+ expect(screen.getByText(`${TEST_STRINGS.FLOW_DEPTH_PREFIX} 1`)).toBeInTheDocument();
+ expect(
+ screen.getByText(`${TEST_STRINGS.PARENT_PREFIX} ${TEST_FRAME_NAMES.PARENT_FRAME}`)
+ ).toBeInTheDocument();
+
+ expect(TestComponent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isInSubflow: true,
+ flowDepth: 1,
+ parentFlowContext: {
+ parentFrame: TEST_FRAME_NAMES.PARENT_FRAME,
+ },
+ }),
+ undefined
+ );
+ });
+ });
+
+ describe('Navigation Event Handling', () => {
+ test('given user navigates to frame then dispatches navigateToFrame action', async () => {
+ const user = userEvent.setup();
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /navigate next/i }));
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ navigateToFrame(TEST_FRAME_NAMES.NEXT_FRAME as any)
+ );
+ });
+
+ test('given user navigates with return keyword then dispatches returnFromFlow action', async () => {
+ const user = userEvent.setup();
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ expect(mockDispatch).toHaveBeenCalledWith(returnFromFlow());
+ });
+
+ test('given user navigates to flow with navigation object then dispatches navigateToFlow with returnFrame', async () => {
+ const user = userEvent.setup();
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /go to flow/i }));
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ navigateToFlow({
+ flow: mockFlowRegistry.anotherFlow,
+ returnFrame: TEST_FRAME_NAMES.RETURN_FRAME as any,
+ })
+ );
+ });
+
+ test('given navigation event with no target defined then logs error', async () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ const component = TestComponent.mock.calls[0][0];
+ component.onNavigate(TEST_EVENTS.NON_EXISTENT_EVENT);
+
+ expect(vi.mocked(console.error)).toHaveBeenCalledWith(
+ expect.stringContaining(`No target defined for event ${TEST_EVENTS.NON_EXISTENT_EVENT}`)
+ );
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ test('given navigation to flow key then dispatches navigateToFlow action', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ const component = TestComponent.mock.calls[0][0];
+
+ addEventToMockFlow(TEST_EVENTS.DIRECT_FLOW, TEST_FLOW_NAMES.ANOTHER_FLOW);
+ component.onNavigate(TEST_EVENTS.DIRECT_FLOW);
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ navigateToFlow({ flow: mockFlowRegistry[TEST_FLOW_NAMES.ANOTHER_FLOW] })
+ );
+ });
+
+ test('given navigation to unknown target type then logs error', () => {
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState().flow })
+ );
+
+ render();
+
+ const component = TestComponent.mock.calls[0][0];
+
+ addEventToMockFlow(TEST_EVENTS.UNKNOWN_TARGET, TEST_FRAME_NAMES.UNKNOWN_TARGET);
+ component.onNavigate(TEST_EVENTS.UNKNOWN_TARGET);
+
+ expect(vi.mocked(console.error)).toHaveBeenCalledWith(
+ expect.stringContaining(`Unknown target type: ${TEST_FRAME_NAMES.UNKNOWN_TARGET}`)
+ );
+ });
+ });
+
+ describe('Return From Subflow', () => {
+ test('given user returns from subflow then dispatches returnFromFlow action', async () => {
+ const user = userEvent.setup();
+ vi.mocked(useSelector).mockImplementation((selector: any) =>
+ selector({ flow: createMockState({ flowStack: mockSubflowStack }).flow })
+ );
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /return/i }));
+
+ expect(mockDispatch).toHaveBeenCalledWith(returnFromFlow());
+ });
+ });
+});
diff --git a/app/src/tests/components/common/FlowView.test.tsx b/app/src/tests/components/common/FlowView.test.tsx
new file mode 100644
index 00000000..64253c87
--- /dev/null
+++ b/app/src/tests/components/common/FlowView.test.tsx
@@ -0,0 +1,427 @@
+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,
+ mockCancelClick,
+ mockCardClick,
+ mockCardListItems,
+ MockCustomContent,
+ mockExplicitButtons,
+ mockItemClick,
+ mockPrimaryAction,
+ mockPrimaryActionDisabled,
+ mockPrimaryActionLoading,
+ mockPrimaryClick,
+ mockSetupConditionCards,
+ resetAllMocks,
+} from '@/tests/fixtures/components/common/FlowViewMocks';
+
+describe('FlowView', () => {
+ beforeEach(() => {
+ resetAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ describe('Basic Rendering', () => {
+ test('given title and subtitle then renders both correctly', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SUBTITLE)).toBeInTheDocument();
+ });
+
+ test('given only title then renders without subtitle', () => {
+ render();
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.MAIN_TITLE)).toBeInTheDocument();
+ expect(screen.queryByText(FLOW_VIEW_STRINGS.SUBTITLE)).not.toBeInTheDocument();
+ });
+
+ test('given custom content then renders content', () => {
+ render(} />);
+
+ expect(screen.getByTestId('custom-content')).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.CUSTOM_CONTENT)).toBeInTheDocument();
+ });
+ });
+
+ describe('Setup Conditions Variant', () => {
+ test('given setup condition cards then renders all cards', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_1_DESC)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_2_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_2_DESC)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_3_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.SETUP_CARD_3_DESC)).toBeInTheDocument();
+ });
+
+ test('given fulfilled condition then shows check icon', () => {
+ const fulfilledCard = createSetupConditionCard({ isFulfilled: true });
+
+ render(
+
+ );
+
+ // The IconCheck component should be rendered when isFulfilled is true
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toBeInTheDocument();
+ });
+
+ test('given selected condition then applies active variant', () => {
+ const selectedCard = createSetupConditionCard({ isSelected: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'setupCondition--active');
+ });
+
+ test('given disabled condition then disables card', () => {
+ const disabledCard = createSetupConditionCard({ isDisabled: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ expect(card).toBeDisabled();
+ });
+
+ test('given user clicks setup card then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockCardClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Panel Variant', () => {
+ test('given button panel cards then renders all cards', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_1_DESC)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_2_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.PANEL_CARD_2_DESC)).toBeInTheDocument();
+ });
+
+ test('given selected panel card then applies active variant', () => {
+ const selectedCard = createButtonPanelCard({ isSelected: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'buttonPanel--active');
+ });
+
+ test('given user clicks panel card then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockCardClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Card List Variant', () => {
+ test('given card list items then renders all items', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_2_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_2_SUBTITLE)).toBeInTheDocument();
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_3_TITLE)).toBeInTheDocument();
+ });
+
+ test('given item without subtitle then renders without subtitle', () => {
+ const itemWithoutSubtitle = createCardListItem({ subtitle: undefined });
+
+ render(
+
+ );
+
+ expect(screen.getByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE)).toBeInTheDocument();
+ expect(screen.queryByText(FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE)).not.toBeInTheDocument();
+ });
+
+ test('given selected item then applies active variant', () => {
+ const selectedItem = createCardListItem({ isSelected: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ expect(card).toHaveAttribute('data-variant', 'cardList--active');
+ });
+
+ test('given disabled item then disables card', () => {
+ const disabledItem = createCardListItem({ isDisabled: true });
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ expect(card).toBeDisabled();
+ });
+
+ test('given user clicks list item then calls onClick handler', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const card = screen.getByRole('button', {
+ name: new RegExp(FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE),
+ });
+ await user.click(card);
+
+ expect(mockItemClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Configuration', () => {
+ test('given explicit buttons then renders them', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.BACK_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CONTINUE_BUTTON })
+ ).toBeInTheDocument();
+ });
+
+ test('given cancel-only preset then renders only cancel button', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON })
+ ).not.toBeInTheDocument();
+ });
+
+ test('given none preset then renders no buttons', () => {
+ render();
+
+ expect(screen.queryByTestId('multi-button-footer')).not.toBeInTheDocument();
+ });
+
+ test('given primary and cancel actions then renders both buttons', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON })
+ ).toBeInTheDocument();
+ });
+
+ test('given disabled primary action then renders disabled button', () => {
+ render(
+
+ );
+
+ const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON });
+ expect(submitButton).toBeDisabled();
+ });
+
+ test('given loading primary action then passes loading state', () => {
+ render(
+
+ );
+
+ const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON });
+ expect(submitButton).toHaveAttribute('data-loading', 'true');
+ });
+
+ test('given no cancel action onClick then uses default console.log', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON });
+ await user.click(cancelButton);
+
+ expect(vi.mocked(console.log)).toHaveBeenCalledWith(FLOW_VIEW_STRINGS.CANCEL_CLICKED_MSG);
+ });
+
+ test('given user clicks cancel button then calls cancel handler', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const cancelButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON });
+ await user.click(cancelButton);
+
+ expect(mockCancelClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('given user clicks primary button then calls primary handler', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const submitButton = screen.getByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON });
+ await user.click(submitButton);
+
+ expect(mockPrimaryClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Button Precedence', () => {
+ test('given explicit buttons and convenience props then explicit buttons take precedence', () => {
+ render(
+
+ );
+
+ // Should show explicit buttons, not the primary/cancel actions
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.BACK_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CONTINUE_BUTTON })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.SUBMIT_BUTTON })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
+ ).not.toBeInTheDocument();
+ });
+
+ test('given no actions and no preset then renders default cancel button', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: FLOW_VIEW_STRINGS.CANCEL_BUTTON })
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/app/src/tests/fixtures/components/FlowContainerMocks.tsx b/app/src/tests/fixtures/components/FlowContainerMocks.tsx
new file mode 100644
index 00000000..6edb3d7d
--- /dev/null
+++ b/app/src/tests/fixtures/components/FlowContainerMocks.tsx
@@ -0,0 +1,178 @@
+import { vi } from 'vitest';
+
+// Test constants
+export const TEST_STRINGS = {
+ NO_FLOW_MESSAGE: 'No flow available',
+ TEST_COMPONENT_TEXT: 'Test Component',
+ ANOTHER_COMPONENT_TEXT: 'Another Test Component',
+ IN_SUBFLOW_TEXT: 'In Subflow',
+ FLOW_DEPTH_PREFIX: 'Flow Depth:',
+ PARENT_PREFIX: 'Parent:',
+ COMPONENT_NOT_FOUND_PREFIX: 'Component not found:',
+ AVAILABLE_COMPONENTS_PREFIX: 'Available components:',
+} as const;
+
+export const TEST_FLOW_NAMES = {
+ TEST_FLOW: 'testFlow',
+ ANOTHER_FLOW: 'anotherFlow',
+ PARENT_FLOW: 'parentFlow',
+ FLOW_WITHOUT_EVENTS: 'flowWithoutEvents',
+} as const;
+
+export const TEST_FRAME_NAMES = {
+ TEST_FRAME: 'testFrame',
+ NEXT_FRAME: 'nextFrame',
+ START_FRAME: 'startFrame',
+ PARENT_FRAME: 'parentFrame',
+ FRAME_WITH_NO_EVENTS: 'frameWithNoEvents',
+ NON_EXISTENT_COMPONENT: 'nonExistentComponent',
+ RETURN_FRAME: 'returnFrame',
+ UNKNOWN_TARGET: 'unknownTargetValue',
+} as const;
+
+export const TEST_EVENTS = {
+ NEXT: 'next',
+ SUBMIT: 'submit',
+ GO_TO_FLOW: 'goToFlow',
+ BACK: 'back',
+ INVALID_EVENT: 'invalidEvent',
+ NON_EXISTENT_EVENT: 'nonExistentEvent',
+ DIRECT_FLOW: 'directFlow',
+ UNKNOWN_TARGET: 'unknownTarget',
+} as const;
+
+export const NAVIGATION_TARGETS = {
+ RETURN_KEYWORD: '__return__',
+} as const;
+
+export const mockFlow = {
+ initialFrame: TEST_FRAME_NAMES.TEST_FRAME as any,
+ frames: {
+ [TEST_FRAME_NAMES.TEST_FRAME]: {
+ component: TEST_FRAME_NAMES.TEST_FRAME as any,
+ on: {
+ [TEST_EVENTS.NEXT]: TEST_FRAME_NAMES.NEXT_FRAME,
+ [TEST_EVENTS.SUBMIT]: NAVIGATION_TARGETS.RETURN_KEYWORD,
+ [TEST_EVENTS.GO_TO_FLOW]: {
+ flow: TEST_FLOW_NAMES.ANOTHER_FLOW,
+ returnTo: TEST_FRAME_NAMES.RETURN_FRAME as any,
+ },
+ [TEST_EVENTS.INVALID_EVENT]: null,
+ },
+ },
+ [TEST_FRAME_NAMES.NEXT_FRAME]: {
+ component: TEST_FRAME_NAMES.NEXT_FRAME as any,
+ on: {
+ [TEST_EVENTS.BACK]: TEST_FRAME_NAMES.TEST_FRAME,
+ },
+ },
+ },
+};
+
+export const mockFlowWithoutEvents = {
+ initialFrame: TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS as any,
+ frames: {
+ [TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS]: {
+ component: TEST_FRAME_NAMES.FRAME_WITH_NO_EVENTS as any,
+ on: {},
+ },
+ },
+};
+
+export const mockSubflowStack = [
+ {
+ flow: {
+ initialFrame: TEST_FRAME_NAMES.PARENT_FRAME as any,
+ frames: {},
+ },
+ frame: TEST_FRAME_NAMES.PARENT_FRAME,
+ },
+];
+
+export const TestComponent = vi.fn(
+ ({ onNavigate, onReturn, isInSubflow, flowDepth, parentFlowContext }: any) => {
+ return (
+
+
{TEST_STRINGS.TEST_COMPONENT_TEXT}
+
+
+
+
+ {isInSubflow &&
{TEST_STRINGS.IN_SUBFLOW_TEXT}
}
+ {flowDepth > 0 && (
+
+ {TEST_STRINGS.FLOW_DEPTH_PREFIX} {flowDepth}
+
+ )}
+ {parentFlowContext && (
+
+ {TEST_STRINGS.PARENT_PREFIX} {parentFlowContext.parentFrame}
+
+ )}
+
+ );
+ }
+);
+
+export const AnotherTestComponent = vi.fn(() => {
+ return {TEST_STRINGS.ANOTHER_COMPONENT_TEXT}
;
+});
+
+export const mockComponentRegistry = {
+ [TEST_FRAME_NAMES.TEST_FRAME]: TestComponent,
+ [TEST_FRAME_NAMES.NEXT_FRAME]: AnotherTestComponent,
+};
+
+export const mockFlowRegistry = {
+ [TEST_FLOW_NAMES.TEST_FLOW]: mockFlow,
+ [TEST_FLOW_NAMES.ANOTHER_FLOW]: {
+ initialFrame: TEST_FRAME_NAMES.START_FRAME as any,
+ frames: {
+ [TEST_FRAME_NAMES.START_FRAME]: {
+ component: TEST_FRAME_NAMES.START_FRAME as any,
+ on: {},
+ },
+ },
+ },
+};
+
+export const createMockState = (overrides = {}) => ({
+ flow: {
+ currentFlow: mockFlow,
+ currentFrame: TEST_FRAME_NAMES.TEST_FRAME,
+ flowStack: [],
+ ...overrides,
+ },
+});
+
+// Additional mock functions for dynamic test scenarios
+export const addEventToMockFlow = (eventName: string, target: any) => {
+ const testFrame = mockFlow.frames[TEST_FRAME_NAMES.TEST_FRAME];
+ if (testFrame && testFrame.on) {
+ (testFrame.on as any)[eventName] = target;
+ }
+};
+
+export const cleanupDynamicEvents = () => {
+ // Reset to original state
+ const testFrame = mockFlow.frames[TEST_FRAME_NAMES.TEST_FRAME];
+ if (testFrame && testFrame.on) {
+ testFrame.on = {
+ [TEST_EVENTS.NEXT]: TEST_FRAME_NAMES.NEXT_FRAME,
+ [TEST_EVENTS.SUBMIT]: NAVIGATION_TARGETS.RETURN_KEYWORD,
+ [TEST_EVENTS.GO_TO_FLOW]: {
+ flow: TEST_FLOW_NAMES.ANOTHER_FLOW,
+ returnTo: TEST_FRAME_NAMES.RETURN_FRAME as any,
+ },
+ [TEST_EVENTS.INVALID_EVENT]: null,
+ };
+ }
+};
diff --git a/app/src/tests/fixtures/components/common/FlowViewMocks.tsx b/app/src/tests/fixtures/components/common/FlowViewMocks.tsx
new file mode 100644
index 00000000..58bfc93f
--- /dev/null
+++ b/app/src/tests/fixtures/components/common/FlowViewMocks.tsx
@@ -0,0 +1,242 @@
+import { vi } from 'vitest';
+import { ButtonConfig } from '@/components/common/FlowView';
+
+// Test constants for strings
+export const FLOW_VIEW_STRINGS = {
+ // Titles
+ MAIN_TITLE: 'Test Flow Title',
+ SUBTITLE: 'This is a test subtitle',
+
+ // Button labels
+ CANCEL_BUTTON: 'Cancel',
+ SUBMIT_BUTTON: 'Submit',
+ CONTINUE_BUTTON: 'Continue',
+ BACK_BUTTON: 'Back',
+
+ // Setup condition cards
+ SETUP_CARD_1_TITLE: 'First Setup Condition',
+ SETUP_CARD_1_DESC: 'Description for first setup condition',
+ SETUP_CARD_2_TITLE: 'Second Setup Condition',
+ SETUP_CARD_2_DESC: 'Description for second setup condition',
+ SETUP_CARD_3_TITLE: 'Third Setup Condition',
+ SETUP_CARD_3_DESC: 'Description for third setup condition',
+
+ // Button panel cards
+ PANEL_CARD_1_TITLE: 'Option One',
+ PANEL_CARD_1_DESC: 'Description for option one',
+ PANEL_CARD_2_TITLE: 'Option Two',
+ PANEL_CARD_2_DESC: 'Description for option two',
+
+ // Card list items
+ LIST_ITEM_1_TITLE: 'List Item One',
+ LIST_ITEM_1_SUBTITLE: 'Subtitle for item one',
+ LIST_ITEM_2_TITLE: 'List Item Two',
+ LIST_ITEM_2_SUBTITLE: 'Subtitle for item two',
+ LIST_ITEM_3_TITLE: 'List Item Three',
+
+ // Content
+ CUSTOM_CONTENT: 'Custom content for testing',
+
+ // Console log messages
+ CANCEL_CLICKED_MSG: 'Cancel clicked',
+} as const;
+
+// Test constants for variants
+export const FLOW_VIEW_VARIANTS = {
+ SETUP_CONDITIONS: 'setupConditions' as const,
+ BUTTON_PANEL: 'buttonPanel' as const,
+ CARD_LIST: 'cardList' as const,
+} as const;
+
+// Test constants for button presets
+export const BUTTON_PRESETS = {
+ CANCEL_ONLY: 'cancel-only' as const,
+ CANCEL_PRIMARY: 'cancel-primary' as const,
+ NONE: 'none' as const,
+} as const;
+
+// Test constants for button variants
+export const BUTTON_VARIANTS = {
+ DEFAULT: 'default' as const,
+ FILLED: 'filled' as const,
+ DISABLED: 'disabled' as const,
+} as const;
+
+// Mock functions
+export const mockOnClick = vi.fn();
+export const mockCancelClick = vi.fn();
+export const mockPrimaryClick = vi.fn();
+export const mockCardClick = vi.fn();
+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,
+ onClick: mockCardClick,
+ isSelected: false,
+ isDisabled: false,
+ isFulfilled: false,
+ },
+ {
+ title: FLOW_VIEW_STRINGS.SETUP_CARD_2_TITLE,
+ description: FLOW_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,
+ onClick: mockCardClick,
+ isSelected: false,
+ isDisabled: false,
+ isFulfilled: true,
+ },
+];
+
+// Mock button panel cards
+export const mockButtonPanelCards = [
+ {
+ title: FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE,
+ description: FLOW_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,
+ onClick: mockCardClick,
+ isSelected: true,
+ isDisabled: false,
+ },
+];
+
+// Mock card list items
+export const mockCardListItems = [
+ {
+ title: FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE,
+ subtitle: FLOW_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,
+ onClick: mockItemClick,
+ isSelected: true,
+ isDisabled: false,
+ },
+ {
+ title: FLOW_VIEW_STRINGS.LIST_ITEM_3_TITLE,
+ onClick: mockItemClick,
+ isSelected: false,
+ isDisabled: true,
+ },
+];
+
+// Mock button configurations
+export const mockExplicitButtons: ButtonConfig[] = [
+ {
+ label: FLOW_VIEW_STRINGS.BACK_BUTTON,
+ variant: BUTTON_VARIANTS.DEFAULT,
+ onClick: mockOnClick,
+ },
+ {
+ label: FLOW_VIEW_STRINGS.CONTINUE_BUTTON,
+ variant: BUTTON_VARIANTS.FILLED,
+ onClick: mockOnClick,
+ },
+];
+
+export const mockPrimaryAction = {
+ label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON,
+ onClick: mockPrimaryClick,
+ isLoading: false,
+ isDisabled: false,
+};
+
+export const mockPrimaryActionDisabled = {
+ label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON,
+ onClick: mockPrimaryClick,
+ isLoading: false,
+ isDisabled: true,
+};
+
+export const mockPrimaryActionLoading = {
+ label: FLOW_VIEW_STRINGS.SUBMIT_BUTTON,
+ onClick: mockPrimaryClick,
+ isLoading: true,
+ isDisabled: false,
+};
+
+export const mockCancelAction = {
+ label: FLOW_VIEW_STRINGS.CANCEL_BUTTON,
+ onClick: mockCancelClick,
+};
+
+// Mock custom content component
+export const MockCustomContent = () => (
+ {FLOW_VIEW_STRINGS.CUSTOM_CONTENT}
+);
+
+// Helper function to reset all mocks
+export const resetAllMocks = () => {
+ mockOnClick.mockClear();
+ mockCancelClick.mockClear();
+ mockPrimaryClick.mockClear();
+ mockCardClick.mockClear();
+ mockItemClick.mockClear();
+};
+
+// Mock the MultiButtonFooter component
+vi.mock('@/components/common/MultiButtonFooter', () => ({
+ default: vi.fn(({ buttons }: { buttons: ButtonConfig[] }) => (
+
+ {buttons.map((button, index) => (
+
+ ))}
+
+ )),
+}));
+
+// Test data generators
+export const createSetupConditionCard = (overrides = {}) => ({
+ title: FLOW_VIEW_STRINGS.SETUP_CARD_1_TITLE,
+ description: FLOW_VIEW_STRINGS.SETUP_CARD_1_DESC,
+ onClick: mockCardClick,
+ isSelected: false,
+ isDisabled: false,
+ isFulfilled: false,
+ ...overrides,
+});
+
+export const createButtonPanelCard = (overrides = {}) => ({
+ title: FLOW_VIEW_STRINGS.PANEL_CARD_1_TITLE,
+ description: FLOW_VIEW_STRINGS.PANEL_CARD_1_DESC,
+ onClick: mockCardClick,
+ isSelected: false,
+ isDisabled: false,
+ ...overrides,
+});
+
+export const createCardListItem = (overrides = {}) => ({
+ title: FLOW_VIEW_STRINGS.LIST_ITEM_1_TITLE,
+ subtitle: FLOW_VIEW_STRINGS.LIST_ITEM_1_SUBTITLE,
+ onClick: mockItemClick,
+ isSelected: false,
+ isDisabled: false,
+ ...overrides,
+});
diff --git a/app/src/tests/fixtures/reducers/flowReducerMocks.ts b/app/src/tests/fixtures/reducers/flowReducerMocks.ts
new file mode 100644
index 00000000..201a57b8
--- /dev/null
+++ b/app/src/tests/fixtures/reducers/flowReducerMocks.ts
@@ -0,0 +1,237 @@
+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;
+ }>;
+}
+
+// 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: [],
+};
+
+// Helper function to create a flow state
+export const createFlowState = (overrides: Partial = {}): FlowState => ({
+ ...INITIAL_STATE,
+ ...overrides,
+});
+
+// Helper function to create a flow stack entry
+export const createFlowStackEntry = (flow: Flow, frame: ComponentKey) => ({
+ flow,
+ frame,
+});
+
+// Mock flow stack scenarios
+export const mockEmptyStack: Array<{ flow: Flow; frame: ComponentKey }> = [];
+
+export const mockSingleLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [
+ createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME),
+];
+
+export const mockTwoLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [
+ createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME),
+ createFlowStackEntry(mockSubFlow, FRAME_NAMES.SUB_INITIAL_FRAME), // This is the frame we'll return to
+];
+
+export const mockThreeLevelStack: Array<{ flow: Flow; frame: ComponentKey }> = [
+ createFlowStackEntry(mockMainFlow, FRAME_NAMES.SECOND_FRAME),
+ createFlowStackEntry(mockSubFlow, FRAME_NAMES.SUB_SECOND_FRAME),
+ createFlowStackEntry(mockNestedFlow, FRAME_NAMES.NESTED_INITIAL_FRAME),
+];
+
+// State scenarios for testing
+export const mockStateWithMainFlow = createFlowState({
+ currentFlow: mockMainFlow,
+ currentFrame: FRAME_NAMES.INITIAL_FRAME,
+ flowStack: mockEmptyStack,
+});
+
+export const mockStateWithSubFlow = createFlowState({
+ currentFlow: mockSubFlow,
+ currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME,
+ flowStack: mockSingleLevelStack,
+});
+
+export const mockStateWithNestedFlow = createFlowState({
+ currentFlow: mockNestedFlow,
+ currentFrame: FRAME_NAMES.NESTED_INITIAL_FRAME,
+ flowStack: mockTwoLevelStack,
+});
+
+export const mockStateInMiddleFrame = createFlowState({
+ currentFlow: mockMainFlow,
+ currentFrame: FRAME_NAMES.SECOND_FRAME,
+ flowStack: mockEmptyStack,
+});
+
+export const mockStateWithoutCurrentFlow = createFlowState({
+ currentFlow: null,
+ currentFrame: FRAME_NAMES.INITIAL_FRAME,
+ flowStack: mockEmptyStack,
+});
+
+export const mockStateWithoutCurrentFrame = createFlowState({
+ currentFlow: mockMainFlow,
+ currentFrame: null,
+ flowStack: mockEmptyStack,
+});
+
+// Action payloads
+export const mockSetFlowPayload = mockMainFlow;
+
+export const mockNavigateToFramePayload = FRAME_NAMES.SECOND_FRAME;
+
+export const mockNavigateToFlowPayload = {
+ flow: mockSubFlow,
+ returnFrame: undefined,
+};
+
+export const mockNavigateToFlowWithReturnPayload = {
+ flow: mockSubFlow,
+ returnFrame: FRAME_NAMES.RETURN_FRAME,
+};
+
+// Expected states after actions
+export const expectedStateAfterSetFlow: FlowState = {
+ currentFlow: mockMainFlow,
+ currentFrame: FRAME_NAMES.INITIAL_FRAME,
+ flowStack: [],
+};
+
+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)],
+};
+
+export const expectedStateAfterNavigateToFlowWithReturn: FlowState = {
+ currentFlow: mockSubFlow,
+ currentFrame: FRAME_NAMES.SUB_INITIAL_FRAME,
+ flowStack: [createFlowStackEntry(mockMainFlow, FRAME_NAMES.RETURN_FRAME)],
+};
+
+export const expectedStateAfterReturnFromFlow: FlowState = {
+ currentFlow: mockMainFlow,
+ currentFrame: FRAME_NAMES.SECOND_FRAME,
+ flowStack: [],
+};
+
+export const expectedStateAfterClearFlow: FlowState = INITIAL_STATE;
diff --git a/app/src/tests/fixtures/types/flowMocks.ts b/app/src/tests/fixtures/types/flowMocks.ts
new file mode 100644
index 00000000..310ff5fa
--- /dev/null
+++ b/app/src/tests/fixtures/types/flowMocks.ts
@@ -0,0 +1,169 @@
+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/reducers/flowReducer.test.ts b/app/src/tests/reducers/flowReducer.test.ts
new file mode 100644
index 00000000..f4f7230f
--- /dev/null
+++ b/app/src/tests/reducers/flowReducer.test.ts
@@ -0,0 +1,354 @@
+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(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(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(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(mockMainFlow));
+
+ expect(state).toEqual(expectedStateAfterSetFlow);
+ expect(state.flowStack).toEqual(mockEmptyStack);
+ });
+ });
+
+ 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(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 does nothing', () => {
+ const state = flowReducer(mockStateWithMainFlow, returnFromFlow());
+
+ expect(state).toEqual(mockStateWithMainFlow);
+ expect(state.currentFlow).toEqual(mockMainFlow);
+ expect(state.currentFrame).toEqual(FRAME_NAMES.INITIAL_FRAME);
+ 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(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(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
+ state = flowReducer(state, returnFromFlow());
+ expect(state.flowStack).toEqual(mockEmptyStack);
+
+ // Second return - should do nothing
+ state = flowReducer(state, returnFromFlow());
+ expect(state.currentFlow).toEqual(mockMainFlow);
+ expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME);
+
+ // Third return - should still do nothing
+ state = flowReducer(state, returnFromFlow());
+ expect(state.currentFlow).toEqual(mockMainFlow);
+ expect(state.currentFrame).toEqual(FRAME_NAMES.SECOND_FRAME);
+ });
+ });
+});
diff --git a/app/src/tests/types/flow.test.ts b/app/src/tests/types/flow.test.ts
new file mode 100644
index 00000000..8ec0e2bc
--- /dev/null
+++ b/app/src/tests/types/flow.test.ts
@@ -0,0 +1,384 @@
+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/types/flow.ts b/app/src/types/flow.ts
index 69383588..6d911a3c 100644
--- a/app/src/types/flow.ts
+++ b/app/src/types/flow.ts
@@ -49,7 +49,6 @@ export interface FlowComponentProps {
isInSubflow: boolean;
flowDepth: number;
parentFlowContext?: {
- flowName: string;
parentFrame: ComponentKey;
};
}