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; }; }