) => unknown) => selector({
+ config: mockStudyConfig,
+ answers: {},
+ storageEngineFailedToConnect: mockStorageEngineFailedToConnect,
+ modes: { developmentModeEnabled: false },
+ showStudyBrowser: false,
+ }),
+ useStoreDispatch: () => vi.fn(),
+ useStoreActions: () => ({
+ toggleShowHelpText: vi.fn(),
+ toggleStudyBrowser: vi.fn(),
+ incrementHelpCounter: vi.fn(),
+ setAlertModal: vi.fn(),
+ }),
+ useFlatSequence: () => [],
+}));
+
+vi.mock('../../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: { getEngine: () => 'localStorage', updateProgressData: vi.fn() } }),
+}));
+
+vi.mock('../../../storage/engines/utils', () => ({
+ calculateProgressData: vi.fn(() => ({ completedSteps: 0, totalSteps: 1 })),
+}));
+
+vi.mock('../../../utils/Prefix', () => ({ PREFIX: '/' }));
+vi.mock('../../../utils/nextParticipant', () => ({ getNewParticipant: vi.fn() }));
+
+vi.mock('../RecordingAudioWaveform', () => ({
+ RecordingAudioWaveform: () => ,
+}));
+
+vi.mock('../../../utils/handleComponentInheritance', () => ({
+ studyComponentToIndividualComponent: vi.fn(() => ({
+ withProgressBar: false,
+ showTitle: true,
+ })),
+}));
+
+vi.mock('../../../store/hooks/useRecording', () => ({
+ useRecordingContext: () => mockedRecordingContext,
+}));
+
+vi.mock('../../../utils/useDeviceRules', () => ({
+ useDeviceRules: () => ({
+ isBrowserAllowed: true,
+ isDeviceAllowed: true,
+ isInputAllowed: true,
+ isDisplayAllowed: true,
+ }),
+}));
+
+vi.mock('../../../utils/notifications', () => ({
+ hideNotification: vi.fn(),
+ showNotification: vi.fn(() => 'notification-id'),
+}));
+
+vi.mock('../../../utils/recordingWarnings', () => ({
+ getMutedInstruction: () => 'Muted warning',
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('AppHeader interactive', () => {
+ beforeEach(() => { mockStorageEngineFailedToConnect = false; });
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
+
+ test('covers storageEngineFailedToConnect effect: setTimeout setup and callback', async () => {
+ mockStorageEngineFailedToConnect = true;
+ vi.useFakeTimers();
+ await act(async () => { render(); });
+ await act(async () => { vi.advanceTimersByTime(5000); });
+ });
+
+ test('covers cleanup of storageEngineFailedToConnect effect on unmount', async () => {
+ mockStorageEngineFailedToConnect = true;
+ vi.useFakeTimers();
+ const { unmount } = await act(async () => render(
+ ,
+ ));
+ unmount();
+ });
+});
+
+describe('AppHeader', () => {
+ beforeEach(() => {
+ mockedCurrentComponent = 'componentA';
+ mockedRecordingContext = {
+ isScreenRecording: false,
+ isAudioRecording: false,
+ setIsMuted: vi.fn(),
+ isMuted: false,
+ clickToRecord: false,
+ isSpeakingWhileMuted: false,
+ showMutedWarning: false,
+ screenRecordingError: null,
+ audioRecordingError: null,
+ currentComponentHasAudioRecording: false,
+ audioStatus: 'idle',
+ };
+ });
+
+ test('renders progress bar in normal mode', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html.length).toBeGreaterThan(0);
+ // No "Demo Mode" badge when data collection is enabled
+ expect(html).not.toContain('Demo Mode');
+ });
+
+ test('shows study browser and analyze links in development mode', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Study Browser');
+ expect(html).toContain('Analyze');
+ expect(html).toContain('Next Participant');
+ });
+
+ test('renders header content when data collection is disabled', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // With no storageEngine connection, shows disconnected state
+ expect(html).toContain('Storage Disconnected');
+ // Dev mode controls should not appear
+ expect(html).not.toContain('Study Browser');
+ });
+
+ // --- Audio/recording state tests from original file ---
+
+ test('shows disabled mic state when audio permission is denied before recording starts', () => {
+ mockedRecordingContext = {
+ ...mockedRecordingContext,
+ clickToRecord: true,
+ currentComponentHasAudioRecording: true,
+ audioRecordingError: 'Microphone permission denied',
+ audioStatus: 'denied',
+ };
+
+ const html = renderToStaticMarkup();
+
+ // The error text is rendered directly in a element
+ expect(html).toContain('Microphone permission denied');
+ });
+
+ test('shows pending mic state before audio permission is granted', () => {
+ mockedRecordingContext = {
+ ...mockedRecordingContext,
+ clickToRecord: true,
+ currentComponentHasAudioRecording: true,
+ audioStatus: 'pending',
+ };
+
+ const html = renderToStaticMarkup();
+
+ // Pending state renders an ActionIcon (button) with aria-label; tooltip labels don't render in static markup
+ expect(html).toContain('
+ ),
+ Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ,
+ Group: ({ children }: { children: ReactNode }) => {children}
,
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('../../../store/store', () => ({
+ useStoreSelector: (
+ selector: (state: { isStalledConfig: boolean; modes: { developmentModeEnabled: boolean } }) => boolean,
+ ) => selector({ isStalledConfig: mockedIsStalledConfig, modes: { developmentModeEnabled: true } }),
+}));
+
+vi.mock('../../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: () => mockedIsAnalysis,
+}));
+
+vi.mock('../../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: () => ({ uiConfig: { contactEmail: 'test@test.com' } }),
+}));
+
+vi.mock('../../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: undefined }),
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useStudyId: () => 'test-study',
+}));
+
+vi.mock('../../../utils/nextParticipant', () => ({
+ getNewParticipant: vi.fn(),
+}));
+
+vi.mock('react-router', () => ({
+ useHref: () => '/test-study',
+}));
+
+describe('ConfigVersionWarningModal', () => {
+ test('does not show warning when stalled config is false', () => {
+ mockedIsStalledConfig = false;
+ mockedIsAnalysis = false;
+
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain('Study Configuration Has Changed');
+ });
+
+ test('shows warning when stalled config is detected in participant mode', () => {
+ mockedIsStalledConfig = true;
+ mockedIsAnalysis = false;
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Study Configuration Has Changed');
+ });
+
+ test('does not show warning during analysis replay', () => {
+ mockedIsStalledConfig = true;
+ mockedIsAnalysis = true;
+
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain('Study Configuration Has Changed');
+ });
+});
+
+describe('ConfigVersionWarningModal — interactive / effect branches', () => {
+ beforeEach(() => {
+ mockedIsStalledConfig = false;
+ mockedIsAnalysis = false;
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ test('modal auto-dismisses after 10 seconds', async () => {
+ mockedIsStalledConfig = true;
+ await act(async () => { render(); });
+ expect(screen.getByText('Study Configuration Has Changed')).toBeDefined();
+
+ await act(async () => { vi.advanceTimersByTime(10001); });
+ expect(screen.queryByText('Study Configuration Has Changed')).toBeNull();
+ });
+
+ test('"Next Participant" button calls getNewParticipant', async () => {
+ mockedIsStalledConfig = true;
+
+ await act(async () => { render(); });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Next Participant'));
+ });
+
+ expect(vi.mocked(getNewParticipant)).toHaveBeenCalled();
+ });
+
+ test('modal is hidden when studyConfig is stalled but analysis mode is active', async () => {
+ mockedIsStalledConfig = true;
+ mockedIsAnalysis = true;
+
+ await act(async () => { render(); });
+ expect(screen.queryByText('Study Configuration Has Changed')).toBeNull();
+ });
+});
diff --git a/src/components/interface/tests/DeviceRestrictionString.spec.ts b/src/components/interface/tests/DeviceRestrictionString.spec.ts
new file mode 100644
index 0000000000..9dfcc88923
--- /dev/null
+++ b/src/components/interface/tests/DeviceRestrictionString.spec.ts
@@ -0,0 +1,188 @@
+import { describe, expect, test } from 'vitest';
+import type { DisplayRules, StudyRules } from '../../../parser/types';
+import type { DeviceRuleStatus } from '../DeviceRestrictionString';
+import {
+ getConfiguredDeviceRestrictionLines,
+ getConfiguredDeviceRestrictionTooltip,
+ getUnmetDeviceRestrictionLines,
+ getUnmetDeviceRestrictionTooltip,
+} from '../DeviceRestrictionString';
+
+const allAllowed: DeviceRuleStatus = {
+ isBrowserAllowed: true,
+ isDeviceAllowed: true,
+ isInputAllowed: true,
+ isDisplayAllowed: true,
+};
+
+const allBlocked: DeviceRuleStatus = {
+ isBrowserAllowed: false,
+ isDeviceAllowed: false,
+ isInputAllowed: false,
+ isDisplayAllowed: false,
+};
+
+// ---------------------------------------------------------------------------
+// getConfiguredDeviceRestrictionLines
+// ---------------------------------------------------------------------------
+
+describe('getConfiguredDeviceRestrictionLines', () => {
+ test('returns empty array when studyRules is undefined', () => {
+ expect(getConfiguredDeviceRestrictionLines(undefined)).toEqual([]);
+ });
+
+ test('includes browser names without version when minVersion is absent', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'chrome' }] } };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toEqual(['Browser: chrome']);
+ });
+
+ test('includes browser name with minVersion when provided', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'firefox', minVersion: 100 }] } };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines[0]).toBe('Browser: firefox >= 100');
+ });
+
+ test('joins multiple browsers with comma', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'chrome' }, { name: 'safari' }] } };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines[0]).toBe('Browser: chrome, safari');
+ });
+
+ test('includes devices in title case', () => {
+ const rules: StudyRules = { devices: { allowed: ['desktop', 'mobile'] } };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines[0]).toBe('Device: Desktop, Mobile');
+ });
+
+ test('includes inputs in title case', () => {
+ const rules: StudyRules = { inputs: { allowed: ['mouse', 'touch'] } };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines[0]).toBe('Input: Mouse, Touch');
+ });
+
+ test('includes both min and max display as separate lines', () => {
+ const rules: StudyRules = {
+ display: {
+ minWidth: 800, minHeight: 600, maxWidth: 1920, maxHeight: 1080,
+ },
+ };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toContain('Display (min): 800 x 600 px');
+ expect(lines).toContain('Display (max): 1920 x 1080 px');
+ });
+
+ test('includes only minWidth when only minWidth is set', () => {
+ const rules: StudyRules = { display: { minWidth: 1024 } as Partial as DisplayRules };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toContain('Display (min width): 1024 px');
+ });
+
+ test('includes only minHeight when only minHeight is set', () => {
+ const rules: StudyRules = { display: { minHeight: 768 } as Partial as DisplayRules };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toContain('Display (min height): 768 px');
+ });
+
+ test('includes only maxWidth when only maxWidth is set', () => {
+ const rules: StudyRules = { display: { maxWidth: 2560 } as Partial as DisplayRules };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toContain('Display (max width): 2560 px');
+ });
+
+ test('includes only maxHeight when only maxHeight is set', () => {
+ const rules: StudyRules = { display: { maxHeight: 1440 } as Partial as DisplayRules };
+ const lines = getConfiguredDeviceRestrictionLines(rules);
+ expect(lines).toContain('Display (max height): 1440 px');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getConfiguredDeviceRestrictionTooltip
+// ---------------------------------------------------------------------------
+
+describe('getConfiguredDeviceRestrictionTooltip', () => {
+ test('returns empty string when there are no configured lines', () => {
+ expect(getConfiguredDeviceRestrictionTooltip(undefined)).toBe('');
+ });
+
+ test('returns formatted tooltip with header when lines exist', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'chrome' }] } };
+ const tooltip = getConfiguredDeviceRestrictionTooltip(rules);
+ expect(tooltip).toBe('Device Requirement\nBrowser: chrome');
+ });
+
+ test('joins multiple restriction lines with newline', () => {
+ const rules: StudyRules = {
+ browsers: { allowed: [{ name: 'chrome' }] },
+ devices: { allowed: ['desktop'] },
+ };
+ const tooltip = getConfiguredDeviceRestrictionTooltip(rules);
+ expect(tooltip).toContain('Browser: chrome');
+ expect(tooltip).toContain('Device: Desktop');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getUnmetDeviceRestrictionLines
+// ---------------------------------------------------------------------------
+
+describe('getUnmetDeviceRestrictionLines', () => {
+ test('returns empty array when studyRules is undefined', () => {
+ expect(getUnmetDeviceRestrictionLines(undefined, allBlocked)).toEqual([]);
+ });
+
+ test('returns empty array when all constraints are met', () => {
+ const rules: StudyRules = {
+ browsers: { allowed: [{ name: 'chrome' }] },
+ devices: { allowed: ['desktop'] },
+ };
+ expect(getUnmetDeviceRestrictionLines(rules, allAllowed)).toEqual([]);
+ });
+
+ test('reports unmet browser constraint', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'chrome', minVersion: 100 }] } };
+ const lines = getUnmetDeviceRestrictionLines(rules, { ...allAllowed, isBrowserAllowed: false });
+ expect(lines[0]).toBe('Browser: chrome >= 100');
+ });
+
+ test('reports unmet device constraint', () => {
+ const rules: StudyRules = { devices: { allowed: ['desktop'] } };
+ const lines = getUnmetDeviceRestrictionLines(rules, { ...allAllowed, isDeviceAllowed: false });
+ expect(lines[0]).toBe('Device: Desktop');
+ });
+
+ test('reports unmet input constraint', () => {
+ const rules: StudyRules = { inputs: { allowed: ['mouse'] } };
+ const lines = getUnmetDeviceRestrictionLines(rules, { ...allAllowed, isInputAllowed: false });
+ expect(lines[0]).toBe('Input: Mouse');
+ });
+
+ test('reports unmet display constraints', () => {
+ const rules: StudyRules = { display: { minWidth: 800, minHeight: 600 } };
+ const lines = getUnmetDeviceRestrictionLines(rules, { ...allAllowed, isDisplayAllowed: false });
+ expect(lines).toContain('Display (min): 800 x 600 px');
+ });
+
+ test('does not report browser when isBrowserAllowed is true', () => {
+ const rules: StudyRules = { browsers: { allowed: [{ name: 'chrome' }] } };
+ const lines = getUnmetDeviceRestrictionLines(rules, { ...allAllowed, isBrowserAllowed: true });
+ expect(lines).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getUnmetDeviceRestrictionTooltip
+// ---------------------------------------------------------------------------
+
+describe('getUnmetDeviceRestrictionTooltip', () => {
+ test('returns empty string when no constraints are unmet', () => {
+ expect(getUnmetDeviceRestrictionTooltip(undefined, allAllowed)).toBe('');
+ });
+
+ test('returns formatted tooltip for unmet constraints', () => {
+ const rules: StudyRules = { devices: { allowed: ['mobile'] } };
+ const tooltip = getUnmetDeviceRestrictionTooltip(rules, { ...allAllowed, isDeviceAllowed: false });
+ expect(tooltip).toBe('Device Requirement\nDevice: Mobile');
+ });
+});
diff --git a/src/components/interface/DeviceWarning.spec.tsx b/src/components/interface/tests/DeviceWarning.spec.tsx
similarity index 59%
rename from src/components/interface/DeviceWarning.spec.tsx
rename to src/components/interface/tests/DeviceWarning.spec.tsx
index 34d98592b2..a60b8b269c 100644
--- a/src/components/interface/DeviceWarning.spec.tsx
+++ b/src/components/interface/tests/DeviceWarning.spec.tsx
@@ -1,9 +1,12 @@
import { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
- beforeEach, describe, expect, test, vi,
+ render, act, cleanup,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
} from 'vitest';
-import { DeviceWarning } from './DeviceWarning';
+import { DeviceWarning } from '../DeviceWarning';
type StudyRulesMock = {
display?: {
@@ -74,13 +77,13 @@ vi.mock('@tabler/icons-react', () => ({
IconDeviceDesktop: () => display-icon,
}));
-vi.mock('../../store/hooks/useStudyConfig', () => ({
+vi.mock('../../../store/hooks/useStudyConfig', () => ({
useStudyConfig: () => ({
studyRules: mockedStudyRules,
}),
}));
-vi.mock('../../utils/useDeviceRules', () => ({
+vi.mock('../../../utils/useDeviceRules', () => ({
useDeviceRules: () => mockedDeviceRules,
}));
@@ -88,7 +91,7 @@ vi.mock('react-router', () => ({
useNavigate: () => vi.fn(),
}));
-vi.mock('../../storage/storageEngineHooks', () => ({
+vi.mock('../../../storage/storageEngineHooks', () => ({
useStorageEngine: () => ({
storageEngine: {
rejectCurrentParticipant: vi.fn().mockResolvedValue(undefined),
@@ -199,4 +202,119 @@ describe('DeviceWarning', () => {
const html = renderToStaticMarkup();
expect(html).toBe('');
});
+
+ test('shows custom blockedMessage for browser violation', () => {
+ mockedStudyRules = {
+ browsers: { blockedMessage: 'This browser is not supported.', allowed: [] },
+ };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isBrowserAllowed: false,
+ };
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('This browser is not supported.');
+ });
+
+ test('shows custom blockedMessage for device violation', () => {
+ mockedStudyRules = {
+ devices: { blockedMessage: 'Mobile devices not allowed.', allowed: [] },
+ };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isDeviceAllowed: false,
+ currentDevice: 'mobile',
+ };
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Mobile devices not allowed.');
+ });
+
+ test('shows custom blockedMessage for input violation', () => {
+ mockedStudyRules = {
+ inputs: { blockedMessage: 'Touch input not supported.', allowed: [] },
+ };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isInputAllowed: false,
+ currentInputs: ['touch'],
+ };
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Touch input not supported.');
+ });
+
+ test('shows custom blockedMessage for display violation', () => {
+ mockedStudyRules = {
+ display: { blockedMessage: 'Screen too small.', minWidth: 1200, minHeight: 800 },
+ };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isDisplayAllowed: false,
+ currentDisplay: { width: 900, height: 700 },
+ };
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Screen too small.');
+ });
+
+ test('shows allowed browser list when no blockedMessage', () => {
+ mockedStudyRules = {
+ browsers: { allowed: [{ name: 'Firefox', minVersion: 100 }, { name: 'Chrome' }] },
+ };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isBrowserAllowed: false,
+ };
+
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Firefox');
+ expect(html).toContain('v100 or later');
+ expect(html).toContain('Chrome');
+ });
+});
+
+// ── interactive tests (covers effect lines) ────────────────────────────────────
+
+describe('DeviceWarning interactive', () => {
+ afterEach(() => { cleanup(); });
+
+ beforeEach(() => {
+ mockedStudyRules = {};
+ mockedDeviceRules = {
+ isBrowserAllowed: true,
+ isDeviceAllowed: true,
+ isInputAllowed: true,
+ isDisplayAllowed: true,
+ currentBrowser: { name: 'chrome', version: 120 },
+ currentDevice: 'desktop',
+ currentInputs: ['mouse'],
+ currentDisplay: { width: 1920, height: 1080 },
+ };
+ });
+
+ test('covers storageEngineRef and navigateRef update effects on mount', async () => {
+ await act(async () => { render(); });
+ });
+
+ test('covers countdown timer setup when display requirement is not met', async () => {
+ mockedStudyRules = { display: { minWidth: 1200 } };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isDisplayAllowed: false,
+ currentDisplay: { width: 1000, height: 1080 },
+ };
+ await act(async () => { render(); });
+ });
+
+ test('covers else branch (clears timer) when display requirement is met after render', async () => {
+ mockedStudyRules = { display: { minWidth: 1200 } };
+ mockedDeviceRules = {
+ ...mockedDeviceRules,
+ isDisplayAllowed: false,
+ currentDisplay: { width: 1000, height: 1080 },
+ };
+ const { unmount } = await act(async () => render());
+ unmount();
+ });
});
diff --git a/src/components/interface/tests/HelpModal.spec.tsx b/src/components/interface/tests/HelpModal.spec.tsx
new file mode 100644
index 0000000000..e0ce24b354
--- /dev/null
+++ b/src/components/interface/tests/HelpModal.spec.tsx
@@ -0,0 +1,136 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup, screen,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { HelpModal } from '../HelpModal';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+let mockShowHelpText = true;
+let mockConfig: { components: Record; uiConfig: { helpTextPath: string | undefined } } = {
+ components: {},
+ uiConfig: { helpTextPath: undefined },
+};
+let mockGetStaticAssetByPath = vi.fn().mockResolvedValue(undefined);
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../../../store/store', () => ({
+ useStoreSelector: (selector: (s: Record) => unknown) => selector({
+ showHelpText: mockShowHelpText,
+ config: mockConfig,
+ }),
+ useStoreActions: () => ({ toggleShowHelpText: vi.fn() }),
+ useStoreDispatch: () => vi.fn(),
+}));
+
+vi.mock('../../../utils/getStaticAsset', () => ({
+ getStaticAssetByPath: (...args: unknown[]) => mockGetStaticAssetByPath(...args),
+}));
+
+vi.mock('../../../utils/Prefix', () => ({
+ PREFIX: '/',
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useCurrentComponent: () => 'trial1',
+}));
+
+vi.mock('../../../utils/handleComponentInheritance', () => ({
+ studyComponentToIndividualComponent: () => ({ helpTextPath: undefined }),
+}));
+
+vi.mock('../../ReactMarkdownWrapper', () => ({
+ ReactMarkdownWrapper: ({ text }: { text: string }) => (
+ {text}
+ ),
+}));
+
+vi.mock('../../../ResourceNotFound', () => ({
+ ResourceNotFound: ({ path }: { path?: string }) => (
+ {path ?? 'not found'}
+ ),
+}));
+
+vi.mock('@mantine/core', () => ({
+ Modal: ({ opened, children }: { opened: boolean; children: ReactNode }) => (
+ opened ? {children}
: null
+ ),
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('HelpModal', () => {
+ beforeEach(() => {
+ mockShowHelpText = true;
+ mockConfig = {
+ components: {},
+ uiConfig: { helpTextPath: undefined },
+ };
+ mockGetStaticAssetByPath = vi.fn().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test('renders nothing when showHelpText is false', async () => {
+ mockShowHelpText = false;
+ await act(async () => {
+ render();
+ });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ test('shows ResourceNotFound when no helpTextPath configured', async () => {
+ mockConfig = {
+ components: {},
+ uiConfig: { helpTextPath: undefined },
+ };
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId('not-found')).toBeDefined();
+ });
+
+ test('shows markdown when asset is found', async () => {
+ mockConfig = {
+ components: {},
+ uiConfig: { helpTextPath: 'help.md' },
+ };
+ mockGetStaticAssetByPath = vi.fn().mockResolvedValue('# Help Content');
+ await act(async () => {
+ render();
+ });
+ const markdown = screen.getByTestId('markdown');
+ expect(markdown.textContent).toBe('# Help Content');
+ });
+
+ test('shows ResourceNotFound when asset fetch returns undefined', async () => {
+ mockConfig = {
+ components: {},
+ uiConfig: { helpTextPath: 'missing.md' },
+ };
+ mockGetStaticAssetByPath = vi.fn().mockResolvedValue(undefined);
+ await act(async () => {
+ render();
+ });
+ expect(screen.getByTestId('not-found')).toBeDefined();
+ });
+
+ test('prefixes the help text path when fetching the asset', async () => {
+ mockConfig = {
+ components: {},
+ uiConfig: { helpTextPath: 'help/guide.md' },
+ };
+ mockGetStaticAssetByPath = vi.fn().mockResolvedValue('content');
+ await act(async () => {
+ render();
+ });
+ expect(mockGetStaticAssetByPath).toHaveBeenCalledWith('/help/guide.md');
+ });
+});
diff --git a/src/components/interface/tests/RecordingAudioWaveform.spec.tsx b/src/components/interface/tests/RecordingAudioWaveform.spec.tsx
new file mode 100644
index 0000000000..656b1ed115
--- /dev/null
+++ b/src/components/interface/tests/RecordingAudioWaveform.spec.tsx
@@ -0,0 +1,130 @@
+import {
+ render, act, cleanup, waitFor,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { RecordingAudioWaveform } from '../RecordingAudioWaveform';
+
+// ── stubs for canvas / Web Audio API (not available in jsdom) ─────────────────
+
+const mockCtx = {
+ getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
+ putImageData: vi.fn(),
+ clearRect: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
+};
+
+const mockAnalyser = {
+ fftSize: 256,
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ getByteTimeDomainData: vi.fn((arr: Uint8Array) => arr.fill(128)),
+};
+
+const mockSource = { connect: vi.fn(), disconnect: vi.fn() };
+
+const mockClose = vi.fn().mockResolvedValue(undefined);
+const mockGetUserMedia = vi.fn();
+
+class MockAudioContext {
+ state: AudioContextState = 'running';
+
+ createMediaStreamSource = vi.fn(() => mockSource);
+
+ createAnalyser = vi.fn(() => mockAnalyser);
+
+ close = mockClose;
+}
+
+vi.mock('@mantine/core', () => ({
+ Flex: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+describe('RecordingAudioWaveform', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetUserMedia.mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] });
+ vi.stubGlobal('AudioContext', vi.fn(MockAudioContext));
+ vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+
+ // Make canvas.getContext return a workable mock in jsdom
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockCtx as unknown as CanvasRenderingContext2D);
+
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: { getUserMedia: mockGetUserMedia },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ test('renders a canvas element', async () => {
+ const { container } = await act(async () => render());
+ expect(container.querySelector('canvas')).not.toBeNull();
+ });
+
+ test('calls getUserMedia with audio:true on mount', async () => {
+ await act(async () => {
+ render();
+ });
+ expect(mockGetUserMedia).toHaveBeenCalledWith({ audio: true });
+ });
+
+ test('shows error message when getUserMedia throws an Error', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockGetUserMedia.mockRejectedValue(new Error('Permission denied'));
+ const { container } = await act(async () => render());
+ // Component logs error to console; canvas still renders
+ expect(container.querySelector('canvas')).not.toBeNull();
+ expect(consoleSpy).toHaveBeenCalledWith('Error accessing microphone:', expect.any(Error));
+ consoleSpy.mockRestore();
+ });
+
+ test('shows generic error when a non-Error is thrown', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockGetUserMedia.mockRejectedValue('unexpected string error');
+ const { container } = await act(async () => render());
+ // Component logs error to console; canvas still renders
+ expect(container.querySelector('canvas')).not.toBeNull();
+ expect(consoleSpy).toHaveBeenCalledWith('Error accessing microphone:', 'unexpected string error');
+ consoleSpy.mockRestore();
+ });
+
+ test('closes AudioContext on unmount', async () => {
+ const { unmount } = await act(async () => render());
+ await waitFor(() => expect(mockGetUserMedia).toHaveBeenCalled());
+ await act(async () => { unmount(); });
+ await waitFor(() => expect(mockClose).toHaveBeenCalled());
+ });
+
+ test('draw callback executes waveform rendering when RAF fires', async () => {
+ // Make RAF invoke the draw callback once with a timestamp > frameInterval
+ let rafFired = false;
+ vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
+ if (!rafFired) {
+ rafFired = true;
+ cb(100);
+ }
+ return 1;
+ }));
+
+ await act(async () => render());
+ await waitFor(() => expect(mockCtx.stroke).toHaveBeenCalled());
+ });
+
+ test('setupAudio returns early when canvas context is null', async () => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ // Should render without crashing even when context is unavailable
+ const { container } = await act(async () => render());
+ expect(container.querySelector('canvas')).not.toBeNull();
+ });
+});
diff --git a/src/components/interface/tests/ScreenRecordingRejection.spec.tsx b/src/components/interface/tests/ScreenRecordingRejection.spec.tsx
new file mode 100644
index 0000000000..f3931c213f
--- /dev/null
+++ b/src/components/interface/tests/ScreenRecordingRejection.spec.tsx
@@ -0,0 +1,34 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ describe, expect, test, vi,
+} from 'vitest';
+import { ReactNode } from 'react';
+import { ScreenRecordingRejection } from '../ScreenRecordingRejection';
+
+vi.mock('@mantine/core', () => ({
+ Modal: ({ children }: { children: ReactNode }) => {children}
,
+ Stack: ({ children }: { children: ReactNode }) => {children}
,
+ Title: ({ children }: { children: ReactNode }) => {children}
,
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconAlertTriangle: () => ,
+}));
+
+describe('ScreenRecordingRejection', () => {
+ test('renders the stopped title', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Screen Recording Stopped');
+ });
+
+ test('renders the explanation text', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Screen recording was stopped');
+ });
+
+ test('renders the close page instruction', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('close this page');
+ });
+});
diff --git a/src/components/interface/tests/StepsPanel.spec.tsx b/src/components/interface/tests/StepsPanel.spec.tsx
new file mode 100644
index 0000000000..e5f7cc5cf0
--- /dev/null
+++ b/src/components/interface/tests/StepsPanel.spec.tsx
@@ -0,0 +1,721 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup, fireEvent,
+} from '@testing-library/react';
+import {
+ afterEach, describe, expect, test, vi,
+} from 'vitest';
+import type { Answer, NumericalResponse } from '../../../parser/types';
+import { Sequence, StoredAnswer } from '../../../store/types';
+import { makeStudyConfig, makeStoredAnswer } from '../../../tests/utils';
+import { getDynamicComponentsForBlock } from '../StepsPanel.utils';
+import { StepsPanel } from '../StepsPanel';
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('@tanstack/react-virtual', () => ({
+ useVirtualizer: ({ count }: { count: number }) => ({
+ getVirtualItems: () => Array.from({ length: count }, (_, i) => ({
+ key: i,
+ index: i,
+ start: i * 32,
+ size: 32,
+ })),
+ getTotalSize: () => count * 32,
+ measureElement: vi.fn(),
+ }),
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useStudyId: () => 'test-study',
+}));
+
+const mockNavigate = vi.fn();
+vi.mock('react-router', () => ({
+ useNavigate: () => mockNavigate,
+ useLocation: () => ({ pathname: '/', search: '' }),
+}));
+
+vi.mock('../../../utils/getSequenceFlatMap', () => ({
+ addPathToComponentBlock: (seq: Sequence) => seq,
+}));
+
+vi.mock('../../../utils/encryptDecryptIndex', () => ({
+ encryptIndex: (i: number) => String(i),
+}));
+
+vi.mock('../../../parser/utils', () => ({
+ isDynamicBlock: () => false,
+ isInheritedComponent: () => false,
+}));
+
+vi.mock('../../../utils/correctAnswer', () => ({
+ componentAnswersAreCorrect: vi.fn(() => true),
+}));
+
+vi.mock('@mantine/core', () => ({
+ Badge: ({ children }: { children: ReactNode }) => {children},
+ Box: ({ children }: { children: ReactNode }) => {children}
,
+ Code: ({ children }: { children: ReactNode }) => {children},
+ Flex: ({ children }: { children: ReactNode }) => {children}
,
+ HoverCard: Object.assign(
+ ({ children }: { children: ReactNode }) => {children}
,
+ {
+ Target: ({ children }: { children: ReactNode }) => {children}
,
+ Dropdown: ({ children }: { children: ReactNode }) => {children}
,
+ },
+ ),
+ NavLink: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
+ {children}
+ ),
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ Tooltip: ({ children }: { children: ReactNode }) => {children}
,
+ Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconArrowsShuffle: () => null,
+ IconBinaryTree: () => null,
+ IconBrain: () => null,
+ IconCheck: () => null,
+ IconChevronUp: () => null,
+ IconDice3: () => null,
+ IconDice5: () => null,
+ IconInfoCircle: () => null,
+ IconPackageImport: () => null,
+ IconX: () => null,
+}));
+
+// ── fixtures ──────────────────────────────────────────────────────────────────
+
+const minimalSequence: Sequence = {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: ['intro', 'end'],
+ skip: [],
+};
+
+const minimalStudyConfig = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ components: {
+ intro: {
+ type: 'markdown',
+ path: 'intro.md',
+ response: [],
+ },
+ },
+ sequence: minimalSequence,
+});
+
+afterEach(() => { cleanup(); });
+
+// ── component rendering tests ──────────────────────────────────────────────────
+
+describe('StepsPanel rendering', () => {
+ test('renders without crashing when no participant sequence provided', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with a participant sequence', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders in analysis mode', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+});
+
+describe('StepsPanel Show more/less buttons', () => {
+ // correctAnswer with 7 keys produces 9-line JSON (> INITIAL_CLAMP=6)
+ const longCorrectAnswer: Answer[] = [
+ { id: 'q1', answer: 1 },
+ { id: 'q2', answer: 2 },
+ { id: 'q3', answer: 3 },
+ { id: 'q4', answer: 4 },
+ { id: 'q5', answer: 5 },
+ { id: 'q6', answer: 6 },
+ { id: 'q7', answer: 7 },
+ ];
+
+ // response array with enough items to exceed INITIAL_CLAMP lines
+ const longResponse: NumericalResponse[] = [
+ { id: 'r1', type: 'numerical', prompt: 'A' },
+ { id: 'r2', type: 'numerical', prompt: 'B' },
+ { id: 'r3', type: 'numerical', prompt: 'C' },
+ ];
+
+ const configWithLongAnswers = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ components: {
+ intro: {
+ type: 'markdown',
+ path: 'intro.md',
+ response: longResponse,
+ correctAnswer: longCorrectAnswer,
+ },
+ },
+ sequence: minimalSequence,
+ });
+
+ const participantAnswer = makeStoredAnswer({
+ answer: { q1: 1 },
+ identifier: 'intro_0',
+ componentName: 'intro',
+ trialOrder: 'root_0',
+ startTime: 0,
+ endTime: 100,
+ });
+
+ test('renders Show more button when correctAnswer JSON exceeds INITIAL_CLAMP', async () => {
+ const { getAllByRole } = await act(async () => render(
+ ,
+ ));
+ const buttons = getAllByRole('button');
+ expect(buttons.some((b) => b.textContent?.includes('Show'))).toBe(true);
+ });
+
+ test('clicking Show more toggles correctAnswer clamp to Show less', async () => {
+ const { getAllByRole } = await act(async () => render(
+ ,
+ ));
+ const buttons = getAllByRole('button');
+ const showMoreBtn = buttons.find((b) => b.textContent === 'Show more');
+ expect(showMoreBtn).toBeDefined();
+ await act(async () => { fireEvent.click(showMoreBtn!); });
+ const btnsAfter = getAllByRole('button');
+ expect(btnsAfter.some((b) => b.textContent === 'Show less')).toBe(true);
+ });
+
+ test('clicking all Show more buttons covers response clamp handler', async () => {
+ const { getAllByRole } = await act(async () => render(
+ ,
+ ));
+ // Click every Show more button (covers both correctAnswer and response clamp handlers)
+ const showMoreBtns = getAllByRole('button').filter((b) => b.textContent === 'Show more');
+ for (const btn of showMoreBtns) {
+ act(() => { fireEvent.click(btn); });
+ }
+ const btnsAfter = getAllByRole('button');
+ expect(btnsAfter.some((b) => b.textContent === 'Show less')).toBe(true);
+ });
+});
+
+describe('StepsPanel NavLink click handler', () => {
+ test('clicking a component item triggers navigation', async () => {
+ mockNavigate.mockClear();
+ const { getAllByRole } = await act(async () => render(
+ ,
+ ));
+ // links: [block(0), intro(1), end(2)] - click component links first to avoid detachment
+ const links = getAllByRole('link');
+ if (links.length > 1) {
+ // Click a component link (index 1 = intro) before block collapses it
+ act(() => { fireEvent.click(links[1]); });
+ }
+ expect(mockNavigate).toHaveBeenCalled();
+ });
+
+ test('clicking a component item in analysis mode navigates to analysis URL', async () => {
+ mockNavigate.mockClear();
+ const { getAllByRole } = await act(async () => render(
+ ,
+ ));
+ // Click the intro component link (index 1) in analysis mode
+ const links = getAllByRole('link');
+ if (links.length > 1) {
+ act(() => { fireEvent.click(links[1]); });
+ }
+ expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('analysis'));
+ });
+
+ test('clicking a block item toggles collapse/expand', async () => {
+ const { getAllByRole, queryAllByRole } = await act(async () => render(
+ ,
+ ));
+ // The block is the first link (index 0); click it to collapse
+ const links = getAllByRole('link');
+ const blockLink = links[0];
+ await act(async () => { fireEvent.click(blockLink); });
+ // After collapse: block still exists but children are gone; click it again to expand
+ const linksAfterCollapse = queryAllByRole('link');
+ if (linksAfterCollapse.length > 0) {
+ await act(async () => { fireEvent.click(linksAfterCollapse[0]); });
+ }
+ expect(linksAfterCollapse.length).toBeGreaterThanOrEqual(0);
+ });
+
+ test('renders with component having parameters in answer', async () => {
+ const answerWithParams = makeStoredAnswer({
+ answer: { q1: 1 },
+ identifier: 'intro_0',
+ componentName: 'intro',
+ trialOrder: 'root_0',
+ startTime: 0,
+ endTime: 100,
+ parameters: { key: 'val' },
+ });
+
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with component having parameters in config', async () => {
+ const configWithParams = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ components: {
+ intro: {
+ type: 'react-component',
+ path: 'intro.tsx',
+ response: [],
+ parameters: { key: 'val' },
+ },
+ },
+ sequence: minimalSequence,
+ });
+
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+});
+
+describe('StepsPanel random order rendering', () => {
+ test('renders shuffle icon for random order sequence block', async () => {
+ const randomSequence: Sequence = {
+ id: 'root',
+ orderPath: 'root',
+ order: 'random',
+ components: ['intro'],
+ skip: [],
+ };
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with randomized response options (covers hasRandomization radio/matrix paths)', async () => {
+ const configWithRandomization = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ components: {
+ intro: {
+ type: 'markdown',
+ path: 'intro.md',
+ response: [
+ {
+ id: 'r1', type: 'radio', prompt: 'Q1', options: ['A', 'B'], optionOrder: 'random',
+ },
+ {
+ id: 'r2', type: 'matrix-radio', prompt: 'Q2', answerOptions: ['A', 'B'], questionOptions: ['Q1'], questionOrder: 'random',
+ },
+ ],
+ },
+ },
+ sequence: minimalSequence,
+ });
+
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+});
+
+// ── excluded blocks / components tests ──────────────────────────────────────────
+
+describe('StepsPanel excluded blocks and components', () => {
+ // Study config: block1 has ['intro', 'survey'], participant only has ['intro']
+ const studyConfigWithExtraComponents = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ sequence: {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'block1',
+ orderPath: 'root-block1',
+ order: 'fixed',
+ components: ['intro', 'survey'],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ components: {
+ intro: { type: 'markdown', path: 'intro.md', response: [] },
+ survey: { type: 'markdown', path: 'survey.md', response: [] },
+ },
+ });
+
+ const participantWithExcludedComponent: Sequence = {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'block1',
+ orderPath: 'root-block1',
+ order: 'fixed',
+ components: ['intro'], // 'survey' is excluded
+ skip: [],
+ },
+ ],
+ skip: [],
+ };
+
+ test('renders excluded component from study sequence', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ // Study config: block1 has [nested1, nested2 (excluded)]
+ const studyConfigWithExcludedBlock = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ sequence: {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'block1',
+ orderPath: 'root-block1',
+ order: 'fixed',
+ components: [
+ {
+ id: 'nested1',
+ orderPath: 'root-block1-nested1',
+ order: 'fixed',
+ components: ['intro'],
+ skip: [],
+ },
+ {
+ id: 'nested2',
+ orderPath: 'root-block1-nested2',
+ order: 'fixed',
+ components: ['survey'],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ components: {
+ intro: { type: 'markdown', path: 'intro.md', response: [] },
+ survey: { type: 'markdown', path: 'survey.md', response: [] },
+ },
+ });
+
+ const participantWithExcludedBlock: Sequence = {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'block1',
+ orderPath: 'root-block1',
+ order: 'fixed',
+ components: [
+ {
+ id: 'nested1',
+ orderPath: 'root-block1-nested1',
+ order: 'fixed',
+ components: ['intro'],
+ skip: [],
+ },
+ // nested2 is excluded from participant sequence
+ ],
+ skip: [],
+ },
+ ],
+ skip: [],
+ };
+
+ test('renders excluded block with string children', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ // Excluded block with a nested sub-block
+ const studyConfigWithNestedExcludedBlock = makeStudyConfig({
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ },
+ sequence: {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'block1',
+ orderPath: 'root-block1',
+ order: 'fixed',
+ components: [
+ {
+ id: 'nested1',
+ orderPath: 'root-block1-nested1',
+ order: 'fixed',
+ components: ['intro'],
+ skip: [],
+ },
+ {
+ id: 'nested2',
+ orderPath: 'root-block1-nested2',
+ order: 'fixed',
+ components: [
+ {
+ id: 'deepBlock',
+ orderPath: 'root-block1-nested2-deepBlock',
+ order: 'fixed',
+ components: ['survey'],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ components: {
+ intro: { type: 'markdown', path: 'intro.md', response: [] },
+ survey: { type: 'markdown', path: 'survey.md', response: [] },
+ },
+ });
+
+ test('excluded block with nested sub-block children', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+});
+
+// ── nested expand tests ──────────────────────────────────────────────────────────
+
+describe('StepsPanel nested expand', () => {
+ // 3-level deep sequence: root > child block > grandchild block > intro
+ const deepSequence: Sequence = {
+ id: 'root',
+ orderPath: 'root',
+ order: 'fixed',
+ components: [
+ {
+ id: 'child',
+ orderPath: 'root-child',
+ order: 'fixed',
+ components: [
+ {
+ id: 'grandchild',
+ orderPath: 'root-child-grandchild',
+ order: 'fixed',
+ components: ['intro'],
+ skip: [],
+ },
+ ],
+ skip: [],
+ },
+ ],
+ skip: [],
+ };
+
+ test('collapse then expand nested block', async () => {
+ const { getAllByRole, queryAllByRole } = await act(async () => render(
+ ,
+ ));
+ // Click the root block (first link) to collapse all children
+ const links = getAllByRole('link');
+ await act(async () => { fireEvent.click(links[0]); });
+ // Now click root block again to expand (re-insert with nested expand logic)
+ const linksAfterCollapse = queryAllByRole('link');
+ if (linksAfterCollapse.length > 0) {
+ await act(async () => { fireEvent.click(linksAfterCollapse[0]); });
+ }
+ expect(queryAllByRole('link').length).toBeGreaterThan(0);
+ });
+});
+
+// ── utility tests ──────────────────────────────────────────────────────────────
+
+describe('StepsPanel tree walking', () => {
+ test('does not append answer-derived components for non-dynamic blocks', () => {
+ const block: Sequence = {
+ id: 'drawing100m',
+ order: 'fixed',
+ orderPath: 'root-0',
+ components: ['drawing100m', 'drawingFollowUp100M'],
+ skip: [],
+ interruptions: [],
+ };
+
+ const participantAnswers = {
+ drawing100m_3: {
+ componentName: 'drawing100m',
+ } as StoredAnswer,
+ };
+
+ expect(getDynamicComponentsForBlock(block, participantAnswers, 3)).toEqual([]);
+ });
+
+ test('appends answer-derived components for dynamic blocks', () => {
+ const block: Sequence = {
+ id: 'dynamicDrawing',
+ order: 'dynamic',
+ orderPath: 'root-0',
+ components: [],
+ skip: [],
+ interruptions: [],
+ };
+
+ const participantAnswers = {
+ dynamicDrawing_2_generatedA_0: {
+ componentName: 'generatedA',
+ } as StoredAnswer,
+ dynamicDrawing_2_generatedB_1: {
+ componentName: 'generatedB',
+ } as StoredAnswer,
+ };
+
+ expect(getDynamicComponentsForBlock(block, participantAnswers, 2)).toEqual(['generatedA', 'generatedB']);
+ });
+
+ test('does not include answers from similarly prefixed indices', () => {
+ const block: Sequence = {
+ id: 'dynamicDrawing',
+ order: 'dynamic',
+ orderPath: 'root-0',
+ components: [],
+ skip: [],
+ interruptions: [],
+ };
+
+ const participantAnswers = {
+ dynamicDrawing_2_generatedA_0: {
+ componentName: 'generatedA',
+ } as StoredAnswer,
+ dynamicDrawing_20_generatedWrong_0: {
+ componentName: 'generatedWrong',
+ } as StoredAnswer,
+ };
+
+ expect(getDynamicComponentsForBlock(block, participantAnswers, 2)).toEqual(['generatedA']);
+ });
+});
diff --git a/src/components/response/tests/RankingInput.spec.tsx b/src/components/response/tests/RankingInput.spec.tsx
new file mode 100644
index 0000000000..b1e1e20967
--- /dev/null
+++ b/src/components/response/tests/RankingInput.spec.tsx
@@ -0,0 +1,600 @@
+import React from 'react';
+import {
+ render, act, fireEvent, cleanup,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
+import { RankingInput } from '../RankingInput';
+import type { RankingResponse } from '../../../parser/types';
+
+// ── DnD handler capture ──────────────────────────────────────────────────────
+
+let capturedOnDragStart: ((e: DragStartEvent) => void) | undefined;
+let capturedOnDragEnd: ((e: DragEndEvent) => void) | undefined;
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('@dnd-kit/core', () => ({
+ DndContext: vi.fn(({
+ onDragStart,
+ onDragEnd,
+ children,
+ }: {
+ onDragStart?: (e: DragStartEvent) => void;
+ onDragEnd?: (e: DragEndEvent) => void;
+ children?: React.ReactNode;
+ }) => {
+ capturedOnDragStart = onDragStart;
+ capturedOnDragEnd = onDragEnd;
+ return React.createElement(React.Fragment, null, children);
+ }),
+ DragOverlay: ({ children }: { children?: React.ReactNode }) => (
+ React.createElement(React.Fragment, null, children ?? null)
+ ),
+ PointerSensor: class {},
+ KeyboardSensor: class {},
+ useSensor: vi.fn(),
+ useSensors: vi.fn(() => []),
+ useDroppable: vi.fn(() => ({ setNodeRef: vi.fn(), isOver: false })),
+ rectIntersection: vi.fn(),
+}));
+
+function arrayMoveMock(arr: unknown[], from: number, to: number): unknown[] {
+ const newArr = [...arr];
+ const [item] = newArr.splice(from, 1);
+ newArr.splice(to, 0, item!);
+ return newArr;
+}
+
+vi.mock('@dnd-kit/sortable', () => ({
+ arrayMove: vi.fn(arrayMoveMock),
+ SortableContext: ({ children }: { children?: React.ReactNode }) => (
+ React.createElement(React.Fragment, null, children)
+ ),
+ useSortable: vi.fn(() => ({
+ attributes: {},
+ listeners: {},
+ setNodeRef: vi.fn(),
+ transform: null,
+ transition: null,
+ isDragging: false,
+ })),
+ verticalListSortingStrategy: {},
+}));
+
+vi.mock('@dnd-kit/utilities', () => ({
+ CSS: { Transform: { toString: vi.fn(() => '') } },
+}));
+
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ Button: ({ children, onClick, disabled }: {
+ children?: React.ReactNode; onClick?: () => void; disabled?: boolean;
+ }) => React.createElement('button', { type: 'button', onClick, disabled }, children),
+ Flex: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ Group: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ Paper: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ Stack: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children),
+}));
+
+vi.mock('clsx', () => ({ default: (...args: unknown[]) => args.filter(Boolean).join(' ') }));
+
+vi.mock('../InputLabel', () => ({
+ InputLabel: ({ prompt }: { prompt: string }) => React.createElement('label', null, prompt),
+}));
+
+vi.mock('../OptionLabel', () => ({
+ OptionLabel: ({ label }: { label: string }) => React.createElement('span', null, label),
+}));
+
+vi.mock('../css/RankingDnd.module.css', () => ({
+ default: { item: 'item', itemDragging: 'itemDragging' },
+}));
+
+vi.mock('../../../store/store', () => ({
+ useStoreActions: () => ({ setRankingAnswers: vi.fn().mockReturnValue({}) }),
+ useStoreDispatch: () => vi.fn(),
+}));
+
+vi.mock('../../../utils/stringOptions', () => ({
+ parseStringOptions: (opts: (string | { value: string; label: string })[]) => opts.map((o) => (
+ typeof o === 'string' ? { value: o, label: o } : o
+ )),
+}));
+
+// ── fixtures ──────────────────────────────────────────────────────────────────
+
+const OPTIONS = ['Item A', 'Item B', 'Item C'];
+
+function makeResponse(type: 'ranking-sublist' | 'ranking-categorical' | 'ranking-pairwise', extra: Partial = {}): RankingResponse {
+ return {
+ type,
+ id: 'q1',
+ prompt: 'Rank these',
+ required: false,
+ options: OPTIONS,
+ ...extra,
+ } as RankingResponse;
+}
+
+const baseProps = {
+ index: 0,
+ disabled: false,
+ enumerateQuestions: false,
+};
+
+// ── lifecycle ─────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ capturedOnDragStart = undefined;
+ capturedOnDragEnd = undefined;
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── helper ────────────────────────────────────────────────────────────────────
+
+function makeDragEnd(
+ activeId: string,
+ overId: string | null,
+): DragEndEvent {
+ return {
+ active: { id: activeId, data: { current: undefined }, rect: { current: { initial: null, translated: null } } },
+ over: overId ? {
+ id: overId,
+ data: { current: undefined },
+ rect: {
+ width: 0, height: 0, left: 0, right: 0, top: 0, bottom: 0,
+ },
+ } : null,
+ collisions: null,
+ delta: { x: 0, y: 0 },
+ activatorEvent: new Event('pointerdown'),
+ } as DragEndEvent;
+}
+
+function makeDragStart(activeId: string): DragStartEvent {
+ return {
+ active: { id: activeId, data: { current: undefined }, rect: { current: { initial: null, translated: null } } },
+ activatorEvent: new Event('pointerdown'),
+ } as DragStartEvent;
+}
+
+// ══ RankingSublistComponent ══════════════════════════════════════════════════
+
+describe('RankingSublistComponent', () => {
+ test('renders with empty answer', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('renders with pre-filled answer (covers initialState with existing answer.value)', () => {
+ const answer = { value: { 'Item A': '0', 'Item B': '1' } };
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('renders with numItems that truncates selected (covers slice branch)', () => {
+ const answer = { value: { 'Item A': '0', 'Item B': '1', 'Item C': '2' } };
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('handleDragStart sets activeId', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragStart?.(makeDragStart('Item A'));
+ });
+ // DragOverlay renders activeItem when activeId is set — just verify no crash
+ expect(capturedOnDragStart).toBeDefined();
+ });
+
+ test('handleDragEnd: no over → early return', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', null));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: disabled → early return', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'selected'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: fromSelected to selected (reorder)', async () => {
+ const answer = { value: { 'Item A': '0', 'Item B': '1' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'Item B'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: fromSelected to selected zone (reorder to end)', async () => {
+ const answer = { value: { 'Item A': '0', 'Item B': '1' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'selected'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: fromUnassigned to selected', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item C', 'selected'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: fromUnassigned to selected but numItems exceeded', async () => {
+ const answer = { value: { 'Item A': '0' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item B', 'selected'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: fromSelected to unassigned', async () => {
+ const answer = { value: { 'Item A': '0' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'unassigned'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: else path (item not in either list)', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('NonExistent', 'unassigned'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+});
+
+// ══ RankingCategoricalComponent ══════════════════════════════════════════════
+
+describe('RankingCategoricalComponent', () => {
+ test('renders with empty answer', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('renders with pre-filled categorical answer', () => {
+ const answer = { value: { 'Item A': 'HIGH', 'Item B': 'MEDIUM', 'Item C': 'LOW' } };
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('handleDragStart sets activeId', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragStart?.(makeDragStart('Item A'));
+ });
+ expect(capturedOnDragStart).toBeDefined();
+ });
+
+ test('handleDragEnd: no over → early return', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', null));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: sourceCategory === targetCategory → early return', async () => {
+ const answer = { value: { 'Item A': 'HIGH' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'HIGH'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: invalid targetCategory → early return', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'INVALID_ZONE'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: move item from unassigned to HIGH', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'HIGH'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: numItems exceeded for target category', async () => {
+ const answer = { value: { 'Item A': 'HIGH' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ // Move from unassigned to HIGH which already has 1 item = numItems
+ capturedOnDragEnd?.(makeDragEnd('Item B', 'HIGH'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: move item from HIGH to MEDIUM', async () => {
+ const answer = { value: { 'Item A': 'HIGH' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'MEDIUM'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+});
+
+// ══ RankingPairwiseComponent ═════════════════════════════════════════════════
+
+describe('RankingPairwiseComponent', () => {
+ test('renders with empty answer', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('renders with pre-filled pairwise answer (covers pair[position] rendering)', () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high', 'Item B_1': 'pair-0-low' } };
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeDefined();
+ });
+
+ test('Add New Pair button click adds a pair', async () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ const buttons = getAllByRole('button');
+ const addBtn = buttons.find((b) => b.textContent === 'Add New Pair');
+ expect(addBtn).toBeDefined();
+ await act(async () => { fireEvent.click(addBtn!); });
+ // Should have added a new pair without error
+ expect(getAllByRole('button').length).toBeGreaterThan(0);
+ });
+
+ test('Add New Pair is no-op when disabled', async () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ const buttons = getAllByRole('button');
+ const addBtn = buttons.find((b) => b.textContent === 'Add New Pair');
+ expect(addBtn).toBeDefined();
+ await act(async () => { fireEvent.click(addBtn!); });
+ });
+
+ test('handleDragStart sets activeId', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragStart?.(makeDragStart('Item A'));
+ });
+ expect(capturedOnDragStart).toBeDefined();
+ });
+
+ test('handleDragEnd: no over → early return', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', null));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: drop to unassigned removes item from pair', async () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A_0', 'unassigned'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: drop from unassigned to pair-high', async () => {
+ render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'pair-0-high'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: position already occupied (one item limit)', async () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ // Try to drop another item into pair-0-high which already has an item
+ capturedOnDragEnd?.(makeDragEnd('Item B', 'pair-0-high'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: same item in opposite position error', async () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high' } };
+ render(
+ ,
+ );
+ await act(async () => {
+ // Try to drop Item A into the opposite position (pair-0-low)
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'pair-0-low'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: duplicate pair detection (covers checkForDuplicatePair)', async () => {
+ // pair-0 has Item A (high) vs Item B (low); try to create pair-1 with same combo
+ const answer = {
+ value: {
+ 'Item A_0': 'pair-0-high',
+ 'Item B_1': 'pair-0-low',
+ },
+ };
+ render(
+ ,
+ );
+ // Add a new pair first
+ await act(async () => {
+ const buttons = document.querySelectorAll('button');
+ const addBtn = Array.from(buttons).find((b) => b.textContent === 'Add New Pair');
+ if (addBtn) fireEvent.click(addBtn);
+ });
+ // Now drag Item A to pair-1-high — creates a temp with Item A_temp on pair-1
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A', 'pair-1-high'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleDragEnd: move existing positioned item to new pair position', async () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high' } };
+ render(
+ ,
+ );
+ // Move an already-placed item (non-unassigned) to a different position
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item A_0', 'pair-0-low'));
+ });
+ expect(capturedOnDragEnd).toBeDefined();
+ });
+
+ test('handleRemovePair via X button', async () => {
+ const answer = { value: { 'Item A_0': 'pair-0-high' } };
+ const { getAllByRole } = render(
+ ,
+ );
+ const buttons = getAllByRole('button');
+ const xButton = buttons.find((b) => b.textContent === 'X');
+ expect(xButton).toBeDefined();
+ await act(async () => { fireEvent.click(xButton!); });
+ // After removing pair, pair count decreases
+ expect(getAllByRole('button').length).toBeGreaterThan(0);
+ });
+
+ test('handleRemovePair is no-op when disabled', async () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ const buttons = getAllByRole('button');
+ const xButton = buttons.find((b) => b.textContent === 'X');
+ if (xButton) {
+ await act(async () => { fireEvent.click(xButton); });
+ }
+ });
+});
+
+// ══ RankingInput — main component ═══════════════════════════════════════════
+
+describe('RankingInput — main component', () => {
+ test('renders error text when error is set', async () => {
+ // Trigger an error via numItems exceeded
+ const answer = { value: { 'Item A': '0' } };
+ const { container } = render(
+ ,
+ );
+ await act(async () => {
+ capturedOnDragEnd?.(makeDragEnd('Item B', 'selected'));
+ });
+ expect(container).toBeDefined();
+ });
+
+ test('renders secondaryText when provided', () => {
+ const response = makeResponse('ranking-sublist', { secondaryText: 'secondary hint' });
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain('secondary hint');
+ });
+
+ test('renders prompt when non-empty', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain('Rank these');
+ });
+});
diff --git a/src/components/response/tests/ResponseBlock.spec.tsx b/src/components/response/tests/ResponseBlock.spec.tsx
new file mode 100644
index 0000000000..1cfd8ad470
--- /dev/null
+++ b/src/components/response/tests/ResponseBlock.spec.tsx
@@ -0,0 +1,276 @@
+import { ReactNode } from 'react';
+import { render, act, fireEvent } from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ describe, expect, test, vi,
+} from 'vitest';
+import type { IndividualComponent } from '../../../parser/types';
+import { ResponseBlock } from '../ResponseBlock';
+import { makeStoredAnswer } from '../../../tests/utils';
+
+// ── mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }: { children?: ReactNode }) => {children}
,
+ Button: ({ children, disabled, onClick }: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => (
+
+ ),
+ Group: ({ children }: { children?: ReactNode }) => {children}
,
+ Text: ({ children }: { children?: ReactNode }) => {children}
,
+ ThemeIcon: ({ children }: { children?: ReactNode }) => {children},
+}));
+
+vi.mock('react-router', () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+ useParams: vi.fn(() => ({})),
+ useSearchParams: vi.fn(() => [new URLSearchParams()]),
+}));
+
+vi.mock('@trrack/core', () => ({
+ Registry: {
+ create: vi.fn(() => ({ register: vi.fn(() => vi.fn()) })),
+ },
+ initializeTrrack: vi.fn(() => ({
+ apply: vi.fn(),
+ graph: { backend: {} },
+ })),
+}));
+
+vi.mock('lodash.isequal', () => ({ default: vi.fn(() => true) }));
+
+vi.mock('../../../store/store', () => ({
+ useFlatSequence: vi.fn(() => [{ id: 'trial1', component: 'trial1' }]),
+ useStoreDispatch: vi.fn(() => vi.fn()),
+ useStoreActions: vi.fn(() => ({
+ updateResponseBlockValidation: vi.fn((v: unknown) => v),
+ saveIncorrectAnswer: vi.fn((v: unknown) => v),
+ setResponseSubmitAttempt: vi.fn((v: unknown) => v),
+ setStimulusSubmitAttempt: vi.fn((v: unknown) => v),
+ })),
+ useStoreSelector: vi.fn((selector: (s: Record) => unknown) => selector({
+ answers: {},
+ analysisProvState: {},
+ clickedPrevious: false,
+ modes: { dataCollectionEnabled: true },
+ reactiveAnswers: {},
+ matrixAnswers: {},
+ rankingAnswers: {},
+ responseSubmitAttempted: { trial1_0: false },
+ sequence: {
+ order: 'fixed', orderPath: '', components: [], skip: [],
+ },
+ trialValidation: { trial1_0: {} },
+ completed: false,
+ })),
+}));
+
+vi.mock('../../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: vi.fn(() => ({
+ uiConfig: {
+ nextButtonLocation: 'belowStimulus',
+ provideFeedback: false,
+ allowFailedTraining: true,
+ trainingAttempts: 2,
+ nextButtonText: 'Next',
+ nextOnEnter: false,
+ },
+ components: {},
+ })),
+}));
+
+vi.mock('../../../store/hooks/useStoredAnswer', () => ({
+ useStoredAnswer: vi.fn(() => ({
+ formOrder: { response: ['q1'] },
+ questionOrders: {},
+ optionOrders: {},
+ })),
+}));
+
+vi.mock('../../../store/hooks/useWindowEvents', () => ({
+ useWindowEvents: vi.fn(() => ({ current: [] })),
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useCurrentStep: vi.fn(() => 0),
+ useCurrentIdentifier: vi.fn(() => 'trial1_0'),
+ useStudyId: vi.fn(() => 'test-study'),
+}));
+
+vi.mock('../utils', () => ({
+ generateInitFields: vi.fn(() => ({})),
+ mergeReactiveAnswers: vi.fn((_: unknown, values: unknown) => values),
+ useAnswerField: vi.fn(() => ({
+ values: {},
+ isValid: vi.fn(() => true),
+ setValues: vi.fn(),
+ setInitialValues: vi.fn(),
+ reset: vi.fn(),
+ getInputProps: vi.fn(() => ({ value: '', onChange: vi.fn() })),
+ })),
+ usesStandaloneDontKnowField: vi.fn(() => false),
+}));
+
+vi.mock('../../../utils/correctAnswer', () => ({
+ responseAnswerIsCorrect: vi.fn(() => true),
+}));
+
+vi.mock('../customResponseModules', () => ({
+ getCustomResponseModule: vi.fn(() => null),
+ getCustomResponseModuleLoadError: vi.fn(() => null),
+}));
+
+vi.mock('../ResponseSwitcher', () => ({
+ ResponseSwitcher: ({ response }: { response: { type: string } }) => (
+ {response.type}
+ ),
+}));
+
+vi.mock('../FeedbackAlert', () => ({
+ FeedbackAlert: () => null,
+}));
+
+vi.mock('../../NextButton', () => ({
+ NextButton: ({ label, disabled, checkAnswer }: { label?: string; disabled?: boolean; checkAnswer?: ReactNode }) => (
+
+ {checkAnswer}
+
+
+ ),
+}));
+
+// ── fixture ───────────────────────────────────────────────────────────────────
+
+const baseConfig: IndividualComponent = {
+ type: 'questionnaire',
+ response: [
+ {
+ type: 'shortText', id: 'q1', prompt: 'Question 1', required: false,
+ },
+ ],
+};
+
+// ── ResponseBlock ─────────────────────────────────────────────────────────────
+
+describe('ResponseBlock', () => {
+ test('renders without error', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain(' {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('switcher-shortText');
+ });
+
+ test('shows NextButton when location matches nextButtonLocation', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('Next');
+ });
+
+ test('omits NextButton when location does not match nextButtonLocation', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).not.toContain('Next');
+ });
+
+ test('skips ResponseSwitcher when response is hidden', () => {
+ const hiddenConfig = {
+ ...baseConfig,
+ response: [
+ {
+ type: 'shortText', id: 'q1', prompt: 'Q1', required: false, hidden: true,
+ },
+ ],
+ } as IndividualComponent;
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).not.toContain('switcher-shortText');
+ });
+
+ test('renders with provided style prop without error', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('switcher-shortText');
+ });
+
+ test('uses custom nextButtonText from config', () => {
+ const configWithText = {
+ ...baseConfig,
+ nextButtonText: 'Submit',
+ } as IndividualComponent;
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('Submit');
+ });
+
+ test('renders Check Answer button when provideFeedback and correctAnswer exist', () => {
+ const configWithFeedback = {
+ ...baseConfig,
+ provideFeedback: true,
+ correctAnswer: [{ id: 'q1', answer: 'correct' }],
+ } as IndividualComponent;
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('Check Answer');
+ });
+
+ test('does not add required=true for textOnly responses', () => {
+ const textOnlyConfig = {
+ type: 'questionnaire',
+ response: [{ type: 'textOnly', id: 'q1', prompt: 'Read this.' }],
+ } as IndividualComponent;
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('switcher-textOnly');
+ });
+
+ test('initialises from status.answer when status prop is provided', () => {
+ const status = makeStoredAnswer({ answer: { q1: 'hello' } });
+ // render (not SSR) so useEffect fires and reads status.answer
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector('div')).toBeTruthy();
+ });
+
+ test('clicking Check Answer calls checkAnswerProvideFeedback', async () => {
+ const configWithFeedback = {
+ ...baseConfig,
+ provideFeedback: true,
+ correctAnswer: [{ id: 'q1', answer: 'correct' }],
+ } as IndividualComponent;
+ const { container } = render(
+
,
+ );
+ const checkBtn = Array.from(container.querySelectorAll('button')).find(
+ (b) => b.textContent === 'Check Answer',
+ );
+ await act(async () => { fireEvent.click(checkBtn!); });
+ // After a correct answer the Check Answer button should become disabled
+ expect(checkBtn).toHaveProperty('disabled', true);
+ });
+
+ test('nextOnEnter=true registers keydown listener that fires checkAnswerProvideFeedback', async () => {
+ const configWithEnter = {
+ ...baseConfig,
+ nextOnEnter: true,
+ provideFeedback: true,
+ correctAnswer: [{ id: 'q1', answer: 'correct' }],
+ } as IndividualComponent;
+ render(
);
+ await act(async () => { fireEvent.keyDown(window, { key: 'Enter' }); });
+ // Verifies the listener registered without throwing
+ });
+});
diff --git a/src/components/response/tests/ResponseInput.spec.tsx b/src/components/response/tests/ResponseInput.spec.tsx
new file mode 100644
index 0000000000..d9cf20018e
--- /dev/null
+++ b/src/components/response/tests/ResponseInput.spec.tsx
@@ -0,0 +1,1234 @@
+import { ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ render, act, cleanup,
+} from '@testing-library/react';
+import {
+ afterEach, describe, expect, test, vi,
+} from 'vitest';
+import { useMove } from '@mantine/hooks';
+import { generateSliderBreakValues } from '../sliderBreaks';
+import { HorizontalHandler } from '../HorizontalHandler';
+import { OptionLabel } from '../OptionLabel';
+import { InputLabel } from '../InputLabel';
+import { TextOnlyInput } from '../TextOnlyInput';
+import { StringInput } from '../StringInput';
+import { TextAreaInput } from '../TextAreaInput';
+import { NumericInput } from '../NumericInput';
+import { ReactiveInput } from '../ReactiveInput';
+import { DropdownInput } from '../DropdownInput';
+import { CheckBoxInput } from '../CheckBoxInput';
+import { ButtonsInput } from '../ButtonsInput';
+import { RadioInput } from '../RadioInput';
+import { LikertInput } from '../LikertInput';
+import { SliderInput } from '../SliderInput';
+import { MatrixInput } from '../MatrixInput';
+import { RankingInput } from '../RankingInput';
+import { ResponseSwitcher } from '../ResponseSwitcher';
+import { FeedbackAlert } from '../FeedbackAlert';
+import type {
+ ButtonsResponse,
+ CheckboxResponse,
+ DropdownResponse,
+ IndividualComponent,
+ MatrixRadioResponse,
+ RadioResponse,
+ ReactiveResponse,
+ Response,
+ ShortTextResponse,
+ SliderResponse,
+} from '../../../parser/types';
+
+// ── mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('@mantine/core', () => {
+ function Div({ children }: { children?: ReactNode }) {
+ return
{children}
;
+ }
+ function Span({ children }: { children?: ReactNode }) {
+ return
{children};
+ }
+ const Input = Object.assign(
+ ({ children }: { children?: ReactNode }) =>
{children}
,
+ {
+ Wrapper: ({ children, label, description }: { children?: ReactNode; label?: ReactNode; description?: ReactNode }) => (
+
+ {label}
+ {description}
+ {children}
+
+ ),
+ Placeholder: ({ children }: { children?: ReactNode }) =>
{children},
+ },
+ );
+ const List = Object.assign(
+ ({ children }: { children?: ReactNode }) =>
,
+ { Item: ({ children }: { children?: ReactNode }) =>
{children} },
+ );
+ const Radio = Object.assign(
+ ({ label, value, children }: { label?: ReactNode; value?: string; children?: ReactNode }) => (
+
+ {label}
+ {children}
+
+ ),
+ {
+ Group: ({ children, label, description }: { children?: ReactNode; label?: ReactNode; description?: ReactNode }) => (
+
+ {label}
+ {description}
+ {children}
+
+ ),
+ Card: ({ children, value }: { children?: ReactNode; value?: string }) => (
+
{children}
+ ),
+ },
+ );
+ const Checkbox = Object.assign(
+ ({ label, value, checked }: { label?: ReactNode; value?: string; checked?: boolean }) => (
+
{label}
+ ),
+ {
+ Group: ({ children, label, description }: { children?: ReactNode; label?: ReactNode; description?: ReactNode }) => (
+
+ {label}
+ {description}
+ {children}
+
+ ),
+ },
+ );
+ return {
+ Group: Div,
+ Stack: Div,
+ Flex: Div,
+ Box: Div,
+ Text: Span,
+ Tooltip: ({ children, label }: { children?: ReactNode; label?: ReactNode }) => (
+
{children}
+ ),
+ Alert: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
+
+ ),
+ Anchor: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
+
{children}
+ ),
+ Divider: () =>
,
+ Input,
+ List,
+ Radio,
+ Checkbox,
+ FocusTrap: Div,
+ NumberInput: ({ label, placeholder, description }: { label?: ReactNode; placeholder?: string; description?: ReactNode }) => (
+
+ {label}
+ {description}
+
+
+ ),
+ TextInput: ({ label, placeholder, description }: { label?: ReactNode; placeholder?: string; description?: ReactNode }) => (
+
+ {label}
+ {description}
+
+
+ ),
+ Textarea: ({ label, placeholder, description }: { label?: ReactNode; placeholder?: string; description?: ReactNode }) => (
+
+ {label}
+ {description}
+
+
+ ),
+ Select: ({ label, description, data }: { label?: ReactNode; description?: ReactNode; data?: { label: string }[] }) => (
+
+ {label}
+ {description}
+
+
+ ),
+ MultiSelect: ({ label, description, data }: { label?: ReactNode; description?: ReactNode; data?: { label: string }[] }) => (
+
+ {label}
+ {description}
+
+
+ ),
+ Slider: ({ min, max }: { min?: number; max?: number }) => (
+
+ ),
+ Paper: ({ children }: { children?: ReactNode }) =>
{children}
,
+ Button: ({ children }: { children?: ReactNode }) =>
,
+ rem: (v: unknown) => String(v),
+ };
+});
+
+vi.mock('@tabler/icons-react', () => ({
+ IconInfoCircle: () =>
icon-info,
+}));
+
+vi.mock('../../ReactMarkdownWrapper', () => ({
+ ReactMarkdownWrapper: ({ text }: { text: string }) =>
{text},
+}));
+
+vi.mock('@mantine/hooks', () => ({
+ useMove: vi.fn(() => ({ ref: { current: null } })),
+}));
+
+vi.mock('../sliderBreaks', () => ({
+ generateSliderBreakValues: vi.fn(() => []),
+}));
+
+vi.mock('../../../store/store', () => ({
+ useStoreDispatch: vi.fn(() => vi.fn()),
+ useStoreActions: vi.fn(() => ({
+ setMatrixAnswersRadio: vi.fn(),
+ setMatrixAnswersCheckbox: vi.fn(),
+ setRankingAnswers: vi.fn(),
+ toggleShowHelpText: vi.fn(),
+ incrementHelpCounter: vi.fn(),
+ })),
+ useStoreSelector: vi.fn((selector: (s: Record
) => unknown) => selector({
+ sequence: { order: 'fixed', components: [] },
+ completed: false,
+ })),
+}));
+
+vi.mock('../../../utils/responseOptions', () => ({
+ getMatrixAnswerOptions: vi.fn((response) => (response.answerOptions || []).map((o: string) => ({ label: o, value: o }))),
+ isMatrixDontKnowValue: vi.fn(() => false),
+ MATRIX_DONT_KNOW_OPTION: { value: '__dontKnow', label: "I don't know" },
+}));
+
+vi.mock('@dnd-kit/core', () => ({
+ DndContext: ({ children }: { children?: ReactNode }) => {children}
,
+ DragOverlay: ({ children }: { children?: ReactNode }) => {children}
,
+ useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
+ useSensor: vi.fn(),
+ useSensors: vi.fn(() => []),
+ PointerSensor: class { },
+ KeyboardSensor: class { },
+ rectIntersection: vi.fn(),
+}));
+
+vi.mock('@dnd-kit/sortable', () => ({
+ SortableContext: ({ children }: { children?: ReactNode }) => {children}
,
+ useSortable: () => ({
+ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, transition: null, isDragging: false,
+ }),
+ arrayMove: vi.fn((arr: unknown[], from: number, to: number) => {
+ const result = [...arr];
+ result.splice(to, 0, result.splice(from, 1)[0]);
+ return result;
+ }),
+ verticalListSortingStrategy: {},
+}));
+
+vi.mock('@dnd-kit/utilities', () => ({
+ CSS: { Transform: { toString: vi.fn(() => '') } },
+}));
+
+vi.mock('clsx', () => ({ default: vi.fn((...args: unknown[]) => args.filter(Boolean).join(' ')) }));
+
+vi.mock('../../../store/hooks/useStoredAnswer', () => ({
+ useStoredAnswer: vi.fn(() => ({ questionOrders: {}, optionOrders: {} })),
+}));
+
+vi.mock('../utils', () => ({
+ generateErrorMessage: vi.fn(() => null),
+ DONT_KNOW_DEFAULT_VALUE: "I don't know",
+ normalizeCheckboxDontKnowValue: vi.fn((v: string[]) => v),
+ usesStandaloneDontKnowField: vi.fn(() => false),
+ getDefaultFieldValue: vi.fn(() => null),
+}));
+
+vi.mock('../../../utils/stringOptions', () => ({
+ parseStringOptions: vi.fn((options: (string | { label: string; value: string })[]) => options.map((o) => (typeof o === 'string' ? { label: o, value: o } : o))),
+ parseStringOptionValue: vi.fn((o: string | { value: string }) => (typeof o === 'string' ? o : o.value)),
+}));
+
+vi.mock('react-router', () => ({
+ useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
+}));
+
+vi.mock('../../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: vi.fn(() => ({
+ uiConfig: { enumerateQuestions: false, responseDividers: false },
+ components: {},
+ })),
+}));
+
+vi.mock('../../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: vi.fn(() => false),
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useCurrentStep: vi.fn(() => 0),
+}));
+
+vi.mock('../../../utils/fetchStylesheet', () => ({
+ useFetchStylesheet: vi.fn(),
+}));
+
+vi.mock('../../../utils/getSequenceFlatMap', () => ({
+ getSequenceFlatMap: vi.fn(() => []),
+}));
+
+vi.mock('../customResponseModules', () => ({
+ getCustomResponseModule: vi.fn(() => null),
+ getCustomResponseModuleLoadError: vi.fn(() => null),
+}));
+
+// ── HorizontalHandler ─────────────────────────────────────────────────────────
+
+describe('HorizontalHandler', () => {
+ test('renders Group (horizontal) when horizontal=true', () => {
+ const html = renderToStaticMarkup(
+
+ item
+ ,
+ );
+ // Group mock renders a ; Stack also renders a
— content is present either way
+ expect(html).toContain('item');
+ });
+
+ test('renders Stack (vertical) when horizontal=false', () => {
+ const html = renderToStaticMarkup(
+
+ item
+ ,
+ );
+ expect(html).toContain('item');
+ });
+});
+
+// ── OptionLabel ───────────────────────────────────────────────────────────────
+
+describe('OptionLabel', () => {
+ test('renders label text', () => {
+ const html = renderToStaticMarkup(
);
+ expect(html).toContain('Option A');
+ });
+
+ test('button mode renders label in Text component', () => {
+ const html = renderToStaticMarkup(
);
+ expect(html).toContain('Click me');
+ });
+
+ test('shows info icon when infoText is provided', () => {
+ const html = renderToStaticMarkup(
);
+ expect(html).toContain('icon-info');
+ expect(html).toContain('Extra info');
+ });
+
+ test('no info icon when infoText is absent', () => {
+ const html = renderToStaticMarkup(
);
+ expect(html).not.toContain('icon-info');
+ });
+});
+
+// ── InputLabel ────────────────────────────────────────────────────────────────
+
+describe('InputLabel', () => {
+ test('renders prompt text', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('What is your name?');
+ });
+
+ test('shows index prefix when enumerateQuestions=true', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('3.');
+ });
+
+ test('no index prefix when enumerateQuestions=false', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).not.toContain('3.');
+ });
+
+ test('shows info icon when infoText provided', () => {
+ const html = renderToStaticMarkup(
+
,
+ );
+ expect(html).toContain('icon-info');
+ });
+});
+
+// ── TextOnlyInput ─────────────────────────────────────────────────────────────
+
+describe('TextOnlyInput', () => {
+ test('renders the response prompt', () => {
+ const html = renderToStaticMarkup(
+
[0]['response']}
+ />,
+ );
+ expect(html).toContain('Read this carefully.');
+ });
+});
+
+// ── StringInput ───────────────────────────────────────────────────────────────
+
+describe('StringInput', () => {
+ const base: ShortTextResponse = {
+ type: 'shortText',
+ id: 'q1',
+ prompt: 'Enter your name',
+ required: false,
+ placeholder: 'Name here',
+ };
+
+ test('renders prompt label and text input', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Enter your name');
+ expect(html).toContain('Name here');
+ });
+
+ test('renders without prompt when prompt is empty', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // No label rendered
+ expect(html).not.toContain('Enter your name');
+ });
+});
+
+// ── TextAreaInput ─────────────────────────────────────────────────────────────
+
+describe('TextAreaInput', () => {
+ test('renders prompt and textarea', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Describe your experience');
+ expect(html).toContain('Write here');
+ });
+});
+
+// ── NumericInput ──────────────────────────────────────────────────────────────
+
+describe('NumericInput', () => {
+ test('renders prompt and number input', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Enter your age');
+ expect(html).toContain('Age');
+ });
+});
+
+// ── ReactiveInput ─────────────────────────────────────────────────────────────
+
+describe('ReactiveInput', () => {
+ const base: ReactiveResponse = {
+ type: 'reactive',
+ id: 'q1',
+ prompt: 'Selected items',
+ required: false,
+ };
+
+ test('renders list items from answer.value array', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('apple');
+ expect(html).toContain('banana');
+ });
+
+ test('renders key:value pairs from object answer', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('key1: val1');
+ });
+
+ test('renders nothing when answer.value is undefined', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // Prompt still shows but no list
+ expect(html).toContain('Selected items');
+ expect(html).not.toContain(' {
+ const base: DropdownResponse = {
+ type: 'dropdown',
+ id: 'q1',
+ prompt: 'Choose a color',
+ required: false,
+ options: ['Red', 'Blue', 'Green'],
+ placeholder: 'Pick one',
+ };
+
+ test('renders single Select with options', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Choose a color');
+ expect(html).toContain('Red');
+ expect(html).toContain('Blue');
+ });
+
+ test('renders MultiSelect when maxSelections > 1', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{ value: '' }}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('data-multiselect');
+ });
+});
+
+// ── CheckBoxInput ─────────────────────────────────────────────────────────────
+
+describe('CheckBoxInput', () => {
+ const base: CheckboxResponse = {
+ type: 'checkbox',
+ id: 'q1',
+ prompt: 'Select all that apply',
+ required: false,
+ options: ['A', 'B', 'C'],
+ horizontal: false,
+ };
+
+ test('renders prompt and checkbox options', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Select all that apply');
+ expect(html).toContain('A');
+ expect(html).toContain('B');
+ expect(html).toContain('C');
+ });
+
+ test('renders "Other" checkbox label when withOther=true and horizontal=true', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{ value: [] }}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ // horizontal=true → label is the literal string 'Other'
+ expect(html).toContain('Other');
+ });
+});
+
+// ── ButtonsInput ──────────────────────────────────────────────────────────────
+
+describe('ButtonsInput', () => {
+ const base: ButtonsResponse = {
+ type: 'buttons',
+ id: 'q1',
+ prompt: 'Pick one',
+ required: false,
+ options: ['Yes', 'No', 'Maybe'],
+ };
+
+ test('renders prompt and button options', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Pick one');
+ expect(html).toContain('Yes');
+ expect(html).toContain('No');
+ expect(html).toContain('Maybe');
+ });
+});
+
+// ── RadioInput ────────────────────────────────────────────────────────────────
+
+describe('RadioInput', () => {
+ const base: RadioResponse = {
+ type: 'radio',
+ id: 'q1',
+ prompt: 'How satisfied are you?',
+ required: false,
+ options: ['Very', 'Somewhat', 'Not at all'],
+ horizontal: false,
+ };
+
+ test('renders prompt and radio options', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('How satisfied are you?');
+ expect(html).toContain('Very');
+ expect(html).toContain('Somewhat');
+ expect(html).toContain('Not at all');
+ });
+
+ test('renders "Other" option when withOther=true and horizontal=true', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ // horizontal=true → "Other" text rendered via Other
+ expect(html).toContain('Other');
+ });
+});
+
+// ── LikertInput ───────────────────────────────────────────────────────────────
+
+describe('LikertInput', () => {
+ test('generates numeric options from numItems', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ // Options 1-5 should be rendered
+ expect(html).toContain('1');
+ expect(html).toContain('5');
+ });
+
+ test('respects start and spacing values', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ // 0, 2, 4
+ expect(html).toContain('0');
+ expect(html).toContain('2');
+ expect(html).toContain('4');
+ });
+});
+
+// ── SliderInput ───────────────────────────────────────────────────────────────
+
+describe('SliderInput', () => {
+ const baseOptions = [{ label: 'Low', value: 0 }, { label: 'High', value: 100 }];
+
+ const base: SliderResponse = {
+ type: 'slider',
+ id: 'q1',
+ prompt: 'Rate your effort',
+ required: false,
+ options: baseOptions,
+ };
+
+ test('renders prompt label', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Rate your effort');
+ });
+
+ test('renders standard Slider component when smeqStyle is false', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-slider');
+ });
+
+ test('renders smeq vertical layout with option labels when smeqStyle=true', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ // smeq renders option labels as text boxes
+ expect(html).toContain('Low');
+ expect(html).toContain('High');
+ // no standard Slider mock rendered
+ expect(html).not.toContain('data-slider');
+ });
+
+ test('renders smeq mark elements when generateSliderBreakValues returns non-empty', () => {
+ // smeq block renders mark elements when labelValues is non-empty
+ vi.mocked(generateSliderBreakValues).mockReturnValueOnce([25, 50, 75]);
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ disabled={false}
+ answer={{}}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Low');
+ });
+
+ test('covers useMove callback body when it is invoked during render', async () => {
+ afterEach(() => { cleanup(); });
+ // Override useMove to call the callback synchronously after component mounts
+ let capturedCallback: ((pos: { x: number; y: number }) => void) | null = null;
+ vi.mocked(useMove).mockImplementationOnce((fn: (pos: { x: number; y: number }) => void) => {
+ capturedCallback = fn;
+ return { ref: { current: null }, active: false };
+ });
+ const mockOnChange = vi.fn();
+ await act(async () => render(
+ [0]['response']}
+ disabled={false}
+ answer={{ onChange: mockOnChange } as Parameters[0]['answer']}
+ index={1}
+ enumerateQuestions={false}
+ />,
+ ));
+ // Call the captured callback to exercise the drag handler
+ act(() => { capturedCallback?.({ x: 0, y: 0.5 }); });
+ expect(mockOnChange).toHaveBeenCalled();
+ cleanup();
+ });
+});
+
+// ── MatrixInput ───────────────────────────────────────────────────────────────
+
+describe('MatrixInput', () => {
+ const base: MatrixRadioResponse = {
+ type: 'matrix-radio',
+ id: 'q1',
+ prompt: 'Please rate each item',
+ required: false,
+ questionOptions: ['Q1', 'Q2'],
+ answerOptions: ['Agree', 'Disagree'],
+ };
+
+ test('renders prompt', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Please rate each item');
+ });
+
+ test('renders column header labels (answer options)', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Agree');
+ expect(html).toContain('Disagree');
+ });
+
+ test('renders row labels (question options)', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Q1');
+ expect(html).toContain('Q2');
+ });
+
+ test('renders matrix-checkbox type using Checkbox cells', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ answer={{ value: {} }}
+ index={1}
+ disabled={false}
+ enumerateQuestions={false}
+ />,
+ );
+ // matrix-checkbox uses Checkbox cells, matrix-radio uses Radio.Group
+ expect(html).toContain('Q1');
+ });
+});
+
+// ── RankingInput ──────────────────────────────────────────────────────────────
+
+describe('RankingInput', () => {
+ const baseOptions = ['Item A', 'Item B', 'Item C'];
+
+ const makeRankingResponse = (type: 'ranking-sublist' | 'ranking-categorical' | 'ranking-pairwise') => ({
+ type,
+ id: 'q1',
+ prompt: 'Rank these items',
+ required: false,
+ options: baseOptions,
+ });
+
+ test('renders prompt for ranking-sublist', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ answer={{ value: {} }}
+ index={1}
+ disabled={false}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Rank these items');
+ });
+
+ test('renders options in ranking-sublist', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ answer={{ value: {} }}
+ index={1}
+ disabled={false}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Item A');
+ expect(html).toContain('Item B');
+ expect(html).toContain('Item C');
+ });
+
+ test('renders ranking-categorical with options', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ answer={{ value: {} }}
+ index={1}
+ disabled={false}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Item A');
+ });
+
+ test('renders ranking-pairwise with Add New Pair button', () => {
+ const html = renderToStaticMarkup(
+ [0]['response']}
+ answer={{ value: {} }}
+ index={1}
+ disabled={false}
+ enumerateQuestions={false}
+ />,
+ );
+ expect(html).toContain('Add New Pair');
+ });
+});
+
+// ── FeedbackAlert ─────────────────────────────────────────────────────────────
+
+describe('FeedbackAlert', () => {
+ const baseResponse = { type: 'shortText', id: 'q1', prompt: 'Q' } as Response;
+ const baseAlertConfig = {
+ q1: {
+ visible: true,
+ title: 'Incorrect Answer',
+ message: 'Please try again. You have 1 attempt left.',
+ color: 'red',
+ },
+ };
+
+ test('returns null when visible is false', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toBe('');
+ });
+
+ test('renders alert title and message when visible', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Incorrect Answer');
+ expect(html).toContain('Please try again');
+ });
+
+ test('shows "review the help text" link when message contains "Please try again"', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('review the help text');
+ });
+
+ test('does not show "review the help text" for other messages', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain('review the help text');
+ });
+
+ test('shows correct answer when attemptsUsed >= trainingAttempts and correctAnswer provided', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('The correct answer was: 42');
+ });
+
+ test('hides correct answer when trainingAttempts is -1 (unlimited)', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain('The correct answer was');
+ });
+
+ test('hides correct answer when correctAnswer is undefined even after all attempts', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain('The correct answer was');
+ });
+});
+
+// ── ResponseSwitcher ──────────────────────────────────────────────────────────
+
+describe('ResponseSwitcher', () => {
+ function makeSwitcherProps(response: Response): Parameters[0] {
+ return {
+ response,
+ form: { value: '' } as Parameters[0]['form'],
+ storedAnswer: {},
+ index: 1,
+ config: { type: 'questionnaire', response: [] } as IndividualComponent,
+ disabled: false,
+ };
+ }
+
+ test('numerical type renders NumericInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Enter age');
+ });
+
+ test('shortText type renders StringInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Your name');
+ });
+
+ test('longText type renders TextAreaInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Describe it');
+ });
+
+ test('likert type renders LikertInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Rate');
+ });
+
+ test('dropdown type renders DropdownInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Pick color');
+ expect(html).toContain('Red');
+ });
+
+ test('slider type renders SliderInput with data-slider', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-slider');
+ });
+
+ test('radio type renders RadioInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Agree?');
+ expect(html).toContain('Yes');
+ });
+
+ test('checkbox type renders CheckBoxInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Select all');
+ expect(html).toContain('A');
+ });
+
+ test('ranking-sublist type renders RankingInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Rank items');
+ });
+
+ test('reactive type renders ReactiveInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Selected');
+ });
+
+ test('matrix-radio type renders MatrixInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Rate each');
+ });
+
+ test('buttons type renders ButtonsInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Choose');
+ expect(html).toContain('OK');
+ });
+
+ test('textOnly type renders TextOnlyInput', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Read this');
+ });
+
+ test('divider type renders hr element', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('
{
+ const html = renderToStaticMarkup(
+ [0]['dontKnowCheckbox']}
+ />,
+ );
+ expect(html).toContain('I don't know');
+ });
+});
diff --git a/src/components/response/sliderBreaks.spec.ts b/src/components/response/tests/sliderBreaks.spec.ts
similarity index 70%
rename from src/components/response/sliderBreaks.spec.ts
rename to src/components/response/tests/sliderBreaks.spec.ts
index 346391cfb4..8e435f7b4b 100644
--- a/src/components/response/sliderBreaks.spec.ts
+++ b/src/components/response/tests/sliderBreaks.spec.ts
@@ -1,8 +1,8 @@
-import { describe, expect, it } from 'vitest';
-import { generateSliderBreakValues, getDefaultSliderSpacing } from './sliderBreaks';
+import { describe, expect, test } from 'vitest';
+import { generateSliderBreakValues, getDefaultSliderSpacing } from '../sliderBreaks';
describe('getDefaultSliderSpacing', () => {
- it('uses largest power of 10 below the range', () => {
+ test('uses largest power of 10 below the range', () => {
expect(getDefaultSliderSpacing(1, 50)).toBe(10);
expect(getDefaultSliderSpacing(1, 80)).toBe(10);
expect(getDefaultSliderSpacing(1, 300)).toBe(100);
@@ -10,39 +10,39 @@ describe('getDefaultSliderSpacing', () => {
});
describe('generateSliderBreakValues', () => {
- it('creates breaks for 1-50 at spacing 10', () => {
+ test('creates breaks for 1-50 at spacing 10', () => {
expect(generateSliderBreakValues(1, 50)).toEqual([10, 20, 30, 40]);
});
- it('creates breaks for 1-80 at spacing 10', () => {
+ test('creates breaks for 1-80 at spacing 10', () => {
expect(generateSliderBreakValues(1, 80)).toEqual([10, 20, 30, 40, 50, 60, 70]);
});
- it('creates breaks for 1-300 at spacing 100', () => {
+ test('creates breaks for 1-300 at spacing 100', () => {
expect(generateSliderBreakValues(1, 300)).toEqual([100, 200]);
});
- it('creates decimal breaks for 0-1', () => {
+ test('creates decimal breaks for 0-1', () => {
expect(getDefaultSliderSpacing(0, 1)).toBe(0.1);
expect(generateSliderBreakValues(0, 1)).toEqual([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]);
});
- it('creates decimal breaks for 0.5-0.55', () => {
+ test('creates decimal breaks for 0.5-0.55', () => {
expect(getDefaultSliderSpacing(0.5, 0.55)).toBe(0.01);
expect(generateSliderBreakValues(0.5, 0.55)).toEqual([0.51, 0.52, 0.53, 0.54]);
});
- it('handles range spanning three orders of magnitude', () => {
+ test('handles range spanning three orders of magnitude', () => {
expect(getDefaultSliderSpacing(1, 1000)).toBe(100);
expect(generateSliderBreakValues(1, 1000)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900]);
});
- it('handles range spanning six orders of magnitude', () => {
+ test('handles range spanning six orders of magnitude', () => {
expect(getDefaultSliderSpacing(1, 1_000_000)).toBe(100_000);
expect(generateSliderBreakValues(1, 1_000_000)).toEqual([100_000, 200_000, 300_000, 400_000, 500_000, 600_000, 700_000, 800_000, 900_000]);
});
- it('handles range spanning ten orders of magnitude', () => {
+ test('handles range spanning ten orders of magnitude', () => {
expect(getDefaultSliderSpacing(1, 10_000_000_000)).toBe(1_000_000_000);
expect(generateSliderBreakValues(1, 10_000_000_000)).toEqual([
1_000_000_000,
@@ -57,23 +57,23 @@ describe('generateSliderBreakValues', () => {
]);
});
- it('supports similar range with spacing 10', () => {
+ test('supports similar range with spacing 10', () => {
expect(generateSliderBreakValues(5, 95)).toEqual([10, 20, 30, 40, 50, 60, 70, 80, 90]);
});
- it('supports similar range with spacing 100', () => {
+ test('supports similar range with spacing 100', () => {
expect(generateSliderBreakValues(35, 520)).toEqual([100, 200, 300, 400, 500]);
});
- it('respects explicitly provided spacing', () => {
+ test('respects explicitly provided spacing', () => {
expect(generateSliderBreakValues(1, 50, 5)).toEqual([5, 10, 15, 20, 25, 30, 35, 40, 45]);
});
- it('supports negative ranges', () => {
+ test('supports negative ranges', () => {
expect(generateSliderBreakValues(-20, 20)).toEqual([-10, 0, 10]);
});
- it('returns empty array for invalid ranges', () => {
+ test('returns empty array for invalid ranges', () => {
expect(generateSliderBreakValues(10, 10)).toEqual([]);
expect(generateSliderBreakValues(20, 10)).toEqual([]);
});
diff --git a/src/components/response/tests/utils.spec.ts b/src/components/response/tests/utils.spec.ts
new file mode 100644
index 0000000000..ac6d3d0ce0
--- /dev/null
+++ b/src/components/response/tests/utils.spec.ts
@@ -0,0 +1,917 @@
+import { renderHook, act } from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, it, test,
+} from 'vitest';
+import type {
+ CheckboxResponse, CustomResponse, DropdownResponse, MatrixResponse, NumericalResponse, Response,
+} from '../../../parser/types';
+import type { CustomResponseValidate } from '../../../store/types';
+import {
+ generateInitFields,
+ getDefaultFieldValue,
+ generateValidation,
+ mergeReactiveAnswers,
+ normalizeCheckboxDontKnowValue,
+ useAnswerField,
+} from '../utils';
+import {
+ checkCheckboxResponseForValidation,
+ generateCustomResponseErrorMessage,
+ generateErrorMessage,
+ REQUIRED_ERROR_MESSAGE,
+ shouldBypassValidationForStandaloneDontKnow,
+ usesStandaloneDontKnowField,
+} from '../responseErrors';
+
+describe('generateInitFields', () => {
+ const originalWindow = globalThis.window;
+
+ beforeEach(() => {
+ Object.defineProperty(globalThis, 'window', {
+ value: { location: { search: '' } },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ if (originalWindow === undefined) {
+ Object.defineProperty(globalThis, 'window', {
+ value: undefined,
+ configurable: true,
+ });
+ return;
+ }
+
+ Object.defineProperty(globalThis, 'window', {
+ value: originalWindow,
+ configurable: true,
+ });
+ });
+
+ it('uses question label when matrix question value is omitted', () => {
+ const response: MatrixResponse = {
+ id: 'matrix-question-fallback',
+ prompt: 'Matrix prompt',
+ type: 'matrix-checkbox',
+ answerOptions: ['Option 1', 'Option 2'],
+ questionOptions: [
+ { label: 'Question without value' },
+ { label: 'Question with value', value: 'question-2' },
+ ],
+ };
+
+ const initialFields = generateInitFields([response], {});
+
+ expect(initialFields).toEqual({
+ 'matrix-question-fallback': {
+ 'Question without value': '',
+ 'question-2': '',
+ },
+ });
+ });
+
+ it('uses response defaults when no stored answer exists', () => {
+ const responses: Response[] = [
+ {
+ id: 'short-default',
+ prompt: 'Short text',
+ type: 'shortText',
+ default: 'prefilled',
+ },
+ {
+ id: 'checkbox-default',
+ prompt: 'Checkbox',
+ type: 'checkbox',
+ options: ['A', 'B'],
+ default: ['A'],
+ },
+ {
+ id: 'matrix-default',
+ prompt: 'Matrix',
+ type: 'matrix-checkbox',
+ answerOptions: ['A', 'B'],
+ questionOptions: ['Q1', 'Q2'],
+ default: {
+ Q1: ['A', 'B'],
+ Q2: ['A'],
+ },
+ },
+ {
+ id: 'likert-default',
+ prompt: 'Likert',
+ type: 'likert',
+ numItems: 5,
+ default: 3,
+ },
+ {
+ id: 'multiselect-dropdown-default',
+ prompt: 'Dropdown',
+ type: 'dropdown',
+ options: ['A', 'B', 'C'],
+ minSelections: 1,
+ default: 'B',
+ },
+ {
+ id: 'custom-default',
+ prompt: 'Custom response',
+ type: 'custom',
+ path: 'demo-form-elements/assets/CustomResponseCard.tsx',
+ default: {
+ chartType: 'Line',
+ confidence: 75,
+ rationale: 'Preset',
+ },
+ },
+ ];
+
+ const initialFields = generateInitFields(responses, {});
+
+ expect(initialFields).toEqual({
+ 'short-default': 'prefilled',
+ 'checkbox-default': ['A'],
+ 'matrix-default': {
+ Q1: 'A|B',
+ Q2: 'A',
+ },
+ 'likert-default': '3',
+ 'multiselect-dropdown-default': ['B'],
+ 'custom-default': {
+ chartType: 'Line',
+ confidence: 75,
+ rationale: 'Preset',
+ },
+ });
+ });
+
+ it('preserves stored falsy answers instead of replacing them with defaults', () => {
+ const responses: Response[] = [
+ {
+ id: 'stored-empty-string',
+ prompt: 'Short text',
+ type: 'shortText',
+ default: 'prefilled',
+ },
+ {
+ id: 'stored-zero',
+ prompt: 'Number',
+ type: 'numerical',
+ default: 5,
+ },
+ {
+ id: 'stored-false',
+ prompt: 'Custom response',
+ type: 'custom',
+ path: 'demo-form-elements/assets/CustomResponseCard.tsx',
+ default: true,
+ },
+ ];
+
+ const initialFields = generateInitFields(responses, {
+ 'stored-empty-string': '',
+ 'stored-zero': 0,
+ 'stored-false': false,
+ });
+
+ expect(initialFields).toEqual({
+ 'stored-empty-string': '',
+ 'stored-zero': 0,
+ 'stored-false': false,
+ });
+ });
+});
+
+describe('generateValidation custom', () => {
+ const response: CustomResponse = {
+ id: 'custom-response-demo',
+ prompt: 'Custom response',
+ type: 'custom',
+ path: 'custom-response/Example.tsx',
+ parameters: {
+ minimumConfidence: 70,
+ },
+ };
+
+ const customValidate: CustomResponseValidate = (value, _values, customResponse) => {
+ const minimumConfidence = customResponse.parameters?.minimumConfidence as number;
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return 'Select a chart type to continue.';
+ }
+ if (typeof value.confidence !== 'number' || value.confidence < minimumConfidence) {
+ return `Set confidence to at least ${minimumConfidence} to continue.`;
+ }
+
+ return null;
+ };
+
+ it('uses the module validate export when a partial object is present', () => {
+ const validation = generateValidation([response], { [response.id]: customValidate });
+ const error = validation[response.id]({
+ chartType: 'Bar',
+ confidence: 50,
+ rationale: '',
+ }, {});
+
+ expect(error).toBe('Set confidence to at least 70 to continue.');
+ });
+
+ it('passes once the custom response module validation succeeds', () => {
+ const validation = generateValidation([response], { [response.id]: customValidate });
+ const error = validation[response.id]({
+ chartType: 'Scatter',
+ confidence: 80,
+ rationale: 'Looks right',
+ }, {});
+
+ expect(error).toBeNull();
+ });
+
+ it('treats empty objects as missing required input', () => {
+ const validation = generateValidation([response], { [response.id]: customValidate });
+ const error = validation[response.id]({}, {});
+
+ expect(error).toBe(REQUIRED_ERROR_MESSAGE);
+ });
+
+ it('treats nested empty string structures as missing required input', () => {
+ const validation = generateValidation([response], { [response.id]: customValidate });
+ const error = validation[response.id]({
+ chartType: '',
+ rationale: '',
+ details: {
+ note: '',
+ },
+ tags: ['', ''],
+ }, {});
+
+ expect(error).toBe(REQUIRED_ERROR_MESSAGE);
+ });
+
+ it('does not treat 0 or false as empty custom values', () => {
+ const validation = generateValidation([response]);
+ const error = validation[response.id]({
+ confidence: 0,
+ confirmed: false,
+ }, {});
+
+ expect(error).toBeNull();
+ });
+
+ it('skips custom validation for optional empty custom responses', () => {
+ const optionalResponse: CustomResponse = {
+ ...response,
+ required: false,
+ };
+
+ const validation = generateValidation([optionalResponse], { [optionalResponse.id]: customValidate });
+ const error = validation[optionalResponse.id](null, {});
+
+ expect(error).toBeNull();
+ });
+
+ it('surfaces module load errors for optional custom responses', () => {
+ const optionalResponse: CustomResponse = {
+ ...response,
+ required: false,
+ };
+
+ const validation = generateValidation(
+ [optionalResponse],
+ {},
+ { [optionalResponse.id]: `Unable to load custom response module at ${optionalResponse.path}` },
+ );
+ const error = validation[optionalResponse.id](null, {});
+
+ expect(error).toBe(`Unable to load custom response module at ${optionalResponse.path}`);
+ });
+
+ it('treats standalone dont-know as a completed custom response', () => {
+ const validation = generateValidation([{
+ ...response,
+ withDontKnow: true,
+ }], { [response.id]: customValidate });
+ const error = validation[response.id](null, {
+ [`${response.id}-dontKnow`]: true,
+ });
+
+ expect(error).toBeNull();
+ });
+});
+
+describe('generateCustomResponseErrorMessage', () => {
+ const response: CustomResponse = {
+ id: 'custom-response-demo',
+ prompt: 'Custom response',
+ type: 'custom',
+ path: 'custom-response/Example.tsx',
+ parameters: {
+ minimumConfidence: 70,
+ },
+ };
+
+ const customValidate: CustomResponseValidate = (value, _values, customResponse) => {
+ const minimumConfidence = customResponse.parameters?.minimumConfidence as number;
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return 'Select a chart type to continue.';
+ }
+ if (typeof value.confidence !== 'number' || value.confidence < minimumConfidence) {
+ return `Set confidence to at least ${minimumConfidence} to continue.`;
+ }
+
+ return null;
+ };
+
+ it('does not show an error for untouched required custom responses', () => {
+ expect(generateCustomResponseErrorMessage(response, null, {}, customValidate)).toBeNull();
+ });
+
+ it('shows validation feedback once the response is partially filled', () => {
+ expect(generateCustomResponseErrorMessage(response, {
+ chartType: 'Bar',
+ confidence: null,
+ rationale: '',
+ }, {}, customValidate, undefined, { showRequiredErrors: true })).toBe('Set confidence to at least 70 to continue.');
+ });
+
+ it('shows no feedback once the current value is valid', () => {
+ expect(generateCustomResponseErrorMessage(response, {
+ chartType: 'Bar',
+ confidence: 80,
+ rationale: '',
+ }, {}, customValidate)).toBeNull();
+ });
+});
+
+describe('mergeReactiveAnswers', () => {
+ it('merges all reactive response ids from a single submission', () => {
+ const mergedValues = mergeReactiveAnswers(
+ [
+ {
+ id: 'answer1',
+ prompt: 'First reactive answer',
+ type: 'reactive',
+ },
+ {
+ id: 'answer2',
+ prompt: 'Second reactive answer',
+ type: 'reactive',
+ },
+ ],
+ { answer1: 0, answer2: 0, other: 'keep-me' },
+ { answer1: 1, answer2: 2 },
+ );
+
+ expect(mergedValues).toEqual({ answer1: 1, answer2: 2, other: 'keep-me' });
+ });
+});
+
+describe('generateErrorMessage checkbox', () => {
+ it('validates checkbox selections when checkbox group value is an array', () => {
+ const checkboxResponse: Response = {
+ id: 'checkbox-response',
+ prompt: 'Checkbox response',
+ type: 'checkbox',
+ required: true,
+ minSelections: 2,
+ options: ['Option 1', 'Option 2', 'Option 3'],
+ };
+
+ const error = generateErrorMessage(
+ checkboxResponse,
+ { value: ['Option 1'] },
+ undefined,
+ { showRequiredErrors: true },
+ );
+
+ expect(error).toBe('Please select at least 2 options');
+ });
+
+ it('suppresses checkbox min/max errors when dont-know is checked', () => {
+ const checkboxResponse: Response = {
+ id: 'checkbox-response',
+ prompt: 'Checkbox response',
+ type: 'checkbox',
+ required: true,
+ minSelections: 2,
+ options: ['Option 1', 'Option 2', 'Option 3'],
+ withDontKnow: true,
+ };
+
+ const error = generateErrorMessage(
+ checkboxResponse,
+ { value: [] },
+ undefined,
+ { values: { 'checkbox-response-dontKnow': true } },
+ );
+
+ expect(error).toBeNull();
+ });
+});
+
+describe('checkCheckboxResponseForValidation', () => {
+ it('bypasses checkbox selection-count validation when dont-know is checked', () => {
+ const checkboxResponse: CheckboxResponse = {
+ id: 'checkbox-response',
+ prompt: 'Checkbox response',
+ type: 'checkbox',
+ required: true,
+ minSelections: 2,
+ options: ['Option 1', 'Option 2', 'Option 3'],
+ withDontKnow: true,
+ };
+
+ expect(checkCheckboxResponseForValidation(checkboxResponse, [], true)).toBeNull();
+ });
+});
+
+describe('shouldBypassValidationForStandaloneDontKnow', () => {
+ it('returns true for standalone dont-know responses', () => {
+ const response: Response = {
+ id: 'q-numerical',
+ prompt: 'Numerical example',
+ type: 'numerical',
+ withDontKnow: true,
+ };
+
+ expect(shouldBypassValidationForStandaloneDontKnow(response, true)).toBe(true);
+ });
+
+ it('returns false for matrix responses because dont-know is inline', () => {
+ const response: MatrixResponse = {
+ id: 'matrix-validation',
+ prompt: 'Matrix prompt',
+ type: 'matrix-radio',
+ required: true,
+ answerOptions: ['0', '1'],
+ questionOptions: ['q1', 'q2'],
+ withDontKnow: true,
+ };
+
+ expect(shouldBypassValidationForStandaloneDontKnow(response, true)).toBe(false);
+ });
+});
+
+describe('normalizeCheckboxDontKnowValue', () => {
+ it('clears all selections when the legacy dont-know token is present', () => {
+ expect(normalizeCheckboxDontKnowValue(["I don't know", 'Option 1'])).toEqual([]);
+ });
+
+ it('leaves regular checkbox selections unchanged', () => {
+ expect(normalizeCheckboxDontKnowValue(['Option 1'])).toEqual(['Option 1']);
+ });
+});
+
+describe('generateErrorMessage requiredValue with dont-know', () => {
+ it('suppresses required-value errors when standalone dont-know is checked', () => {
+ const numericalResponse: Response = {
+ id: 'required-value-response',
+ prompt: 'Required numerical response',
+ type: 'numerical',
+ required: true,
+ requiredValue: 42,
+ withDontKnow: true,
+ };
+
+ const error = generateErrorMessage(numericalResponse, {
+ value: '',
+ }, undefined, {
+ values: { 'required-value-response-dontKnow': true },
+ });
+
+ expect(error).toBeNull();
+ });
+});
+
+describe('generateErrorMessage matrix', () => {
+ const matrixResponse: MatrixResponse = {
+ id: 'matrix-validation',
+ prompt: 'Matrix prompt',
+ type: 'matrix-radio',
+ required: true,
+ answerOptions: ['0', '1'],
+ questionOptions: ['q1', 'q2'],
+ };
+
+ it('does not show matrix incomplete message when untouched', () => {
+ const error = generateErrorMessage(matrixResponse, {
+ value: { q1: '', q2: '' },
+ });
+
+ expect(error).toBeNull();
+ });
+
+ it('shows matrix incomplete message after at least one answer is selected', () => {
+ const error = generateErrorMessage(
+ matrixResponse,
+ { value: { q1: '0', q2: '' } },
+ undefined,
+ { showRequiredErrors: true },
+ );
+
+ expect(error).toBe('Please answer all questions in the matrix to continue.');
+ });
+
+ it('does not show matrix incomplete message when all rows are answered', () => {
+ const error = generateErrorMessage(matrixResponse, {
+ value: { q1: '0', q2: '1' },
+ });
+
+ expect(error).toBeNull();
+ });
+});
+
+// ── checkCheckboxResponseForValidation additional branches ───────────────────
+
+describe('checkCheckboxResponseForValidation — both min and max violated', () => {
+ test('returns range error when minSelections > maxSelections and count falls between', () => {
+ // min=5 > max=2 is an edge case; value.length=3 satisfies: 3 < 5 AND 3 > 2
+ const response: CheckboxResponse = {
+ id: 'q1', prompt: '', type: 'checkbox', options: [], minSelections: 5, maxSelections: 2,
+ };
+ const result = checkCheckboxResponseForValidation(response, ['A', 'B', 'C']);
+ expect(result).toContain('between 5 and 2');
+ });
+
+ test('returns maxSelections error only when only max is violated', () => {
+ const response: CheckboxResponse = {
+ id: 'q1', prompt: '', type: 'checkbox', options: [], maxSelections: 1,
+ };
+ const result = checkCheckboxResponseForValidation(response, ['A', 'B']);
+ expect(result).toContain('at most 1');
+ });
+
+ test('returns null when selection is within range', () => {
+ const response: CheckboxResponse = {
+ id: 'q1', prompt: '', type: 'checkbox', options: [], minSelections: 1, maxSelections: 3,
+ };
+ expect(checkCheckboxResponseForValidation(response, ['A', 'B'])).toBeNull();
+ });
+});
+
+// ── generateErrorMessage — dropdown and numerical branches ───────────────────
+
+describe('generateErrorMessage dropdown', () => {
+ test('returns maxSelections error for dropdown with too many selections', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], required: true, maxSelections: 1,
+ };
+ const result = generateErrorMessage(response, { value: ['A', 'B'] }, undefined, { showRequiredErrors: true });
+ expect(result).toContain('at most 1');
+ });
+
+ test('returns minSelections error for dropdown with too few selections', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], required: true, minSelections: 3,
+ };
+ const result = generateErrorMessage(response, { value: ['A'] }, undefined, { showRequiredErrors: true });
+ expect(result).toContain('at least 3');
+ });
+
+ test('returns null for valid dropdown selection', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], required: true, minSelections: 1, maxSelections: 3,
+ };
+ expect(generateErrorMessage(response, { value: ['A', 'B'] })).toBeNull();
+ });
+});
+
+describe('generateErrorMessage numerical', () => {
+ test('returns between error when value is outside min and max', () => {
+ const response: NumericalResponse = {
+ id: 'q1', prompt: '', type: 'numerical', required: true, min: 1, max: 10,
+ };
+ expect(generateErrorMessage(response, { value: 50 }, undefined, { showRequiredErrors: true }))
+ .toContain('between 1 and 10');
+ });
+
+ test('returns min error when value is below min only', () => {
+ const response: NumericalResponse = {
+ id: 'q1', prompt: '', type: 'numerical', required: true, min: 5,
+ };
+ expect(generateErrorMessage(response, { value: 2 }, undefined, { showRequiredErrors: true }))
+ .toContain('5 or greater');
+ });
+
+ test('returns max error when value is above max only', () => {
+ const response: NumericalResponse = {
+ id: 'q1', prompt: '', type: 'numerical', required: true, max: 10,
+ };
+ expect(generateErrorMessage(response, { value: 20 }, undefined, { showRequiredErrors: true }))
+ .toContain('10 or less');
+ });
+
+ test('returns null when numerical value is in range', () => {
+ const response: NumericalResponse = {
+ id: 'q1', prompt: '', type: 'numerical', required: true, min: 1, max: 10,
+ };
+ expect(generateErrorMessage(response, { value: 5 })).toBeNull();
+ });
+});
+
+describe('generateErrorMessage else branch — requiredValue mismatch', () => {
+ test('returns error when shortText value does not match requiredValue', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', required: true, requiredValue: 'correct',
+ };
+ expect(generateErrorMessage(response, { value: 'wrong' }, undefined, { showRequiredErrors: true }))
+ .toContain('correct');
+ });
+
+ test('returns null when shortText value matches requiredValue', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', required: true, requiredValue: 'correct',
+ };
+ expect(generateErrorMessage(response, { value: 'correct' })).toBeNull();
+ });
+
+ test('returns null when no value and no requiredValue', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', required: false,
+ };
+ expect(generateErrorMessage(response, { value: 'anything' })).toBeNull();
+ });
+});
+
+// ── usesStandaloneDontKnowField ───────────────────────────────────────────────
+
+describe('usesStandaloneDontKnowField', () => {
+ test('returns true for non-matrix response with withDontKnow', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', withDontKnow: true,
+ };
+ expect(usesStandaloneDontKnowField(response)).toBe(true);
+ });
+
+ test('returns false for matrix-radio with withDontKnow', () => {
+ const response: MatrixResponse = {
+ id: 'q1', prompt: '', type: 'matrix-radio', answerOptions: [], questionOptions: [], withDontKnow: true,
+ };
+ expect(usesStandaloneDontKnowField(response)).toBe(false);
+ });
+
+ test('returns false for matrix-checkbox with withDontKnow', () => {
+ const response: MatrixResponse = {
+ id: 'q1', prompt: '', type: 'matrix-checkbox', answerOptions: [], questionOptions: [], withDontKnow: true,
+ };
+ expect(usesStandaloneDontKnowField(response)).toBe(false);
+ });
+
+ test('returns false when withDontKnow is not set', () => {
+ const response: Response = { id: 'q1', prompt: '', type: 'shortText' };
+ expect(usesStandaloneDontKnowField(response)).toBe(false);
+ });
+});
+
+// ── getDefaultFieldValue ──────────────────────────────────────────────────────
+
+describe('getDefaultFieldValue', () => {
+ test('returns null when no default property exists', () => {
+ const response: Response = { id: 'q1', prompt: '', type: 'shortText' };
+ expect(getDefaultFieldValue(response)).toBeNull();
+ });
+
+ test('returns null when default is undefined', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', default: undefined,
+ };
+ expect(getDefaultFieldValue(response)).toBeNull();
+ });
+
+ test('returns string for likert default', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'likert', numItems: 5, default: 3,
+ };
+ expect(getDefaultFieldValue(response)).toBe('3');
+ });
+
+ test('returns array for checkbox default array', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'checkbox', options: [], default: ['A', 'B'],
+ };
+ expect(getDefaultFieldValue(response)).toEqual(['A', 'B']);
+ });
+
+ test('returns matrix-radio default as-is', () => {
+ const response: MatrixResponse = {
+ id: 'q1',
+ prompt: '',
+ type: 'matrix-radio',
+ answerOptions: [],
+ questionOptions: [],
+ default: { Q1: 'Yes' },
+ };
+ expect(getDefaultFieldValue(response as Response)).toEqual({ Q1: 'Yes' });
+ });
+
+ test('converts matrix-checkbox default array values to pipe-joined strings', () => {
+ const response: MatrixResponse = {
+ id: 'q1',
+ prompt: '',
+ type: 'matrix-checkbox',
+ answerOptions: [],
+ questionOptions: [],
+ default: { Q1: ['A', 'B'], Q2: ['C'] },
+ };
+ const result = getDefaultFieldValue(response as Response) as Record;
+ expect(result.Q1).toBe('A|B');
+ expect(result.Q2).toBe('C');
+ });
+
+ test('returns single value for single-select dropdown', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], default: 'Red',
+ };
+ expect(getDefaultFieldValue(response as Response)).toBe('Red');
+ });
+
+ test('returns first element for array dropdown default when single-select', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], default: ['Red', 'Blue'],
+ };
+ expect(getDefaultFieldValue(response as Response)).toBe('Red');
+ });
+
+ test('returns array for multiselect dropdown with single string default', () => {
+ const response: DropdownResponse = {
+ id: 'q1', prompt: '', type: 'dropdown', options: [], maxSelections: 2, default: 'Red',
+ };
+ expect(getDefaultFieldValue(response as Response)).toEqual(['Red']);
+ });
+
+ test('returns raw default for other response types', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', default: 'hello',
+ };
+ expect(getDefaultFieldValue(response)).toBe('hello');
+ });
+});
+
+// ── generateInitFields additional branches ────────────────────────────────────
+
+describe('generateInitFields additional branches', () => {
+ const savedWindow = globalThis.window;
+
+ beforeEach(() => {
+ Object.defineProperty(globalThis, 'window', {
+ value: { location: { search: '?color=blue' } },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(globalThis, 'window', {
+ value: savedWindow,
+ configurable: true,
+ });
+ });
+
+ test('initializes reactive response to empty array', () => {
+ const response: Response = { id: 'q1', prompt: '', type: 'reactive' };
+ expect(generateInitFields([response], {})).toMatchObject({ q1: [] });
+ });
+
+ test('initializes ranking-categorical to empty array', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'ranking-categorical', options: [],
+ };
+ expect(generateInitFields([response], {})).toMatchObject({ q1: [] });
+ });
+
+ test('initializes ranking-pairwise to empty array', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'ranking-pairwise', options: [],
+ };
+ expect(generateInitFields([response], {})).toMatchObject({ q1: [] });
+ });
+
+ test('initializes slider with startingValue', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'slider', options: [], startingValue: 75,
+ };
+ expect(generateInitFields([response], {})).toMatchObject({ q1: '75' });
+ });
+
+ test('reads paramCapture value from window.location.search', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', paramCapture: 'color',
+ };
+ const result = generateInitFields([response], {});
+ expect(result).toMatchObject({ q1: 'blue' });
+ });
+
+ test('adds dontKnow field defaulting to false when not in stored answer', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', withDontKnow: true,
+ };
+ expect(generateInitFields([response], {})).toMatchObject({ 'q1-dontKnow': false });
+ });
+
+ test('uses dontKnow value from stored answer when available', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'shortText', withDontKnow: true,
+ };
+ expect(generateInitFields([response], { q1: 'val', 'q1-dontKnow': true })).toMatchObject({ 'q1-dontKnow': true });
+ });
+
+ test('adds other field when withOther is set', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'radio', options: [], withOther: true,
+ };
+ expect(generateInitFields([response], {})).toMatchObject({ 'q1-other': '' });
+ });
+
+ test('uses stored other value when available', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'radio', options: [], withOther: true,
+ };
+ const result = generateInitFields([response], { q1: 'A', 'q1-other': 'custom' });
+ expect(result).toMatchObject({ 'q1-other': 'custom' });
+ });
+
+ test('uses stored answer value when present', () => {
+ const response: Response = { id: 'q1', prompt: '', type: 'shortText' };
+ expect(generateInitFields([response], { q1: 'saved text' })).toMatchObject({ q1: 'saved text' });
+ });
+});
+
+// ── generateErrorMessage — answer.checked branch ─────────────────────────────
+
+describe('generateErrorMessage — answer.checked branch', () => {
+ test('uses answer.checked when it is an array', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'checkbox', required: true, requiredValue: ['A', 'B'], options: ['A', 'B'],
+ };
+ // Pass checked instead of value — should find mismatch
+ const error = generateErrorMessage(response, { checked: ['A'] }, undefined, { showRequiredErrors: true });
+ expect(error).toContain('to continue');
+ });
+
+ test('uses options label in error when options param is passed', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'checkbox', required: true, requiredValue: ['A', 'B'], options: ['A', 'B'],
+ };
+ const options = [{ label: 'Option A', value: 'A' }];
+ const error = generateErrorMessage(response, { checked: ['A'] }, options, { showRequiredErrors: true });
+ expect(error).toContain('select');
+ });
+
+ test('matching checked values against requiredValue returns null', () => {
+ const response: Response = {
+ id: 'q1', prompt: '', type: 'checkbox', required: true, requiredValue: ['A', 'B'], options: ['A', 'B'],
+ };
+ const error = generateErrorMessage(response, { checked: ['B', 'A'] });
+ expect(error).toBeNull();
+ });
+});
+
+// ── useAnswerField ─────────────────────────────────────────────────────────────
+
+describe('useAnswerField', () => {
+ test('returns a form object with initial values from responses', () => {
+ const responses: Response[] = [
+ { id: 'name', prompt: 'Name', type: 'shortText' },
+ ];
+ const { result } = renderHook(() => useAnswerField(responses, 'step1', {}));
+ expect(result.current.values).toMatchObject({ name: '' });
+ });
+
+ test('resets form when currentStep changes', async () => {
+ const responses: Response[] = [
+ {
+ id: 'name', prompt: 'Name', type: 'shortText', default: 'default',
+ },
+ ];
+ const { result, rerender } = renderHook(
+ ({ step }: { step: string }) => useAnswerField(responses, step, {}),
+ { initialProps: { step: 'step1' } },
+ );
+
+ await act(async () => {
+ rerender({ step: 'step2' });
+ });
+
+ // After step change the form should have reset
+ expect(result.current.values).toBeDefined();
+ });
+
+ test('validates required field and returns error for empty value', () => {
+ const responses: Response[] = [
+ {
+ id: 'name', prompt: 'Name', type: 'shortText', required: true,
+ },
+ ];
+ const { result } = renderHook(() => useAnswerField(responses, 'step1', {}));
+ const errors = result.current.validate();
+ expect(errors.hasErrors).toBe(true);
+ });
+
+ test('validates required array field returns error for empty array', () => {
+ const responses: Response[] = [
+ {
+ id: 'color', prompt: 'Color', type: 'checkbox', options: ['Red', 'Blue'], required: true,
+ },
+ ];
+ const { result } = renderHook(() => useAnswerField(responses, 'step1', {}));
+ const errors = result.current.validate();
+ expect(errors.hasErrors).toBe(true);
+ });
+});
diff --git a/src/components/response/utils.spec.ts b/src/components/response/utils.spec.ts
deleted file mode 100644
index 5a2204202e..0000000000
--- a/src/components/response/utils.spec.ts
+++ /dev/null
@@ -1,681 +0,0 @@
-import {
- afterEach, beforeEach, describe, expect, it,
-} from 'vitest';
-import type {
- CheckboxResponse, CustomResponse, MatrixResponse, Response,
-} from '../../parser/types';
-import type { CustomResponseValidate } from '../../store/types';
-import {
- generateInitFields,
- generateValidation,
- mergeReactiveAnswers,
- normalizeCheckboxDontKnowValue,
-} from './utils';
-import {
- REQUIRED_ERROR_MESSAGE,
- checkCheckboxResponseForValidation,
- generateCustomResponseErrorMessage,
- generateErrorMessage,
- shouldBypassValidationForStandaloneDontKnow,
- summarizeResponseIssues,
-} from './responseErrors';
-
-describe('generateInitFields', () => {
- const originalWindow = globalThis.window;
-
- beforeEach(() => {
- Object.defineProperty(globalThis, 'window', {
- value: { location: { search: '' } },
- configurable: true,
- });
- });
-
- afterEach(() => {
- if (originalWindow === undefined) {
- Object.defineProperty(globalThis, 'window', {
- value: undefined,
- configurable: true,
- });
- return;
- }
-
- Object.defineProperty(globalThis, 'window', {
- value: originalWindow,
- configurable: true,
- });
- });
-
- it('uses question label when matrix question value is omitted', () => {
- const response: MatrixResponse = {
- id: 'matrix-question-fallback',
- prompt: 'Matrix prompt',
- type: 'matrix-checkbox',
- answerOptions: ['Option 1', 'Option 2'],
- questionOptions: [
- { label: 'Question without value' },
- { label: 'Question with value', value: 'question-2' },
- {
- label: 'Obstructive - Supportive',
- value: 'obstructive-supportive',
- leftLabel: 'Obstructive',
- rightLabel: 'Supportive',
- },
- ],
- };
-
- const initialFields = generateInitFields([response], {});
-
- expect(initialFields).toEqual({
- 'matrix-question-fallback': {
- 'Question without value': '',
- 'question-2': '',
- 'obstructive-supportive': '',
- },
- });
- });
-
- it('uses response defaults when no stored answer exists', () => {
- const responses: Response[] = [
- {
- id: 'short-default',
- prompt: 'Short text',
- type: 'shortText',
- default: 'prefilled',
- },
- {
- id: 'checkbox-default',
- prompt: 'Checkbox',
- type: 'checkbox',
- options: ['A', 'B'],
- default: ['A'],
- },
- {
- id: 'matrix-default',
- prompt: 'Matrix',
- type: 'matrix-checkbox',
- answerOptions: ['A', 'B'],
- questionOptions: ['Q1', 'Q2'],
- default: {
- Q1: ['A', 'B'],
- Q2: ['A'],
- },
- },
- {
- id: 'likert-default',
- prompt: 'Likert',
- type: 'likert',
- numItems: 5,
- default: 3,
- },
- {
- id: 'multiselect-dropdown-default',
- prompt: 'Dropdown',
- type: 'dropdown',
- options: ['A', 'B', 'C'],
- minSelections: 1,
- default: 'B',
- },
- {
- id: 'custom-default',
- prompt: 'Custom response',
- type: 'custom',
- path: 'demo-form-elements/assets/CustomResponseCard.tsx',
- default: {
- chartType: 'Line',
- confidence: 75,
- rationale: 'Preset',
- },
- },
- ];
-
- const initialFields = generateInitFields(responses, {});
-
- expect(initialFields).toEqual({
- 'short-default': 'prefilled',
- 'checkbox-default': ['A'],
- 'matrix-default': {
- Q1: 'A|B',
- Q2: 'A',
- },
- 'likert-default': '3',
- 'multiselect-dropdown-default': ['B'],
- 'custom-default': {
- chartType: 'Line',
- confidence: 75,
- rationale: 'Preset',
- },
- });
- });
-
- it('preserves stored falsy answers instead of replacing them with defaults', () => {
- const responses: Response[] = [
- {
- id: 'stored-empty-string',
- prompt: 'Short text',
- type: 'shortText',
- default: 'prefilled',
- },
- {
- id: 'stored-zero',
- prompt: 'Number',
- type: 'numerical',
- default: 5,
- },
- {
- id: 'stored-false',
- prompt: 'Custom response',
- type: 'custom',
- path: 'demo-form-elements/assets/CustomResponseCard.tsx',
- default: true,
- },
- ];
-
- const initialFields = generateInitFields(responses, {
- 'stored-empty-string': '',
- 'stored-zero': 0,
- 'stored-false': false,
- });
-
- expect(initialFields).toEqual({
- 'stored-empty-string': '',
- 'stored-zero': 0,
- 'stored-false': false,
- });
- });
-});
-
-describe('generateValidation custom', () => {
- const response: CustomResponse = {
- id: 'custom-response-demo',
- prompt: 'Custom response',
- type: 'custom',
- path: 'custom-response/Example.tsx',
- parameters: {
- minimumConfidence: 70,
- },
- };
-
- const customValidate: CustomResponseValidate = (value, _values, customResponse) => {
- const minimumConfidence = customResponse.parameters?.minimumConfidence as number;
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return 'Select a chart type to continue.';
- }
- if (typeof value.confidence !== 'number' || value.confidence < minimumConfidence) {
- return `Set confidence to at least ${minimumConfidence} to continue.`;
- }
-
- return null;
- };
-
- it('uses the module validate export when a partial object is present', () => {
- const validation = generateValidation([response], { [response.id]: customValidate });
- const error = validation[response.id]({
- chartType: 'Bar',
- confidence: 50,
- rationale: '',
- }, {});
-
- expect(error).toBe('Set confidence to at least 70 to continue.');
- });
-
- it('passes once the custom response module validation succeeds', () => {
- const validation = generateValidation([response], { [response.id]: customValidate });
- const error = validation[response.id]({
- chartType: 'Scatter',
- confidence: 80,
- rationale: 'Looks right',
- }, {});
-
- expect(error).toBeNull();
- });
-
- it('treats empty objects as missing required input', () => {
- const validation = generateValidation([response], { [response.id]: customValidate });
- const error = validation[response.id]({}, {});
-
- expect(error).toBe(REQUIRED_ERROR_MESSAGE);
- });
-
- it('treats nested empty string structures as missing required input', () => {
- const validation = generateValidation([response], { [response.id]: customValidate });
- const error = validation[response.id]({
- chartType: '',
- rationale: '',
- details: {
- note: '',
- },
- tags: ['', ''],
- }, {});
-
- expect(error).toBe(REQUIRED_ERROR_MESSAGE);
- });
-
- it('does not treat 0 or false as empty custom values', () => {
- const validation = generateValidation([response]);
- const error = validation[response.id]({
- confidence: 0,
- confirmed: false,
- }, {});
-
- expect(error).toBeNull();
- });
-
- it('skips custom validation for optional empty custom responses', () => {
- const optionalResponse: CustomResponse = {
- ...response,
- required: false,
- };
-
- const validation = generateValidation([optionalResponse], { [optionalResponse.id]: customValidate });
- const error = validation[optionalResponse.id](null, {});
-
- expect(error).toBeNull();
- });
-
- it('surfaces module load errors for optional custom responses', () => {
- const optionalResponse: CustomResponse = {
- ...response,
- required: false,
- };
-
- const validation = generateValidation(
- [optionalResponse],
- {},
- { [optionalResponse.id]: `Unable to load custom response module at ${optionalResponse.path}` },
- );
- const error = validation[optionalResponse.id](null, {});
-
- expect(error).toBe(`Unable to load custom response module at ${optionalResponse.path}`);
- });
-
- it('treats standalone dont-know as a completed custom response', () => {
- const validation = generateValidation([{
- ...response,
- withDontKnow: true,
- }], { [response.id]: customValidate });
- const error = validation[response.id](null, {
- [`${response.id}-dontKnow`]: true,
- });
-
- expect(error).toBeNull();
- });
-});
-
-describe('generateCustomResponseErrorMessage', () => {
- const response: CustomResponse = {
- id: 'custom-response-demo',
- prompt: 'Custom response',
- type: 'custom',
- path: 'custom-response/Example.tsx',
- parameters: {
- minimumConfidence: 70,
- },
- };
-
- const customValidate: CustomResponseValidate = (value, _values, customResponse) => {
- const minimumConfidence = customResponse.parameters?.minimumConfidence as number;
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return 'Select a chart type to continue.';
- }
- if (typeof value.confidence !== 'number' || value.confidence < minimumConfidence) {
- return `Set confidence to at least ${minimumConfidence} to continue.`;
- }
-
- return null;
- };
-
- it('does not show an error for untouched required custom responses', () => {
- expect(generateCustomResponseErrorMessage(response, null, {}, customValidate)).toBeNull();
- });
-
- it('shows the required message for untouched required custom responses after submit', () => {
- expect(generateCustomResponseErrorMessage(response, null, {}, customValidate, undefined, { showRequiredErrors: true })).toBe('Please answer this question to continue.');
- });
-
- it('shows validation feedback once the response is partially filled and errors are revealed', () => {
- expect(generateCustomResponseErrorMessage(response, {
- chartType: 'Bar',
- confidence: null,
- rationale: '',
- }, {}, customValidate, undefined, { showRequiredErrors: true })).toBe('Set confidence to at least 70 to continue.');
- });
-
- it('stays quiet for invalid responses until errors are revealed', () => {
- expect(generateCustomResponseErrorMessage(response, {
- chartType: 'Bar',
- confidence: null,
- rationale: '',
- }, {}, customValidate)).toBeNull();
- });
-
- it('shows no feedback once the current value is valid', () => {
- expect(generateCustomResponseErrorMessage(response, {
- chartType: 'Bar',
- confidence: 80,
- rationale: '',
- }, {}, customValidate)).toBeNull();
- });
-});
-
-describe('mergeReactiveAnswers', () => {
- it('merges all reactive response ids from a single submission', () => {
- const mergedValues = mergeReactiveAnswers(
- [
- {
- id: 'answer1',
- prompt: 'First reactive answer',
- type: 'reactive',
- },
- {
- id: 'answer2',
- prompt: 'Second reactive answer',
- type: 'reactive',
- },
- ],
- { answer1: 0, answer2: 0, other: 'keep-me' },
- { answer1: 1, answer2: 2 },
- );
-
- expect(mergedValues).toEqual({ answer1: 1, answer2: 2, other: 'keep-me' });
- });
-});
-
-describe('generateErrorMessage checkbox', () => {
- it('treats checkbox other without text as invalid', () => {
- const checkboxResponse: Response = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- options: ['Option 1', 'Option 2'],
- withOther: true,
- };
-
- const error = generateErrorMessage(checkboxResponse, {
- value: ['__other'],
- }, undefined, { showRequiredErrors: true, values: { 'checkbox-response-other': '' } });
-
- expect(error).toBe('Please fill in Other to continue.');
- });
-
- it('removes required error when dont-know is checked', () => {
- const checkboxResponse: Response = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- options: ['Option 1', 'Option 2', 'Option 3'],
- withDontKnow: true,
- };
-
- const error = generateErrorMessage(checkboxResponse, {
- value: [],
- }, undefined, { showRequiredErrors: true, values: { 'checkbox-response-dontKnow': true } });
-
- expect(error).toBeNull();
- });
-
- it('validates checkbox selections when checkbox group value is an array', () => {
- const checkboxResponse: Response = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- minSelections: 2,
- options: ['Option 1', 'Option 2', 'Option 3'],
- };
-
- const error = generateErrorMessage(checkboxResponse, { value: ['Option 1'] }, undefined, { showRequiredErrors: true });
-
- expect(error).toBe('Please select at least 2 options');
- });
-
- it('suppresses checkbox min/max errors when dont-know is checked', () => {
- const checkboxResponse: Response = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- minSelections: 2,
- options: ['Option 1', 'Option 2', 'Option 3'],
- withDontKnow: true,
- };
-
- const error = generateErrorMessage(checkboxResponse, { value: [] }, undefined, { values: { 'checkbox-response-dontKnow': true } });
-
- expect(error).toBeNull();
- });
-});
-
-describe('generateErrorMessage radio', () => {
- it('treats radio other without text as invalid', () => {
- const radioResponse: Response = {
- id: 'radio-response',
- prompt: 'Radio response',
- type: 'radio',
- required: true,
- options: ['Option 1', 'Option 2'],
- withOther: true,
- };
-
- const error = generateErrorMessage(radioResponse, {
- value: 'other',
- }, undefined, { showRequiredErrors: true, values: { 'radio-response-other': '' } });
-
- expect(error).toBe('Please fill in Other to continue.');
- });
-});
-
-describe('generateValidation other inputs', () => {
- it('treats radio other without text as empty input', () => {
- const response: Response = {
- id: 'radio-response',
- prompt: 'Radio response',
- type: 'radio',
- required: true,
- options: ['Option 1', 'Option 2'],
- withOther: true,
- };
-
- const validation = generateValidation([response]);
-
- expect(validation[response.id]('other', {
- [response.id]: 'other',
- [`${response.id}-other`]: '',
- })).toBe(REQUIRED_ERROR_MESSAGE);
- });
-
- it('treats checkbox other without text as empty input', () => {
- const response: Response = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- options: ['Option 1', 'Option 2'],
- withOther: true,
- };
-
- const validation = generateValidation([response]);
-
- expect(validation[response.id](['__other'], {
- [response.id]: ['__other'],
- [`${response.id}-other`]: '',
- })).toBe(REQUIRED_ERROR_MESSAGE);
- });
-});
-
-describe('checkCheckboxResponseForValidation', () => {
- it('bypasses checkbox selection-count validation when dont-know is checked', () => {
- const checkboxResponse: CheckboxResponse = {
- id: 'checkbox-response',
- prompt: 'Checkbox response',
- type: 'checkbox',
- required: true,
- minSelections: 2,
- options: ['Option 1', 'Option 2', 'Option 3'],
- withDontKnow: true,
- };
-
- expect(checkCheckboxResponseForValidation(checkboxResponse, [], true)).toBeNull();
- });
-});
-
-describe('shouldBypassValidationForStandaloneDontKnow', () => {
- it('returns true for standalone dont-know responses', () => {
- const response: Response = {
- id: 'q-numerical',
- prompt: 'Numerical example',
- type: 'numerical',
- withDontKnow: true,
- };
-
- expect(shouldBypassValidationForStandaloneDontKnow(response, true)).toBe(true);
- });
-
- it('returns false for matrix responses because dont-know is inline', () => {
- const response: MatrixResponse = {
- id: 'matrix-validation',
- prompt: 'Matrix prompt',
- type: 'matrix-radio',
- required: true,
- answerOptions: ['0', '1'],
- questionOptions: ['q1', 'q2'],
- withDontKnow: true,
- };
-
- expect(shouldBypassValidationForStandaloneDontKnow(response, true)).toBe(false);
- });
-});
-
-describe('normalizeCheckboxDontKnowValue', () => {
- it('clears all selections when the legacy dont-know token is present', () => {
- expect(normalizeCheckboxDontKnowValue(["I don't know", 'Option 1'])).toEqual([]);
- });
-
- it('leaves regular checkbox selections unchanged', () => {
- expect(normalizeCheckboxDontKnowValue(['Option 1'])).toEqual(['Option 1']);
- });
-});
-
-describe('generateErrorMessage requiredValue with dont-know', () => {
- it('suppresses required-value errors when standalone dont-know is checked', () => {
- const numericalResponse: Response = {
- id: 'required-value-response',
- prompt: 'Required numerical response',
- type: 'numerical',
- required: true,
- requiredValue: 42,
- withDontKnow: true,
- };
-
- const error = generateErrorMessage(numericalResponse, {
- value: '',
- }, undefined, { values: { 'required-value-response-dontKnow': true } });
-
- expect(error).toBeNull();
- });
-});
-
-describe('generateErrorMessage matrix', () => {
- const matrixResponse: MatrixResponse = {
- id: 'matrix-validation',
- prompt: 'Matrix prompt',
- type: 'matrix-radio',
- required: true,
- answerOptions: ['0', '1'],
- questionOptions: [
- {
- label: 'Obstructive - Supportive',
- value: 'q1',
- leftLabel: 'Obstructive',
- rightLabel: 'Supportive',
- },
- 'q2',
- ],
- };
-
- it('does not show matrix incomplete message when untouched', () => {
- const error = generateErrorMessage(matrixResponse, {
- value: { q1: '', q2: '' },
- });
-
- expect(error).toBeNull();
- });
-
- it('shows matrix incomplete message after at least one answer is selected and errors are revealed', () => {
- const error = generateErrorMessage(matrixResponse, {
- value: { q1: '0', q2: '' },
- }, undefined, { showRequiredErrors: true });
-
- expect(error).toBe('Please answer all questions in the matrix to continue.');
- });
-
- it('does not show matrix incomplete message when all rows are answered', () => {
- const error = generateErrorMessage(matrixResponse, {
- value: { q1: '0', q2: '1' },
- });
-
- expect(error).toBeNull();
- });
-});
-
-describe('summarizeResponseIssues', () => {
- it('counts unanswered and invalid responses separately', () => {
- const responses: Response[] = [
- {
- id: 'missing-text',
- prompt: 'Missing text',
- type: 'shortText',
- required: true,
- },
- {
- id: 'invalid-number',
- prompt: 'Invalid number',
- type: 'numerical',
- required: true,
- min: 0,
- max: 10,
- },
- {
- id: 'invalid-radio-other',
- prompt: 'Invalid other',
- type: 'radio',
- required: true,
- options: ['A', 'B'],
- withOther: true,
- },
- ];
-
- const summary = summarizeResponseIssues(responses, {
- 'missing-text': '',
- 'invalid-number': 99,
- 'invalid-radio-other': 'other',
- 'invalid-radio-other-other': '',
- });
-
- expect(summary).toEqual({
- unansweredCount: 1,
- invalidCount: 2,
- });
- });
-
- it('treats untouched required matrix responses as unanswered', () => {
- const responses: Response[] = [{
- id: 'matrix-response',
- prompt: 'Matrix',
- type: 'matrix-radio',
- required: true,
- answerOptions: ['A', 'B'],
- questionOptions: ['Q1', 'Q2'],
- }];
-
- const summary = summarizeResponseIssues(responses, {
- 'matrix-response': { Q1: '', Q2: '' },
- });
-
- expect(summary).toEqual({
- unansweredCount: 1,
- invalidCount: 0,
- });
- });
-});
diff --git a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
new file mode 100644
index 0000000000..64aa596136
--- /dev/null
+++ b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
@@ -0,0 +1,141 @@
+import { render, act, cleanup } from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { ScreenRecordingReplay } from '../ScreenRecordingReplay';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+let mockIsAnalysis = false;
+let mockStorageEngine: Record> | null = null;
+let mockSearchParams = new URLSearchParams();
+let mockUpdateReplayRef = vi.fn();
+let mockIsPlaying = false;
+let mockVideoRef: { current: HTMLVideoElement | null } = { current: null };
+let mockCanPlayScreenRecording = false;
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('react-router', () => ({
+ useSearchParams: () => [mockSearchParams, vi.fn()],
+}));
+
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }: { children?: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('../../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: mockStorageEngine }),
+}));
+
+const mockDispatch = vi.fn();
+const mockSetAnalysisHasScreenRecording = vi.fn();
+const mockSetAnalysisCanPlayScreenRecording = vi.fn();
+
+vi.mock('../../../store/store', () => ({
+ useStoreSelector: () => mockCanPlayScreenRecording,
+ useStoreActions: () => ({
+ setAnalysisHasScreenRecording: mockSetAnalysisHasScreenRecording,
+ setAnalysisCanPlayScreenRecording: mockSetAnalysisCanPlayScreenRecording,
+ }),
+ useStoreDispatch: () => mockDispatch,
+}));
+
+vi.mock('../../../routes/utils', () => ({
+ useCurrentIdentifier: () => 'component1',
+}));
+
+vi.mock('../../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: () => mockIsAnalysis,
+}));
+
+vi.mock('../../../store/hooks/useReplay', () => ({
+ useReplayContext: () => ({
+ videoRef: mockVideoRef,
+ updateReplayRef: mockUpdateReplayRef,
+ isPlaying: mockIsPlaying,
+ }),
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('ScreenRecordingReplay', () => {
+ beforeEach(() => {
+ mockIsAnalysis = false;
+ mockStorageEngine = null;
+ mockSearchParams = new URLSearchParams();
+ mockUpdateReplayRef = vi.fn();
+ mockIsPlaying = false;
+ mockVideoRef = { current: null };
+ mockCanPlayScreenRecording = false;
+ mockDispatch.mockClear();
+ });
+
+ afterEach(() => { cleanup(); });
+
+ test('renders without crashing', async () => {
+ const { container } = await act(async () => render());
+ expect(container).toBeDefined();
+ });
+
+ test('does not render video when analysisCanPlayScreenRecording is false', async () => {
+ const { container } = await act(async () => render());
+ expect(container.querySelector('video')).toBeNull();
+ });
+
+ test('dispatches store actions on mount when not in analysis mode', async () => {
+ await act(async () => render());
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ test('covers url=null path when participantId is provided', async () => {
+ // isAnalysis=true, storageEngine returns null URL → dispatches setAnalysisHasScreenRecording(false)
+ mockIsAnalysis = true;
+ mockStorageEngine = { getScreenRecording: vi.fn().mockResolvedValue(null) };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ await act(async () => { render(); });
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ test('sets hasScreenRecording true when URL is returned', async () => {
+ mockIsAnalysis = true;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ await act(async () => { render(); });
+ expect(mockDispatch).toHaveBeenCalledWith(mockSetAnalysisHasScreenRecording(true));
+ });
+
+ // Error-path tests (missing participantId, getScreenRecording rejection) omitted
+ // because the component re-throws in the catch block, producing unhandled promise
+ // rejections that vitest flags as test instability.
+
+ test('sets video src and calls updateReplayRef when videoRef.current exists', async () => {
+ mockIsAnalysis = true;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const mockVideo = { preload: '', src: '' } as Pick as HTMLVideoElement;
+ mockVideoRef = { current: mockVideo };
+ await act(async () => { render(); });
+ expect(mockVideo.src).toBe('http://example.com/video.mp4');
+ expect(mockUpdateReplayRef).toHaveBeenCalled();
+ });
+
+ test('renders video element when analysisCanPlayScreenRecording is true', async () => {
+ mockCanPlayScreenRecording = true;
+ const { container } = await act(async () => render());
+ expect(container.querySelector('video')).not.toBeNull();
+ });
+
+ test('video border is grey when isPlaying is true', async () => {
+ mockCanPlayScreenRecording = true;
+ mockIsPlaying = true;
+ const { container } = await act(async () => render());
+ const video = container.querySelector('video');
+ expect(video).not.toBeNull();
+ expect(video?.style.border).toContain('rgb(204, 204, 204)');
+ });
+});
diff --git a/src/components/settings/tests/GlobalSettings.spec.tsx b/src/components/settings/tests/GlobalSettings.spec.tsx
new file mode 100644
index 0000000000..9032bc4d82
--- /dev/null
+++ b/src/components/settings/tests/GlobalSettings.spec.tsx
@@ -0,0 +1,287 @@
+import { ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ render, act, cleanup, screen, fireEvent,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { GlobalSettings } from '../GlobalSettings';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+let mockIsCloud = true;
+let mockGetUserManagementData = vi.fn().mockResolvedValue(null);
+let mockStorageEngine: Record> | null = null;
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../../../store/hooks/useAuth', () => ({
+ useAuth: () => ({
+ user: { user: { email: 'test@test.com' } },
+ triggerAuth: vi.fn(),
+ logout: vi.fn(),
+ }),
+}));
+
+vi.mock('../../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: mockStorageEngine }),
+}));
+
+vi.mock('../../../storage/engines/utils/storageEngineHelpers', () => ({
+ isCloudStorageEngine: () => mockIsCloud,
+}));
+
+vi.mock('../../../Login', () => ({
+ signIn: vi.fn(),
+}));
+
+vi.mock('../../../storage/engines/SupabaseStorageEngine', () => ({
+ SupabaseStorageEngine: class { },
+}));
+
+vi.mock('@mantine/form', () => ({
+ useForm: () => ({
+ values: { email: '' },
+ getInputProps: () => ({}),
+ onSubmit: (fn: () => void) => fn,
+ setValues: vi.fn(),
+ }),
+ isEmail: () => () => null,
+}));
+
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ ),
+ Box: ({ children, component, onSubmit }: { children: ReactNode; component?: string; onSubmit?: React.FormEventHandler }) => (
+ component === 'form' ? : {children}
+ ),
+ Button: ({
+ children, onClick, type,
+ }: { children: ReactNode; onClick?: () => void; type?: 'button' | 'submit' | 'reset' }) => (
+
+ ),
+ Card: ({ children }: { children: ReactNode }) => {children}
,
+ Container: ({ children }: { children: ReactNode }) => {children}
,
+ Flex: ({ children }: { children: ReactNode }) => {children}
,
+ LoadingOverlay: ({ visible }: { visible: boolean }) => (
+ visible ? : null
+ ),
+ Modal: ({ opened, children, title }: { opened: boolean; children: ReactNode; title?: ReactNode }) => (
+ opened ? (
+
+ {title}
+ {children}
+
+ ) : null
+ ),
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ TextInput: ({ label }: { label?: ReactNode }) => ,
+ Title: ({ children }: { children: ReactNode }) => {children}
,
+ Tooltip: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconAt: () => null,
+ IconTrashX: () => null,
+ IconUserPlus: ({ onClick }: { onClick?: () => void }) => ,
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('GlobalSettings', () => {
+ beforeEach(() => {
+ mockIsCloud = true;
+ mockGetUserManagementData = vi.fn().mockResolvedValue(null);
+ mockStorageEngine = {
+ getUserManagementData: mockGetUserManagementData,
+ getEngine: vi.fn().mockReturnValue('supabase'),
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test('shows "disabled" state initially (no effects in static render)', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Authentication is currently disabled');
+ expect(html).toContain('Enable Authentication');
+ });
+
+ test('does not show loading overlay initially', () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain('data-testid="loading-overlay"');
+ });
+
+ test('shows "enabled" state after effect resolves with isEnabled:true', async () => {
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: true };
+ if (key === 'adminUsers') return { adminUsersList: [{ email: 'test@test.com', uid: '1' }] };
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('Authentication is enabled.')).toBeDefined();
+ expect(screen.queryByText('Authentication is currently disabled.')).toBeNull();
+ });
+
+ test('shows "disabled" state after effect resolves with isEnabled:false', async () => {
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: false };
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('Authentication is currently disabled.')).toBeDefined();
+ });
+
+ test('shows "disabled" state when storageEngine is not a cloud engine', async () => {
+ mockIsCloud = false;
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('Authentication is currently disabled.')).toBeDefined();
+ expect(mockGetUserManagementData).not.toHaveBeenCalled();
+ });
+
+ test('opens enable-auth confirm modal via supabase session on button click', async () => {
+ mockStorageEngine = {
+ getUserManagementData: mockGetUserManagementData,
+ getEngine: vi.fn().mockReturnValue('supabase'),
+ getSession: vi.fn().mockResolvedValue({
+ data: { session: { user: { email: 'test@test.com', id: '123' } } },
+ }),
+ };
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: false };
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Enable Authentication'));
+ });
+
+ expect(screen.getByText('Enable Authentication?')).toBeDefined();
+ });
+
+ test('shows trash button and opens remove modal for non-current admin user', async () => {
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: true };
+ if (key === 'adminUsers') {
+ return { adminUsersList: [{ email: 'other@test.com', uid: '2' }] };
+ }
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('other@test.com')).toBeDefined();
+
+ await act(async () => {
+ const buttons = screen.getAllByRole('button');
+ const trashButton = buttons.find((b) => !b.textContent && b.getAttribute('type') === 'button' && !b.getAttribute('data-testid'));
+ if (trashButton) fireEvent.click(trashButton);
+ });
+
+ // Remove modal should be open (has "Are you sure" text)
+ const dialogs = screen.queryAllByRole('dialog');
+ expect(dialogs.length).toBeGreaterThan(0);
+ });
+
+ test('confirmRemoveUser calls removeAdminUser and refreshes list', async () => {
+ const mockRemoveAdminUser = vi.fn().mockResolvedValue(undefined);
+ mockStorageEngine = {
+ getUserManagementData: mockGetUserManagementData,
+ getEngine: vi.fn().mockReturnValue('supabase'),
+ removeAdminUser: mockRemoveAdminUser,
+ };
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: true };
+ if (key === 'adminUsers') {
+ return { adminUsersList: [{ email: 'other@test.com', uid: '2' }] };
+ }
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ // Open remove modal
+ await act(async () => {
+ const buttons = screen.getAllByRole('button');
+ const trashButton = buttons.find((b) => !b.textContent && b.getAttribute('type') === 'button' && !b.getAttribute('data-testid'));
+ if (trashButton) fireEvent.click(trashButton);
+ });
+
+ // Click "Yes, I'm sure."
+ await act(async () => {
+ fireEvent.click(screen.getByText(/yes.*sure/i));
+ });
+
+ expect(mockRemoveAdminUser).toHaveBeenCalledWith('other@test.com');
+ });
+
+ test('handleAddUser calls addAdminUser and refreshes list', async () => {
+ const mockAddAdminUser = vi.fn().mockResolvedValue(undefined);
+ mockStorageEngine = {
+ getUserManagementData: mockGetUserManagementData,
+ getEngine: vi.fn().mockReturnValue('supabase'),
+ addAdminUser: mockAddAdminUser,
+ };
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: true };
+ if (key === 'adminUsers') return { adminUsersList: [{ email: 'test@test.com', uid: '1' }] };
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ // Open the add-user modal
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('icon-user-plus'));
+ });
+
+ // Modal is open — click Save to submit the form
+ await act(async () => {
+ fireEvent.click(screen.getByText('Save'));
+ });
+
+ expect(mockAddAdminUser).toHaveBeenCalled();
+ });
+
+ test('Log out button is present when auth is enabled', async () => {
+ mockGetUserManagementData.mockImplementation(async (key: string) => {
+ if (key === 'authentication') return { isEnabled: true };
+ if (key === 'adminUsers') {
+ return { adminUsersList: [{ email: 'test@test.com', uid: '1' }] };
+ }
+ return null;
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByRole('button', { name: 'Log out' })).toBeDefined();
+ });
+});
diff --git a/src/components/tests/ConfigSwitcher.spec.tsx b/src/components/tests/ConfigSwitcher.spec.tsx
new file mode 100644
index 0000000000..686c2903c9
--- /dev/null
+++ b/src/components/tests/ConfigSwitcher.spec.tsx
@@ -0,0 +1,251 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup,
+} from '@testing-library/react';
+import {
+ afterEach, describe, expect, test, vi,
+} from 'vitest';
+import type {
+ ParsedConfig, ParserErrorWarning, StudyConfig,
+} from '../../parser/types';
+import { ConfigSwitcher } from '../ConfigSwitcher';
+import { makeGlobalConfig, makeStorageEngine, makeStudyConfig } from '../../tests/utils';
+import { useStorageEngine } from '../../storage/storageEngineHooks';
+import { getSequenceConditions } from '../../utils/handleConditionLogic';
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('@mantine/core', () => ({
+ Anchor: ({ children }: { children: ReactNode }) => {children},
+ AppShell: Object.assign(
+ ({ children }: { children: ReactNode }) => {children}
,
+ { Main: ({ children }: { children: ReactNode }) => {children} },
+ ),
+ Badge: ({ children }: { children: ReactNode }) => {children},
+ Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ ),
+ Card: ({ children }: { children: ReactNode }) => {children}
,
+ Container: ({ children }: { children: ReactNode }) => {children}
,
+ CopyButton: ({ children }: { children: (props: { copied: boolean; copy: () => void }) => ReactNode }) => (
+ {children({ copied: false, copy: vi.fn() })}
+ ),
+ Divider: () =>
,
+ Flex: ({ children }: { children: ReactNode }) => {children}
,
+ Image: ({ src, alt }: { src?: string; alt?: string }) =>
,
+ MultiSelect: () => ,
+ Skeleton: ({ children }: { children?: ReactNode }) => {children}
,
+ rem: (v: number) => `${v}px`,
+ Tabs: Object.assign(
+ ({ children }: { children: ReactNode }) => {children}
,
+ {
+ List: ({ children }: { children: ReactNode }) => {children}
,
+ Tab: ({ children }: { children: ReactNode }) => {children}
,
+ Panel: ({ children }: { children: ReactNode }) => {children}
,
+ },
+ ),
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ Tooltip: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconBan: () => null,
+ IconBrandFirebase: () => null,
+ IconBrandSupabase: () => null,
+ IconChartHistogram: () => null,
+ IconCheck: () => null,
+ IconCopy: () => null,
+ IconDatabase: () => null,
+ IconDeviceDesktop: () => null,
+ IconExternalLink: () => null,
+ IconGraph: () => null,
+ IconGraphOff: () => null,
+ IconListCheck: () => null,
+ IconMicrophone: () => null,
+ IconSchema: () => null,
+ IconSchemaOff: () => null,
+}));
+
+vi.mock('firebase/firestore', () => ({
+ Timestamp: { now: () => ({ toDate: () => new Date() }) },
+}));
+
+vi.mock('react-router', () => ({
+ useNavigate: () => vi.fn(),
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
+}));
+
+vi.mock('../../utils/sanitizeStringForUrl', () => ({
+ sanitizeStringForUrl: (s: string) => s,
+}));
+
+vi.mock('../../utils/Prefix', () => ({ PREFIX: '/' }));
+
+vi.mock('../ErrorLoadingConfig', () => ({
+ ErrorLoadingConfig: () => ,
+}));
+
+vi.mock('../../analysis/interface/ParticipantStatusBadges', () => ({
+ ParticipantStatusBadges: () => ,
+}));
+
+vi.mock('../../storage/storageEngineHooks', () => ({
+ useStorageEngine: vi.fn(() => ({ storageEngine: null })),
+}));
+
+vi.mock('../../store/hooks/useAuth', () => ({
+ useAuth: () => ({ user: { isAdmin: true, determiningStatus: false } }),
+}));
+
+vi.mock('../../storage/engines/utils', () => ({
+ isCloudStorageEngine: () => false,
+}));
+
+vi.mock('../../utils/handleConditionLogic', () => ({
+ getSequenceConditions: vi.fn(() => []),
+}));
+
+vi.mock('../../utils/useStudyRecordings', () => ({
+ useStudyRecordings: () => ({ hasAudio: false, hasScreenRecording: false }),
+}));
+
+vi.mock('../../utils/useDeviceRules', () => ({
+ useDeviceRules: () => ({
+ isBrowserAllowed: true,
+ isDeviceAllowed: true,
+ isInputAllowed: true,
+ isDisplayAllowed: true,
+ }),
+}));
+
+vi.mock('../interface/DeviceRestrictionString', () => ({
+ getUnmetDeviceRestrictionLines: vi.fn(() => []),
+ getUnmetDeviceRestrictionTooltip: vi.fn(() => ''),
+}));
+
+// ── fixtures ──────────────────────────────────────────────────────────────────
+
+const globalConfig = makeGlobalConfig({ configsList: ['test-study'] });
+
+const minimalStudyConfig = makeStudyConfig();
+
+const parsedStudyConfig: ParsedConfig = {
+ ...minimalStudyConfig,
+ errors: [],
+ warnings: [],
+};
+
+const studyConfigs: Record | null> = {
+ 'test-study': parsedStudyConfig,
+};
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+afterEach(() => { cleanup(); });
+
+describe('ConfigSwitcher', () => {
+ test('renders without crashing', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with empty studyConfigs', async () => {
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with empty configsList', async () => {
+ const emptyConfig = makeGlobalConfig();
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders config with errors', async () => {
+ const mockError: ParserErrorWarning = {
+ instancePath: '', message: 'Parse error occurred', params: {}, category: 'invalid-config',
+ };
+ const studyConfigsWithErrors: Record | null> = {
+ 'test-study': {
+ ...minimalStudyConfig,
+ errors: [mockError],
+ warnings: [{ ...mockError, message: 'A warning', category: 'unused-component' }],
+ },
+ };
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders config with warnings but no errors', async () => {
+ const mockWarning: ParserErrorWarning = {
+ instancePath: '', message: 'A warning message', params: {}, category: 'unused-component',
+ };
+ const studyConfigsWithWarnings: Record | null> = {
+ 'test-study': {
+ ...minimalStudyConfig,
+ errors: [],
+ warnings: [mockWarning],
+ },
+ };
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders study with conditions', async () => {
+ vi.mocked(getSequenceConditions).mockReturnValueOnce(['condA', 'condB']);
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container.textContent).toContain('condA');
+ });
+
+ test('tab selection based on configName prefixes (demos, examples, etc.)', async () => {
+ const multiGlobalConfig = makeGlobalConfig({
+ configsList: ['demo-one', 'example-two', 'tutorial-three'],
+ });
+ const multiConfigs: Record | null> = {
+ 'demo-one': parsedStudyConfig,
+ 'example-two': parsedStudyConfig,
+ 'tutorial-three': parsedStudyConfig,
+ };
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with null config entry', async () => {
+ const configsWithNull: Record | null> = {
+ 'test-study': null,
+ };
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+
+ test('renders with a mock storageEngine that returns modes', async () => {
+ const mockEngine = {
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: true, developmentModeEnabled: false, dataSharingEnabled: false }),
+ getParticipantsStatusCounts: vi.fn().mockResolvedValue({
+ completed: 5, inProgress: 2, rejected: 1, minTime: null, maxTime: null,
+ }),
+ getConditionData: vi.fn().mockResolvedValue({ conditionCounts: {} }),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ };
+ vi.mocked(useStorageEngine).mockReturnValueOnce({ storageEngine: makeStorageEngine(mockEngine), setStorageEngine: vi.fn() });
+ const { container } = await act(async () => render(
+ ,
+ ));
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/ErrorLoadingConfig.spec.tsx b/src/components/tests/ErrorLoadingConfig.spec.tsx
similarity index 96%
rename from src/components/ErrorLoadingConfig.spec.tsx
rename to src/components/tests/ErrorLoadingConfig.spec.tsx
index d715eaa76e..bdf2548963 100644
--- a/src/components/ErrorLoadingConfig.spec.tsx
+++ b/src/components/tests/ErrorLoadingConfig.spec.tsx
@@ -1,8 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { MantineProvider } from '@mantine/core';
import { describe, expect, test } from 'vitest';
-import { ParsedConfig, StudyConfig } from '../parser/types';
-import { ErrorLoadingConfig } from './ErrorLoadingConfig';
+import { ParsedConfig, StudyConfig } from '../../parser/types';
+import { ErrorLoadingConfig } from '../ErrorLoadingConfig';
describe('ErrorLoadingConfig', () => {
test('separates non-combinable grouped messages on new lines', () => {
diff --git a/src/components/tests/NextButton.spec.tsx b/src/components/tests/NextButton.spec.tsx
new file mode 100644
index 0000000000..4df32d9565
--- /dev/null
+++ b/src/components/tests/NextButton.spec.tsx
@@ -0,0 +1,296 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup, screen,
+} from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import type { IndividualComponent } from '../../parser/types';
+import { NextButton } from '../NextButton';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+let mockIsNextDisabled = false;
+let mockIdentifier = 'intro_0';
+const mockGoToNextStep = vi.fn();
+const mockNavigate = vi.fn();
+let mockStudyConfig: {
+ uiConfig: {
+ nextButtonDisableTime: number | undefined;
+ nextButtonEnableTime: number | undefined;
+ nextOnEnter: boolean;
+ previousButtonText: string;
+ timeoutReject: boolean;
+ };
+} = {
+ uiConfig: {
+ nextButtonDisableTime: undefined,
+ nextButtonEnableTime: undefined,
+ nextOnEnter: false,
+ previousButtonText: 'Previous',
+ timeoutReject: false,
+ },
+};
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../../store/hooks/useNextStep', () => ({
+ useNextStep: () => ({
+ isNextDisabled: mockIsNextDisabled,
+ goToNextStep: mockGoToNextStep,
+ }),
+}));
+
+vi.mock('../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: () => mockStudyConfig,
+}));
+
+vi.mock('react-router', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+vi.mock('../../routes/utils', () => ({
+ useCurrentIdentifier: () => mockIdentifier,
+}));
+
+vi.mock('../PreviousButton', () => ({
+ PreviousButton: ({ label }: { label?: string }) => (
+
+ ),
+}));
+
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, title }: { children: ReactNode; title?: ReactNode }) => (
+
+ ),
+ Button: ({
+ children, disabled, onClick, type,
+ }: { children: ReactNode; disabled?: boolean; onClick?: () => void; type?: string }) => (
+
+ ),
+ Group: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconAlertTriangle: () => null,
+ IconInfoCircle: () => null,
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('NextButton', () => {
+ beforeEach(() => {
+ mockIsNextDisabled = false;
+ mockIdentifier = 'intro_0';
+ mockNavigate.mockReset();
+ mockStudyConfig = {
+ uiConfig: {
+ nextButtonDisableTime: undefined,
+ nextButtonEnableTime: undefined,
+ nextOnEnter: false,
+ previousButtonText: 'Previous',
+ timeoutReject: false,
+ },
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ test('renders Next button with default label', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Next');
+ });
+
+ test('renders button with custom label', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Continue');
+ });
+
+ test('does not render PreviousButton when config.previousButton is false', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain('data-testid="prev-btn"');
+ });
+
+ test('renders PreviousButton when config.previousButton is true', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-testid="prev-btn"');
+ expect(html).toContain('Back');
+ });
+
+ test('button is disabled when disabled prop is true', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('disabled');
+ });
+
+ test('button is disabled when isNextDisabled is true', () => {
+ mockIsNextDisabled = true;
+ const html = renderToStaticMarkup();
+ expect(html).toContain('disabled');
+ });
+
+ test('renders checkAnswer element when provided', () => {
+ const html = renderToStaticMarkup(
+ Check Answer} onNext={vi.fn()} />,
+ );
+ expect(html).toContain('Check Answer');
+ });
+
+ test('shows "Please wait" alert after render when nextButtonEnableTime is set', async () => {
+ mockStudyConfig = {
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ nextButtonEnableTime: 5000,
+ },
+ };
+ await act(async () => {
+ render(
);
+ });
+ expect(screen.getByRole('alert')).toBeDefined();
+ expect(screen.getByText('Please wait')).toBeDefined();
+ });
+
+ test('does not show "Please wait" alert when no enable time is configured', async () => {
+ await act(async () => {
+ render(
);
+ });
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ test('shows "Next button disables soon" alert when timer is approaching disableTime', async () => {
+ // timer starts at 0; disableTime=5000 means (5000 - 0) = 5000ms < 10000ms and > 0
+ mockStudyConfig = {
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ nextButtonDisableTime: 5000,
+ },
+ };
+ await act(async () => {
+ render(
);
+ });
+ expect(screen.getByText('Next button disables soon')).toBeDefined();
+ });
+
+ test('shows "Next button disabled" alert when timer has passed disableTime', async () => {
+ vi.useFakeTimers();
+ mockStudyConfig = {
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ nextButtonDisableTime: 100,
+ timeoutReject: false,
+ },
+ };
+ await act(async () => {
+ render(
);
+ });
+ // Advance past disableTime (100ms) and into the <10000ms window
+ await act(async () => {
+ vi.advanceTimersByTime(9500);
+ });
+ vi.useRealTimers();
+ expect(screen.getByText('Next button disabled')).toBeDefined();
+ });
+
+ test('does not show "Next button disabled" alert when timeoutReject is true', async () => {
+ vi.useFakeTimers();
+ mockStudyConfig = {
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ nextButtonDisableTime: 100,
+ timeoutReject: true,
+ },
+ };
+ await act(async () => {
+ render(
);
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(9500);
+ });
+ vi.useRealTimers();
+ expect(screen.queryByText('Next button disabled')).toBeNull();
+ });
+
+ test('nextOnEnter: pressing Enter calls goToNextStep', async () => {
+ const onNext = vi.fn();
+ mockStudyConfig = {
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ nextOnEnter: true,
+ },
+ };
+ await act(async () => {
+ render(
);
+ });
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ });
+ expect(onNext).toHaveBeenCalled();
+ });
+
+ test('resets auto-advance state when the current identifier changes', async () => {
+ const config = {
+ type: 'questionnaire',
+ response: [],
+ nextButtonAutoAdvanceTime: 1000,
+ } as unknown as IndividualComponent;
+
+ vi.useFakeTimers();
+ let rerender!: ReturnType
['rerender'];
+
+ await act(async () => {
+ ({ rerender } = render(
+ ,
+ ));
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1100);
+ });
+
+ expect(mockGoToNextStep).toHaveBeenCalledTimes(1);
+ expect(mockGoToNextStep).toHaveBeenLastCalledWith(false);
+
+ mockIdentifier = 'intro_0_followup_1';
+
+ await act(async () => {
+ rerender(
+ ,
+ );
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1100);
+ });
+
+ expect(mockGoToNextStep).toHaveBeenCalledTimes(2);
+ expect(mockGoToNextStep).toHaveBeenLastCalledWith(false);
+ vi.useRealTimers();
+ });
+});
diff --git a/src/components/tests/PreviousButton.spec.tsx b/src/components/tests/PreviousButton.spec.tsx
new file mode 100644
index 0000000000..337dd040bd
--- /dev/null
+++ b/src/components/tests/PreviousButton.spec.tsx
@@ -0,0 +1,69 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { render, screen, fireEvent } from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { ReactNode } from 'react';
+import { PreviousButton } from '../PreviousButton';
+import { usePreviousStep } from '../../store/hooks/usePreviousStep';
+
+const mockGoToPreviousStep = vi.fn();
+const mockDispatch = vi.fn();
+
+vi.mock('@mantine/core', () => ({
+ Button: ({
+ children, disabled, onClick,
+ }: { children: ReactNode; disabled?: boolean; onClick?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('../../store/hooks/usePreviousStep', () => ({
+ usePreviousStep: vi.fn(() => ({
+ isPreviousDisabled: false,
+ goToPreviousStep: mockGoToPreviousStep,
+ })),
+}));
+
+vi.mock('../../store/store', () => ({
+ useStoreActions: vi.fn(() => ({
+ setClickedPrevious: vi.fn(() => ({ type: 'setClickedPrevious' })),
+ })),
+ useStoreDispatch: vi.fn(() => mockDispatch),
+}));
+
+beforeEach(() => vi.clearAllMocks());
+afterEach(() => vi.restoreAllMocks());
+
+describe('PreviousButton', () => {
+ test('renders with the default label "Previous"', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Previous');
+ });
+
+ test('renders a custom label when provided', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Back');
+ });
+
+ test('button is disabled when isPreviousDisabled is true', () => {
+ vi.mocked(usePreviousStep).mockReturnValueOnce({
+ isPreviousDisabled: true, goToPreviousStep: vi.fn(),
+ });
+ const html = renderToStaticMarkup();
+ expect(html).toContain('disabled');
+ });
+
+ test('button is not disabled when isPreviousDisabled is false', () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain('disabled=""');
+ });
+
+ test('dispatches setClickedPrevious and calls goToPreviousStep on click', () => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByText('Previous'));
+ expect(mockDispatch).toHaveBeenCalled();
+ expect(mockGoToPreviousStep).toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/src/components/tests/ReactMarkdownWrapper.spec.tsx b/src/components/tests/ReactMarkdownWrapper.spec.tsx
new file mode 100644
index 0000000000..116f5e9ac0
--- /dev/null
+++ b/src/components/tests/ReactMarkdownWrapper.spec.tsx
@@ -0,0 +1,155 @@
+import { ReactNode } from 'react';
+import { render } from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ describe, expect, test, vi,
+} from 'vitest';
+import { ReactMarkdownWrapper } from '../ReactMarkdownWrapper';
+
+vi.mock('@mantine/core', () => ({
+ Anchor: ({ children, href }: { children: ReactNode; href?: string }) => {children},
+ Code: ({ children }: { children: ReactNode }) => {children},
+ Image: ({ src, alt }: { src?: string; alt?: string }) =>
,
+ List: Object.assign(
+ ({ children }: { children: ReactNode }) => ,
+ { Item: ({ children }: { children: ReactNode }) => {children} },
+ ),
+ Table: Object.assign(
+ ({ children }: { children: ReactNode }) => ,
+ {
+ Thead: ({ children }: { children: ReactNode }) => {children},
+ Tbody: ({ children }: { children: ReactNode }) => {children},
+ Tr: ({ children }: { children: ReactNode }) => {children}
,
+ Th: ({ children }: { children: ReactNode }) => {children} | ,
+ Td: ({ children }: { children: ReactNode }) => {children} | ,
+ },
+ ),
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ Title: ({ children, order }: { children: ReactNode; order?: number }) => {
+ const level = order ?? 1;
+ if (level === 2) return {children}
;
+ if (level === 3) return {children}
;
+ if (level === 4) return {children}
;
+ if (level === 5) return {children}
;
+ if (level === 6) return {children}
;
+ return {children}
;
+ },
+}));
+
+vi.mock('../../utils/Prefix', () => ({ PREFIX: '/' }));
+
+describe('ReactMarkdownWrapper', () => {
+ test('renders nothing when text is empty', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toBe('');
+ });
+
+ test('renders plain text', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('Hello world');
+ });
+
+ test('renders heading levels', () => {
+ const { container } = render();
+ expect(container.querySelector('h1') ?? container.textContent).toBeDefined();
+ });
+
+ test('renders inline mode without block wrapper', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('Inline');
+ expect(container.textContent).toContain('text');
+ });
+
+ test('appends asterisk to required text', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('*');
+ });
+
+ test('renders markdown with multiple headings', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('H2');
+ expect(container.textContent).toContain('H3');
+ });
+
+ test('renders links', () => {
+ const { container } = render();
+ const anchor = container.querySelector('a');
+ expect(anchor).not.toBeNull();
+ expect(anchor?.href).toContain('example.com');
+ });
+
+ test('renders code blocks', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('inline code');
+ });
+
+ test('renders lists', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('Item 1');
+ expect(container.textContent).toContain('Item 2');
+ });
+
+ test('renders required with no text node (element-only leaf)', () => {
+ // A required marker on text ending with an inline element (bold) exercises
+ // the else branch in the rehypeAsterisk plugin.
+ const { container } = render();
+ expect(container.textContent).toContain('bold text');
+ expect(container.textContent).toContain('*');
+ });
+
+ test('renders h4, h5, h6 headings', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('H4');
+ expect(container.textContent).toContain('H5');
+ expect(container.textContent).toContain('H6');
+ });
+
+ test('renders ordered list', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('First');
+ expect(container.textContent).toContain('Second');
+ });
+
+ test('renders table with header and body', () => {
+ const md = '| A | B |\n|---|---|\n| 1 | 2 |';
+ const { container } = render();
+ expect(container.textContent).toContain('A');
+ expect(container.textContent).toContain('1');
+ });
+
+ test('renders img with relative src via PREFIX', () => {
+ const { container } = render();
+ const img = container.querySelector('img');
+ expect(img).not.toBeNull();
+ });
+
+ test('renders img with absolute src', () => {
+ const { container } = render();
+ const img = container.querySelector('img');
+ expect(img).not.toBeNull();
+ expect(img?.src).toContain('example.com');
+ });
+
+ test('required asterisk: beforeLastWord empty (single word text)', () => {
+ // Single word → beforeLastWord = '' → conditional spread produces []
+ const { container } = render();
+ expect(container.textContent).toContain('Word');
+ expect(container.textContent).toContain('*');
+ });
+
+ test('required asterisk: last child is an element not text', () => {
+ // word
→ b has children [text("word"), br]
+ // → lastNode.children.at(-1) = br (element, not text) → ELSE branch
+ const { container } = render();
+ expect(container.textContent).toContain('word');
+ expect(container.textContent).toContain('*');
+ });
+
+ test('required asterisk: text with trailing space (afterLastWord non-empty)', () => {
+ // "hello world" → beforeLastWord="hello ", lastWord="world", afterLastWord=""
+ // but inline mode produces a span so slightly different path
+ const { container } = render();
+ expect(container.textContent).toContain('world');
+ expect(container.textContent).toContain('*');
+ });
+});
diff --git a/src/components/tests/Shell.spec.tsx b/src/components/tests/Shell.spec.tsx
new file mode 100644
index 0000000000..38d5aa9146
--- /dev/null
+++ b/src/components/tests/Shell.spec.tsx
@@ -0,0 +1,321 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup, waitFor,
+} from '@testing-library/react';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { useRoutes } from 'react-router';
+import { Shell } from '../Shell';
+import type { ParsedConfig, StudyConfig } from '../../parser/types';
+import { getStudyConfig, resolveConfigKey } from '../../utils/fetchConfig';
+import { makeGlobalConfig, makeStudyConfig } from '../../tests/utils';
+import { studyStoreCreator } from '../../store/store';
+import { parseConditionParam } from '../../utils/handleConditionLogic';
+import { parseStudyConfig } from '../../parser/parser';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+let mockStudyId = 'test-study';
+let mockStorageEngine: Record> | null = null;
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../../routes/utils', () => ({
+ useStudyId: () => mockStudyId,
+}));
+
+vi.mock('../../utils/fetchConfig', () => ({
+ getStudyConfig: vi.fn().mockResolvedValue(null),
+ resolveConfigKey: vi.fn(() => 'test-study'),
+}));
+
+vi.mock('../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: mockStorageEngine }),
+}));
+
+vi.mock('../../utils/handleRandomSequences', () => ({
+ generateSequenceArray: vi.fn().mockResolvedValue([{
+ id: 'root', order: 'fixed', components: [], skip: [], orderPath: 'root',
+ }]),
+}));
+
+vi.mock('../../parser/parser', () => ({
+ parseStudyConfig: vi.fn().mockResolvedValue({ components: {}, sequences: {} }),
+}));
+
+vi.mock('../../utils/handleConditionLogic', () => ({
+ filterSequenceByCondition: vi.fn().mockReturnValue({
+ id: 'root', order: 'fixed', components: [], skip: [], orderPath: 'root',
+ }),
+ parseConditionParam: vi.fn(() => []),
+ resolveParticipantConditions: vi.fn(() => []),
+}));
+
+vi.mock('../../utils/encryptDecryptIndex', () => ({
+ encryptIndex: vi.fn((x: number) => String(x)),
+}));
+
+vi.mock('../../storage/engines/utils', () => ({
+ hash: vi.fn(() => 'abc123'),
+}));
+
+vi.mock('../ErrorLoadingConfig', () => ({
+ ErrorLoadingConfig: () => ,
+}));
+
+vi.mock('../../ResourceNotFound', () => ({
+ ResourceNotFound: () => ,
+}));
+
+vi.mock('../StepRenderer', () => ({
+ StepRenderer: () => ,
+}));
+
+vi.mock('../../controllers/ComponentController', () => ({
+ ComponentController: () => ,
+}));
+
+vi.mock('../../utils/NavigateWithParams', () => ({
+ NavigateWithParams: () => null,
+}));
+
+vi.mock('react-router', () => ({
+ useRoutes: vi.fn(() => ),
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
+}));
+
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ ),
+ LoadingOverlay: ({ visible }: { visible: boolean }) => (
+ visible ? : null
+ ),
+ Stack: ({ children }: { children: ReactNode }) => {children}
,
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ Title: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('react-redux', () => ({
+ Provider: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('../../store/store', () => ({
+ studyStoreCreator: vi.fn().mockResolvedValue({
+ store: { getState: vi.fn(), dispatch: vi.fn(), subscribe: vi.fn() },
+ }),
+ StudyStoreContext: {
+ Provider: ({ children }: { children: ReactNode }) => {children}
,
+ },
+}));
+
+// ── fixtures ──────────────────────────────────────────────────────────────────
+
+const globalConfig = makeGlobalConfig({
+ configsList: ['test-study'],
+ configs: { 'test-study': { path: 'test-study.json' } },
+});
+
+const mockActiveConfig: ParsedConfig = {
+ ...makeStudyConfig(),
+ errors: [],
+ warnings: [],
+};
+
+const baseSession = {
+ participantId: 'p1',
+ participantConfigHash: 'abc123', // matches hash() mock default return
+ searchParams: {},
+ conditions: [],
+ sequence: {
+ id: 'root', order: 'fixed', components: [], skip: [], orderPath: 'root',
+ },
+ completed: false,
+ answers: {},
+};
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('Shell', () => {
+ beforeEach(() => {
+ mockStudyId = 'test-study';
+ mockStorageEngine = null;
+ vi.mocked(getStudyConfig).mockResolvedValue(null);
+ vi.mocked(resolveConfigKey).mockReturnValue('test-study');
+ vi.mocked(parseConditionParam).mockReturnValue([]);
+ vi.mocked(useRoutes).mockReturnValue();
+ // jsdom doesn't provide window.screen.orientation; stub it so Shell.tsx doesn't throw
+ vi.stubGlobal('screen', {
+ width: 1920,
+ height: 1080,
+ availWidth: 1920,
+ availHeight: 1080,
+ colorDepth: 24,
+ pixelDepth: 24,
+ orientation: { type: 'landscape-primary' },
+ });
+ // Always stub fetch so initializeUserStoreRouting never makes real network calls
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ test('shows loading overlay when routes are not yet initialized', async () => {
+ const { getByTestId } = await act(async () => render());
+ expect(getByTestId('loading-overlay')).toBeDefined();
+ });
+
+ test('shows ResourceNotFound for an invalid study ID', async () => {
+ vi.mocked(resolveConfigKey).mockReturnValue(null);
+ const { getByTestId } = await act(async () => render());
+ expect(getByTestId('resource-not-found')).toBeDefined();
+ });
+
+ test('__revisit-widget: canonicalStudyId returns routeStudyId', async () => {
+ mockStudyId = '__revisit-widget';
+ const { getByTestId } = await act(async () => render());
+ // Widget is a valid study (isValidStudyId = true) but no activeConfig yet → loading
+ expect(getByTestId('loading-overlay')).toBeDefined();
+ });
+
+ test('__revisit-widget: registers message listener, posts READY, handles CONFIG message', async () => {
+ mockStudyId = '__revisit-widget';
+ const addEventSpy = vi.spyOn(window, 'addEventListener');
+ const postMessageSpy = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => { });
+
+ const { unmount } = await act(async () => render());
+
+ expect(addEventSpy).toHaveBeenCalledWith('message', expect.any(Function));
+ expect(postMessageSpy).toHaveBeenCalledWith({ type: 'revisitWidget/READY' }, '*');
+
+ // Dispatch CONFIG to cover the listener body
+ await act(async () => {
+ window.dispatchEvent(new MessageEvent('message', {
+ data: { type: 'revisitWidget/CONFIG', payload: '{}' },
+ }));
+ });
+ await waitFor(() => expect(vi.mocked(parseStudyConfig)).toHaveBeenCalled());
+
+ // Unmount triggers cleanup (removeEventListener)
+ unmount();
+
+ addEventSpy.mockRestore();
+ postMessageSpy.mockRestore();
+ });
+
+ test('happy path: initializeUserStoreRouting runs and renders study', async () => {
+ vi.mocked(getStudyConfig).mockResolvedValue(mockActiveConfig);
+
+ mockStorageEngine = {
+ initializeStudyDb: vi.fn().mockResolvedValue(undefined),
+ saveConfig: vi.fn().mockResolvedValue(undefined),
+ getSequenceArray: vi.fn().mockResolvedValue(['seq1']), // non-null → no setSequenceArray
+ getModes: vi.fn().mockResolvedValue({ developmentModeEnabled: false, dataSharingEnabled: false, dataCollectionEnabled: true }),
+ initializeParticipantSession: vi.fn().mockResolvedValue(baseSession),
+ getParticipantCompletionStatus: vi.fn().mockResolvedValue(false),
+ peekCurrentParticipantId: vi.fn().mockResolvedValue(undefined),
+ getAllConfigsFromHash: vi.fn().mockResolvedValue({}),
+ isConnected: vi.fn().mockReturnValue(true),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ };
+
+ render();
+ await waitFor(() => expect(mockStorageEngine!.initializeStudyDb).toHaveBeenCalled(), { timeout: 3000 });
+ await waitFor(() => expect(vi.mocked(studyStoreCreator)).toHaveBeenCalled(), { timeout: 3000 });
+ });
+
+ test('calls setSequenceArray when getSequenceArray returns null', async () => {
+ vi.mocked(getStudyConfig).mockResolvedValue(mockActiveConfig);
+
+ mockStorageEngine = {
+ initializeStudyDb: vi.fn().mockResolvedValue(undefined),
+ saveConfig: vi.fn().mockResolvedValue(undefined),
+ getSequenceArray: vi.fn().mockResolvedValue(null), // null → calls setSequenceArray
+ setSequenceArray: vi.fn().mockResolvedValue(undefined),
+ getModes: vi.fn().mockResolvedValue({ developmentModeEnabled: false, dataSharingEnabled: false, dataCollectionEnabled: true }),
+ initializeParticipantSession: vi.fn().mockResolvedValue(baseSession),
+ getParticipantCompletionStatus: vi.fn().mockResolvedValue(false),
+ peekCurrentParticipantId: vi.fn().mockResolvedValue(undefined),
+ getAllConfigsFromHash: vi.fn().mockResolvedValue({}),
+ isConnected: vi.fn().mockReturnValue(true),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ };
+
+ render();
+ await waitFor(() => expect(mockStorageEngine!.setSequenceArray).toHaveBeenCalled(), { timeout: 3000 });
+ });
+
+ test('covers study condition update path', async () => {
+ vi.mocked(getStudyConfig).mockResolvedValue(mockActiveConfig);
+ vi.mocked(parseConditionParam).mockReturnValue(['condA']);
+
+ mockStorageEngine = {
+ initializeStudyDb: vi.fn().mockResolvedValue(undefined),
+ saveConfig: vi.fn().mockResolvedValue(undefined),
+ getSequenceArray: vi.fn().mockResolvedValue(['seq1']),
+ getModes: vi.fn().mockResolvedValue({ developmentModeEnabled: true, dataSharingEnabled: true, dataCollectionEnabled: true }),
+ initializeParticipantSession: vi.fn().mockResolvedValue({
+ ...baseSession,
+ conditions: ['condA'],
+ searchParams: { condition: 'condA' },
+ }),
+ updateParticipantSearchParams: vi.fn().mockResolvedValue(undefined),
+ updateStudyCondition: vi.fn().mockResolvedValue(undefined),
+ getParticipantCompletionStatus: vi.fn().mockResolvedValue(false),
+ peekCurrentParticipantId: vi.fn().mockResolvedValue(undefined),
+ getAllConfigsFromHash: vi.fn().mockResolvedValue({}),
+ isConnected: vi.fn().mockReturnValue(true),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ };
+
+ render();
+ await waitFor(() => expect(mockStorageEngine!.updateStudyCondition).toHaveBeenCalled(), { timeout: 3000 });
+ });
+
+ test('covers participantConfigHash mismatch → getAllConfigsFromHash', async () => {
+ vi.mocked(getStudyConfig).mockResolvedValue(mockActiveConfig);
+
+ mockStorageEngine = {
+ initializeStudyDb: vi.fn().mockResolvedValue(undefined),
+ saveConfig: vi.fn().mockResolvedValue(undefined),
+ getSequenceArray: vi.fn().mockResolvedValue(['seq1']),
+ getModes: vi.fn().mockResolvedValue({ developmentModeEnabled: false, dataSharingEnabled: false, dataCollectionEnabled: true }),
+ initializeParticipantSession: vi.fn().mockResolvedValue({
+ ...baseSession,
+ participantConfigHash: 'differentHash', // differs from hash() mock ('abc123')
+ }),
+ getParticipantCompletionStatus: vi.fn().mockResolvedValue(false),
+ peekCurrentParticipantId: vi.fn().mockResolvedValue(undefined),
+ getAllConfigsFromHash: vi.fn().mockResolvedValue({ differentHash: mockActiveConfig }),
+ isConnected: vi.fn().mockReturnValue(true),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ };
+
+ render();
+ await waitFor(() => expect(mockStorageEngine!.getAllConfigsFromHash).toHaveBeenCalled(), { timeout: 3000 });
+ });
+
+ test('covers catch block when initializeStudyDb rejects', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.mocked(getStudyConfig).mockResolvedValue(mockActiveConfig);
+
+ mockStorageEngine = {
+ initializeStudyDb: vi.fn().mockRejectedValue(new Error('db init failed')),
+ isConnected: vi.fn().mockReturnValue(true),
+ getEngine: vi.fn().mockReturnValue('firebase'),
+ getModes: vi.fn().mockResolvedValue({ developmentModeEnabled: true, dataSharingEnabled: true, dataCollectionEnabled: true }),
+ getParticipantCompletionStatus: vi.fn().mockResolvedValue(false),
+ peekCurrentParticipantId: vi.fn().mockResolvedValue(undefined),
+ };
+
+ render();
+ await waitFor(() => expect(vi.mocked(studyStoreCreator)).toHaveBeenCalled(), { timeout: 3000 });
+
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/src/components/tests/StepRenderer.spec.tsx b/src/components/tests/StepRenderer.spec.tsx
new file mode 100644
index 0000000000..d027d07881
--- /dev/null
+++ b/src/components/tests/StepRenderer.spec.tsx
@@ -0,0 +1,208 @@
+import { ReactNode } from 'react';
+import { render, act } from '@testing-library/react';
+import {
+ describe, expect, test, vi,
+} from 'vitest';
+import { StepRenderer } from '../StepRenderer';
+import { shouldConfirmTabClose } from '../../utils/closeTabConfirmation';
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../interface/AppAside', () => ({
+ AppAside: () => ,
+}));
+
+vi.mock('../interface/AppHeader', () => ({
+ AppHeader: () => ,
+}));
+
+vi.mock('../interface/AppNavBar', () => ({
+ AppNavBar: () => ,
+}));
+
+vi.mock('../interface/HelpModal', () => ({
+ HelpModal: () => null,
+}));
+
+vi.mock('../interface/AlertModal', () => ({
+ AlertModal: () => null,
+}));
+
+vi.mock('../interface/ConfigVersionWarningModal', () => ({
+ ConfigVersionWarningModal: () => null,
+}));
+
+vi.mock('../interface/AnalysisFooter', () => ({
+ AnalysisFooter: () => null,
+}));
+
+vi.mock('../interface/ScreenRecordingRejection', () => ({
+ ScreenRecordingRejection: () => null,
+}));
+
+vi.mock('../interface/DeviceWarning', () => ({
+ DeviceWarning: () => null,
+}));
+
+vi.mock('../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: () => ({
+ studyRules: undefined,
+ uiConfig: {
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ windowEventDebounceTime: 100,
+ },
+ components: {},
+ sequences: {},
+ }),
+}));
+
+vi.mock('../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: () => false,
+}));
+
+vi.mock('../../store/hooks/useWindowEvents', () => ({
+ WindowEventsContext: { Provider: ({ children }: { children: ReactNode }) => {children} },
+}));
+
+vi.mock('../../store/hooks/useRecording', () => ({
+ useRecording: () => ({ isRejected: false }),
+ RecordingContext: { Provider: ({ children }: { children: ReactNode }) => {children} },
+}));
+
+vi.mock('../../store/hooks/useReplay', () => ({
+ useReplay: () => ({
+ seekTime: 0,
+ setSeekTime: vi.fn(),
+ duration: 0,
+ speed: 1,
+ isPlaying: false,
+ setIsPlaying: vi.fn(),
+ updateReplayRef: vi.fn(),
+ setSpeed: vi.fn(),
+ forceEmitTimeUpdate: vi.fn(),
+ setDuration: vi.fn(),
+ hasEnded: false,
+ replayEvent: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+ }),
+ ReplayContext: { Provider: ({ children }: { children: ReactNode }) => {children} },
+}));
+
+vi.mock('../../store/store', () => ({
+ useStoreSelector: (selector: (s: Record) => unknown) => selector({
+ showStudyBrowser: false,
+ modes: { developmentModeEnabled: false, dataCollectionEnabled: true },
+ completed: false,
+ analysisHasScreenRecording: false,
+ analysisCanPlayScreenRecording: false,
+ }),
+ useStoreDispatch: () => vi.fn(),
+ useStoreActions: () => ({ toggleStudyBrowser: vi.fn() }),
+}));
+
+vi.mock('../../routes/utils', () => ({
+ useCurrentComponent: () => 'intro',
+}));
+
+vi.mock('../../utils/handleComponentInheritance', () => ({
+ studyComponentToIndividualComponent: vi.fn(() => ({
+ withSidebar: true,
+ sidebarWidth: 300,
+ showTitleBar: true,
+ windowEventDebounceTime: 100,
+ })),
+}));
+
+vi.mock('../../utils/fetchStylesheet', () => ({
+ useFetchStylesheet: vi.fn(),
+}));
+
+vi.mock('../../utils/closeTabConfirmation', () => ({
+ handleBeforeUnload: vi.fn(),
+ shouldConfirmTabClose: vi.fn(() => false),
+}));
+
+vi.mock('react-router', () => ({
+ Outlet: () => ,
+}));
+
+vi.mock('@mantine/core', () => ({
+ AppShell: Object.assign(
+ ({ children }: { children: ReactNode }) => {children}
,
+ {
+ Header: ({ children }: { children: ReactNode }) => ,
+ Navbar: ({ children }: { children: ReactNode }) => ,
+ Aside: ({ children }: { children: ReactNode }) => ,
+ Main: ({ children }: { children: ReactNode }) => {children},
+ Footer: ({ children }: { children: ReactNode }) => ,
+ },
+ ),
+ Button: ({ children }: { children: ReactNode }) => ,
+ Flex: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconArrowLeft: () => null,
+}));
+
+vi.mock('lodash.debounce', () => ({
+ default: (fn: (...args: unknown[]) => unknown) => fn,
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('StepRenderer', () => {
+ test('renders the app shell', async () => {
+ const { getByTestId } = await act(async () => render());
+ expect(getByTestId('app-shell')).toBeDefined();
+ });
+
+ test('renders outlet (study content area)', async () => {
+ const { getAllByTestId } = await act(async () => render());
+ expect(getAllByTestId('outlet').length).toBeGreaterThan(0);
+ });
+
+ test('window event listeners fire debounced callbacks', async () => {
+ const addSpy = vi.spyOn(window, 'addEventListener');
+ await act(async () => render());
+
+ // Verify that event listeners were registered for tracked events
+ const registeredEvents = addSpy.mock.calls.map(([event]) => event);
+ expect(registeredEvents).toContain('focus');
+ expect(registeredEvents).toContain('mousemove');
+ expect(registeredEvents).toContain('scroll');
+
+ // Fire each tracked window/document event so the debounce callbacks (which
+ // now call through immediately thanks to the lodash.debounce mock) are covered.
+ window.dispatchEvent(new Event('focus', { bubbles: true }));
+ window.dispatchEvent(new InputEvent('input', { data: 'x' }));
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
+ window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1, clientY: 2 }));
+ window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1, clientY: 2 }));
+ window.dispatchEvent(new Event('resize'));
+ window.dispatchEvent(new MouseEvent('mousemove', { clientX: 3, clientY: 4 }));
+ window.dispatchEvent(new Event('scroll'));
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ addSpy.mockRestore();
+ });
+
+ test('cleanup removes all event listeners on unmount', async () => {
+ const { unmount } = await act(async () => render());
+ // Unmount triggers the useEffect cleanup which removes all event listeners
+ expect(() => act(() => { unmount(); })).not.toThrow();
+ });
+
+ test('beforeunload listener added when shouldConfirmClose is true', async () => {
+ vi.mocked(shouldConfirmTabClose).mockReturnValue(true);
+ const addEventSpy = vi.spyOn(window, 'addEventListener');
+ const { unmount } = await act(async () => render());
+ expect(addEventSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
+ // Unmount triggers cleanup: removeEventListener
+ act(() => { unmount(); });
+ addEventSpy.mockRestore();
+ vi.mocked(shouldConfirmTabClose).mockReturnValue(false);
+ });
+});
diff --git a/src/components/tests/StudyEnd.spec.tsx b/src/components/tests/StudyEnd.spec.tsx
new file mode 100644
index 0000000000..a44d23422f
--- /dev/null
+++ b/src/components/tests/StudyEnd.spec.tsx
@@ -0,0 +1,347 @@
+import { ReactNode } from 'react';
+import {
+ render, act, cleanup, screen, waitFor,
+} from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { StudyEnd } from '../StudyEnd';
+import { download } from '../downloader/DownloadTidy';
+
+// ── mutable state ─────────────────────────────────────────────────────────────
+
+function createDeferred() {
+ let resolve!: (value: T) => void;
+
+ return {
+ promise: new Promise((res) => {
+ resolve = res;
+ }),
+ resolve,
+ };
+}
+
+let mockIsAnalysis = false;
+type FinalizeParticipantResult = { status: 'complete' | 'retry' | 'error' };
+type MockStorageEngine = {
+ finalizeParticipant?: () => Promise;
+ getModes: () => Promise<{ dataCollectionEnabled: boolean }>;
+ getCurrentParticipantId: () => Promise;
+ getParticipantData: () => Promise;
+ getCurrentParticipantDataSnapshot: () => unknown;
+};
+
+let mockStudyConfig: {
+ studyMetadata: { title: string };
+ uiConfig: {
+ studyEndMsg: string | undefined;
+ autoDownloadStudy: boolean;
+ autoDownloadTime: number | undefined;
+ studyEndAutoRedirectURL: string | undefined;
+ studyEndAutoRedirectDelay: number | undefined;
+ urlParticipantIdParam: string | undefined;
+ };
+} = {
+ studyMetadata: { title: 'Test Study' },
+ uiConfig: {
+ studyEndMsg: undefined,
+ autoDownloadStudy: false,
+ autoDownloadTime: undefined,
+ studyEndAutoRedirectURL: undefined,
+ studyEndAutoRedirectDelay: undefined,
+ urlParticipantIdParam: undefined,
+ },
+};
+let mockStorageEngine: MockStorageEngine | undefined;
+let mockDataCollectionEnabled = false;
+const mockFinalizeLoopHandlers: { onComplete?: () => void; onUnexpectedError?: (error: unknown) => void } = {};
+
+// ── mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: () => mockStudyConfig,
+}));
+
+vi.mock('../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: () => mockIsAnalysis,
+}));
+
+vi.mock('../../storage/storageEngineHooks', () => ({
+ useStorageEngine: () => ({ storageEngine: mockStorageEngine }),
+}));
+
+vi.mock('../../store/store', () => ({
+ useStoreDispatch: () => vi.fn(),
+ useStoreActions: () => ({ setParticipantCompleted: vi.fn(), setIsSubmittingFinal: vi.fn() }),
+ useStoreSelector: (selector: (state: { modes: { dataCollectionEnabled: boolean } }) => unknown) => selector({ modes: { dataCollectionEnabled: mockDataCollectionEnabled } }),
+}));
+
+vi.mock('../StudyEnd.utils', () => ({
+ DEFAULT_STUDY_END_FINALIZE_STATE: {
+ error: null,
+ failedAttemptCount: 0,
+ isRetryingAutomatically: false,
+ manualRetryRequired: false,
+ retryAllowed: true,
+ retryDelayMs: null,
+ },
+ createStudyEndFinalizeLoop: ({ onComplete, onUnexpectedError }: { onComplete: () => void; onUnexpectedError: (error: unknown) => void }) => {
+ mockFinalizeLoopHandlers.onComplete = onComplete;
+ mockFinalizeLoopHandlers.onUnexpectedError = onUnexpectedError;
+ return {
+ start: async () => {
+ if (!mockStorageEngine) return;
+ try {
+ const result = await mockStorageEngine.finalizeParticipant?.();
+ if (result?.status === 'complete') {
+ onComplete();
+ }
+ } catch (error) {
+ onUnexpectedError(error);
+ }
+ },
+ cancel: vi.fn(),
+ retryNow: vi.fn(),
+ };
+ },
+}));
+
+vi.mock('../../routes/utils', () => ({
+ useStudyId: () => 'test-study',
+}));
+
+vi.mock('../../utils/useDisableBrowserBack', () => ({
+ useDisableBrowserBack: vi.fn(),
+}));
+
+vi.mock('../downloader/DownloadTidy', () => ({
+ download: vi.fn(),
+}));
+
+vi.mock('../ReactMarkdownWrapper', () => ({
+ ReactMarkdownWrapper: ({ text }: { text: string }) => (
+ {text}
+ ),
+}));
+
+vi.mock('@mantine/core', () => ({
+ Center: ({ children }: { children: ReactNode }) => {children}
,
+ Flex: ({ children }: { children: ReactNode }) => {children}
,
+ Loader: () => ,
+ Space: () => ,
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('StudyEnd', () => {
+ beforeEach(() => {
+ mockIsAnalysis = false;
+ mockStudyConfig = {
+ studyMetadata: { title: 'Test Study' },
+ uiConfig: {
+ studyEndMsg: undefined,
+ autoDownloadStudy: false,
+ autoDownloadTime: undefined,
+ studyEndAutoRedirectURL: undefined,
+ studyEndAutoRedirectDelay: undefined,
+ urlParticipantIdParam: undefined,
+ },
+ };
+ mockDataCollectionEnabled = false;
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockResolvedValue({ status: 'complete' }),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test('shows "Thank you" message by default (data collection disabled)', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Thank you for completing the study');
+ });
+
+ test('shows custom studyEndMsg when configured', () => {
+ mockStudyConfig = {
+ ...mockStudyConfig,
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ studyEndMsg: 'Custom end message',
+ },
+ };
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Custom end message');
+ });
+
+ test('shows loader when data collection is enabled and not yet complete', async () => {
+ const finalizeParticipant = createDeferred<{ status: 'complete' | 'retry' | 'error' }>();
+ mockDataCollectionEnabled = true;
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockImplementation(() => finalizeParticipant.promise),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: true }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('Please wait while your answers are uploaded.')).toBeDefined();
+ expect(screen.getByTestId('loader')).toBeDefined();
+ });
+
+ test('shows "Thank you" after completion in analysis mode', async () => {
+ mockIsAnalysis = true;
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('Thank you for completing the study. You may close this window now.')).toBeDefined();
+ expect(screen.queryByTestId('loader')).toBeNull();
+ });
+
+ test('renders markdown wrapper for custom studyEndMsg', async () => {
+ mockIsAnalysis = true;
+ mockStudyConfig = {
+ ...mockStudyConfig,
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ studyEndMsg: '# Study Complete',
+ },
+ };
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByTestId('markdown').textContent).toBe('# Study Complete');
+ });
+
+ test('sets completed when finalizeParticipant resolves complete', async () => {
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockResolvedValue({ status: 'complete' }),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ await act(async () => { render(); });
+ // finalizeParticipant returns complete -> completed set to true -> loader disappears
+ expect(screen.queryByTestId('loader')).toBeNull();
+ });
+
+ test('handles finalizeParticipant error gracefully', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockRejectedValue(new Error('network error')),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ await act(async () => { render(); });
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('error'),
+ expect.any(Error),
+ );
+ consoleSpy.mockRestore();
+ });
+
+ test('does not crash when storageEngine is undefined and data collection is disabled', async () => {
+ mockStorageEngine = undefined;
+ await act(async () => { render(); });
+ expect(screen.getByText('Thank you for completing the study. You may close this window now.')).toBeDefined();
+ });
+
+ test('autoDownload fires when completed and delayCounter <= 0', async () => {
+ const downloadSpy = vi.mocked(download);
+ mockIsAnalysis = true; // analysis mode sets completed=true immediately
+ mockStudyConfig = {
+ ...mockStudyConfig,
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ autoDownloadStudy: true,
+ autoDownloadTime: undefined, // → autoDownloadDelay = -1, delayCounter = -1 ≤ 0
+ },
+ };
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockResolvedValue({ status: 'complete' }),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue({ participantId: 'p1' }),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ await act(async () => { render(); });
+ await waitFor(() => { expect(downloadSpy).toHaveBeenCalled(); });
+ });
+
+ test('replaces {PARTICIPANT_ID} in studyEndMsg when urlParticipantIdParam is set', async () => {
+ mockIsAnalysis = true;
+ mockStudyConfig = {
+ ...mockStudyConfig,
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ studyEndMsg: 'Hello {PARTICIPANT_ID}!',
+ urlParticipantIdParam: 'pid',
+ },
+ };
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockResolvedValue({ status: 'complete' }),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p42'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ await act(async () => { render(); });
+ await waitFor(() => {
+ expect(screen.getByTestId('markdown').textContent).toBe('Hello p42!');
+ });
+ });
+
+ test('sets up redirect timeout when studyEndAutoRedirectURL configured', async () => {
+ vi.useFakeTimers();
+ // jsdom's window.location.replace is non-configurable; replace the whole location object
+ const replaceSpy = vi.fn();
+ const origLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ replace: replaceSpy, assign: vi.fn(), href: '', pathname: '/', search: '',
+ },
+ });
+ mockIsAnalysis = false;
+ mockStorageEngine = {
+ finalizeParticipant: vi.fn().mockResolvedValue({ status: 'complete' }),
+ getModes: vi.fn().mockResolvedValue({ dataCollectionEnabled: false }),
+ getCurrentParticipantId: vi.fn().mockResolvedValue('p1'),
+ getParticipantData: vi.fn().mockResolvedValue(null),
+ getCurrentParticipantDataSnapshot: vi.fn().mockReturnValue(null),
+ };
+ mockStudyConfig = {
+ ...mockStudyConfig,
+ uiConfig: {
+ ...mockStudyConfig.uiConfig,
+ studyEndAutoRedirectURL: 'https://example.com',
+ studyEndAutoRedirectDelay: 500,
+ },
+ };
+ const { unmount } = await act(async () => render());
+ await act(async () => { await Promise.resolve(); });
+ await act(async () => { vi.advanceTimersByTime(600); });
+ expect(replaceSpy).toHaveBeenCalledWith('https://example.com');
+ unmount(); // exercises clearTimeout cleanup
+ Object.defineProperty(window, 'location', { configurable: true, value: origLocation });
+ vi.useRealTimers();
+ });
+});
diff --git a/src/components/StudyEnd.utils.spec.ts b/src/components/tests/StudyEnd.utils.spec.ts
similarity index 98%
rename from src/components/StudyEnd.utils.spec.ts
rename to src/components/tests/StudyEnd.utils.spec.ts
index 627dafebe8..eb3eef491d 100644
--- a/src/components/StudyEnd.utils.spec.ts
+++ b/src/components/tests/StudyEnd.utils.spec.ts
@@ -6,11 +6,11 @@ import {
test,
vi,
} from 'vitest';
-import { FinalizeParticipantResult, StorageEngine } from '../storage/engines/types';
+import { FinalizeParticipantResult, StorageEngine } from '../../storage/engines/types';
import {
createStudyEndFinalizeLoop,
DEFAULT_STUDY_END_FINALIZE_STATE,
-} from './StudyEnd.utils';
+} from '../StudyEnd.utils';
function createDeferred() {
let resolve!: (value: T) => void;
diff --git a/src/components/tests/TimedOut.spec.tsx b/src/components/tests/TimedOut.spec.tsx
new file mode 100644
index 0000000000..90e0795a09
--- /dev/null
+++ b/src/components/tests/TimedOut.spec.tsx
@@ -0,0 +1,22 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ describe, expect, test, vi,
+} from 'vitest';
+import { ReactNode } from 'react';
+import { TimedOut } from '../TimedOut';
+
+vi.mock('@mantine/core', () => ({
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+describe('TimedOut', () => {
+ test('renders the timed-out message', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('you have not answered the questions within the given time limit');
+ });
+
+ test('renders null DOM element (no wrapper tags beyond the Text)', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('');
+ });
+});
diff --git a/src/components/tests/TrainingFailed.spec.tsx b/src/components/tests/TrainingFailed.spec.tsx
new file mode 100644
index 0000000000..65d23d304e
--- /dev/null
+++ b/src/components/tests/TrainingFailed.spec.tsx
@@ -0,0 +1,44 @@
+import { render, waitFor } from '@testing-library/react';
+import {
+ afterEach, describe, expect, test, vi,
+} from 'vitest';
+import { ReactNode } from 'react';
+import { TrainingFailed } from '../TrainingFailed';
+import { useStorageEngine } from '../../storage/storageEngineHooks';
+
+const mockReject = vi.fn(() => Promise.resolve());
+
+vi.mock('@mantine/core', () => ({
+ Text: ({ children }: { children: ReactNode }) =>
{children}
,
+}));
+
+vi.mock('../../storage/storageEngineHooks', () => ({
+ useStorageEngine: vi.fn(() => ({
+ storageEngine: { rejectCurrentParticipant: mockReject },
+ })),
+}));
+
+afterEach(() => vi.clearAllMocks());
+
+describe('TrainingFailed', () => {
+ test('renders the training-failed message', () => {
+ const { container, unmount } = render();
+ expect(container.textContent).toContain("you didn't answer the training correctly");
+ unmount();
+ });
+
+ test('calls rejectCurrentParticipant with the correct reason on mount', async () => {
+ const { unmount } = render();
+ await waitFor(() => expect(mockReject).toHaveBeenCalledWith('Failed training'));
+ unmount();
+ });
+
+ test('does not throw when storageEngine is undefined', async () => {
+ vi.mocked(useStorageEngine).mockReturnValueOnce({
+ storageEngine: undefined,
+ } as ReturnType);
+ const { unmount } = render();
+ expect(mockReject).not.toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/src/components/nextButtonTimeout.spec.ts b/src/components/tests/nextButtonTimeout.spec.ts
similarity index 98%
rename from src/components/nextButtonTimeout.spec.ts
rename to src/components/tests/nextButtonTimeout.spec.ts
index 0eacf1a983..aa4e3e548c 100644
--- a/src/components/nextButtonTimeout.spec.ts
+++ b/src/components/tests/nextButtonTimeout.spec.ts
@@ -4,7 +4,7 @@ import {
DEFAULT_AUTO_ADVANCE_WARNING_TIME,
formatAutoAdvanceWarningMessage,
getAutoAdvanceWarning,
-} from './nextButtonTimeout';
+} from '../nextButtonTimeout';
describe('nextButtonTimeout', () => {
test('formats warning messages with the remaining seconds placeholder', () => {
diff --git a/src/controllers/tests/ComponentController.spec.tsx b/src/controllers/tests/ComponentController.spec.tsx
new file mode 100644
index 0000000000..168ef7ce9e
--- /dev/null
+++ b/src/controllers/tests/ComponentController.spec.tsx
@@ -0,0 +1,770 @@
+import React, { ReactNode } from 'react';
+import { render, waitFor, act } from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ afterEach, beforeEach, describe, expect, test, vi,
+} from 'vitest';
+import { useSearchParams, useNavigate } from 'react-router';
+import { View } from 'react-vega';
+import { useStoredAnswer } from '../../store/hooks/useStoredAnswer';
+import type {
+ ImageComponent,
+ MarkdownComponent,
+ ReactComponent,
+ VegaComponent,
+ VideoComponent,
+ WebsiteComponent,
+} from '../../parser/types';
+import type { StoreState } from '../../store/types';
+import { ComponentController } from '../ComponentController';
+import { ErrorBoundary } from '../ErrorBoundary';
+import { IframeController } from '../IframeController';
+import { ImageController } from '../ImageController';
+import { MarkdownController } from '../MarkdownController';
+import { ReactComponentController } from '../ReactComponentController';
+import { VegaController } from '../VegaController';
+import { VideoController } from '../VideoController';
+import { useCurrentComponent, useCurrentStep } from '../../routes/utils';
+import { useStorageEngine } from '../../storage/storageEngineHooks';
+import { getStaticAssetByPath, getJsonAssetByPath } from '../../utils/getStaticAsset';
+import { useStoreDispatch, useStoreSelector } from '../../store/store';
+import { findBlockForStep } from '../../utils/getSequenceFlatMap';
+import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
+import { useRecordingConfig } from '../../store/hooks/useRecordingConfig';
+import { makeStoredAnswer, makeStorageEngine } from '../../tests/utils';
+
+// ── mutable state ────────────────────────────────────────────────────────────
+
+let mockVegaImpl: React.FC = () => React.createElement('div', null, 'Vega');
+
+let mockStoreActions = {
+ setReactiveAnswers: vi.fn(),
+ updateResponseBlockValidation: vi.fn(),
+ setAlertModal: vi.fn(),
+ setAnalysisCanPlayScreenRecording: vi.fn(),
+};
+
+// ── mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('@mantine/core', () => ({
+ Image: ({ src }: { src?: string }) =>
,
+ Text: ({ children }: { children: ReactNode }) => {children}
,
+ Box: ({ children }: { children: ReactNode }) => {children}
,
+ Center: ({ children }: { children: ReactNode }) => {children}
,
+ Title: ({ children }: { children: ReactNode }) => {children}
,
+ Loader: () => Loader
,
+ LoadingOverlay: () => LoadingOverlay
,
+}));
+
+vi.mock('@tabler/icons-react', () => ({
+ IconPlugConnectedX: () => icon-plug-x,
+}));
+
+vi.mock('../../ResourceNotFound', () => ({
+ ResourceNotFound: ({ path, email }: { path?: string; email?: string }) => (
+
+ ResourceNotFound:
+ {path ?? email}
+
+ ),
+}));
+
+vi.mock('../../components/StudyEnd', () => ({
+ StudyEnd: () => StudyEnd
,
+}));
+
+vi.mock('../../components/TrainingFailed', () => ({
+ TrainingFailed: () => TrainingFailed
,
+}));
+
+vi.mock('../../components/TimedOut', () => ({
+ TimedOut: () => TimedOut
,
+}));
+
+vi.mock('../../components/ReactMarkdownWrapper', () => ({
+ ReactMarkdownWrapper: ({ text }: { text: string }) => (
+ {text}
+ ),
+}));
+
+vi.mock('../../components/response/ResponseBlock', () => ({
+ ResponseBlock: () => ResponseBlock
,
+}));
+
+vi.mock('../../components/screenRecording/ScreenRecordingReplay', () => ({
+ ScreenRecordingReplay: () => ScreenRecordingReplay
,
+}));
+
+vi.mock('../../utils/getStaticAsset', () => ({
+ getStaticAssetByPath: vi.fn(),
+ getJsonAssetByPath: vi.fn(),
+}));
+
+vi.mock('../../utils/Prefix', () => ({
+ PREFIX: '/test/',
+}));
+
+vi.mock('../../store/store', () => ({
+ useStoreDispatch: vi.fn(() => vi.fn()),
+ useStoreActions: vi.fn(() => mockStoreActions),
+ useStoreSelector: vi.fn((selector: (s: StoreState) => StoreState[keyof StoreState]) => selector({
+ answers: {},
+ analysisCanPlayScreenRecording: false,
+ analysisProvState: {
+ sidebar: { form: {} }, aboveStimulus: { form: {} }, belowStimulus: { form: {} }, stimulus: null,
+ },
+ trialValidation: {
+ trial1_0: {
+ aboveStimulus: { valid: true, values: {} },
+ belowStimulus: { valid: true, values: {} },
+ sidebar: { valid: true, values: {} },
+ stimulus: { valid: true, values: {} },
+ provenanceGraph: {
+ aboveStimulus: undefined,
+ belowStimulus: undefined,
+ sidebar: undefined,
+ stimulus: undefined,
+ },
+ },
+ },
+ stimulusSubmitAttempted: {
+ trial1_0: false,
+ },
+ sequence: {
+ order: 'fixed', orderPath: '', components: [], skip: [],
+ },
+ modes: { dataCollectionEnabled: true, developmentModeEnabled: false, dataSharingEnabled: false },
+ participantId: 'pid-1',
+ } as Partial as StoreState)),
+}));
+
+vi.mock('../../routes/utils', () => ({
+ useCurrentStep: vi.fn(() => 0),
+ useCurrentComponent: vi.fn(() => 'end'),
+ useCurrentIdentifier: vi.fn(() => 'trial1_0'),
+ useStudyId: vi.fn(() => 'test-study'),
+}));
+
+vi.mock('react-router', () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+ useParams: vi.fn(() => ({})),
+ useSearchParams: vi.fn(() => [new URLSearchParams()]),
+}));
+
+vi.mock('react-redux', () => ({
+ useDispatch: vi.fn(() => vi.fn()),
+}));
+
+vi.mock('../../store/hooks/useStudyConfig', () => ({
+ useStudyConfig: vi.fn(() => ({
+ components: {},
+ uiConfig: { contactEmail: 'test@test.com', instructionLocation: 'sidebar' },
+ sequence: { order: 'fixed', components: [] },
+ })),
+}));
+
+vi.mock('../../storage/storageEngineHooks', () => ({
+ useStorageEngine: vi.fn(() => ({
+ storageEngine: {
+ isConnected: () => true,
+ getEngine: () => 'localStorage',
+ addParticipantTags: vi.fn(),
+ },
+ })),
+}));
+
+vi.mock('../../store/hooks/useStoredAnswer', () => ({
+ useStoredAnswer: vi.fn(() => null),
+}));
+
+vi.mock('../../store/hooks/useIsAnalysis', () => ({
+ useIsAnalysis: vi.fn(() => false),
+}));
+
+vi.mock('../../store/hooks/useRecordingConfig', () => ({
+ useRecordingConfig: vi.fn(() => ({ studyHasScreenRecording: false })),
+}));
+
+vi.mock('../../utils/useDisableBrowserBack', () => ({
+ useDisableBrowserBack: vi.fn(),
+}));
+
+vi.mock('../../utils/fetchStylesheet', () => ({
+ useFetchStylesheet: vi.fn(),
+}));
+
+vi.mock('../../utils/getSequenceFlatMap', () => ({
+ findBlockForStep: vi.fn(() => []),
+}));
+
+vi.mock('../../utils/handleComponentInheritance', () => ({
+ studyComponentToIndividualComponent: vi.fn(() => ({
+ type: 'markdown',
+ path: '/test.md',
+ response: [],
+ instructionLocation: 'sidebar',
+ })),
+}));
+
+vi.mock('../../utils/encryptDecryptIndex', () => ({
+ decryptIndex: vi.fn((v: string) => v),
+ encryptIndex: vi.fn((v: number) => String(v)),
+}));
+
+vi.mock('../../utils/componentStyle', () => ({
+ getComponentContainerStyle: vi.fn(() => ({})),
+}));
+
+vi.mock('../../store/hooks/useEvent', () => ({
+ useEvent: ) => ReturnType>(fn: T): T => fn,
+}));
+
+vi.mock('@trrack/core', () => ({
+ Registry: {
+ create: vi.fn(() => ({
+ register: vi.fn(() => vi.fn()),
+ })),
+ },
+ initializeTrrack: vi.fn(() => ({
+ apply: vi.fn(),
+ graph: { backend: {} },
+ })),
+}));
+
+vi.mock('react-vega', () => ({
+ Vega: vi.fn((props: Record) => mockVegaImpl(props)),
+}));
+
+vi.mock('react-vega/lib/Vega', () => ({}));
+
+vi.mock('plyr-react', () => ({
+ usePlyr: vi.fn(() => ({ current: null })),
+}));
+
+vi.mock('plyr-react/plyr.css', () => ({}));
+
+vi.mock('@visdesignlab/upset2-react', () => ({
+ Upset: () => null,
+}));
+
+afterEach(() => vi.restoreAllMocks());
+
+// ── typed fixtures ────────────────────────────────────────────────────────────
+
+const imageConfig: ImageComponent = { type: 'image', path: '/test.png', response: [] };
+const markdownConfig: MarkdownComponent = { type: 'markdown', path: '/test.md', response: [] };
+// A path that does not match any file in src/public/ — always produces ResourceNotFound.
+const missingReactConfig: ReactComponent = { type: 'react-component', path: 'nonexistent/component.tsx', response: [] };
+const vegaPathConfig: VegaComponent = { type: 'vega', path: '/test.json', response: [] };
+const vegaInlineConfig: VegaComponent = { type: 'vega', config: {}, response: [] };
+const videoConfig: VideoComponent = { type: 'video', path: '/test.mp4', response: [] };
+const websiteConfig: WebsiteComponent = { type: 'website', path: 'https://example.com', response: [] };
+
+// ── ErrorBoundary ─────────────────────────────────────────────────────────────
+
+describe('ErrorBoundary', () => {
+ test('renders children when there is no error', () => {
+ const html = renderToStaticMarkup(
+ hello,
+ );
+ expect(html).toContain('hello');
+ });
+
+ test('getDerivedStateFromError returns hasError true with the error object', () => {
+ const err = new Error('Test error');
+ const state = ErrorBoundary.getDerivedStateFromError(err);
+ expect(state.hasError).toBe(true);
+ expect(state.error).toBe(err);
+ });
+
+ test('renders error message when a child throws', () => {
+ function ThrowingChild(): ReactNode {
+ throw new Error('test error');
+ }
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
+ const { container } = render(
+ ,
+ );
+ spy.mockRestore();
+ expect(container.textContent).toContain('Error: test error');
+ });
+});
+
+// ── ImageController ───────────────────────────────────────────────────────────
+
+describe('ImageController', () => {
+ test('renders an img element while loading for a relative path', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('
{
+ const absoluteConfig: ImageComponent = { type: 'image', path: 'https://example.com/img.png', response: [] };
+ const html = renderToStaticMarkup();
+ expect(html).toContain('
{
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce(undefined);
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('ResourceNotFound'));
+ });
+
+ test('renders img after fetch returns content', async () => {
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce('image-data');
+ const { container } = render();
+ await waitFor(() => expect(container.querySelector('img')).toBeTruthy());
+ });
+});
+
+// ── MarkdownController ────────────────────────────────────────────────────────
+
+describe('MarkdownController', () => {
+ test('renders ReactMarkdownWrapper while loading', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('data-testid="markdown"');
+ });
+
+ test('renders ResourceNotFound after fetch returns undefined', async () => {
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce(undefined);
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('ResourceNotFound'));
+ });
+
+ test('renders markdown text after fetch resolves with content', async () => {
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce('# Hello World');
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('# Hello World'));
+ });
+});
+
+// ── ReactComponentController ──────────────────────────────────────────────────
+
+describe('ReactComponentController', () => {
+ test('renders ResourceNotFound when the module path is not in import.meta.glob', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('ResourceNotFound');
+ });
+});
+
+// ── VegaController ────────────────────────────────────────────────────────────
+
+describe('VegaController', () => {
+ test('shows loading state initially for a path-based config', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Loading...');
+ });
+
+ test('shows loading state initially for an inline config', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Loading...');
+ });
+
+ test('renders Vega after path-based config fetches successfully', async () => {
+ vi.mocked(getJsonAssetByPath).mockResolvedValueOnce({ $schema: 'vega', marks: [] });
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('Vega'));
+ });
+
+ test('renders ResourceNotFound when path-based fetch returns undefined', async () => {
+ vi.mocked(getJsonAssetByPath).mockResolvedValueOnce(undefined);
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('ResourceNotFound'));
+ });
+
+ test('renders Vega for an inline config object', async () => {
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('Vega'));
+ });
+});
+
+// ── VideoController ───────────────────────────────────────────────────────────
+
+describe('VideoController', () => {
+ test('renders video element when html5 asset is found', async () => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0);
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce('video-data');
+ const { container } = render();
+ await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
+ });
+
+ test('renders ResourceNotFound when html5 asset is not found', async () => {
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce(undefined);
+ const { container } = render();
+ await waitFor(() => expect(container.textContent).toContain('ResourceNotFound'));
+ });
+
+ test('renders video element for a valid YouTube URL', async () => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0);
+ const youtubeConfig: VideoComponent = { type: 'video', path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', response: [] };
+ const { container } = render();
+ await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
+ });
+
+ test('dispatches store action when forceCompletion is true', async () => {
+ const mockDispatch = vi.fn();
+ vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch);
+ vi.mocked(getStaticAssetByPath).mockResolvedValueOnce('video-data');
+ const forceConfig: VideoComponent = {
+ type: 'video', path: '/test.mp4', forceCompletion: true, response: [],
+ };
+ render();
+ await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
+ });
+
+ test('renders LoadingOverlay while loading for a local path', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('LoadingOverlay');
+ });
+
+ test('renders LoadingOverlay while loading for a YouTube URL', () => {
+ const youtubeConfig: VideoComponent = { type: 'video', path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', response: [] };
+ const html = renderToStaticMarkup();
+ expect(html).toContain('LoadingOverlay');
+ });
+
+ test('renders LoadingOverlay while loading for a Vimeo URL', () => {
+ const vimeoConfig: VideoComponent = { type: 'video', path: 'https://vimeo.com/123456789', response: [] };
+ const html = renderToStaticMarkup();
+ expect(html).toContain('LoadingOverlay');
+ });
+});
+
+// ── IframeController ──────────────────────────────────────────────────────────
+
+describe('IframeController', () => {
+ test('covers sendMessage via answers effect on mount', async () => {
+ render();
+ // answers effect fires sendMessage; ref.current is the iframe element in jsdom
+ // so postMessage is invoked on its contentWindow — no assertion needed beyond no-throw
+ });
+
+ test('covers sendMessage via provState effect', async () => {
+ render(
+ ,
+ );
+ // provState effect fires sendMessage
+ });
+
+ test('dispatches store actions when an ANSWERS window message arrives with matching iframeId', async () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(
+ '11111111-2222-3333-4444-555555555555' as `${string}-${string}-${string}-${string}-${string}`,
+ );
+ const mockDispatch = vi.fn();
+ vi.mocked(useStoreDispatch).mockReturnValueOnce(mockDispatch);
+ render();
+ window.dispatchEvent(new MessageEvent('message', {
+ data: { iframeId: '11111111-2222-3333-4444-555555555555', type: '@REVISIT_COMMS/ANSWERS', message: { q1: 'yes' } },
+ }));
+ await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
+ });
+
+ test('sends STUDY_DATA when a WINDOW_READY message arrives and parameters are set', async () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(
+ '11111111-2222-3333-4444-555555555555' as `${string}-${string}-${string}-${string}-${string}`,
+ );
+ const websiteWithParams: WebsiteComponent = {
+ type: 'website', path: 'https://example.com', parameters: { key: 'val' }, response: [],
+ };
+ render();
+ window.dispatchEvent(new MessageEvent('message', {
+ data: { iframeId: '11111111-2222-3333-4444-555555555555', type: '@REVISIT_COMMS/WINDOW_READY' },
+ }));
+ // sendMessage called with STUDY_DATA; ref.current.contentWindow.postMessage fires
+ });
+
+ test('renders iframe with the original src for an http path', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('