From a97ef2eb971e8577ce52a10a6bfe9b45ed12aaa0 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 19 Mar 2026 19:16:24 -0600 Subject: [PATCH 001/209] Refine startup error handling --- src/components/Shell.tsx | 34 +++++++++++++++--- src/components/Shell.utils.spec.ts | 55 ++++++++++++++++++++++++++++++ src/components/Shell.utils.ts | 44 ++++++++++++++++++++++++ src/store/store.tsx | 9 ++--- src/store/types.ts | 3 +- 5 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/components/Shell.utils.spec.ts create mode 100644 src/components/Shell.utils.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b88dfd16ca..4a882ff757 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -32,11 +32,17 @@ import { ResourceNotFound } from '../ResourceNotFound'; import { encryptIndex } from '../utils/encryptDecryptIndex'; import { parseStudyConfig } from '../parser/parser'; import { hash } from '../storage/engines/utils'; +import { REVISIT_MODE } from '../storage/engines/types'; import { filterSequenceByCondition, parseConditionParam, resolveParticipantConditions, } from '../utils/handleConditionLogic'; +import { + getInitialStartupAlert, + getScreenOrientationType, + isStorageStartupFailure, +} from './Shell.utils'; export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Pull study config @@ -93,6 +99,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Check that we have a storage engine and active config (studyId is set for config, but typescript complains) if (!storageEngine || !activeConfig || !canonicalStudyId) return; + let modes: Record | null = null; try { // Make sure that we have a study database and that the study database has a sequence array await storageEngine.initializeStudyDb(canonicalStudyId); @@ -105,7 +112,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ); } - const modes = await storageEngine.getModes(canonicalStudyId); + modes = await storageEngine.getModes(canonicalStudyId); // Get or generate participant session const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam @@ -131,7 +138,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { availHeight: window.screen.availHeight, availWidth: window.screen.availWidth, colorDepth: window.screen.colorDepth, - orientation: window.screen.orientation.type, + orientation: getScreenOrientationType(window.screen), pixelDepth: window.screen.pixelDepth, }, ip: ip.ip, @@ -188,6 +195,23 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setStore(newStore); } catch (error) { console.error('Error initializing user store routing:', error); + const isStorageFailure = isStorageStartupFailure(storageEngine, import.meta.env.VITE_STORAGE_ENGINE); + const resolvedModes = modes ?? await storageEngine.getModes(canonicalStudyId).catch(() => null); + const developmentModeEnabledForAlert = resolvedModes?.developmentModeEnabled ?? false; + const fallbackModes = { + developmentModeEnabled: resolvedModes?.developmentModeEnabled ?? true, + dataSharingEnabled: resolvedModes?.dataSharingEnabled ?? true, + dataCollectionEnabled: false, + }; + const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam + ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) + || undefined + : undefined; + const resumeParticipantId = participantId || urlParticipantId; + const initialAlertModal = !isStorageFailure + ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) + : undefined; + // Fallback: initialize the store with empty data const generatedSequences = generateSequenceArray(activeConfig); const matchingSequence = generatedSequences[0]; @@ -215,10 +239,12 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ip: '', }, {}, - { developmentModeEnabled: true, dataSharingEnabled: true, dataCollectionEnabled: false }, + fallbackModes, '', false, - true, + isStorageFailure, + false, + initialAlertModal, ); setStore(emptyStore); } diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts new file mode 100644 index 0000000000..23f500b097 --- /dev/null +++ b/src/components/Shell.utils.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'vitest'; +import { + getInitialStartupAlert, + getScreenOrientationType, + getStartupErrorMessage, + isStorageStartupFailure, +} from './Shell.utils'; + +describe('Shell utilities', () => { + test('returns an empty orientation when screen.orientation is unavailable', () => { + const mockScreen = { + orientation: undefined, + } as unknown as Screen; + + expect(getScreenOrientationType(mockScreen)).toBe(''); + }); + + test('detects storage startup failures from connectivity', () => { + const mockStorageEngine = { + getEngine: () => 'supabase' as const, + isConnected: () => false, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); + }); + + test('detects storage startup failures from engine mismatch', () => { + const mockStorageEngine = { + getEngine: () => 'localStorage' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); + }); + + test('uses the caught error message in development mode', () => { + expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ + show: true, + title: 'Problem loading the study', + message: 'Bad startup state', + }); + }); + + test('uses resume copy outside development mode when resuming a participant', () => { + expect(getInitialStartupAlert(new Error('ignored'), false, 'abc123')).toEqual({ + show: true, + title: 'Problem loading the study', + message: 'This study session could not be resumed.', + }); + }); + + test('falls back to the generic startup message for empty errors', () => { + expect(getStartupErrorMessage('')).toBe('There was a problem loading the study.'); + }); +}); diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts new file mode 100644 index 0000000000..885503e246 --- /dev/null +++ b/src/components/Shell.utils.ts @@ -0,0 +1,44 @@ +import type { StorageEngine } from '../storage/engines/types'; +import type { AlertModalState } from '../store/types'; + +type StartupStorageStatus = Pick; + +const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; +const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; + +export function getScreenOrientationType(screen: Screen) { + return screen.orientation?.type ?? ''; +} + +export function isStorageStartupFailure( + storageEngine: StartupStorageStatus, + configuredEngine: string, +) { + return !storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine; +} + +export function getStartupErrorMessage(error: unknown) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + + return GENERIC_STARTUP_ERROR; +} + +export function getInitialStartupAlert( + error: unknown, + developmentModeEnabled: boolean, + resumeParticipantId?: string | null, +): AlertModalState { + return { + show: true, + title: 'Problem loading the study', + message: developmentModeEnabled + ? getStartupErrorMessage(error) + : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), + }; +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 4466853900..36e4a52c29 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -6,8 +6,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { ParsedStringOption, ResponseBlockLocation, StudyConfig, ValueOf, Answer, ParticipantData, } from '../parser/types'; -import { - StoredAnswer, TrialValidation, TrrackedProvenance, StoreState, Sequence, ParticipantMetadata, +import type { + AlertModalState, StoredAnswer, TrialValidation, TrrackedProvenance, StoreState, Sequence, ParticipantMetadata, } from './types'; import { getSequenceFlatMap } from '../utils/getSequenceFlatMap'; import { REVISIT_MODE } from '../storage/engines/types'; @@ -25,6 +25,7 @@ export async function studyStoreCreator( completed: boolean, storageEngineFailedToConnect: boolean, isStalledConfig: boolean = false, + initialAlertModal?: AlertModalState, ) { const flatSequence = getSequenceFlatMap(sequence); @@ -111,7 +112,7 @@ export async function studyStoreCreator( config, showStudyBrowser: true, showHelpText: false, - alertModal: { show: false, message: '', title: '' }, + alertModal: initialAlertModal ?? { show: false, message: '', title: '' }, trialValidation: Object.keys(answers).length > 0 ? allValid : emptyValidation, reactiveAnswers: {}, metadata, @@ -203,7 +204,7 @@ export async function studyStoreCreator( toggleShowHelpText: (state) => { state.showHelpText = !state.showHelpText; }, - setAlertModal: (state, action: PayloadAction<{ show: boolean; message: string; title: string }>) => { + setAlertModal: (state, action: PayloadAction) => { state.alertModal = action.payload; }, setReactiveAnswers: (state, action: PayloadAction>>) => { diff --git a/src/store/types.ts b/src/store/types.ts index 0f047bcd32..b954a3e839 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -161,6 +161,7 @@ export interface Sequence { } export type FormElementProvenance = { form: StoredAnswer['answer'] }; +export type AlertModalState = { show: boolean, message: string, title: string }; export interface StoreState { studyId: string; participantId: string; @@ -169,7 +170,7 @@ export interface StoreState { config: StudyConfig; showStudyBrowser: boolean; showHelpText: boolean; - alertModal: { show: boolean, message: string, title: string }; + alertModal: AlertModalState; trialValidation: TrialValidation; reactiveAnswers: Record>; metadata: ParticipantMetadata; From 1d18108304a370fab3d11a90becd355c3ac6c211 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 22:17:15 -0600 Subject: [PATCH 002/209] Handle startup storage fallback and resume alerts --- src/components/Shell.tsx | 102 +++++++++++++++------------- src/components/Shell.utils.spec.ts | 18 +++++ src/components/Shell.utils.ts | 7 +- src/storage/engines/types.ts | 11 +++ src/storage/tests/highLevel.spec.ts | 8 +++ 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 4a882ff757..f49fb7be93 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -100,27 +100,13 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { if (!storageEngine || !activeConfig || !canonicalStudyId) return; let modes: Record | null = null; + let storageOperationFailed = false; + const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam + ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) + || undefined + : undefined; try { - // Make sure that we have a study database and that the study database has a sequence array - await storageEngine.initializeStudyDb(canonicalStudyId); - await storageEngine.saveConfig(activeConfig); - - const sequenceArray = await storageEngine.getSequenceArray(); - if (!sequenceArray) { - await storageEngine.setSequenceArray( - await generateSequenceArray(activeConfig), - ); - } - - modes = await storageEngine.getModes(canonicalStudyId); - - // Get or generate participant session - const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam - ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) - || undefined - : undefined; const searchParamsObject = Object.fromEntries(searchParams.entries()); - const ipTimeoutController = new AbortController(); const ipTimeoutId = window.setTimeout(() => ipTimeoutController.abort(), 1200); const ipRes = await fetch('https://api.ipify.org?format=json', { @@ -144,32 +130,50 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ip: ip.ip, }; - let participantSession = await storageEngine.initializeParticipantSession( - searchParamsObject, - activeConfig, - metadata, - participantId || urlParticipantId, - ); - - if (studyCondition.length > 0 && modes.developmentModeEnabled) { - const updatedSearchParams = { - ...participantSession.searchParams, - condition: studyCondition.join(','), - }; - await storageEngine.updateParticipantSearchParams(updatedSearchParams); - await storageEngine.updateStudyCondition(studyCondition); - participantSession = { - ...participantSession, - searchParams: updatedSearchParams, - conditions: studyCondition, - }; - } const activeHash = await hash(JSON.stringify(activeConfig)); - + let participantSession!: Awaited>; let participantConfig = activeConfig; + try { + // Make sure that we have a study database and that the study database has a sequence array + await storageEngine.initializeStudyDb(canonicalStudyId); + await storageEngine.saveConfig(activeConfig); - if (participantSession.participantConfigHash !== activeHash) { - participantConfig = (await storageEngine.getAllConfigsFromHash([participantSession.participantConfigHash], canonicalStudyId))[participantSession.participantConfigHash] as ParsedConfig; + const sequenceArray = await storageEngine.getSequenceArray(); + if (!sequenceArray) { + await storageEngine.setSequenceArray(generateSequenceArray(activeConfig)); + } + + modes = await storageEngine.getModes(canonicalStudyId); + participantSession = await storageEngine.initializeParticipantSession( + searchParamsObject, + activeConfig, + metadata, + participantId || urlParticipantId, + ); + + if (studyCondition.length > 0 && modes.developmentModeEnabled) { + const updatedSearchParams = { + ...participantSession.searchParams, + condition: studyCondition.join(','), + }; + await storageEngine.updateParticipantSearchParams(updatedSearchParams); + await storageEngine.updateStudyCondition(studyCondition); + participantSession = { + ...participantSession, + searchParams: updatedSearchParams, + conditions: studyCondition, + }; + } + + if (participantSession.participantConfigHash !== activeHash) { + participantConfig = (await storageEngine.getAllConfigsFromHash( + [participantSession.participantConfigHash], + canonicalStudyId, + ))[participantSession.participantConfigHash] as ParsedConfig; + } + } catch (storageError) { + storageOperationFailed = true; + throw storageError; } const effectiveStudyCondition = resolveParticipantConditions({ @@ -195,7 +199,11 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setStore(newStore); } catch (error) { console.error('Error initializing user store routing:', error); - const isStorageFailure = isStorageStartupFailure(storageEngine, import.meta.env.VITE_STORAGE_ENGINE); + const isStorageFailure = isStorageStartupFailure( + storageEngine, + import.meta.env.VITE_STORAGE_ENGINE, + storageOperationFailed, + ); const resolvedModes = modes ?? await storageEngine.getModes(canonicalStudyId).catch(() => null); const developmentModeEnabledForAlert = resolvedModes?.developmentModeEnabled ?? false; const fallbackModes = { @@ -203,11 +211,9 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { dataSharingEnabled: resolvedModes?.dataSharingEnabled ?? true, dataCollectionEnabled: false, }; - const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam - ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) - || undefined - : undefined; - const resumeParticipantId = participantId || urlParticipantId; + const resumeParticipantId = participantId + || urlParticipantId + || await storageEngine.peekCurrentParticipantId(canonicalStudyId); const initialAlertModal = !isStorageFailure ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) : undefined; diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts index 23f500b097..95c5feafd0 100644 --- a/src/components/Shell.utils.spec.ts +++ b/src/components/Shell.utils.spec.ts @@ -33,6 +33,24 @@ describe('Shell utilities', () => { expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); }); + test('detects cloud storage startup failures from thrown storage operations', () => { + const mockStorageEngine = { + getEngine: () => 'supabase' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase', true)).toBe(true); + }); + + test('does not treat local storage startup errors as connectivity failures', () => { + const mockStorageEngine = { + getEngine: () => 'localStorage' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'localStorage', true)).toBe(false); + }); + test('uses the caught error message in development mode', () => { expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ show: true, diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts index 885503e246..ae0755abff 100644 --- a/src/components/Shell.utils.ts +++ b/src/components/Shell.utils.ts @@ -13,8 +13,13 @@ export function getScreenOrientationType(screen: Screen) { export function isStorageStartupFailure( storageEngine: StartupStorageStatus, configuredEngine: string, + storageOperationFailed: boolean = false, ) { - return !storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine; + if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { + return true; + } + + return storageOperationFailed && configuredEngine !== 'localStorage'; } export function getStartupErrorMessage(error: unknown) { diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 11043cf4ba..db9de5cb59 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -453,6 +453,17 @@ export abstract class StorageEngine { return this.currentParticipantId; } + // Returns the current participant ID if it already exists in memory or persistence without creating a new one. + async peekCurrentParticipantId(studyId?: string) { + const studyIdToUse = studyId || this.studyId; + if (studyIdToUse) { + const storageKey = `${this.collectionPrefix}${studyIdToUse}/currentParticipantId`; + return await this.participantStore.getItem(storageKey) || undefined; + } + + return this.currentParticipantId; + } + // Clears the current participant ID from persistence and resets the currentParticipantId property. async clearCurrentParticipantId() { this.currentParticipantId = undefined; diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index e332563122..fac5483786 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -147,6 +147,14 @@ describe.each([ expect(currentParticipantId).toBeDefined(); }); + test('peekCurrentParticipantId returns persisted IDs without creating a new one', async () => { + expect(await storageEngine.peekCurrentParticipantId(studyId)).toBeUndefined(); + + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + expect(await storageEngine.peekCurrentParticipantId(studyId)).toBe(participantSession.participantId); + }); + // clearCurrentParticipantId tested in rejectParticipant test // _getSequence tested in rejectParticipant test From 72156142fc5ecd36f2645bb36231af49e2fa691c Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 22:24:48 -0600 Subject: [PATCH 003/209] Inline Shell startup helpers --- src/components/Shell.tsx | 55 +++++++++++++++++++--- src/components/Shell.utils.spec.ts | 73 ------------------------------ src/components/Shell.utils.ts | 49 -------------------- 3 files changed, 49 insertions(+), 128 deletions(-) delete mode 100644 src/components/Shell.utils.spec.ts delete mode 100644 src/components/Shell.utils.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index f49fb7be93..b17c063e51 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -26,23 +26,66 @@ import { StepRenderer } from './StepRenderer'; import { useStorageEngine } from '../storage/storageEngineHooks'; import { generateSequenceArray } from '../utils/handleRandomSequences'; import { getStudyConfig, resolveConfigKey } from '../utils/fetchConfig'; -import { ParticipantMetadata } from '../store/types'; +import type { AlertModalState, ParticipantMetadata } from '../store/types'; import { ErrorLoadingConfig } from './ErrorLoadingConfig'; import { ResourceNotFound } from '../ResourceNotFound'; import { encryptIndex } from '../utils/encryptDecryptIndex'; import { parseStudyConfig } from '../parser/parser'; import { hash } from '../storage/engines/utils'; +import type { StorageEngine } from '../storage/engines/types'; import { REVISIT_MODE } from '../storage/engines/types'; import { filterSequenceByCondition, parseConditionParam, resolveParticipantConditions, } from '../utils/handleConditionLogic'; -import { - getInitialStartupAlert, - getScreenOrientationType, - isStorageStartupFailure, -} from './Shell.utils'; + +type StartupStorageStatus = Pick; + +const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; +const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; + +export function getScreenOrientationType(screen: Screen) { + return screen.orientation?.type ?? ''; +} + +export function isStorageStartupFailure( + storageEngine: StartupStorageStatus, + configuredEngine: string, + storageOperationFailed: boolean = false, +) { + if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { + return true; + } + + return storageOperationFailed && configuredEngine !== 'localStorage'; +} + +export function getStartupErrorMessage(error: unknown) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + + return GENERIC_STARTUP_ERROR; +} + +export function getInitialStartupAlert( + error: unknown, + developmentModeEnabled: boolean, + resumeParticipantId?: string | null, +): AlertModalState { + return { + show: true, + title: 'Problem loading the study', + message: developmentModeEnabled + ? getStartupErrorMessage(error) + : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), + }; +} export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Pull study config diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts deleted file mode 100644 index 95c5feafd0..0000000000 --- a/src/components/Shell.utils.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - getInitialStartupAlert, - getScreenOrientationType, - getStartupErrorMessage, - isStorageStartupFailure, -} from './Shell.utils'; - -describe('Shell utilities', () => { - test('returns an empty orientation when screen.orientation is unavailable', () => { - const mockScreen = { - orientation: undefined, - } as unknown as Screen; - - expect(getScreenOrientationType(mockScreen)).toBe(''); - }); - - test('detects storage startup failures from connectivity', () => { - const mockStorageEngine = { - getEngine: () => 'supabase' as const, - isConnected: () => false, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); - }); - - test('detects storage startup failures from engine mismatch', () => { - const mockStorageEngine = { - getEngine: () => 'localStorage' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); - }); - - test('detects cloud storage startup failures from thrown storage operations', () => { - const mockStorageEngine = { - getEngine: () => 'supabase' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase', true)).toBe(true); - }); - - test('does not treat local storage startup errors as connectivity failures', () => { - const mockStorageEngine = { - getEngine: () => 'localStorage' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'localStorage', true)).toBe(false); - }); - - test('uses the caught error message in development mode', () => { - expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ - show: true, - title: 'Problem loading the study', - message: 'Bad startup state', - }); - }); - - test('uses resume copy outside development mode when resuming a participant', () => { - expect(getInitialStartupAlert(new Error('ignored'), false, 'abc123')).toEqual({ - show: true, - title: 'Problem loading the study', - message: 'This study session could not be resumed.', - }); - }); - - test('falls back to the generic startup message for empty errors', () => { - expect(getStartupErrorMessage('')).toBe('There was a problem loading the study.'); - }); -}); diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts deleted file mode 100644 index ae0755abff..0000000000 --- a/src/components/Shell.utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { StorageEngine } from '../storage/engines/types'; -import type { AlertModalState } from '../store/types'; - -type StartupStorageStatus = Pick; - -const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; -const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; - -export function getScreenOrientationType(screen: Screen) { - return screen.orientation?.type ?? ''; -} - -export function isStorageStartupFailure( - storageEngine: StartupStorageStatus, - configuredEngine: string, - storageOperationFailed: boolean = false, -) { - if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { - return true; - } - - return storageOperationFailed && configuredEngine !== 'localStorage'; -} - -export function getStartupErrorMessage(error: unknown) { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - if (typeof error === 'string' && error.trim().length > 0) { - return error; - } - - return GENERIC_STARTUP_ERROR; -} - -export function getInitialStartupAlert( - error: unknown, - developmentModeEnabled: boolean, - resumeParticipantId?: string | null, -): AlertModalState { - return { - show: true, - title: 'Problem loading the study', - message: developmentModeEnabled - ? getStartupErrorMessage(error) - : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), - }; -} From 0e0510eda2521594c3b47a75dc36cafd4f2a0d01 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 23:22:03 -0600 Subject: [PATCH 004/209] Guard Shell startup participant lookup fallback --- src/components/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b17c063e51..9d798dfbd5 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -256,7 +256,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }; const resumeParticipantId = participantId || urlParticipantId - || await storageEngine.peekCurrentParticipantId(canonicalStudyId); + || await storageEngine.peekCurrentParticipantId(canonicalStudyId).catch(() => undefined); const initialAlertModal = !isStorageFailure ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) : undefined; From c464b16e0a7065324b9a52e06f24da3e9599018e Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:31:32 -0600 Subject: [PATCH 005/209] Update setting for unit test coverage --- package.json | 4 +++- vite.config.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e1ec15747..2954702356 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "generate-library-examples": "node libraryExampleStudyGenerator.cjs", "test": "playwright test", "unittest": "vitest", + "unittest-coverage": "vitest run --coverage --coverage.reporter=text --run", "find-revisit-users": "bash scripts/find-revisit-users.sh", "preinstall": "node -e \"if(!/yarn\\.js$/.test(process.env.npm_execpath))throw new Error('Use yarn')\"", "postinstall": "husky" @@ -105,6 +106,7 @@ "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", "@vitejs/plugin-react-swc": "^4.1.0", + "@vitest/coverage-v8": "3.2.4", "eslint": "^9.36.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", @@ -118,4 +120,4 @@ "vitest": "^3.2.4", "vitest-localstorage-mock": "^0.1.2" } -} +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index b442591f08..2e7d3a97d3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,23 @@ export default defineConfig(({ command, mode }) => { fileParallelism: true, maxWorkers: '100%', minWorkers: 1, + coverage: { + provider: 'v8', + all: true, + exclude: [ + 'public/**', + 'src/public/**', + 'dist/**', + 'eslint.config.js', + 'vite.config.ts', + 'playwright.config.ts', + 'src/vite-env.d.ts', + 'src/lodash.d.ts', + 'src/analysis/types.ts', + 'tests/checkSavedAnswers.ts', + 'tests/utils.ts', + ], + }, }, }; }); From f24ddc6c1045cc08f58704f2f6b77deb96bfd2e0 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:32:27 -0600 Subject: [PATCH 006/209] Add library cjs tests --- LibraryDocGenerator.spec.ts | 124 +++++++++++++++++++++++++++ LibraryExampleStudyGenerator.spec.ts | 121 ++++++++++++++++++++++++++ libraryDocGenerator.cjs | 66 ++++++++------ libraryExampleStudyGenerator.cjs | 113 ++++++++++++------------ 4 files changed, 343 insertions(+), 81 deletions(-) create mode 100644 LibraryDocGenerator.spec.ts create mode 100644 LibraryExampleStudyGenerator.spec.ts diff --git a/LibraryDocGenerator.spec.ts b/LibraryDocGenerator.spec.ts new file mode 100644 index 0000000000..5585736c24 --- /dev/null +++ b/LibraryDocGenerator.spec.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRequire } from 'module'; +import { + afterEach, + describe, + expect, + it, +} from 'vitest'; + +const require = createRequire(import.meta.url); +const { generateMd, generateLibraryDocs, getLibraries } = require('./libraryDocGenerator.cjs'); + +describe('libraryDocGenerator', () => { + const tempDirs: string[] = []; + + afterEach(() => { + tempDirs.forEach((dir) => { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + tempDirs.length = 0; + }); + + it('generateMd includes components, sequences, and reference sections', () => { + const md = generateMd('demo-lib', { + description: 'Demo description', + reference: 'Some reference', + doi: '10.1000/xyz', + externalLink: 'https://example.com', + components: { beta: {}, alpha: {} }, + sequences: { second: {}, first: {} }, + additionalDescription: 'Extra details', + }, true); + + expect(md).toContain('# demo-lib'); + expect(md).toContain('## Available Components'); + expect(md).toContain('- alpha'); + expect(md).toContain('- beta'); + expect(md).toContain('## Available Sequences'); + expect(md).toContain('- first'); + expect(md).toContain('- second'); + expect(md).toContain('## Reference'); + expect(md).toContain('https://dx.doi.org/10.1000/xyz'); + expect(md).toContain('## Additional Description'); + }); + + it('generateMd handles example reference text and external-link-only docs links', () => { + const exampleMd = generateMd('demo-lib', { + description: 'Demo description', + reference: 'Some reference', + components: {}, + sequences: {}, + }, false); + + expect(exampleMd).toContain('This is an example study of the library `demo-lib`.'); + expect(exampleMd).toContain('Some reference'); + expect(exampleMd).not.toContain(':::note[Reference]'); + + const docsMd = generateMd('demo-lib', { + description: 'Demo description', + externalLink: 'https://example.com', + components: {}, + sequences: {}, + }, true); + + expect(docsMd).toContain('referenceLinks={['); + expect(docsMd).toContain('{name: "demo-lib", url: "https://example.com"}'); + expect(docsMd).not.toContain('{name: "DOI"'); + + const docsWithDoiOnly = generateMd('demo-lib', { + description: 'Demo description', + doi: '10.1000/xyz', + components: {}, + sequences: {}, + }, true); + + expect(docsWithDoiOnly).toContain('{name: "DOI", url: "https://dx.doi.org/10.1000/xyz"}'); + expect(docsWithDoiOnly).not.toContain('{name: "demo-lib", url:'); + }); + + it('getLibraries filters hidden entries and .DS_Store entries', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-doc-list-')); + tempDirs.push(base); + const libsPath = path.join(base, 'public', 'libraries'); + fs.mkdirSync(libsPath, { recursive: true }); + fs.mkdirSync(path.join(libsPath, 'alpha')); + fs.mkdirSync(path.join(libsPath, '.hidden')); + fs.writeFileSync(path.join(libsPath, '.DS_Store'), ''); + + expect(getLibraries(libsPath)).toEqual(['alpha']); + }); + + it('generateLibraryDocs writes docs and example markdown when assets folder exists', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-doc-run-')); + tempDirs.push(base); + const libraryName = 'alpha'; + const librariesPath = path.join(base, 'public', 'libraries', libraryName); + const exampleAssetsPath = path.join(base, 'public', `library-${libraryName}`, 'assets'); + + fs.mkdirSync(librariesPath, { recursive: true }); + fs.mkdirSync(exampleAssetsPath, { recursive: true }); + fs.writeFileSync( + path.join(librariesPath, 'config.json'), + JSON.stringify({ + description: 'Alpha description', + components: { compA: {} }, + sequences: {}, + }), + ); + + generateLibraryDocs(base); + + const docsOut = path.join(base, 'docsLibraries', `${libraryName}.md`); + const exampleOut = path.join(exampleAssetsPath, `${libraryName}.md`); + + expect(fs.existsSync(docsOut)).toBe(true); + expect(fs.existsSync(exampleOut)).toBe(true); + expect(fs.readFileSync(docsOut, 'utf8')).toContain('# alpha'); + expect(fs.readFileSync(exampleOut, 'utf8')).toContain('This is an example study'); + }); +}); diff --git a/LibraryExampleStudyGenerator.spec.ts b/LibraryExampleStudyGenerator.spec.ts new file mode 100644 index 0000000000..87a8a4ac94 --- /dev/null +++ b/LibraryExampleStudyGenerator.spec.ts @@ -0,0 +1,121 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRequire } from 'module'; +import { + afterEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +const require = createRequire(import.meta.url); +const { + createExampleConfig, + generateLibraryExamples, + getLibraries, +} = require('./libraryExampleStudyGenerator.cjs'); + +describe('libraryExampleStudyGenerator', () => { + const tempDirs: string[] = []; + + afterEach(() => { + tempDirs.forEach((dir) => { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + tempDirs.length = 0; + }); + + it('createExampleConfig builds config with expected defaults', () => { + const config = createExampleConfig('my-lib'); + + expect(config.studyMetadata.title).toBe('my-lib Example Study'); + expect(config.importedLibraries).toEqual(['my-lib']); + expect(config.components.introduction.path).toBe('library-my-lib/assets/my-lib.md'); + expect(config.sequence.components).toEqual(['introduction']); + }); + + it('getLibraries filters hidden entries and .DS_Store entries', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-example-list-')); + tempDirs.push(base); + const libsPath = path.join(base, 'public', 'libraries'); + fs.mkdirSync(libsPath, { recursive: true }); + fs.mkdirSync(path.join(libsPath, 'alpha')); + fs.mkdirSync(path.join(libsPath, '.hidden')); + fs.writeFileSync(path.join(libsPath, '.DS_Store'), ''); + + expect(getLibraries(libsPath)).toEqual(['alpha']); + }); + + it('generateLibraryExamples creates missing example study and invokes doc generation command', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-example-run-')); + tempDirs.push(base); + const libraryName = 'alpha'; + const librariesPath = path.join(base, 'public', 'libraries', libraryName); + fs.mkdirSync(librariesPath, { recursive: true }); + + const execFn = vi.fn(); + generateLibraryExamples(base, execFn); + + const examplePath = path.join(base, 'public', `library-${libraryName}`); + const configPath = path.join(examplePath, 'config.json'); + const assetsPath = path.join(examplePath, 'assets'); + + expect(fs.existsSync(examplePath)).toBe(true); + expect(fs.existsSync(assetsPath)).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + expect(execFn).toHaveBeenCalledTimes(1); + expect(execFn).toHaveBeenCalledWith('node libraryDocGenerator.cjs', expect.any(Function)); + }); + + it('logs an error when doc generator execution returns an error', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-example-error-')); + tempDirs.push(base); + fs.mkdirSync(path.join(base, 'public', 'libraries', 'alpha'), { recursive: true }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const execFn = ( + _command: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => callback(new Error('test Error'), '', ''); + + generateLibraryExamples(base, execFn); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error running libraryDocGenerator.cjs: Error: test Error')); + errorSpy.mockRestore(); + }); + + it('logs stderr when doc generator execution writes to stderr', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-example-stderr-')); + tempDirs.push(base); + fs.mkdirSync(path.join(base, 'public', 'libraries', 'alpha'), { recursive: true }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const execFn = ( + _command: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => callback(null, '', 'warning output'); + + generateLibraryExamples(base, execFn); + + expect(errorSpy).toHaveBeenCalledWith('libraryDocGenerator.cjs stderr: warning output'); + errorSpy.mockRestore(); + }); + + it('logs stdout when doc generator execution succeeds', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-example-stdout-')); + tempDirs.push(base); + fs.mkdirSync(path.join(base, 'public', 'libraries', 'alpha'), { recursive: true }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const execFn = ( + _command: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => callback(null, 'generated docs', ''); + + generateLibraryExamples(base, execFn); + + expect(logSpy).toHaveBeenCalledWith('generated docs'); + logSpy.mockRestore(); + }); +}); diff --git a/libraryDocGenerator.cjs b/libraryDocGenerator.cjs index c33d3b9812..f2d9209953 100644 --- a/libraryDocGenerator.cjs +++ b/libraryDocGenerator.cjs @@ -54,40 +54,50 @@ import StructuredLinks from '@site/src/components/StructuredLinks/StructuredLink />` : ''} `; -const librariesPath = path.join(__dirname, './public/libraries'); -const docsLibrariesPath = path.join(__dirname, './docsLibraries'); - -const libraries = fs.readdirSync(librariesPath) +const getLibraries = (libsPath) => fs.readdirSync(libsPath) .filter((library) => !library.startsWith('.') && !library.endsWith('.DS_Store')); -if (!fs.existsSync(docsLibrariesPath)) { - fs.mkdirSync(docsLibrariesPath); -} +const generateLibraryDocs = (base) => { + const librariesPath = path.join(base, 'public', 'libraries'); + const docsLibrariesPath = path.join(base, 'docsLibraries'); -libraries.forEach((library) => { - const libraryPath = path.join(librariesPath, library, 'config.json'); - const libraryConfig = JSON.parse(fs.readFileSync(libraryPath, 'utf8')); + const libraries = getLibraries(librariesPath); - const docsMd = generateMd(library, libraryConfig, true); - const exampleMd = generateMd(library, libraryConfig, false); + if (!fs.existsSync(docsLibrariesPath)) { + fs.mkdirSync(docsLibrariesPath); + } - // Save to docsLibraries folder - const docsLibraryPath = path.join(docsLibrariesPath, `${library}.md`); - fs.writeFileSync(docsLibraryPath, docsMd); - // eslint-disable-next-line no-console - console.log(`Documentation saved to ${docsLibraryPath}`); + libraries.forEach((library) => { + const libraryPath = path.join(librariesPath, library, 'config.json'); + const libraryConfig = JSON.parse(fs.readFileSync(libraryPath, 'utf8')); - // Save to example study assets folder if assets folder exists - // Add a prefix to baseMarkdown when saving to example assets - const exampleAssetsPath = path.join(__dirname, 'public', `library-${library}`, 'assets'); - if (fs.existsSync(exampleAssetsPath)) { - const exampleDocsPath = path.join(exampleAssetsPath, `${library}.md`); - fs.writeFileSync(exampleDocsPath, exampleMd); + const docsMd = generateMd(library, libraryConfig, true); + const exampleMd = generateMd(library, libraryConfig, false); + // Save to docsLibraries folder + const docsLibraryPath = path.join(docsLibrariesPath, `${library}.md`); + fs.writeFileSync(docsLibraryPath, docsMd); // eslint-disable-next-line no-console - console.log(`Documentation saved to ${exampleDocsPath}`); - } -}); + console.log(`Documentation saved to ${docsLibraryPath}`); + + // Save to example study assets folder if assets folder exists + // Add a prefix to baseMarkdown when saving to example assets + const exampleAssetsPath = path.join(base, 'public', `library-${library}`, 'assets'); + if (fs.existsSync(exampleAssetsPath)) { + const exampleDocsPath = path.join(exampleAssetsPath, `${library}.md`); + fs.writeFileSync(exampleDocsPath, exampleMd); + + // eslint-disable-next-line no-console + console.log(`Documentation saved to ${exampleDocsPath}`); + } + }); + + // eslint-disable-next-line no-console + console.log('Library documentation generated'); +}; + +if (require.main === module) { + generateLibraryDocs(__dirname); +} -// eslint-disable-next-line no-console -console.log('Library documentation generated'); +module.exports = { generateMd, getLibraries, generateLibraryDocs }; diff --git a/libraryExampleStudyGenerator.cjs b/libraryExampleStudyGenerator.cjs index 05cbfdc894..9186f83655 100644 --- a/libraryExampleStudyGenerator.cjs +++ b/libraryExampleStudyGenerator.cjs @@ -11,11 +11,6 @@ const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); -// Path to the libraries directory containing reusable components and sequences -const librariesPath = path.join(__dirname, './public/libraries'); -const publicPath = path.join(__dirname, './public'); - - // Create example study config template const createExampleConfig = (libraryName) => ({ $schema: 'https://raw.githubusercontent.com/revisit-studies/study/dev/src/parser/StudyConfigSchema.json', @@ -49,60 +44,72 @@ const createExampleConfig = (libraryName) => ({ }, }); -// Process each library -const libraries = fs.readdirSync(librariesPath) +const getLibraries = (libsPath) => fs.readdirSync(libsPath) .filter(library => !library.startsWith('.') && !library.endsWith('.DS_Store')); -libraries.forEach((library) => { - // Skip hidden folders and files, and libraries in skip list - if (library.startsWith('.')) { - // eslint-disable-next-line no-console - console.log(`Skipping ${library} library`); - return; - } +const generateLibraryExamples = (base, execFn) => { + const librariesPath = path.join(base, 'public', 'libraries'); + const publicPath = path.join(base, 'public'); - const exampleFolderName = `library-${library}`; - const examplePath = path.join(publicPath, exampleFolderName); + // Process each library + const libraries = getLibraries(librariesPath); - // Check if example folder already exists - if (!fs.existsSync(examplePath)) { - // Create the example folder - fs.mkdirSync(examplePath); - // eslint-disable-next-line no-console - console.log(`Created ${exampleFolderName} directory`); + libraries.forEach((library) => { + // Skip hidden folders and files, and libraries in skip list + if (library.startsWith('.')) { + // eslint-disable-next-line no-console + console.log(`Skipping ${library} library`); + return; + } - // Create assets directory - const assetsPath = path.join(examplePath, 'assets'); - fs.mkdirSync(assetsPath); - // eslint-disable-next-line no-console - console.log(`Created ${exampleFolderName}/assets directory`); + const exampleFolderName = `library-${library}`; + const examplePath = path.join(publicPath, exampleFolderName); - // Create config.json - const configPath = path.join(examplePath, 'config.json'); - const configContent = createExampleConfig(library); - fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2)); - // eslint-disable-next-line no-console - console.log(`Created/Updated ${exampleFolderName}/config.json`); - } + // Check if example folder already exists + if (!fs.existsSync(examplePath)) { + // Create the example folder + fs.mkdirSync(examplePath); + // eslint-disable-next-line no-console + console.log(`Created ${exampleFolderName} directory`); -}); + // Create assets directory + const assetsPath = path.join(examplePath, 'assets'); + fs.mkdirSync(assetsPath); + // eslint-disable-next-line no-console + console.log(`Created ${exampleFolderName}/assets directory`); + + // Create config.json + const configPath = path.join(examplePath, 'config.json'); + const configContent = createExampleConfig(library); + fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2)); + // eslint-disable-next-line no-console + console.log(`Created/Updated ${exampleFolderName}/config.json`); + } + }); -// eslint-disable-next-line no-console -console.log('Library example generation complete'); - -// Run libraryDocGenerator.cjs after example generation -// To generate the library.md files which will be placed in the assets/ folder of each example study for the introduction component -// eslint-disable-next-line no-console -console.log('Generating library documentation...'); -exec('node libraryDocGenerator.cjs', (error, stdout, stderr) => { - if (error) { - console.error(`Error running libraryDocGenerator.cjs: ${error}`); - return; - } - if (stderr) { - console.error(`libraryDocGenerator.cjs stderr: ${stderr}`); - return; - } // eslint-disable-next-line no-console - console.log(stdout); -}); + console.log('Library example generation complete'); + + // Run libraryDocGenerator.cjs after example generation + // To generate the library.md files which will be placed in the assets/ folder of each example study for the introduction component + // eslint-disable-next-line no-console + console.log('Generating library documentation...'); + execFn('node libraryDocGenerator.cjs', (error, stdout, stderr) => { + if (error) { + console.error(`Error running libraryDocGenerator.cjs: ${error}`); + return; + } + if (stderr) { + console.error(`libraryDocGenerator.cjs stderr: ${stderr}`); + return; + } + // eslint-disable-next-line no-console + console.log(stdout); + }); +}; + +if (require.main === module) { + generateLibraryExamples(__dirname, exec); +} + +module.exports = { createExampleConfig, getLibraries, generateLibraryExamples }; From 042a71ca8c55f92610ec15436ee87dfe5bb502e1 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:00:58 -0600 Subject: [PATCH 007/209] Add analysis unit tests --- package.json | 6 +- .../LiveMonitor/LiveMonitorView.spec.tsx | 335 ++++++++++ .../config/ConfigView.spec.tsx | 335 ++++++++++ .../management/ManageView.spec.tsx | 455 +++++++++++++ .../replay/AllTasksTimeline.spec.tsx | 329 ++++++++++ .../individualStudy/stats/StatsView.spec.tsx | 347 ++++++++++ .../summary/SummaryView.spec.tsx | 245 +++++++ .../individualStudy/table/TableView.spec.tsx | 306 +++++++++ .../thinkAloud/ThinkAloudView.spec.tsx | 365 +++++++++++ src/storage/tests/analysis-config.spec.ts | 50 +- .../tests/analysis-data-management.spec.ts | 36 -- ...gement.spec.ts => analysis-manage.spec.ts} | 55 +- .../tests/analysis-study-summary.spec.ts | 278 ++++++++ vite.config.ts | 1 + yarn.lock | 603 +++++++++++++++++- 15 files changed, 3685 insertions(+), 61 deletions(-) create mode 100644 src/analysis/individualStudy/LiveMonitor/LiveMonitorView.spec.tsx create mode 100644 src/analysis/individualStudy/config/ConfigView.spec.tsx create mode 100644 src/analysis/individualStudy/management/ManageView.spec.tsx create mode 100644 src/analysis/individualStudy/replay/AllTasksTimeline.spec.tsx create mode 100644 src/analysis/individualStudy/stats/StatsView.spec.tsx create mode 100644 src/analysis/individualStudy/summary/SummaryView.spec.tsx create mode 100644 src/analysis/individualStudy/table/TableView.spec.tsx create mode 100644 src/analysis/individualStudy/thinkAloud/ThinkAloudView.spec.tsx delete mode 100644 src/storage/tests/analysis-data-management.spec.ts rename src/storage/tests/{analysis-stage-management.spec.ts => analysis-manage.spec.ts} (68%) create mode 100644 src/storage/tests/analysis-study-summary.spec.ts diff --git a/package.json b/package.json index 2954702356..534fc110c6 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,9 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.36.0", "@playwright/test": "^1.55.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/d3": "^7.4.0", "@types/lodash.isequal": "^4.5.8", "@types/react": "^19.1.13", @@ -115,9 +118,10 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.4.0", "husky": "^9.1.7", + "jsdom": "^29.0.1", "lint-staged": "^16.1.6", "typescript": "^5.9.2", "vitest": "^3.2.4", "vitest-localstorage-mock": "^0.1.2" } -} \ No newline at end of file +} diff --git a/src/analysis/individualStudy/LiveMonitor/LiveMonitorView.spec.tsx b/src/analysis/individualStudy/LiveMonitor/LiveMonitorView.spec.tsx new file mode 100644 index 0000000000..20215dfead --- /dev/null +++ b/src/analysis/individualStudy/LiveMonitor/LiveMonitorView.spec.tsx @@ -0,0 +1,335 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + describe, expect, test, vi, +} from 'vitest'; +import { SequenceAssignment } from '../../../storage/engines/types'; +import { + getFilteredParticipantProgress, + groupParticipantProgress, + LiveMonitorView, +} from './LiveMonitorView'; +import { ParticipantSection } from './ParticipantSection'; +import { ProgressHeatmap } from './ProgressHeatmap'; + +// ── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@mantine/core', () => ({ + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Card: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Title: ({ children }: { children: ReactNode }) =>
{children}
, + Badge: ({ children }: { children: ReactNode }) => {children}, + ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + Center: ({ children }: { children: ReactNode }) =>
{children}
, + Indicator: ({ children }: { children: ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + Button: ({ children }: { children: ReactNode }) => , + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Grid: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Col: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + RingProgress: ({ label }: { label?: ReactNode }) =>
{label}
, + Collapse: ({ children, in: open }: { children: ReactNode; in?: boolean }) => ( + open ?
{children}
: null + ), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconCheck: () => icon-check, + IconWifi: () => icon-wifi, + IconWifiOff: () => icon-wifioff, + IconRefresh: () => icon-refresh, + IconChevronDown: () => icon-chevron-down, + IconChevronRight: () => icon-chevron-right, +})); + +vi.mock('../../../storage/engines/FirebaseStorageEngine', () => ({ + FirebaseStorageEngine: class {}, +})); + +// ── fixture helpers ─────────────────────────────────────────────────────────── + +function makeAssignment(overrides: Partial = {}): SequenceAssignment { + return { + participantId: 'p1', + stage: 'DEFAULT', + answered: [], + total: 5, + completed: null, + rejected: false, + createdTime: 1_700_000_000_000, + isDynamic: false, + ...overrides, + } as unknown as SequenceAssignment; +} + +// ── getFilteredParticipantProgress ──────────────────────────────────────────── + +describe('getFilteredParticipantProgress', () => { + test('returns empty array when no assignments', () => { + expect(getFilteredParticipantProgress([], ['inprogress'], ['ALL'])).toEqual([]); + }); + + test('maps progress as percentage of answered/total', () => { + const a = makeAssignment({ answered: ['q1', 'q2'], total: 4 }); + const [result] = getFilteredParticipantProgress([a], ['inprogress'], ['ALL']); + expect(result.progress).toBe(50); + }); + + test('progress is 0 when total is 0', () => { + const a = makeAssignment({ answered: [], total: 0 }); + const [result] = getFilteredParticipantProgress([a], ['inprogress'], ['ALL']); + expect(result.progress).toBe(0); + }); + + test('isCompleted is true when completed is non-null', () => { + const a = makeAssignment({ completed: 1_700_000_000_000 }); + const [result] = getFilteredParticipantProgress([a], ['completed'], ['ALL']); + expect(result.isCompleted).toBe(true); + }); + + test('isRejected is true when rejected is true', () => { + const a = makeAssignment({ rejected: true }); + const [result] = getFilteredParticipantProgress([a], ['rejected'], ['ALL']); + expect(result.isRejected).toBe(true); + }); + + test('filters out participants whose status is not in includedParticipants', () => { + const a = makeAssignment(); // inprogress + const result = getFilteredParticipantProgress([a], ['completed'], ['ALL']); + expect(result).toHaveLength(0); + }); + + test('selectedStages ALL passes any stage', () => { + const a = makeAssignment({ stage: 'STAGE_B' }); + const result = getFilteredParticipantProgress([a], ['inprogress'], ['ALL']); + expect(result).toHaveLength(1); + }); + + test('selectedStages filters by specific stage', () => { + const a1 = makeAssignment({ participantId: 'p1', stage: 'STAGE_A' }); + const a2 = makeAssignment({ participantId: 'p2', stage: 'STAGE_B' }); + const result = getFilteredParticipantProgress([a1, a2], ['inprogress'], ['STAGE_A']); + expect(result).toHaveLength(1); + expect(result[0].assignment.participantId).toBe('p1'); + }); + + test('sorts by createdTime descending (newest first)', () => { + const a1 = makeAssignment({ participantId: 'old', createdTime: 1_000 }); + const a2 = makeAssignment({ participantId: 'new', createdTime: 2_000 }); + const result = getFilteredParticipantProgress([a1, a2], ['inprogress'], ['ALL']); + expect(result[0].assignment.participantId).toBe('new'); + }); +}); + +// ── groupParticipantProgress ────────────────────────────────────────────────── + +describe('groupParticipantProgress', () => { + function makeProgress(isCompleted: boolean, isRejected: boolean) { + return { + assignment: makeAssignment(), + progress: 50, + isCompleted, + isRejected, + }; + } + + test('splits into inProgress, completed, rejected groups', () => { + const items = [ + makeProgress(false, false), // inProgress + makeProgress(true, false), // completed + makeProgress(true, true), // rejected (rejected takes precedence) + makeProgress(false, true), // rejected + ]; + const { inProgress, completed, rejected } = groupParticipantProgress(items); + expect(inProgress).toHaveLength(1); + expect(completed).toHaveLength(1); + expect(rejected).toHaveLength(2); + }); + + test('empty input returns empty groups', () => { + const { inProgress, completed, rejected } = groupParticipantProgress([]); + expect(inProgress).toHaveLength(0); + expect(completed).toHaveLength(0); + expect(rejected).toHaveLength(0); + }); +}); + +// ── LiveMonitorView ─────────────────────────────────────────────────────────── + +describe('LiveMonitorView', () => { + const baseProps = { + studyConfig: {} as Parameters[0]['studyConfig'], + includedParticipants: ['inprogress', 'completed', 'rejected'], + selectedStages: ['ALL'], + }; + + test('renders Live Monitor heading', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Live Monitor'); + }); + + test('shows 0 counts when no storageEngine provided', () => { + const html = renderToStaticMarkup(); + // All participant counts are 0 since no assignments + expect(html).toContain('0'); + }); + + test('shows Completed, Active, Rejected badges', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Completed'); + expect(html).toContain('Active'); + expect(html).toContain('Rejected'); + }); + + test('shows disconnected wifi icon when no storageEngine', () => { + const html = renderToStaticMarkup(); + // Without a Firebase engine, useEffect will set status to 'disconnected' + // but renderToStaticMarkup captures initial state ('connecting') — wifioff shown + expect(html).toContain('icon-wifioff'); + }); + + test('shows In Progress, Completed, Rejected section titles', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('In Progress'); + expect(html).toContain('Completed'); + expect(html).toContain('Rejected'); + }); +}); + +// ── ParticipantSection ──────────────────────────────────────────────────────── + +describe('ParticipantSection', () => { + function ProgressLabel({ progress, assignment: _assignment }: { assignment: SequenceAssignment; progress: number }) { + return {Math.round(progress)}; + } + + const baseProps = { + title: 'In Progress', + titleColor: 'orange', + progressValue: (_: SequenceAssignment, progress: number) => progress, + progressColor: 'orange', + progressLabel: ProgressLabel, + }; + + test('renders section title with participant count', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('In Progress'); + expect(html).toContain('(0)'); + }); + + test('renders each participant card with participantId', () => { + const participants = [ + { + assignment: makeAssignment({ participantId: 'p-alpha', answered: ['q1'], total: 4 }), progress: 25, isCompleted: false, isRejected: false, + }, + ]; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('p-alpha'); + }); + + test('shows DYNAMIC badge when showDynamicBadge and assignment.isDynamic', () => { + const participants = [ + { + assignment: makeAssignment({ participantId: 'p1', isDynamic: true }), progress: 50, isCompleted: false, isRejected: false, + }, + ]; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('DYNAMIC'); + }); + + test('no DYNAMIC badge when isDynamic is false', () => { + const participants = [ + { + assignment: makeAssignment({ participantId: 'p1', isDynamic: false }), progress: 50, isCompleted: false, isRejected: false, + }, + ]; + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('DYNAMIC'); + }); + + test('renders ProgressHeatmap when showProgressHeatmap is true', () => { + const participants = [ + { + assignment: makeAssignment({ participantId: 'p1', answered: ['q1'], total: 3 }), progress: 33, isCompleted: false, isRejected: false, + }, + ]; + const html = renderToStaticMarkup( + , + ); + // ProgressHeatmap renders an SVG + expect(html).toContain(' { + test('returns null when total is 0', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(''); + }); + + test('returns null when total is NaN', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(''); + }); + + test('renders SVG with Q labels for each task', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Q1'); + expect(html).toContain('Q2'); + expect(html).toContain('Q3'); + }); + + test('answered tasks use green fill', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('green'); + }); + + test('unanswered tasks use grey fill', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('grey'); + }); + + test('dynamic mode uses teal fill and shows ? indicator', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('teal'); + expect(html).toContain('?'); + }); + + test('no ? indicator when isDynamic but answered is empty', () => { + const html = renderToStaticMarkup( + // isDynamic with no answers → totalTasks=0 → returns null + , + ); + // totalTasks = answered.length = 0, total check: total=3 > 0 so renders + // but totalTasks=0, so loop doesn't run and no ? added (isDynamic && totalTasks > 0 is false) + expect(html).not.toContain('?'); + }); +}); diff --git a/src/analysis/individualStudy/config/ConfigView.spec.tsx b/src/analysis/individualStudy/config/ConfigView.spec.tsx new file mode 100644 index 0000000000..071eade572 --- /dev/null +++ b/src/analysis/individualStudy/config/ConfigView.spec.tsx @@ -0,0 +1,335 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + render, screen, act, cleanup, fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + afterEach, beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { ParticipantData } from '../../../storage/types'; +import { ConfigInfo } from './utils'; +import { ConfigView } from './ConfigView'; +import { downloadConfigFile, downloadConfigFilesZip } from '../../../utils/handleDownloadFiles'; + +function makeParticipant(configHash: string): ParticipantData { + return { + participantId: 'p1', + participantConfigHash: configHash, + sequence: { + id: 'root', order: 'fixed', orderPath: 'root', components: [], skip: [], interruptions: [], + }, + participantIndex: 0, + answers: {}, + searchParams: {}, + metadata: { + userAgent: '', resolution: { width: 0, height: 0 }, language: '', ip: '', + }, + completed: false, + rejected: false, + } as unknown as ParticipantData; +} + +// Capture what gets passed to useMantineReactTable so we can test columns / options +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let capturedTableOptions: Record | null = null; + +let mockStorageEngine: { getAllConfigsFromHash: ReturnType } | undefined; + +vi.mock('../../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ storageEngine: mockStorageEngine }), +})); + +vi.mock('mantine-react-table', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useMantineReactTable: (options: Record) => { + capturedTableOptions = options; + return {}; + }, + MantineReactTable: () =>
table
, +})); + +vi.mock('@mantine/core', () => ({ + Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Space: () =>
, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Tooltip: ({ label, children }: { label: string; children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Modal: ({ opened, children }: { opened: boolean; children: ReactNode }) => (opened ?
{children}
: null), + ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + Loader: () =>
Loading...
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Paper: ({ children }: { children: ReactNode }) =>
{children}
, + Box: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@tabler/icons-react', () => ({ + IconInfoCircle: () => info, + IconDownload: () => download, + IconEye: () => eye, + IconArrowsLeftRight: () => compare, + IconCopy: () => copy, +})); + +vi.mock('../../../utils/handleDownloadFiles', () => ({ + downloadConfigFile: vi.fn().mockResolvedValue(undefined), + downloadConfigFilesZip: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./ConfigDiffModal', () => ({ + ConfigDiffModal: () =>
diff modal
, +})); + +const mockConfigInfo: ConfigInfo = { + hash: 'abcdef1234567890', + version: '1.0.0', + date: '2026-01-01', + timeFrame: 'N/A', + participantCount: 2, + config: { studyMetadata: { version: '1.0.0', date: '2026-01-01' } } as ConfigInfo['config'], +}; + +describe('ConfigView', () => { + beforeEach(() => { + capturedTableOptions = null; + mockStorageEngine = { + getAllConfigsFromHash: vi.fn().mockResolvedValue({ [mockConfigInfo.hash]: mockConfigInfo.config }), + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + // ── SSR / static rendering ─────────────────────────────────────────────── + + test('shows loader in initial loading state', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Loading config data...'); + }); + + test('renders without crashing when no storageEngine is provided', () => { + mockStorageEngine = undefined; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Loading config data...'); + }); + + test('renders without crashing when studyId is omitted', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Loading config data...'); + }); + + test('useMantineReactTable is configured with row selection and virtual scroll', () => { + renderToStaticMarkup(); + expect(capturedTableOptions).not.toBeNull(); + expect(capturedTableOptions!.enableRowSelection).toBe(true); + expect(capturedTableOptions!.enableRowVirtualization).toBe(true); + expect(capturedTableOptions!.enablePagination).toBe(false); + expect(capturedTableOptions!.enableDensityToggle).toBe(false); + }); + + test('table columns include expected headers', () => { + renderToStaticMarkup(); + const headers = capturedTableOptions!.columns.map((c: { header: string }) => c.header); + expect(headers).toContain('#'); + expect(headers).toContain('Version'); + expect(headers).toContain('Hash'); + expect(headers).toContain('Date'); + expect(headers).toContain('Time Frame'); + expect(headers).toContain('Participants'); + expect(headers).toContain('Actions'); + }); + + test('configIndex column Cell renders row number', () => { + renderToStaticMarkup(); + const col = capturedTableOptions!.columns.find((c: { id?: string }) => c.id === 'configIndex'); + expect(col.Cell({ row: { index: 0 } })).toBe(1); + expect(col.Cell({ row: { index: 4 } })).toBe(5); + }); + + test('version column Cell renders version text', () => { + renderToStaticMarkup(); + const col = capturedTableOptions!.columns.find((c: { accessorKey?: string }) => c.accessorKey === 'version'); + const html = renderToStaticMarkup(col.Cell({ row: { original: { version: '2.5.0' } as ConfigInfo } })); + expect(html).toContain('2.5.0'); + }); + + test('hash column Cell renders truncated hash and copy tooltip', () => { + renderToStaticMarkup(); + const col = capturedTableOptions!.columns.find((c: { accessorKey?: string }) => c.accessorKey === 'hash'); + const html = renderToStaticMarkup(col.Cell({ row: { original: { hash: 'abcdef1234567890' } as ConfigInfo } })); + expect(html).toContain('abcdef'); + expect(html).toContain('Copy hash'); + }); + + test('actions column Cell renders View and Download buttons', () => { + renderToStaticMarkup(); + const col = capturedTableOptions!.columns.find((c: { id?: string }) => c.id === 'actions'); + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => 'hashA' } })); + expect(html).toContain('View'); + expect(html).toContain('Download'); + }); + + test('renderTopToolbarCustomActions renders nothing when no rows are checked', () => { + renderToStaticMarkup(); + const html = renderToStaticMarkup(capturedTableOptions!.renderTopToolbarCustomActions()); + expect(html).not.toContain('Download Configs'); + expect(html).not.toContain('Compare'); + }); + + // ── useEffect: post-mount state transitions ────────────────────────────── + + test('useEffect clears configs and stops loading when storageEngine is missing', async () => { + mockStorageEngine = undefined; + await act(async () => { + render(); + }); + expect(screen.getByText('No data available')).toBeDefined(); + }); + + test('useEffect clears configs and stops loading when studyId is missing', async () => { + await act(async () => { + render(); + }); + expect(screen.getByText('No data available')).toBeDefined(); + }); + + test('useEffect fetches configs and renders table when storageEngine and studyId are present', async () => { + const participants = [makeParticipant(mockConfigInfo.hash)]; + await act(async () => { + render(); + }); + expect(mockStorageEngine!.getAllConfigsFromHash).toHaveBeenCalledWith( + [mockConfigInfo.hash], + 'test-study', + ); + expect(screen.getByText('table')).toBeDefined(); + }); + + test('useEffect sets empty configs and stops loading when fetch throws', async () => { + mockStorageEngine!.getAllConfigsFromHash.mockRejectedValue(new Error('network error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + render(); + }); + expect(consoleSpy).toHaveBeenCalledWith('Error fetching configs:', expect.any(Error)); + expect(screen.getByText('No data available')).toBeDefined(); + }); + + // ── useCallback handlers ───────────────────────────────────────────────── + + test('handleCopyHash writes to clipboard', () => { + const writeText = vi.fn().mockResolvedValue(undefined); + // jsdom 29 doesn't expose navigator.clipboard in non-secure contexts — + // define the property directly so the handler doesn't throw + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + renderToStaticMarkup(); + const hashCol = capturedTableOptions!.columns.find( + (c: { accessorKey?: string }) => c.accessorKey === 'hash', + ); + + const { container } = render( + hashCol.Cell({ row: { original: { hash: 'abcdef1234' } as ConfigInfo } }), + ); + fireEvent.click(container.querySelector('button')!); + + expect(writeText).toHaveBeenCalledWith('abcdef1234'); + }); + + test('handleDownloadConfig calls downloadConfigFile with correct args', async () => { + const participants = [makeParticipant(mockConfigInfo.hash)]; + await act(async () => { + render(); + }); + + const col = capturedTableOptions!.columns.find((c: { id?: string }) => c.id === 'actions'); + const user = userEvent.setup(); + const { getAllByText } = render(col.Cell({ cell: { getValue: () => mockConfigInfo.hash } })); + await user.click(getAllByText('Download')[0]); + + expect(downloadConfigFile).toHaveBeenCalledWith({ + studyId: 'test-study', + hash: mockConfigInfo.hash, + config: mockConfigInfo.config, + }); + }); + + test('handleDownloadConfigs calls downloadConfigFilesZip with selected hashes', async () => { + const participants = [makeParticipant(mockConfigInfo.hash)]; + await act(async () => { + render(); + }); + + // Simulate a checked row by injecting state via the onRowSelectionChange captured in table options + await act(async () => { + capturedTableOptions!.onRowSelectionChange({ [mockConfigInfo.hash]: true }); + }); + + const toolbar = renderToStaticMarkup(capturedTableOptions!.renderTopToolbarCustomActions()); + expect(toolbar).toContain('Download Configs'); + + const user = userEvent.setup(); + const { getByText } = render(capturedTableOptions!.renderTopToolbarCustomActions()); + await user.click(getByText(/Download Configs/)); + + expect(downloadConfigFilesZip).toHaveBeenCalledWith(expect.objectContaining({ + studyId: 'test-study', + hashes: [mockConfigInfo.hash], + })); + }); + + test('handleCompareConfigs opens compare modal when two rows are selected', async () => { + const participants = [makeParticipant(mockConfigInfo.hash)]; + await act(async () => { + render(); + }); + + await act(async () => { + capturedTableOptions!.onRowSelectionChange({ hashA: true, hashB: true }); + }); + + const toolbar = renderToStaticMarkup(capturedTableOptions!.renderTopToolbarCustomActions()); + expect(toolbar).toContain('Compare'); + + const user = userEvent.setup(); + const { getByText, container } = render(capturedTableOptions!.renderTopToolbarCustomActions()); + await user.click(getByText('Compare')); + + // After clicking, the compare modal should open — rendered by ConfigView's state + // The modal is controlled by ConfigView's state, so we verify the button fired without error + expect(container).toBeDefined(); + }); + + test('handleViewConfig opens view modal when View button is clicked', async () => { + const participants = [makeParticipant(mockConfigInfo.hash)]; + let container!: HTMLElement; + await act(async () => { + ({ container } = render()); + }); + + const col = capturedTableOptions!.columns.find((c: { id?: string }) => c.id === 'actions'); + const user = userEvent.setup(); + const { getAllByText } = render(col.Cell({ cell: { getValue: () => mockConfigInfo.hash } })); + + await act(async () => { + await user.click(getAllByText('View')[0]); + }); + + // The modal open state is in ConfigView — verify no crash and container exists + expect(container).toBeDefined(); + }); +}); diff --git a/src/analysis/individualStudy/management/ManageView.spec.tsx b/src/analysis/individualStudy/management/ManageView.spec.tsx new file mode 100644 index 0000000000..b8e6743443 --- /dev/null +++ b/src/analysis/individualStudy/management/ManageView.spec.tsx @@ -0,0 +1,455 @@ +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 { ManageView } from './ManageView'; +import { RevisitModesItem } from './RevisitModesItem'; +import { StageManagementItem } from './StageManagementItem'; +import { DataManagementItem } from './DataManagementItem'; + +let mockStorageEngine: { + getModes: ReturnType; + setMode: ReturnType; + getStageData: ReturnType; + setCurrentStage: ReturnType; + updateStageColor: ReturnType; + getSnapshots: ReturnType; + createSnapshot: ReturnType; + renameSnapshot: ReturnType; + restoreSnapshot: ReturnType; + removeSnapshotOrLive: ReturnType; + getAllParticipantsData: ReturnType; +} | undefined; + +vi.mock('../../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ storageEngine: mockStorageEngine }), +})); + +vi.mock('@mantine/core', () => ({ + Paper: ({ children }: { children: ReactNode }) =>
{children}
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Title: ({ children }: { children: ReactNode }) =>

{children}

, + Text: ({ children, span }: { children: ReactNode; span?: boolean }) => (span ? {children} :

{children}

), + Button: ({ children, onClick, disabled }: { children: ReactNode; onClick?: () => void; disabled?: boolean }) => ( + + ), + TextInput: ({ onChange, placeholder }: { onChange?: React.ChangeEventHandler; placeholder?: string }) => ( + + ), + ColorInput: ({ value }: { value?: string }) => , + Loader: () =>
Loading...
, + LoadingOverlay: () => null, + ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + Radio: ({ checked, onChange }: { checked: boolean; onChange?: () => void }) => ( + + ), + Switch: ({ checked, onChange }: { checked?: boolean; onChange?: React.ChangeEventHandler }) => ( + + ), + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Modal: ({ opened, children }: { opened: boolean; children: ReactNode }) => (opened ?
{children}
: null), + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + Space: () =>
, + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Table: Object.assign( + ({ children }: { children: ReactNode }) => {children}
, + { + 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}, + }, + ), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconEdit: () => edit, + IconCheck: () => check, + IconX: () => x, + IconTrashX: () => trash, + IconRefresh: () => refresh, + IconPencil: () => pencil, +})); + +vi.mock('@mantine/modals', () => ({ + openConfirmModal: vi.fn(), +})); + +vi.mock('../../../utils/notifications', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../components/downloader/DownloadButtons', () => ({ + DownloadButtons: () =>
DownloadButtons
, +})); + +const successResponse = { status: 'SUCCESS' as const, notifications: [] }; +const DEFAULT_STAGE_COLOR = '#F05A30'; + +const makeEngine = () => ({ + getModes: vi.fn().mockResolvedValue({ + dataCollectionEnabled: true, + developmentModeEnabled: false, + dataSharingEnabled: false, + }), + setMode: vi.fn().mockResolvedValue(undefined), + getStageData: vi.fn().mockResolvedValue({ + currentStage: { stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }, + allStages: [{ stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }], + }), + setCurrentStage: vi.fn().mockResolvedValue(undefined), + updateStageColor: vi.fn().mockResolvedValue(undefined), + getSnapshots: vi.fn().mockResolvedValue({}), + createSnapshot: vi.fn().mockResolvedValue(successResponse), + renameSnapshot: vi.fn().mockResolvedValue(successResponse), + restoreSnapshot: vi.fn().mockResolvedValue(successResponse), + removeSnapshotOrLive: vi.fn().mockResolvedValue(successResponse), + getAllParticipantsData: vi.fn().mockResolvedValue([]), +}); + +describe('ManageView', () => { + beforeEach(() => { + mockStorageEngine = makeEngine(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + // ── ManageView layout ──────────────────────────────────────────────────── + + test('renders all three management sections', async () => { + await act(async () => { + render( ({})} />); + }); + expect(screen.getByText('ReVISit Modes')).toBeDefined(); + expect(screen.getByText('Stage Management')).toBeDefined(); + expect(screen.getByText('Data Management')).toBeDefined(); + }); + + // ── RevisitModesItem ───────────────────────────────────────────────────── + + test('RevisitModesItem renders nothing before fetch completes', () => { + const html = renderToStaticMarkup(); + expect(html).toBe(''); + }); + + test('RevisitModesItem renders mode section titles after fetch', async () => { + await act(async () => { + render(); + }); + expect(screen.getByText('ReVISit Modes')).toBeDefined(); + expect(screen.getByText('Data Collection')).toBeDefined(); + expect(screen.getByText('Development Mode')).toBeDefined(); + expect(screen.getByText('Share Data and Make Analytics Interface Public')).toBeDefined(); + }); + + test('RevisitModesItem calls getModes with the provided studyId', async () => { + await act(async () => { + render(); + }); + expect(mockStorageEngine!.getModes).toHaveBeenCalledWith('my-study'); + }); + + test('RevisitModesItem renders nothing when storageEngine is undefined', () => { + mockStorageEngine = undefined; + const html = renderToStaticMarkup(); + expect(html).toBe(''); + }); + + test('RevisitModesItem handleSwitch calls setMode and updates state', async () => { + await act(async () => { + render(); + }); + const checkboxes = screen.getAllByRole('checkbox'); + await act(async () => { + fireEvent.click(checkboxes[0]); + }); + expect(mockStorageEngine!.setMode).toHaveBeenCalledWith('test-study', 'dataCollectionEnabled', false); + }); + + test('RevisitModesItem handleSwitch covers developmentMode and dataSharing branches', async () => { + await act(async () => { + render(); + }); + const checkboxes = screen.getAllByRole('checkbox'); + // developmentModeEnabled starts false → click sets true + await act(async () => { fireEvent.click(checkboxes[1]); }); + expect(mockStorageEngine!.setMode).toHaveBeenCalledWith('test-study', 'developmentModeEnabled', true); + // dataSharingEnabled starts false → click sets true + await act(async () => { fireEvent.click(checkboxes[2]); }); + expect(mockStorageEngine!.setMode).toHaveBeenCalledWith('test-study', 'dataSharingEnabled', true); + }); + + // ── StageManagementItem ────────────────────────────────────────────────── + + test('StageManagementItem shows loader before data loads', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Loading stage data...'); + }); + + test('StageManagementItem renders table and Add New Stage button after data loads', async () => { + await act(async () => { + render(); + }); + expect(screen.getByText('Stage Management')).toBeDefined(); + expect(screen.getByText('DEFAULT')).toBeDefined(); + expect(screen.getByText('Add New Stage')).toBeDefined(); + }); + + test('StageManagementItem calls getStageData with the provided studyId', async () => { + await act(async () => { + render(); + }); + expect(mockStorageEngine!.getStageData).toHaveBeenCalledWith('my-study'); + }); + + test('StageManagementItem shows defaults and sets asyncStatus on getStageData error', async () => { + mockStorageEngine!.getStageData.mockRejectedValue(new Error('db error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + render(); + }); + expect(consoleSpy).toHaveBeenCalledWith('Failed to load stage data:', expect.any(Error)); + expect(screen.getByText('DEFAULT')).toBeDefined(); + }); + + test('StageManagementItem handleSetCurrentStage calls setCurrentStage when radio clicked', async () => { + mockStorageEngine!.getStageData.mockResolvedValue({ + currentStage: { stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }, + allStages: [ + { stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }, + { stageName: 'REVIEW', color: '#00AAFF' }, + ], + }); + await act(async () => { + render(); + }); + const radios = screen.getAllByRole('radio'); + await act(async () => { + fireEvent.click(radios[1]); + }); + expect(mockStorageEngine!.setCurrentStage).toHaveBeenCalledWith('test-study', 'REVIEW', '#00AAFF'); + }); + + test('StageManagementItem handleEditStage shows edit inputs, handleCancelEdit resets', async () => { + await act(async () => { + render(); + }); + const editBtn = screen.getByText('edit').closest('button')!; + await act(async () => { fireEvent.click(editBtn); }); + // cancel button (X icon) is now visible + const cancelBtn = screen.getByText('x').closest('button')!; + await act(async () => { fireEvent.click(cancelBtn); }); + // after cancel, edit button should be back + expect(screen.getByText('edit').closest('button')).toBeDefined(); + }); + + test('StageManagementItem handleSaveEdit calls updateStageColor then refreshes', async () => { + mockStorageEngine!.updateStageColor = vi.fn().mockResolvedValue(undefined); + await act(async () => { + render(); + }); + const editBtn = screen.getByText('edit').closest('button')!; + await act(async () => { fireEvent.click(editBtn); }); + const saveBtn = screen.getByText('check').closest('button')!; + await act(async () => { fireEvent.click(saveBtn); }); + expect(mockStorageEngine!.updateStageColor).toHaveBeenCalledWith('test-study', 'DEFAULT', DEFAULT_STAGE_COLOR); + expect(mockStorageEngine!.getStageData).toHaveBeenCalledTimes(2); + }); + + test('StageManagementItem handleAddNewStage shows new row, handleCancelAddNewStage hides it', async () => { + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(screen.getByText('Add New Stage')); }); + // new row appears with its name input + expect(screen.getByPlaceholderText('Enter stage name')).toBeDefined(); + // cancel the new stage row + const cancelBtns = screen.getAllByText('x'); + await act(async () => { fireEvent.click(cancelBtns[cancelBtns.length - 1].closest('button')!); }); + expect(screen.getByText('Add New Stage')).toBeDefined(); + }); + + test('StageManagementItem handleSaveNewStage shows error for invalid name', async () => { + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(screen.getByText('Add New Stage')); }); + // click save without entering a name — should show validation error, not call setCurrentStage + const saveBtns = screen.getAllByText('check'); + await act(async () => { fireEvent.click(saveBtns[saveBtns.length - 1].closest('button')!); }); + expect(mockStorageEngine!.setCurrentStage).not.toHaveBeenCalled(); + }); + + test('StageManagementItem handleSaveNewStage success calls setCurrentStage and refreshes', async () => { + mockStorageEngine!.getStageData + .mockResolvedValueOnce({ + currentStage: { stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }, + allStages: [{ stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }], + }) + .mockResolvedValueOnce({ + currentStage: { stageName: 'NEWSTAGE', color: DEFAULT_STAGE_COLOR }, + allStages: [ + { stageName: 'DEFAULT', color: DEFAULT_STAGE_COLOR }, + { stageName: 'NEWSTAGE', color: DEFAULT_STAGE_COLOR }, + ], + }); + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(screen.getByText('Add New Stage')); }); + fireEvent.change(screen.getByPlaceholderText('Enter stage name'), { target: { value: 'NEWSTAGE' } }); + const saveBtns = screen.getAllByText('check'); + await act(async () => { fireEvent.click(saveBtns[saveBtns.length - 1].closest('button')!); }); + expect(mockStorageEngine!.setCurrentStage).toHaveBeenCalledWith('test-study', 'NEWSTAGE', DEFAULT_STAGE_COLOR); + expect(mockStorageEngine!.getStageData).toHaveBeenCalledTimes(2); + }); + + // ── DataManagementItem ─────────────────────────────────────────────────── + + test('DataManagementItem returns null when storageEngine is undefined', async () => { + mockStorageEngine = undefined; + let container: HTMLElement; + await act(async () => { + ({ container } = render( ({})} />)); + }); + expect(container!.firstChild).toBeNull(); + }); + + test('DataManagementItem renders main actions and "No snapshots" when snapshots empty', async () => { + await act(async () => { + render( ({})} />); + }); + expect(screen.getByText('Data Management')).toBeDefined(); + expect(screen.getByText('No snapshots.')).toBeDefined(); + }); + + test('DataManagementItem renders snapshot table rows when snapshots exist', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'my-snapshot' }, + }); + await act(async () => { + render( ({})} />); + }); + expect(screen.getByText('my-snapshot')).toBeDefined(); + expect(screen.getByText('DownloadButtons')).toBeDefined(); + }); + + test('DataManagementItem getDateFromSnapshotName returns null for key without snapshot pattern', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'plain-key': { name: 'no-date-snap' }, + }); + await act(async () => { + render( ({})} />); + }); + // snapshot renders but date cell is null — just verify the row appears without throwing + expect(screen.getByText('no-date-snap')).toBeDefined(); + }); + + test('DataManagementItem createSnapshot called via handleCreateSnapshot', async () => { + const { openConfirmModal } = await import('@mantine/modals'); + await act(async () => { + render( ({})} />); + }); + fireEvent.click(screen.getByText('Snapshot')); + expect(openConfirmModal).toHaveBeenCalled(); + // invoke the onConfirm callback directly + const call = (openConfirmModal as ReturnType).mock.calls[0][0]; + await act(async () => { await call.onConfirm(); }); + expect(mockStorageEngine!.createSnapshot).toHaveBeenCalledWith('test-study', false); + }); + + test('DataManagementItem handleArchiveData calls createSnapshot with archive=true', async () => { + await act(async () => { + render( ({})} />); + }); + // open archive modal + fireEvent.click(screen.getByText('Archive')); + // type the study id to enable the button + const input = screen.getByPlaceholderText('test-study'); + fireEvent.change(input, { target: { value: 'test-study' } }); + await act(async () => { fireEvent.click(screen.getAllByText('Archive')[1]); }); + expect(mockStorageEngine!.createSnapshot).toHaveBeenCalledWith('test-study', true); + }); + + test('DataManagementItem handleDeleteLive calls removeSnapshotOrLive', async () => { + await act(async () => { + render( ({})} />); + }); + fireEvent.click(screen.getByText('Delete')); + const input = screen.getByPlaceholderText('test-study'); + fireEvent.change(input, { target: { value: 'test-study' } }); + await act(async () => { fireEvent.click(screen.getAllByText('Delete')[1]); }); + expect(mockStorageEngine!.removeSnapshotOrLive).toHaveBeenCalledWith('test-study', 'test-study'); + }); + + test('DataManagementItem rename and delete snapshot actions work from snapshot row', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'snap-one' }, + }); + await act(async () => { + render( ({})} />); + }); + // open rename modal + fireEvent.click(screen.getByText('pencil').closest('button')!); + const renameInput = screen.getByPlaceholderText('test-study-snapshot-2026T01:00'); + fireEvent.change(renameInput, { target: { value: 'new-name' } }); + await act(async () => { fireEvent.click(screen.getByText('Rename')); }); + expect(mockStorageEngine!.renameSnapshot).toHaveBeenCalledWith('test-study-snapshot-2026T01:00', 'new-name', 'test-study'); + }); + + test('DataManagementItem delete snapshot modal calls removeSnapshotOrLive', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'snap-one' }, + }); + await act(async () => { + render( ({})} />); + }); + fireEvent.click(screen.getByText('trash').closest('button')!); + const input = screen.getByPlaceholderText('test-study'); + fireEvent.change(input, { target: { value: 'test-study' } }); + await act(async () => { fireEvent.click(screen.getAllByText('Delete')[1]); }); + expect(mockStorageEngine!.removeSnapshotOrLive).toHaveBeenCalledWith('test-study-snapshot-2026T01:00', 'test-study'); + }); + + test('DataManagementItem restore snapshot modal fires via openConfirmModal', async () => { + const { openConfirmModal } = await import('@mantine/modals'); + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'snap-one' }, + }); + await act(async () => { + render( ({})} />); + }); + fireEvent.click(screen.getByText('refresh').closest('button')!); + expect(openConfirmModal).toHaveBeenCalled(); + const call = (openConfirmModal as ReturnType).mock.calls[0][0]; + await act(async () => { await call.onConfirm(); }); + expect(mockStorageEngine!.restoreSnapshot).toHaveBeenCalledWith('test-study', 'test-study-snapshot-2026T01:00'); + }); + + test('DataManagementItem snapshotAction shows notification on failure', async () => { + const { showNotification } = await import('../../../utils/notifications'); + mockStorageEngine!.createSnapshot.mockResolvedValue({ + status: 'ERROR', + error: { title: 'Oops', message: 'Something went wrong' }, + }); + const { openConfirmModal } = await import('@mantine/modals'); + await act(async () => { + render( ({})} />); + }); + fireEvent.click(screen.getByText('Snapshot')); + const call = (openConfirmModal as ReturnType).mock.calls[0][0]; + await act(async () => { await call.onConfirm(); }); + expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ color: 'red' })); + }); +}); diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.spec.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.spec.tsx new file mode 100644 index 0000000000..9c09f89bc5 --- /dev/null +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.spec.tsx @@ -0,0 +1,329 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + describe, expect, test, vi, +} from 'vitest'; +import * as d3 from 'd3'; +import { StudyConfig } from '../../../parser/types'; +import { ParticipantData } from '../../../storage/types'; +import { AllTasksTimeline } from './AllTasksTimeline'; +import { SingleTask } from './SingleTask'; +import { SingleTaskLabelLines } from './SingleTaskLabelLines'; + +// ── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@mantine/core', () => ({ + Center: ({ children }: { children: ReactNode }) =>
{children}
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => ( +
+
{label}
+ {children} +
+ ), + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Group: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@tabler/icons-react', () => ({ + IconCheck: () => icon-check, + IconMicrophone: () => icon-microphone, + IconProgress: () => icon-progress, + IconX: () => icon-x, +})); + +vi.mock('@mantine/hooks', () => ({ + useResizeObserver: () => [{ current: null }, { width: 60, height: 20 }], +})); + +vi.mock('../../../utils/useNavigateToTrial', () => ({ + useNavigateToTrial: () => vi.fn(), +})); + +vi.mock('../../../utils/correctAnswer', () => ({ + componentAnswersAreCorrect: vi.fn(() => true), +})); + +vi.mock('../../../utils/handleConditionLogic', () => ({ + parseConditionParam: vi.fn(() => []), +})); + +// ── fixtures ───────────────────────────────────────────────────────────────── + +const t0 = 1_700_000_000_000; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeParticipant(overrides: Record = {}): ParticipantData { + return { + participantId: 'pid-1', + conditions: undefined, + searchParams: {}, + answers: { + trial1_0: { + componentName: 'trial1', + startTime: t0, + endTime: t0 + 10_000, + answer: {}, + correctAnswer: [], + trialOrder: '0_0', + windowEvents: [], + }, + }, + ...overrides, + } as unknown as ParticipantData; +} + +const emptyConfig = { + components: {}, + uiConfig: {}, + sequence: { order: 'fixed', components: [] }, +} as unknown as StudyConfig; + +const xScale = d3.scaleLinear([0, 500]).domain([0, 1000]); + +// ── SingleTaskLabelLines ────────────────────────────────────────────────────── + +describe('SingleTaskLabelLines', () => { + test('renders a element with computed coordinates', () => { + const scale = d3.scaleLinear([0, 400]).domain([0, 100]); + const html = renderToStaticMarkup( + + + , + ); + // scaleStart=50 → scale(50)=200; x1=x2=202; y1=100-45-0=55; y2=100-25=75 + expect(html).toContain('x1="202"'); + expect(html).toContain('x2="202"'); + expect(html).toContain('y1="55"'); + expect(html).toContain('y2="75"'); + }); + + test('applies labelHeight offset to y1', () => { + const scale = d3.scaleLinear([0, 400]).domain([0, 100]); + const html = renderToStaticMarkup( + + + , + ); + // y1 = height - 45 - labelHeight = 100 - 45 - 25 = 30 + expect(html).toContain('y1="30"'); + }); +}); + +// ── SingleTask ──────────────────────────────────────────────────────────────── + +describe('SingleTask', () => { + const baseProps = { + name: 'trial1_0', + height: 100, + xScale, + scaleStart: 0, + scaleEnd: 100, + trialOrder: '0_0', + participantId: 'pid-1', + studyId: 'test-study', + incomplete: false, + isCorrect: false, + hasCorrect: false, + hasAudio: false, + }; + + test('renders task name', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('trial1_0'); + }); + + test('shows check icon when hasCorrect and isCorrect', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-check'); + }); + + test('shows x icon when hasCorrect and not isCorrect', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-x'); + }); + + test('shows microphone icon when hasAudio', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-microphone'); + }); + + test('shows progress icon when incomplete', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-progress'); + }); + + test('no correctness icon when hasCorrect is false', () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain('icon-check'); + expect(html).not.toContain('icon-x'); + }); +}); + +// ── AllTasksTimeline ────────────────────────────────────────────────────────── + +describe('AllTasksTimeline', () => { + test('renders an SVG element', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(' { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('trial1_0'); + }); + + test('handles participant with answer values (tooltip shows answer)', () => { + const participant = makeParticipant({ + answers: { + trial1_0: { + componentName: 'trial1', + startTime: t0, + endTime: t0 + 5_000, + answer: { q1: 'yes' }, + correctAnswer: [], + trialOrder: '0_0', + windowEvents: [], + }, + }, + }); + + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('q1'); + expect(html).toContain('yes'); + }); + + test('handles participant with incomplete answer (startTime === 0)', () => { + const participant = makeParticipant({ + answers: { + trial1_0: { + componentName: 'trial1', + startTime: t0, + endTime: t0 + 5_000, + answer: {}, + correctAnswer: [], + trialOrder: '0_0', + windowEvents: [], + }, + trial2_1: { + componentName: 'trial2', + startTime: 0, + endTime: 0, + answer: {}, + correctAnswer: [], + trialOrder: '1_0', + windowEvents: [], + }, + }, + }); + + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('trial1_0'); + expect(html).toContain('trial2_1'); + }); + + test('handles browsed-away window events', () => { + const participant = makeParticipant({ + answers: { + trial1_0: { + componentName: 'trial1', + startTime: t0, + endTime: t0 + 10_000, + answer: {}, + correctAnswer: [], + trialOrder: '0_0', + windowEvents: [ + [t0 + 1_000, 'visibility', 'hidden'], + [t0 + 3_000, 'visibility', 'visible'], + ], + }, + }, + }); + + const html = renderToStaticMarkup( + , + ); + // Browsed-away rect should be rendered inside a Tooltip + expect(html).toContain(' { + // With maxLength set, the xScale domain is [start, start + maxLength] + // — just verify the component renders without error + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(' { + // parseConditionParam is mocked to return [] by default; just verify + // the component renders without error when conditions field is set + const participant = makeParticipant({ + conditions: 'condA,condB', + }); + + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(' ({ + useParams: vi.fn(() => ({})), +})); + +vi.mock('@mantine/hooks', () => ({ + useDisclosure: () => [true, { toggle: vi.fn() }], + useResizeObserver: () => [{ current: null }, { width: 800, height: 400 }], +})); + +vi.mock('react-vega', () => ({ + VegaLite: () =>
VegaLite
, +})); + +vi.mock('@mantine/core', () => ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Divider: () =>
, + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Paper: ({ children, ref: _ref }: { children: ReactNode; ref?: unknown }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Title: ({ children }: { children: ReactNode }) =>
{children}
, + Collapse: ({ children, in: open }: { children: ReactNode; in?: boolean }) => ( + open ?
{children}
: null + ), + Code: ({ children }: { children: ReactNode }) => {children}, + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, + SimpleGrid: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@tabler/icons-react', () => ({ + IconAdjustmentsHorizontal: () => icon-slider, + IconBubbleText: () => icon-text, + IconChartGridDots: () => icon-matrix-cb, + IconChevronDown: () => icon-chevron, + IconCodePlus: () => icon-metadata, + IconCopyCheck: () => icon-buttons, + IconDots: () => icon-likert, + IconDragDrop: () => icon-ranking, + IconGridDots: () => icon-matrix-r, + IconHtml: () => icon-reactive, + IconLetterCase: () => icon-textOnly, + IconNumber123: () => icon-numerical, + IconRadio: () => icon-radio, + IconSelect: () => icon-dropdown, + IconSquares: () => icon-checkbox, +})); + +vi.mock('../../../components/interface/StepsPanel', () => ({ + StepsPanel: () =>
StepsPanel
, +})); + +vi.mock('../summary/OverviewStats', () => ({ + OverviewStats: () =>
OverviewStats
, +})); + +vi.mock('../summary/utils', () => ({ + getOverviewStats: vi.fn(() => ({ + participantCounts: { + total: 5, completed: 3, inProgress: 1, rejected: 1, + }, + startDate: null, + endDate: null, + avgTime: NaN, + avgCleanTime: NaN, + participantsWithInvalidCleanTimeCount: 0, + correctness: NaN, + })), +})); + +vi.mock('../../../utils/handleComponentInheritance', () => ({ + studyComponentToIndividualComponent: vi.fn(() => ({ + type: 'questionnaire', + response: [], + correctAnswer: undefined, + })), +})); + +// ── fixtures ───────────────────────────────────────────────────────────────── + +const emptyConfig: StudyConfig = { + components: { trial1: { type: 'questionnaire', response: [] } as unknown as StudyConfig['components'][string] }, + sequence: { order: 'fixed', components: ['trial1'] } as unknown as StudyConfig['sequence'], +} as unknown as StudyConfig; + +const mockParticipant = { + participantId: 'p1', answers: {}, completed: true, rejected: false, +} as unknown as ParticipantData; + +const mockTrialConfig = { + type: 'questionnaire', + response: [], + correctAnswer: undefined, +} as unknown as IndividualComponent; + +// ── StatsView ───────────────────────────────────────────────────────────────── + +describe('StatsView', () => { + beforeEach(() => { + vi.mocked(useParams).mockReturnValue({}); + }); + + test('shows "No data available" when visibleParticipants is empty', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('No data available.'); + }); + + test('shows StepsPanel and TrialVisualization when participants exist', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('StepsPanel'); + }); + + test('shows OverviewStats when trialId is set and not "end"', () => { + vi.mocked(useParams).mockReturnValue({ trialId: 'trial1' }); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('OverviewStats'); + }); + + test('no OverviewStats when trialId is undefined', () => { + vi.mocked(useParams).mockReturnValue({}); + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('OverviewStats'); + }); + + test('no OverviewStats when trialId is "end"', () => { + vi.mocked(useParams).mockReturnValue({ trialId: 'end' }); + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('OverviewStats'); + }); +}); + +// ── TrialVisualization ─────────────────────────────────────────────────────── + +describe('TrialVisualization', () => { + test('shows "No trial selected" when trialId is undefined', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('No trial selected'); + }); + + test('shows end-component message when trialId is "end"', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('The end component has no data.'); + }); + + test('renders ResponseVisualization items when trialId matches a component', () => { + vi.mocked(studyComponentToIndividualComponent).mockReturnValue({ + type: 'questionnaire', + response: [{ type: 'radio', id: 'q1', prompt: 'Pick one' } as IndividualComponent['response'][number]], + correctAnswer: undefined, + } as unknown as IndividualComponent); + + const html = renderToStaticMarkup( + , + ); + // Config and Timing metadata block + q1 response block + expect(html).toContain('Config and Timing'); + expect(html).toContain('q1'); + }); +}); + +// ── ResponseVisualization ──────────────────────────────────────────────────── + +describe('ResponseVisualization', () => { + const baseProps = { + participantData: [] as ParticipantData[], + trialId: 'trial1', + trialConfig: mockTrialConfig, + }; + + test('metadata type: shows icon and config JSON code block', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-metadata'); + expect(html).toContain('Config and Timing'); + // JSON of trialConfig rendered in Code block (quotes are HTML-entity encoded) + expect(html).toContain('questionnaire'); + }); + + test('metadata type: renders VegaLite timing histogram in right panel', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('VegaLite'); + }); + + test('shortText type: shows text icon and Response Values section', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-text'); + expect(html).toContain('Response Values'); + }); + + test('textOnly type: shows N/A for response values', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('N/A'); + }); + + test('radio type: shows radio icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-radio'); + expect(html).toContain('VegaLite'); + }); + + test('numerical type: shows numerical icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-numerical'); + expect(html).toContain('VegaLite'); + }); + + test('slider type: shows slider icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-slider'); + expect(html).toContain('VegaLite'); + }); + + test('likert type: shows likert icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-likert'); + expect(html).toContain('VegaLite'); + }); + + test('matrix-radio type: shows matrix icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-matrix-r'); + expect(html).toContain('VegaLite'); + }); + + test('matrix-checkbox type: shows matrix-cb icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('icon-matrix-cb'); + expect(html).toContain('VegaLite'); + }); + + test('shows Response Specification JSON for non-metadata types', () => { + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('Response Specification'); + }); + + test('shows Correct Answer block when trialConfig has a matching correctAnswer', () => { + const trialConfigWithAnswer = { + ...mockTrialConfig, + correctAnswer: [{ id: 'q1', answer: 'A' }], + } as unknown as IndividualComponent; + + const html = renderToStaticMarkup( + [0]['response']} + />, + ); + expect(html).toContain('Correct Answer'); + }); +}); diff --git a/src/analysis/individualStudy/summary/SummaryView.spec.tsx b/src/analysis/individualStudy/summary/SummaryView.spec.tsx new file mode 100644 index 0000000000..46a5d6c108 --- /dev/null +++ b/src/analysis/individualStudy/summary/SummaryView.spec.tsx @@ -0,0 +1,245 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + afterEach, beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { cleanup } from '@testing-library/react'; +import { StudyConfig } from '../../../parser/types'; +import { ParticipantData } from '../../../storage/types'; +import type { StorageEngine } from '../../../storage/engines/types'; +import { OverviewData } from '../../types'; +import { useStorageEngine } from '../../../storage/storageEngineHooks'; +import { useAsync } from '../../../store/hooks/useAsync'; +import { SummaryView } from './SummaryView'; +import { OverviewStats } from './OverviewStats'; +import { ComponentStats } from './ComponentStats'; +import { ResponseStats } from './ResponseStats'; + +// ── capturedTableOptions: intercept MRT ───────────────────────────────────── + +type MrtColumn = { + accessorKey?: string; + header?: string; + Cell: ({ cell }: { cell: { getValue(): unknown } }) => unknown; +}; + +let capturedTableOptions: { columns: MrtColumn[] } | null = null; + +vi.mock('mantine-react-table', () => ({ + MantineReactTable: () =>
MantineReactTable
, + useMantineReactTable: (options: { columns: MrtColumn[] }) => { + capturedTableOptions = options; + return options; + }, +})); + +vi.mock('@mantine/core', () => ({ + Paper: ({ children }: { children: ReactNode }) =>
{children}
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Title: ({ children }: { children: ReactNode }) =>

{children}

, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Tooltip: ({ children, label }: { children: ReactNode; label?: string }) => ( +
{children}
+ ), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconAlertTriangle: () => alert, +})); + +vi.mock('../../../storage/storageEngineHooks', () => ({ + useStorageEngine: vi.fn(() => ({ storageEngine: undefined })), +})); + +vi.mock('../../../store/hooks/useAsync', () => ({ + useAsync: vi.fn(() => ({ value: null, status: 'idle', error: null })), +})); + +// ── fixture helpers ────────────────────────────────────────────────────────── + +function makeOverviewData(overrides: Partial = {}): OverviewData { + return { + participantCounts: { + total: 10, completed: 7, inProgress: 2, rejected: 1, + }, + startDate: new Date('2026-01-01'), + endDate: new Date('2026-03-01'), + avgTime: 45.3, + avgCleanTime: 42.1, + participantsWithInvalidCleanTimeCount: 0, + correctness: NaN, + ...overrides, + }; +} + +const emptyConfig: StudyConfig = { + components: { comp1: { type: 'questionnaire', response: [] } as unknown as StudyConfig['components'][string] }, + sequence: { order: 'fixed', components: ['comp1'] } as unknown as StudyConfig['sequence'], +} as unknown as StudyConfig; + +const noParticipants: ParticipantData[] = []; + +// ── SummaryView ────────────────────────────────────────────────────────────── + +describe('SummaryView', () => { + test('renders OverviewStats, ComponentStats, and ResponseStats sections', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Overview Statistics'); + expect(html).toContain('Component Statistics'); + expect(html).toContain('Response Statistics'); + }); +}); + +// ── OverviewStats ──────────────────────────────────────────────────────────── + +describe('OverviewStats', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + test('renders participant counts and stat labels', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Total Participants'); + expect(html).toContain('Completed'); + expect(html).toContain('In Progress'); + expect(html).toContain('Rejected'); + expect(html).toContain('Average Time'); + expect(html).toContain('Average Clean Time'); + expect(html).toContain('Correctness'); + }); + + test('shows mismatch alert when stored counts differ from calculated counts', () => { + vi.mocked(useAsync).mockReturnValue({ + execute: vi.fn(), value: { completed: 3, inProgress: 5, rejected: 0 }, status: 'success', error: null, + } as ReturnType); + vi.mocked(useStorageEngine).mockReturnValue({ storageEngine: {} as unknown as StorageEngine, setStorageEngine: vi.fn() }); + + const html = renderToStaticMarkup( + , + ); + // Alert triangle appears for the mismatched field + expect(html).toContain('alert'); + }); + + test('no mismatch alert when stored counts match calculated counts', () => { + vi.mocked(useAsync).mockReturnValue({ + execute: vi.fn(), value: { completed: 7, inProgress: 2, rejected: 1 }, status: 'success', error: null, + } as ReturnType); + vi.mocked(useStorageEngine).mockReturnValue({ storageEngine: {} as unknown as StorageEngine, setStorageEngine: vi.fn() }); + + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('alert'); + }); + + test('shows excluded-participants warning when participantsWithInvalidCleanTimeCount > 0', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('alert'); + }); + + test('no excluded warning when participantsWithInvalidCleanTimeCount is 0', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('alert'); + }); + + test('no stored counts fetched when studyId is undefined', () => { + renderToStaticMarkup( + , + ); + // useAsync should have been called with null as immediate (no fetch) + expect(vi.mocked(useAsync).mock.calls[0][1]).toBeNull(); + }); +}); + +// ── ComponentStats ─────────────────────────────────────────────────────────── + +describe('ComponentStats', () => { + beforeEach(() => { capturedTableOptions = null; }); + + test('renders Component Statistics title', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Component Statistics'); + }); + + test('avgTime Cell renders formatted seconds', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.accessorKey === 'avgTime')!; + expect(col.Cell({ cell: { getValue: () => 30 } })).toBe('30.0s'); + expect(col.Cell({ cell: { getValue: () => NaN } })).toBe('N/A'); + }); + + test('avgCleanTime Cell renders formatted seconds', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.accessorKey === 'avgCleanTime')!; + expect(col.Cell({ cell: { getValue: () => 25.5 } })).toBe('25.5s'); + }); + + test('correctness Cell renders formatted percentage', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.accessorKey === 'correctness')!; + expect(col.Cell({ cell: { getValue: () => 80 } })).toBe('80.0%'); + expect(col.Cell({ cell: { getValue: () => NaN } })).toBe('N/A'); + }); +}); + +// ── ResponseStats ──────────────────────────────────────────────────────────── + +describe('ResponseStats', () => { + beforeEach(() => { capturedTableOptions = null; }); + + test('renders Response Statistics title', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Response Statistics'); + }); + + test('correctness Cell renders formatted percentage', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.accessorKey === 'correctness')!; + expect(col.Cell({ cell: { getValue: () => 50 } })).toBe('50.0%'); + expect(col.Cell({ cell: { getValue: () => NaN } })).toBe('N/A'); + }); +}); diff --git a/src/analysis/individualStudy/table/TableView.spec.tsx b/src/analysis/individualStudy/table/TableView.spec.tsx new file mode 100644 index 0000000000..5d6ef551f5 --- /dev/null +++ b/src/analysis/individualStudy/table/TableView.spec.tsx @@ -0,0 +1,306 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { useParams } from 'react-router'; +import { StudyConfig } from '../../../parser/types'; +import { ParticipantData } from '../../../storage/types'; +import { TableView } from './TableView'; +import { MetaCell } from './MetaCell'; + +// ── capturedTableOptions ───────────────────────────────────────────────────── + +type MrtColumn = { + header: string; + Cell: ({ cell }: { cell: { getValue(): unknown } }) => ReactNode; +}; + +let capturedTableOptions: { columns: MrtColumn[] } | null = null; + +vi.mock('mantine-react-table', () => ({ + MantineReactTable: () =>
MantineReactTable
, + useMantineReactTable: (opts: { columns: MrtColumn[] }) => { capturedTableOptions = opts; return opts; }, +})); + +vi.mock('react-router', () => ({ + useParams: vi.fn(() => ({ studyId: 'test-study' })), +})); + +vi.mock('@mantine/core', () => ({ + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Space: () =>
, + Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) =>
{children}
, + Badge: ({ children }: { children: ReactNode }) => {children}, + RingProgress: ({ sections }: { sections: { value: number }[] }) =>
{sections[0]?.value}
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => , + Spoiler: ({ children }: { children: ReactNode }) =>
{children}
, + Box: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@tabler/icons-react', () => ({ + IconCheck: () => check, + IconHourglassEmpty: () => hourglass, + IconX: () => x-icon, + IconCopy: () => copy, +})); + +vi.mock('../replay/AllTasksTimeline', () => ({ + AllTasksTimeline: () =>
AllTasksTimeline
, +})); + +vi.mock('../ParticipantRejectModal', () => ({ + ParticipantRejectModal: () =>
ParticipantRejectModal
, +})); + +vi.mock('../../../utils/getSequenceFlatMap', () => ({ + getSequenceFlatMap: vi.fn(() => ['intro', 'trial1', 'trial2', 'end']), +})); + +vi.mock('../../../utils/participantName', () => ({ + participantName: () => 'Test User', +})); + +// ── fixtures ───────────────────────────────────────────────────────────────── + +const emptyConfig = { + components: {}, + uiConfig: { participantNameField: undefined }, + sequence: { order: 'fixed', components: [] }, +} as unknown as StudyConfig; + +const t0 = 1_700_000_000_000; + +function makeParticipant(overrides: Partial = {}): ParticipantData { + return { + participantId: 'pid-1', + participantIndex: 1, + stage: 'DEFAULT', + completed: true, + rejected: false, + conditions: undefined, + searchParams: {}, + answers: { + trial1_0: { + componentName: 'trial1', + startTime: t0, + endTime: t0 + 10_000, + answer: {}, + correctAnswer: [], + trialOrder: '0_0', + windowEvents: [], + }, + }, + metadata: { + userAgent: 'test-agent', + resolution: { width: 1920, height: 1080 }, + language: 'en-US', + ip: '1.2.3.4', + }, + sequence: { order: 'fixed', components: ['trial1'], skip: [] } as unknown as ParticipantData['sequence'], + participantTags: [], + participantConfigHash: 'hash1', + ...overrides, + } as unknown as ParticipantData; +} + +const defaultProps = { + studyConfig: emptyConfig, + allConfigs: {} as Record, + refresh: async () => ({} as Record), + width: 800, + stageColors: { DEFAULT: '#F05A30' }, + selectedParticipants: [], + onSelectionChange: vi.fn(), +}; + +beforeEach(() => { + capturedTableOptions = null; + vi.mocked(useParams).mockReturnValue({ studyId: 'test-study' }); + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText: vi.fn() }, + configurable: true, + }); +}); + +// ── TableView ───────────────────────────────────────────────────────────────── + +describe('TableView', () => { + test('shows "No data available" when visibleParticipants is empty', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('No data available'); + }); + + test('renders MantineReactTable and captures column options when participants exist', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('MantineReactTable'); + expect(capturedTableOptions).not.toBeNull(); + }); + + // ── Status column ────────────────────────────────────────────────────────── + + test('Status Cell: rejected participant shows x-icon and reason', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Status')!; + const html = renderToStaticMarkup(col.Cell({ + cell: { getValue: () => ({ rejected: { reason: 'spam' }, completed: false, percent: 0 }) }, + })); + expect(html).toContain('x-icon'); + expect(html).toContain('spam'); + }); + + test('Status Cell: completed participant shows check icon', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Status')!; + const html = renderToStaticMarkup(col.Cell({ + cell: { getValue: () => ({ rejected: false, completed: true, percent: 1 }) }, + })); + expect(html).toContain('check'); + }); + + test('Status Cell: in-progress participant shows RingProgress percent', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Status')!; + const html = renderToStaticMarkup(col.Cell({ + cell: { getValue: () => ({ rejected: false, completed: false, percent: 0.5 }) }, + })); + expect(html).toContain('50'); // 0.5 * 100 + }); + + // ── Stage column ─────────────────────────────────────────────────────────── + + test('Stage Cell: empty stage shows N/A badge', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Stage')!; + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => '' } })); + expect(html).toContain('N/A'); + }); + + test('Stage Cell: named stage renders stage name', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Stage')!; + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => 'DEFAULT' } })); + expect(html).toContain('DEFAULT'); + }); + + // ── Duration column ──────────────────────────────────────────────────────── + + test('Duration Cell: valid duration shows formatted time', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Duration')!; + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => new Date(90_000) } })); + expect(html).toContain('01:30'); // 90 seconds + }); + + test('Duration Cell: NaN date shows "N/A" (youtubeReadableDuration fallback)', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Duration')!; + // new Date(NaN) is a Date object; Number.isNaN(DateObject) === false → truthy branch + // +new Date(NaN) === NaN → youtubeReadableDuration(NaN) falsy → 'N/A' shown + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => new Date(NaN) } })); + expect(html).toContain('N/A'); + }); + + // ── Start Time column ────────────────────────────────────────────────────── + + test('Start Time Cell: epoch-0 date shows "None"', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Start Time')!; + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => new Date(0) } })); + expect(html).toContain('None'); + }); + + test('Start Time Cell: valid date shows locale string', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Start Time')!; + const d = new Date(2026, 0, 15, 10, 30); + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => d } })); + expect(html).toContain(d.toLocaleDateString([], { hour: '2-digit', minute: '2-digit' })); + }); + + // ── Correct Answers column ───────────────────────────────────────────────── + + test('Correct Answers Cell: shows correct and incorrect counts', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Correct Answers')!; + // 2 correct, 1 incorrect + const html = renderToStaticMarkup(col.Cell({ cell: { getValue: () => [true, true, false] } })); + expect(html).toContain('2'); // correct count + expect(html).toContain('1'); // incorrect count + }); + + // ── Metadata column ──────────────────────────────────────────────────────── + + test('Metadata Cell: renders MetaCell with participant metadata', () => { + renderToStaticMarkup( + , + ); + const col = capturedTableOptions!.columns.find((c) => c.header === 'Metadata')!; + const html = renderToStaticMarkup(col.Cell({ + cell: { + getValue: () => ({ + userAgent: 'Mozilla/5.0', + resolution: { width: 1920, height: 1080 }, + ip: '1.2.3.4', + language: 'en-US', + }), + }, + })); + expect(html).toContain('Mozilla/5.0'); + expect(html).toContain('1.2.3.4'); + }); +}); + +// ── MetaCell ───────────────────────────────────────────────────────────────── + +describe('MetaCell', () => { + test('renders all metadata fields', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('TestAgent/1.0'); + expect(html).toContain('2560'); + expect(html).toContain('10.0.0.1'); + expect(html).toContain('fr-FR'); + }); + + test('renders gracefully when metaData is undefined', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Resolution'); + expect(html).toContain('User Agent'); + }); +}); diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudView.spec.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudView.spec.tsx new file mode 100644 index 0000000000..572386709e --- /dev/null +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudView.spec.tsx @@ -0,0 +1,365 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + describe, expect, test, vi, +} from 'vitest'; +import * as d3 from 'd3'; +import { useParams, useSearchParams } from 'react-router'; +import { Tag } from './types'; +import type { FirebaseStorageEngine } from '../../../storage/engines/FirebaseStorageEngine'; +import { TranscriptSegmentsVis } from './TranscriptSegmentsVis'; +import { Pills } from './tags/Pills'; +import { AddTagDropdown } from './tags/AddTagDropdown'; +import { TagEditor } from './tags/TagEditor'; +import { ThinkAloudAnalysis } from './ThinkAloudAnalysis'; + +// ── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@mantine/core', () => ({ + Center: ({ children }: { children: ReactNode }) =>
{children}
, + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Grid: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Col: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + Textarea: ({ value, placeholder }: { value?: string; placeholder?: string }) => ( +