diff --git a/AGENTS.md b/AGENTS.md index 43033ed9be..f6e9b00a82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ How you should interact with the codebase When working with the ReVISit codebase, work only with the source code files available to you. If you need an external library, please ask for approval first (and include how well used the library is). Make sure to follow best practices for React and TypeScript development, including proper state management, component structuring, and code documentation. Pay extra attention to lifecycle methods and hooks to ensure optimal performance and avoid memory leaks, including any updates to existing code. If you encounter any issues or have suggestions for improvements, feel free to bring them up for discussion. You can run git commands but don't run them unless asked to. Don't interact with GitHub directly. Always check package.json for the scripts available to you for building, testing, and running the project. Testing -When adding a new feature or modifying code try to maximize unit test coverage. Unit tests should be colocated with the files they are testing, have the same names as the file with .spec., and use the vitest framework. Apply this to both UI/react code as well as non-UI code. For UI code, we use playwright for end-to-end testing. Try to add e2e tests for any new features that involve user interaction. E2E tests are located in the tests/ directory at the root of the project. Don't run `yarn test` directly; instead, describe the tests you want to run, and I'll handle executing them. You can run unittests locally using `yarn unittest`. Preferred commands are those listed in package.json (e.g., `yarn unittest`, `yarn lint`, `yarn typecheck`, `yarn serve`, `yarn build`). +When adding a new feature or modifying code try to maximize unit test coverage. Unit tests should live in a sibling `tests/` folder near the code they are testing, keep the same base name as the tested file with `.spec.`, and use the vitest framework. For example, `src/store/hooks/useReplay.ts` should be tested in `src/store/hooks/tests/useReplay.spec.tsx`. Root-level app specs should live in `src/tests/`. Apply this to both UI/react code as well as non-UI code. For UI code, we use playwright for end-to-end testing. Try to add e2e tests for any new features that involve user interaction. E2E tests are located in the `tests/` directory at the repo root. Don't run `yarn test` directly; instead, describe the tests you want to run, and I'll handle executing them. You can run unittests locally using `yarn unittest`. Preferred commands are those listed in package.json (e.g., `yarn unittest`, `yarn lint`, `yarn typecheck`, `yarn serve`, `yarn build`). Parser When adding new features, sometimes it's required to update the parser and the associated types. The parser is located in src/parser/. The parser is responsible for validating and transforming the study config JSON files into a format that the application can use. When updating the parser, ensure that you also update the corresponding types in src/parser/types.ts to reflect any changes made to the study config schema. Make sure to add unit tests for any new parser functionality to ensure its correctness. Additionally, changes to the parser types will require updates to the generated JSON schema files located in src/schemas/. You can regenerate these schema files by running `yarn generate-schemas`. 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..eac24fed47 --- /dev/null +++ b/LibraryExampleStudyGenerator.spec.ts @@ -0,0 +1,88 @@ +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 generateDocsFn = vi.fn(); + generateLibraryExamples(base, generateDocsFn); + + 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(generateDocsFn).toHaveBeenCalledTimes(1); + expect(generateDocsFn).toHaveBeenCalledWith(base); + }); + + it('throws when doc generation fails', () => { + 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 generateDocsFn = vi.fn(() => { + throw new Error('test Error'); + }); + + expect(() => generateLibraryExamples(base, generateDocsFn)).toThrow('test Error'); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error running libraryDocGenerator.cjs: Error: test Error')); + errorSpy.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..1c4870033f 100644 --- a/libraryExampleStudyGenerator.cjs +++ b/libraryExampleStudyGenerator.cjs @@ -9,12 +9,7 @@ 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'); - +const { generateLibraryDocs } = require('./libraryDocGenerator.cjs'); // Create example study config template const createExampleConfig = (libraryName) => ({ @@ -49,60 +44,66 @@ 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, generateDocsFn = generateLibraryDocs) => { + const librariesPath = path.join(base, 'public', 'libraries'); + const publicPath = path.join(base, 'public'); - const exampleFolderName = `library-${library}`; - const examplePath = path.join(publicPath, exampleFolderName); - - // 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`); - } + // Process each library + const libraries = getLibraries(librariesPath); -}); + 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 exampleFolderName = `library-${library}`; + const examplePath = path.join(publicPath, exampleFolderName); + + // 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`); -// eslint-disable-next-line no-console -console.log('Library example generation complete'); + // 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`); + } + }); -// 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) { + // eslint-disable-next-line no-console + console.log('Library example generation complete'); + + // Generate library.md files in the same base directory so example-study assets + // are written to the requested target tree, not the caller's current working directory. + // eslint-disable-next-line no-console + console.log('Generating library documentation...'); + try { + generateDocsFn(base); + } catch (error) { console.error(`Error running libraryDocGenerator.cjs: ${error}`); - return; - } - if (stderr) { - console.error(`libraryDocGenerator.cjs stderr: ${stderr}`); - return; + throw error; } - // eslint-disable-next-line no-console - console.log(stdout); -}); +}; + +if (require.main === module) { + generateLibraryExamples(__dirname); +} + +module.exports = { createExampleConfig, getLibraries, generateLibraryExamples }; diff --git a/package.json b/package.json index 5825240db5..5193e53321 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", "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" @@ -98,6 +99,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", @@ -108,6 +112,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", diff --git a/src/analysis/individualStudy/LiveMonitor/tests/LiveMonitorView.spec.tsx b/src/analysis/individualStudy/LiveMonitor/tests/LiveMonitorView.spec.tsx new file mode 100644 index 0000000000..bc1df7f5c0 --- /dev/null +++ b/src/analysis/individualStudy/LiveMonitor/tests/LiveMonitorView.spec.tsx @@ -0,0 +1,498 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + render, act, cleanup, fireEvent, +} from '@testing-library/react'; +import { + afterEach, beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { SequenceAssignment } from '../../../../storage/engines/types'; +import { makeStorageEngine, makeSequenceAssignment } from '../../../../tests/utils'; +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, onClick }: { children: ReactNode; onClick?: () => void }) => , + 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 makeSequenceAssignment({ + timestamp: 1_700_000_000_000, + total: 5, + createdTime: 1_700_000_000_000, + ...overrides, + }); +} + +// ── 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(overrides: Partial<{ isCompleted: boolean; isRejected: boolean }> = {}) { + return { + assignment: makeAssignment(), + progress: 50, + isCompleted: false, + isRejected: false, + ...overrides, + }; + } + + test('splits into inProgress, completed, rejected groups', () => { + const items = [ + makeProgress(), // inProgress + makeProgress({ isCompleted: true }), // completed + makeProgress({ isCompleted: true, isRejected: true }), // rejected (rejected takes precedence) + makeProgress({ isRejected: 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); + }); +}); + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +// ── 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'); + }); + + test('sets connectionStatus to disconnected when no storageEngine after effect', async () => { + const { container } = await act(async () => render( + , + )); + // After effects run, status is 'disconnected' → icon-wifioff shown + expect(container.textContent).toContain('icon-wifioff'); + }); + + test('sets connectionStatus to connected when listener returns a function', async () => { + const mockUnsubscribe = vi.fn(); + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn((_studyId: string, _cb: (assignments: SequenceAssignment[]) => void) => mockUnsubscribe), + }; + const { container } = await act(async () => render( + , + )); + // With a valid listener returning a function, status becomes 'connected' → icon-wifi shown + expect(container.textContent).toContain('icon-wifi'); + }); +}); + +// ── 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('uses "#N" fallback when participantId is empty', () => { + const participants = [ + { + assignment: makeAssignment({ participantId: '' }), progress: 50, isCompleted: false, isRejected: false, + }, + ]; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('#1'); + }); + + 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(' { + const baseProps = { + studyConfig: {} as Parameters[0]['studyConfig'], + includedParticipants: ['inProgress', 'completed', 'rejected'], + selectedStages: ['ALL'], + studyId: 'test-study', + }; + + beforeEach(() => { vi.clearAllMocks(); }); + + test('listener callback covers handleDataUpdate + InProgressLabel', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn((_id: string, cb: (a: SequenceAssignment[]) => void) => { + cb([makeAssignment({ participantId: 'p-active', answered: ['q1'], total: 4 })]); + return vi.fn(); + }), + }; + const { container } = await act(async () => render( + , + )); + expect(container.textContent).toContain('p-active'); + }); + + test('CompletedLabel and RejectedLabel rendered with completed/rejected assignments', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn((_id: string, cb: (a: SequenceAssignment[]) => void) => { + cb([ + makeAssignment({ + participantId: 'p-done', completed: 1_700_000_000_000, answered: ['q1', 'q2', 'q3', 'q4', 'q5'], total: 5, + }), + makeAssignment({ + participantId: 'p-rej', rejected: true, answered: ['q1'], total: 5, + }), + ]); + return vi.fn(); + }), + }; + const { container } = await act(async () => render( + , + )); + expect(container.textContent).toContain('p-done'); + expect(container.textContent).toContain('p-rej'); + }); + + test('sets connectionStatus to disconnected when listener returns undefined', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn(() => undefined), + }; + const { container } = await act(async () => render( + , + )); + expect(container.textContent).toContain('icon-wifioff'); + }); + + test('Reconnect button click covers handleReconnect success path', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([makeAssignment({ participantId: 'p-new' })]), + _setupSequenceAssignmentListener: vi.fn(() => undefined), + }; + const { getAllByRole } = await act(async () => render( + , + )); + const reconnectBtn = getAllByRole('button').find((b) => b.textContent?.includes('Reconnect')); + expect(reconnectBtn).toBeDefined(); + await act(async () => { fireEvent.click(reconnectBtn!); }); + expect(mockEngine.getAllSequenceAssignments).toHaveBeenCalled(); + }); + + test('Reconnect button click covers handleReconnect error path', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockRejectedValue(new Error('conn failed')), + _setupSequenceAssignmentListener: vi.fn(() => undefined), + }; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + const { getAllByRole } = await act(async () => render( + , + )); + const reconnectBtn = getAllByRole('button').find((b) => b.textContent?.includes('Reconnect')); + expect(reconnectBtn).toBeDefined(); + await act(async () => { fireEvent.click(reconnectBtn!); }); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + test('window offline event covers handleOffline', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn((_id: string, cb: (a: SequenceAssignment[]) => void) => { + cb([]); + return vi.fn(); + }), + }; + await act(async () => render( + , + )); + act(() => { window.dispatchEvent(new Event('offline')); }); + }); + + test('window online event covers handleOnline when disconnected', async () => { + const mockEngine = { + initializeStudyDb: vi.fn(), + getAllSequenceAssignments: vi.fn().mockResolvedValue([]), + _setupSequenceAssignmentListener: vi.fn(() => undefined), + }; + await act(async () => render( + , + )); + await act(async () => { window.dispatchEvent(new Event('online')); }); + expect(mockEngine.getAllSequenceAssignments).toHaveBeenCalled(); + }); +}); + +// ── ProgressHeatmap ─────────────────────────────────────────────────────────── + +describe('ProgressHeatmap', () => { + 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/tests/ConfigView.spec.tsx b/src/analysis/individualStudy/config/tests/ConfigView.spec.tsx new file mode 100644 index 0000000000..f80d4736e9 --- /dev/null +++ b/src/analysis/individualStudy/config/tests/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 { ConfigInfo } from '../utils'; +import { ConfigView } from '../ConfigView'; +import { downloadConfigFile, downloadConfigFilesZip } from '../../../../utils/handleDownloadFiles'; +import { makeParticipant } from '../../../../tests/utils'; + +// Capture what gets passed to useMantineReactTable so we can test columns / options +type CapturedTableOptions = Record & { + columns: { id?: string; header: string; accessorKey?: string; Cell: (arg: Record) => ReactNode }[]; + enableRowSelection: boolean; + enableRowVirtualization: boolean; + enablePagination: boolean; + enableDensityToggle: boolean; + onRowSelectionChange: (sel: Record) => void; + renderTopToolbarCustomActions: () => ReactNode; +}; + +let capturedTableOptions: CapturedTableOptions | null = null; + +let mockStorageEngine: { getAllConfigsFromHash: ReturnType } | undefined; + +vi.mock('../../../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ storageEngine: mockStorageEngine }), +})); + +vi.mock('mantine-react-table', () => ({ + useMantineReactTable: (options: CapturedTableOptions) => { + 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}
, + Badge: ({ 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-04-08', + timeFrame: 'N/A', + participantCount: 2, + config: { studyMetadata: { version: '1.0.0', date: '2026-04-08' } } 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) => c.id === 'configIndex'); + expect(col).toBeDefined(); + if (!col) return; + 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) => c.accessorKey === 'version'); + expect(col).toBeDefined(); + if (!col) return; + 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) => c.accessorKey === 'hash'); + expect(col).toBeDefined(); + if (!col) return; + 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) => c.id === 'actions'); + expect(col).toBeDefined(); + if (!col) return; + 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({ participantConfigHash: 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(() => { }); + 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); + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + renderToStaticMarkup(); + const hashCol = capturedTableOptions!.columns.find( + (c) => c.accessorKey === 'hash', + ); + expect(hashCol).toBeDefined(); + if (!hashCol) return; + + 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({ participantConfigHash: mockConfigInfo.hash })]; + await act(async () => { + render(); + }); + + const col = capturedTableOptions!.columns.find((c) => c.id === 'actions'); + expect(col).toBeDefined(); + if (!col) return; + 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({ participantConfigHash: mockConfigInfo.hash })]; + await act(async () => { + render(); + }); + 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({ participantConfigHash: 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 } = render(capturedTableOptions!.renderTopToolbarCustomActions()); + await user.click(getByText('Compare')); + + // Compare button should open the diff modal + expect(screen.getByText('diff modal')).toBeDefined(); + }); + + test('handleViewConfig opens view modal when View button is clicked', async () => { + await act(async () => { + render(); + }); + + const col = capturedTableOptions!.columns.find((c) => c.id === 'actions'); + expect(col).toBeDefined(); + if (!col) return; + const user = userEvent.setup(); + const { getAllByText } = render(col.Cell({ cell: { getValue: () => mockConfigInfo.hash } })); + + await act(async () => { + await user.click(getAllByText('View')[0]); + }); + + const body = document.body.textContent || ''; + expect(body).toContain('studyMetadata'); + expect(body).toContain('1.0.0'); + expect(body).toContain('2026-04-08'); + }); +}); diff --git a/src/analysis/individualStudy/management/DataManagementItem.tsx b/src/analysis/individualStudy/management/DataManagementItem.tsx index 34b18afbf4..fa31f36fda 100644 --- a/src/analysis/individualStudy/management/DataManagementItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementItem.tsx @@ -243,6 +243,7 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr + ), + TextInput: ({ onChange, placeholder }: { onChange?: React.ChangeEventHandler; placeholder?: string }) => ( + + ), + ColorInput: ({ value }: { value?: string }) => , + Loader: () =>
Loading...
, + LoadingOverlay: () => null, + ActionIcon: ({ + children, onClick, 'aria-label': ariaLabel, + }: { children: ReactNode; onClick?: () => void; 'aria-label'?: string }) => ( + + ), + Radio: ({ checked, onChange, 'aria-label': ariaLabel }: { checked: boolean; onChange?: () => void; 'aria-label'?: string }) => ( + + ), + Switch: ({ checked, onChange, 'aria-label': ariaLabel }: { checked?: boolean; onChange?: React.ChangeEventHandler; 'aria-label'?: string }) => ( + + ), + 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', 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(() => { + vi.clearAllMocks(); + 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 dataCollectionSwitch = screen.getByRole('checkbox', { name: 'Data Collection' }); + await act(async () => { + fireEvent.click(dataCollectionSwitch); + }); + expect(mockStorageEngine!.setMode).toHaveBeenCalledWith('test-study', 'dataCollectionEnabled', false); + }); + + test('RevisitModesItem handleSwitch covers developmentMode and dataSharing branches', async () => { + await act(async () => { + render(); + }); + const devModeSwitch = screen.getByRole('checkbox', { name: 'Development Mode' }); + const dataSharingSwitch = screen.getByRole('checkbox', { name: 'Share Data and Make Analytics Interface Public' }); + await act(async () => { fireEvent.click(devModeSwitch); }); + expect(mockStorageEngine!.setMode).toHaveBeenCalledWith('test-study', 'developmentModeEnabled', true); + await act(async () => { fireEvent.click(dataSharingSwitch); }); + 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(() => { }); + 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 reviewRadio = screen.getByRole('radio', { name: 'Set current stage to REVIEW' }); + await act(async () => { + fireEvent.click(reviewRadio); + }); + expect(mockStorageEngine!.setCurrentStage).toHaveBeenCalledWith('test-study', 'REVIEW', '#00AAFF'); + }); + + test('StageManagementItem handleEditStage shows edit inputs, handleCancelEdit resets', async () => { + await act(async () => { + render(); + }); + const editBtn = screen.getByRole('button', { name: 'Edit stage DEFAULT' }); + await act(async () => { fireEvent.click(editBtn); }); + const cancelBtn = screen.getByRole('button', { name: 'Cancel editing stage DEFAULT' }); + await act(async () => { fireEvent.click(cancelBtn); }); + expect(screen.getByRole('button', { name: 'Edit stage DEFAULT' })).toBeDefined(); + }); + + test('StageManagementItem handleSaveEdit calls updateStageColor then refreshes', async () => { + mockStorageEngine!.updateStageColor = vi.fn().mockResolvedValue(undefined); + await act(async () => { + render(); + }); + const editBtn = screen.getByRole('button', { name: 'Edit stage DEFAULT' }); + await act(async () => { fireEvent.click(editBtn); }); + const saveBtn = screen.getByRole('button', { name: 'Save stage DEFAULT' }); + 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')); }); + expect(screen.getByPlaceholderText('Enter stage name')).toBeDefined(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel new stage' })); }); + 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')); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save new stage' })); }); + 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' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save new stage' })); }); + 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 () => { + 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' } }); + // The second "Archive" text is the confirm button inside the modal + const archiveButtons = screen.getAllByText('Archive').map((el) => el.closest('button')!); + await act(async () => { fireEvent.click(archiveButtons[archiveButtons.length - 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' } }); + // The second "Delete" text is the confirm button inside the modal + const deleteButtons = screen.getAllByText('Delete').map((el) => el.closest('button')!); + await act(async () => { fireEvent.click(deleteButtons[deleteButtons.length - 1]); }); + expect(mockStorageEngine!.removeSnapshotOrLive).toHaveBeenCalledWith('test-study', 'test-study'); + }); + + test('DataManagementItem rename snapshot action works from snapshot row', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'snap-one' }, + }); + await act(async () => { + render( []} />); + }); + const pencilBtn = screen.getByRole('button', { name: 'Rename snapshot snap-one' }); + fireEvent.click(pencilBtn); + 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( []} />); + }); + const trashBtn = screen.getByRole('button', { name: 'Delete snapshot snap-one' }); + fireEvent.click(trashBtn); + const input = screen.getByPlaceholderText('test-study'); + fireEvent.change(input, { target: { value: 'test-study' } }); + // The second "Delete" text is the confirm button inside the modal + const deleteButtons = screen.getAllByText('Delete').map((el) => el.closest('button')!); + await act(async () => { fireEvent.click(deleteButtons[deleteButtons.length - 1]); }); + expect(mockStorageEngine!.removeSnapshotOrLive).toHaveBeenCalledWith('test-study-snapshot-2026T01:00', 'test-study'); + }); + + test('DataManagementItem restore snapshot modal fires via openConfirmModal', async () => { + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'test-study-snapshot-2026T01:00': { name: 'snap-one' }, + }); + await act(async () => { + render( []} />); + }); + const refreshBtn = screen.getByRole('button', { name: 'Restore snapshot snap-one' }); + fireEvent.click(refreshBtn); + 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 () => { + mockStorageEngine!.createSnapshot.mockResolvedValue({ + status: 'ERROR', + error: { title: 'Test error', message: 'Something went wrong' }, + }); + 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/tests/AllTasksTimeline.spec.tsx b/src/analysis/individualStudy/replay/tests/AllTasksTimeline.spec.tsx new file mode 100644 index 0000000000..f164afda5c --- /dev/null +++ b/src/analysis/individualStudy/replay/tests/AllTasksTimeline.spec.tsx @@ -0,0 +1,400 @@ +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 type { StoredAnswer } from '../../../../store/types'; +import { createMockStudyConfig } from '../../../tests/testUtils'; +import { makeStoredAnswer, makeParticipant as _makeParticipant } from '../../../../tests/utils'; +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; + +function makeAnswer(overrides: Partial = {}): StoredAnswer { + return makeStoredAnswer({ + componentName: 'trial1', + trialOrder: '0_0', + startTime: t0, + endTime: t0 + 10_000, + ...overrides, + }); +} + +function makeParticipant(overrides: Partial & { answers?: Record } = {}) { + return _makeParticipant({ + participantId: 'pid-1', + participantConfigHash: 'hash-1', + answers: { trial1_0: makeAnswer() }, + ...overrides, + }); +} + +const emptyConfig: StudyConfig = createMockStudyConfig(); + +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 = { + identifier: '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, + hasScreenRecording: 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: makeAnswer({ startTime: t0, endTime: t0 + 5_000, answer: { q1: 'yes' } }), + }, + }); + + 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: makeAnswer({ startTime: t0, endTime: t0 + 5_000 }), + trial2_1: makeAnswer({ + componentName: 'trial2', startTime: 0, endTime: 0, trialOrder: '1_0', + }), + }, + }); + + 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: makeAnswer({ + 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(' { + const participant = makeParticipant({ + answers: { + trial1_0: makeAnswer({ + endTime: t0 + 20_000, + windowEvents: [ + [t0 + 500, 'mousemove', [100, 200]], + [t0 + 1_000, 'mousedown', [150, 250]], + [t0 + 1_100, 'mouseup', [150, 250]], + [t0 + 2_000, 'keydown', 'Enter'], + [t0 + 2_100, 'keyup', 'Enter'], + [t0 + 3_000, 'scroll', [0, 300]], + [t0 + 4_000, 'focus', 'input#name'], + [t0 + 5_000, 'input', 'input#name'], + [t0 + 6_000, 'resize', [1024, 768]], + [t0 + 7_000, 'visibility', 'hidden'], + [t0 + 9_000, 'visibility', 'visible'], + ], + }), + }, + }); + + const html = renderToStaticMarkup( + , + ); + 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(' { + const participant = makeParticipant({ + answers: { + trial1_0: makeAnswer({ startTime: t0, endTime: t0 + 5_000, answer: { q1: null } }), + }, + }); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('N/A'); + }); + + test('tooltip JSON.stringifies object answer values', () => { + // Use an array so JSON.stringify produces no string-quoted keys (avoids HTML entity encoding) + const participant = makeParticipant({ + answers: { + trial1_0: makeAnswer({ startTime: t0, endTime: t0 + 5_000, answer: { q1: [1, 2] } }), + }, + }); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('[1,2]'); + }); + + test('sortedTaskNames comparator runs with multiple incomplete answers', () => { + // Three incomplete answers: two share trialOrder prefix "0" (hits true branch), + // one has prefix "1" (hits false branch of the prefix comparison) + const participant = makeParticipant({ + answers: { + trial1_0: makeAnswer({ + componentName: 'trial1', startTime: 0, endTime: 0, trialOrder: '0_0', + }), + trial2_0: makeAnswer({ + componentName: 'trial2', startTime: 0, endTime: 0, trialOrder: '0_1', + }), + trial3_0: makeAnswer({ + componentName: 'trial3', startTime: 0, endTime: 0, trialOrder: '1_0', + }), + }, + }); + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('; - studyId?: string; - }, -) { - const { trialId } = useParams(); - - const overviewData = useMemo( - () => (trialId && trialId !== 'end' ? getOverviewStats(visibleParticipants, trialId, studyConfig, allConfigs) : null), - [studyConfig, visibleParticipants, trialId, allConfigs], - ); - - return ( - <> - {overviewData && ( - - )} - - { - (visibleParticipants.length === 0) - ? ( - - No data available. - - ) - : ( - - {/* Trial selection sidebar */} - - - - - - - {/* Visualization and metadata */} - - - ) - } - - - ); -} +import { + Box, Divider, Flex, Paper, Text, +} from '@mantine/core'; +import { useMemo } from 'react'; +import { useParams } from 'react-router'; +import { ParticipantDataWithStatus } from '../../../storage/types'; +import { StudyConfig } from '../../../parser/types'; +import { TrialVisualization } from './TrialVisualization'; +import { StepsPanel } from '../../../components/interface/StepsPanel'; +import { OverviewStats } from '../summary/OverviewStats'; +import { getOverviewStats } from '../summary/utils'; + +export function StatsView( + { + studyConfig, + visibleParticipants, + allConfigs = {}, + studyId, + }: { + studyConfig: StudyConfig; + visibleParticipants: ParticipantDataWithStatus[]; + allConfigs?: Record; + studyId?: string; + }, +) { + const { trialId } = useParams(); + + const overviewData = useMemo( + () => (trialId && trialId !== 'end' ? getOverviewStats(visibleParticipants, trialId, studyConfig, allConfigs) : null), + [studyConfig, visibleParticipants, trialId, allConfigs], + ); + + return ( + <> + {overviewData && ( + + )} + + { + (visibleParticipants.length === 0) + ? ( + + No data available. + + ) + : ( + + {/* Trial selection sidebar */} + + + + + + + {/* Visualization and metadata */} + + + ) + } + + + ); +} diff --git a/src/analysis/individualStudy/stats/tests/StatsView.spec.tsx b/src/analysis/individualStudy/stats/tests/StatsView.spec.tsx new file mode 100644 index 0000000000..a2fd837a11 --- /dev/null +++ b/src/analysis/individualStudy/stats/tests/StatsView.spec.tsx @@ -0,0 +1,362 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { useParams } from 'react-router'; +import { IndividualComponent, StudyConfig } from '../../../../parser/types'; +import { ParticipantData, ParticipantDataWithStatus } from '../../../../storage/types'; +import { studyComponentToIndividualComponent } from '../../../../utils/handleComponentInheritance'; +import { createMockStudyConfig } from '../../../tests/testUtils'; +import { StatsView } from '../StatsView'; +import { TrialVisualization } from '../TrialVisualization'; +import { ResponseVisualization } from '../ResponseVisualization'; + +// ── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('react-router', () => ({ + 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?: React.Ref }) =>
{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: [], + + })), +})); + +// ── fixtures ───────────────────────────────────────────────────────────────── + +const emptyConfig: StudyConfig = createMockStudyConfig({ + components: { trial1: { type: 'questionnaire', response: [] } }, + sequence: { order: 'fixed', components: ['trial1'] }, +}); + +const mockParticipant: ParticipantDataWithStatus = { + participantId: 'p1', + participantConfigHash: 'hash-1', + sequence: { + id: 'root', order: 'fixed', orderPath: 'root', components: [], skip: [], + }, + participantIndex: 0, + answers: {}, + searchParams: {}, + metadata: { + userAgent: '', resolution: { width: 0, height: 0 }, language: '', ip: '', + }, + completed: true, + rejected: false, + participantTags: [], + stage: 'DEFAULT', +}; + +const mockTrialConfig: IndividualComponent = { + type: 'questionnaire', + response: [], +}; + +// ── 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', options: [], + }], + }); + + 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( + , + ); + expect(html).toContain('icon-text'); + expect(html).toContain('Response Values'); + }); + + test('textOnly type: shows N/A for response values', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('N/A'); + }); + + test('radio type: shows radio icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-radio'); + expect(html).toContain('VegaLite'); + }); + + test('numerical type: shows numerical icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-numerical'); + expect(html).toContain('VegaLite'); + }); + + test('slider type: shows slider icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-slider'); + expect(html).toContain('VegaLite'); + }); + + test('likert type: shows likert icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-likert'); + expect(html).toContain('VegaLite'); + }); + + test('matrix-radio type: shows matrix icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-matrix-r'); + expect(html).toContain('VegaLite'); + }); + + test('matrix-checkbox type: shows matrix-cb icon and VegaLite chart', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('icon-matrix-cb'); + expect(html).toContain('VegaLite'); + }); + + test('shows Response Specification JSON for non-metadata types', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Response Specification'); + }); + + test('shows Correct Answer block when trialConfig has a matching correctAnswer', () => { + const trialConfigWithAnswer: IndividualComponent = { + ...mockTrialConfig, + correctAnswer: [{ id: 'q1', answer: 'A' }], + }; + + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Correct Answer'); + }); +}); diff --git a/src/analysis/individualStudy/summary/ComponentStats.tsx b/src/analysis/individualStudy/summary/ComponentStats.tsx index 911d3c659b..cfceab7573 100644 --- a/src/analysis/individualStudy/summary/ComponentStats.tsx +++ b/src/analysis/individualStudy/summary/ComponentStats.tsx @@ -114,14 +114,14 @@ function renderConfigListCell( export function ComponentStats({ visibleParticipants, studyConfig, - allConfigs, - selectedConfigRows, + allConfigs = {}, + selectedConfigRows = [], currentConfigLabel, }: { visibleParticipants: ParticipantDataWithStatus[]; studyConfig: StudyConfig; - allConfigs: Record; - selectedConfigRows: Array<{ configHash: string; configLabel: string; studyConfig: StudyConfig }>; + allConfigs?: Record; + selectedConfigRows?: Array<{ configHash: string; configLabel: string; studyConfig: StudyConfig }>; currentConfigLabel?: string; }) { const useSelectedConfigRows = selectedConfigRows.length > 0; diff --git a/src/analysis/individualStudy/summary/ResponseStats.tsx b/src/analysis/individualStudy/summary/ResponseStats.tsx index e67f1e7794..9a73e1916a 100644 --- a/src/analysis/individualStudy/summary/ResponseStats.tsx +++ b/src/analysis/individualStudy/summary/ResponseStats.tsx @@ -114,14 +114,14 @@ function renderResponseConfigListCell( export function ResponseStats({ visibleParticipants, studyConfig, - allConfigs, - selectedConfigRows, + allConfigs = {}, + selectedConfigRows = [], currentConfigLabel, }: { visibleParticipants: ParticipantDataWithStatus[]; studyConfig: StudyConfig; - allConfigs: Record; - selectedConfigRows: Array<{ configHash: string; configLabel: string; studyConfig: StudyConfig }>; + allConfigs?: Record; + selectedConfigRows?: Array<{ configHash: string; configLabel: string; studyConfig: StudyConfig }>; currentConfigLabel?: string; }) { const useSelectedConfigRows = selectedConfigRows.length > 0; diff --git a/src/analysis/individualStudy/summary/SummaryView.tsx b/src/analysis/individualStudy/summary/SummaryView.tsx index 41eb2f1dda..909dd990b8 100644 --- a/src/analysis/individualStudy/summary/SummaryView.tsx +++ b/src/analysis/individualStudy/summary/SummaryView.tsx @@ -10,18 +10,18 @@ import { getOverviewStats } from './utils'; export function SummaryView({ visibleParticipants, studyConfig, - allConfigs, + allConfigs = {}, studyId, - showStoredCountMismatch, - includedParticipants, + showStoredCountMismatch = false, + includedParticipants = ['completed', 'inProgress', 'rejected'], currentConfigLabel, }: { visibleParticipants: ParticipantDataWithStatus[]; studyConfig: StudyConfig; - allConfigs: Record; + allConfigs?: Record; studyId?: string; - showStoredCountMismatch: boolean; - includedParticipants: string[]; + showStoredCountMismatch?: boolean; + includedParticipants?: string[]; currentConfigLabel?: string; }) { const overviewData = useMemo( diff --git a/src/analysis/individualStudy/summary/tests/SummaryView.spec.tsx b/src/analysis/individualStudy/summary/tests/SummaryView.spec.tsx new file mode 100644 index 0000000000..0fd8a37696 --- /dev/null +++ b/src/analysis/individualStudy/summary/tests/SummaryView.spec.tsx @@ -0,0 +1,249 @@ +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 { ParticipantDataWithStatus } from '../../../../storage/types'; +import { OverviewData } from '../../../types'; +import { useStorageEngine } from '../../../../storage/storageEngineHooks'; +import { useAsync } from '../../../../store/hooks/useAsync'; +import { makeStorageEngine } from '../../../../tests/utils'; +import { createMockStudyConfig } from '../../../tests/testUtils'; +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(): number } }) => string; +}; + +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}
, + Badge: ({ 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 = createMockStudyConfig({ + components: { comp1: { type: 'questionnaire', response: [] } }, + sequence: { order: 'fixed', components: ['comp1'] }, +}); + +const noParticipants: ParticipantDataWithStatus[] = []; + +// ── 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: makeStorageEngine(), 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: makeStorageEngine(), 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/tests/TableView.spec.tsx b/src/analysis/individualStudy/table/tests/TableView.spec.tsx new file mode 100644 index 0000000000..eabaf91699 --- /dev/null +++ b/src/analysis/individualStudy/table/tests/TableView.spec.tsx @@ -0,0 +1,309 @@ +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 { ParticipantDataWithStatus } from '../../../../storage/types'; +import { createMockStudyConfig } from '../../../tests/testUtils'; +import { makeParticipant as _makeParticipant } from '../../../../tests/utils'; +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: StudyConfig = createMockStudyConfig(); + +function makeParticipant(overrides: Partial = {}) { + return _makeParticipant({ + participantId: 'pid-1', + participantIndex: 1, + participantConfigHash: 'hash1', + completed: true, + metadata: { + userAgent: 'test-agent', + resolution: { width: 1920, height: 1080 }, + language: 'en-US', + ip: '1.2.3.4', + }, + sequence: { + orderPath: 'root', order: 'fixed', components: ['trial1'], skip: [], + }, + ...overrides, + }); +} + +const defaultProps = { + studyConfig: emptyConfig, + allConfigs: {} as Record, + refresh: async () => [] as ParticipantDataWithStatus[], + width: 800, + stageColors: { DEFAULT: '#F05A30' }, + selectedParticipants: [] as ParticipantDataWithStatus[], + onSelectionChange: vi.fn<(participants: ParticipantDataWithStatus[]) => void>(), +}; + +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'); + }); + + // ── Optional columns ────────────────────────────────────────────────────── + + test('includes Name column when studyConfig has participantNameField', () => { + const configWithName: StudyConfig = { + ...emptyConfig, + uiConfig: { ...emptyConfig.uiConfig, participantNameField: 'name' }, + }; + renderToStaticMarkup( + , + ); + expect(capturedTableOptions!.columns.find((c) => c.header === 'Name')).toBeDefined(); + }); + + test('includes Condition column when any participant has a condition', () => { + const participantWithCondition = makeParticipant({ searchParams: { condition: 'condA' } }); + renderToStaticMarkup( + , + ); + expect(capturedTableOptions!.columns.find((c) => c.header === 'Condition')).toBeDefined(); + }); +}); + +// ── 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/tests/ThinkAloudAnalysis.spec.tsx b/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx new file mode 100644 index 0000000000..694d4c2f90 --- /dev/null +++ b/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx @@ -0,0 +1,1052 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + render, act, cleanup, fireEvent, +} from '@testing-library/react'; +import { + afterEach, beforeAll, beforeEach, describe, expect, test, vi, +} from 'vitest'; +import * as d3 from 'd3'; +import { useNavigate, useParams, useSearchParams } from 'react-router'; +import type { NavigateFunction } from 'react-router'; +import { EditedText, Tag, TranscribedAudio } from '../types'; +import type { ParticipantData } from '../../../../storage/types'; +import { makeStoredAnswer as makeStoredAnswerBase, makeStorageEngine } from '../../../../tests/utils'; +import type { FirebaseStorageEngine } from '../../../../storage/engines/FirebaseStorageEngine'; +import { useAsync } from '../../../../store/hooks/useAsync'; +import { useReplayContext } from '../../../../store/hooks/useReplay'; +import { Pills } from '../tags/Pills'; +import { AddTagDropdown } from '../tags/AddTagDropdown'; +import { TagEditor } from '../tags/TagEditor'; +import { ThinkAloudAnalysis } from '../ThinkAloudAnalysis'; + +// ── mock delegates (declared before vi.mock so hoisted references resolve) ──── + +const mockThinkAloudFooter = vi.fn(({ onTimeUpdate, setHasAudio }: { onTimeUpdate?: (t: number) => void; setHasAudio?: (b: boolean) => void }) => ( +
+ + +
+)); + +const mockTextEditor = vi.fn(({ onClickLine, transcriptList }: { onClickLine?: (n: number) => void; transcriptList?: EditedText[] }) => ( +
+
{transcriptList?.length ?? 0}
+ +
+)); + +const mockTranscriptLine = vi.fn(({ + text, onTextChange, deleteRowCallback, addRowCallback, setAnnotation, editTagCallback, createTagCallback, addRef, index, +}: { + text: string; onTextChange: (i: number, v: string) => void; deleteRowCallback: (i: number) => void; + addRowCallback: (i: number, pos: number) => void; setAnnotation: (i: number, s: string) => void; + editTagCallback: (oldTag: Tag, newTag: Tag) => void; createTagCallback: (t: Tag) => void; + addRef: (i: number, ref: Pick) => void; index: number; +}) => ( +
+ {text} + + + + + + + +
+)); + +const mockTagSelector = vi.fn(({ onSelectTags, editTagCallback, tags }: { onSelectTags?: (t: Tag[]) => void; editTagCallback?: (oldTag: Tag, newTag: Tag) => void; tags?: Tag[] }) => ( +
+ + {tags && tags.length > 0 && ( + + )} +
+)); + +const createMockNavigate = (): NavigateFunction => vi.fn() as unknown as NavigateFunction; + +// ── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => , + Alert: ({ children }: { children: ReactNode }) =>
{children}
, + AppShell: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Footer: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => , + Center: ({ children }: { children: ReactNode }) =>
{children}
, + CheckIcon: () => check, + ColorPicker: ({ value }: { value?: string }) =>
, + ColorSwatch: ({ color }: { color: string }) =>
, + Combobox: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + DropdownTarget: ({ children }: { children: ReactNode }) =>
{children}
, + Dropdown: ({ children }: { children: ReactNode }) =>
{children}
, + Options: ({ children }: { children: ReactNode }) =>
{children}
, + Option: ({ children }: { children: ReactNode }) =>
{children}
, + EventsTarget: ({ children }: { children: ReactNode }) =>
{children}
, + Empty: ({ children }: { children: ReactNode }) =>
{children}
, + }, + ), + Grid: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Col: ({ children, ref: _r, ...rest }: { children: ReactNode; ref?: React.Ref }) =>
{children}
}, + ), + Group: ({ children, style }: { children: ReactNode; style?: object }) =>
{children}
, + HoverCard: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Target: ({ children }: { children: ReactNode }) =>
{children}
, + Dropdown: ({ children }: { children: ReactNode }) =>
{children}
, + }, + ), + Input: { Placeholder: ({ children }: { children: ReactNode }) => {children} }, + Loader: () => loading, + Pill: Object.assign( + ({ children }: { children: ReactNode }) => {children}, + { Group: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + PillsInput: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Field: () => , Group: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + Popover: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Target: ({ children }: { children: ReactNode }) =>
{children}
, + Dropdown: ({ children }: { children: ReactNode }) =>
{children}
, + }, + ), + SegmentedControl: ({ data, onChange }: { data?: { label: string; value: string }[]; onChange?: (v: string) => void }) => ( +
{data?.map((item) => )}
+ ), + Select: ({ + leftSection, rightSection, onChange, value, data, label, + }: { leftSection?: ReactNode; rightSection?: ReactNode; onChange?: (v: string | null) => void; value?: string; data?: { label: string; value: string }[]; label?: string }) => ( +
+ {leftSection} + + {rightSection} +
+ ), + Stack: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) =>

{children}

, + Textarea: ({ + value, onChange, onKeyDown, onFocus, placeholder, onBlur, defaultValue, + }: { + value?: string; defaultValue?: string; onChange?: React.ChangeEventHandler; + onKeyDown?: React.KeyboardEventHandler; onFocus?: React.FocusEventHandler; + placeholder?: string; onBlur?: React.FocusEventHandler; + }) => ( +