diff --git a/.llms/learnings.md b/.llms/learnings.md index 35192ea5..c2b66b8c 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -350,3 +350,39 @@ Two traps: - `getDriver(LSL)` throws (LSL is not in the driver registry). Any epic that calls `getDriver()` from a search or cancel path guards `deviceType !== LSL` itself, instead of relying on the UI never dispatching it. + +## Early exit = runtime teardown; incomplete = `*.incomplete.csv` + +Runtimes report exactly one of `onFinish(csv)` / `onAbort(csv)` per mount. +Unmounting a running runtime is the abort, and both runtimes report the +trials saved so far *synchronously* on teardown (lab.js datastore / jsPsych +`data.get()`), then stop the study and ignore its later end hook. Waiting for +the runtime's own end is unsafe: jsPsych still awaits `post_trial_gap` after +`abortExperiment()`, and lab.js's flips run on rAF, which a hidden window pauses. +lab.js 23 traps: a bare root `end()` does not stop the study (the flip loop +keeps iterating) — use `controller.jump('abort', { sender: root })`, as lab.js's +debug plugin does — and aborting between a screen's render and show frames +hangs it, so that jump is deferred two frames. `controller.audioContext` is +undefined in 23.x; the real one is `global.audioContext`. + +The run lifecycle lives in the store, not in components. The RunBar button and +`escapeHoldEpic` (a 1 s Escape hold) both dispatch `EndRun`; the reducer sets +`isEnding`, which unmounts the runtime, and the runtime's report becomes +`Stop({ data, outcome })`. The reducer keeps the first `Stop` of a run as +`runOutcome` (the result screen), and `endRunFallbackEpic` sends an empty +incomplete `Stop` after 3 s if no runtime reports (e.g. an imported study that +never loaded). `SetState` resets these live fields so a reopened workspace never +shows a stale result. The stop epic closes the EEG stream, writes behavior, then +`fs:markRecordingIncomplete` renames both files to `*.incomplete.csv`; each step +runs even if an earlier one failed. Every discovery filter +(`src/main/recordings.ts`) and the workflow badges skip that suffix; +`recordingExists` still counts it so the session number is never reused. The +result screen and RunBar say "EEG" only via `selectRecordsEEG` (EEG on *and* a +headset connected). Before this, "End experiment early" wrote the partial run +as a normal file and `closeEEGStream` was never called. + +Agent playtests: Electron's native `showMessageBox` (the no-EEG warning, the +session-taken prompt) and file pickers cannot be driven over CDP, and a covered +window reports `visibilityState: hidden`, which stalls lab.js and CDP +screenshots. Use a subject/session that needs no prompt, and keep the window +uncovered. diff --git a/TODOS.md b/TODOS.md index a9bc6d42..1cee62f7 100644 --- a/TODOS.md +++ b/TODOS.md @@ -28,8 +28,11 @@ Deferred and in-flight work. Keep this current — when something ships, delete - Nav-state distinction: the workflow bar (Prepare/Collect/Clean/Analyze) and the local steps bar (Overview/Background/Protocol/Preview) read as one stacked nav on Prepare — Workstream 4, plan §3.2. (playtest 09-23) - ~~Blocked areas~~ — shipped 2026-09-23 (`WorkspaceAreaGate` on /clean and /analyze, PR #269). - ~~Device chip is display-only~~ — shipped 2026-09-23 (WS2, PR #274). The header chip opens headset setup; the RunBar chip stays status-only. + - ~~Early exit and incomplete runs (WS5)~~ — shipped 2026-09-24 (branch `feat/ws5-early-exit`). `End experiment early` and a held Escape both end the run with no confirm; what was recorded is kept as `*.incomplete.csv` and left out of Clean, Analyze and the badges; the result screen says `Experiment ended early`. + - QA (WS5): the agent playtest could not drive native dialogs or file pickers. Still to check by hand: a complete Faces/Houses run (keeps `-behavior.csv`/`-raw.csv`, shows `Clean this recording →`), the #270 "session 2" prompt after an ended-early session 1, ending an imported jsPsych study early, and a behavior-only run (complete → `Analyze results →`; ended early → only `-behavior.incomplete.csv`). ## Next (V1.5: Visual Polish and Juice) +- [ ] **WS5 participant screens (#273)** — integrate the `participantScreens.ts` builders into each built-in `experiment.ts`; pass `isEEGEnabled` to lab.js params. - [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces//stimuli/<condition>/` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow. - [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker. - [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture. diff --git a/package-lock.json b/package-lock.json index fc469645..b8dffbce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "jspsych": "^8.3.0", "lab.js": "23.0.0-alpha4", "lodash": "^4.18.1", - "mousetrap": "^1.6.5", "muse-js": "^3.1.0", "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", @@ -11926,10 +11925,6 @@ "version": "3.0.2", "license": "MIT" }, - "node_modules/mousetrap": { - "version": "1.6.5", - "license": "Apache-2.0 WITH LLVM-exception" - }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" diff --git a/package.json b/package.json index 47e0394c..f877d09b 100644 --- a/package.json +++ b/package.json @@ -238,7 +238,6 @@ "jspsych": "^8.3.0", "lab.js": "23.0.0-alpha4", "lodash": "^4.18.1", - "mousetrap": "^1.6.5", "muse-js": "^3.1.0", "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts new file mode 100644 index 00000000..91476bc5 --- /dev/null +++ b/src/main/__tests__/recordings.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + isBehaviorFile, + isRawEEGFile, + markRecordingIncomplete, + recordingExists, +} from '../recordings'; + +let dir: string; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')); +}); +afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + +const write = (rel: string) => { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'x'); +}; +const csvFiles = () => + (fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) => + f.endsWith('.csv') + ); + +describe('recordings', () => { + it('complete recordings are discovered', () => { + write('Data/P1/Behavior/P1-A-1-behavior.csv'); + write('Data/P1/EEG/P1-A-1-raw.csv'); + + expect(csvFiles().filter(isRawEEGFile)).toHaveLength(1); + expect(csvFiles().filter(isBehaviorFile)).toHaveLength(1); + }); + + it('an ended-early run keeps its files and its session, but drops out of discovery', () => { + write('Data/P1/Behavior/P1-A-1-behavior.csv'); + write('Data/P1/EEG/P1-A-1-raw.csv'); + + markRecordingIncomplete(dir, 'P1', 'A', 1); + + expect(csvFiles()).toHaveLength(2); + expect(csvFiles().filter(isRawEEGFile)).toEqual([]); + expect(csvFiles().filter(isBehaviorFile)).toEqual([]); + expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); + expect(recordingExists(dir, 'P1', 'A', 2)).toBe(false); + }); + + it('marks a behavior-only run that has no EEG file', () => { + write('Data/P1/Behavior/P1-A-1-behavior.csv'); + + markRecordingIncomplete(dir, 'P1', 'A', 1); + + expect(csvFiles().filter(isBehaviorFile)).toEqual([]); + expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index fd9f12c6..938413be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -44,6 +44,12 @@ import type { LSLStatusKind, } from '../shared/lslTypes'; import { importExperimentFile } from './importExperimentFile'; +import { + isBehaviorFile, + isRawEEGFile, + markRecordingIncomplete, + recordingExists, +} from './recordings'; // Playtest harness: isolate smoke-test state from the user's Electron profile. // Clear the env after reading so child processes don't inherit it. @@ -233,7 +239,7 @@ ipcMain.handle('fs:readWorkspaceRawEEGData', (_event, title) => { recursive: true, }) as string[]; return files - .filter((filepath) => filepath.slice(-7).includes('raw.csv')) + .filter(isRawEEGFile) .map((filepath) => { const fullPath = path.join(getWorkspaceDir(title), filepath); return { name: path.basename(filepath), path: fullPath }; @@ -267,7 +273,7 @@ ipcMain.handle('fs:readWorkspaceBehaviorData', (_event, title) => { recursive: true, }) as string[]; return files - .filter((filepath) => filepath.slice(-12).includes('behavior.csv')) + .filter(isBehaviorFile) .map((filepath) => { const fullPath = path.join(getWorkspaceDir(title), filepath); return { name: path.basename(filepath), path: fullPath }; @@ -424,17 +430,18 @@ ipcMain.handle( } ); -/** True when either artifact of a subject/group/session run is already on disk. */ +/** True when any artifact of a subject/group/session run is on disk, including ended-early ones. */ ipcMain.handle( 'fs:recordingExists', - (_event, title, subject, group, session) => { - const dir = path.join(getWorkspaceDir(title), 'Data', subject); - const stem = `${subject}-${group}-${session}`; - return ( - fs.existsSync(path.join(dir, 'Behavior', `${stem}-behavior.csv`)) || - fs.existsSync(path.join(dir, 'EEG', `${stem}-raw.csv`)) - ); - } + (_event, title, subject, group, session) => + recordingExists(getWorkspaceDir(title), subject, group, session) +); + +/** Hides an ended-early run from Clean, Analyze and badges; the files stay on disk. */ +ipcMain.handle( + 'fs:markRecordingIncomplete', + (_event, title, subject, group, session) => + markRecordingIncomplete(getWorkspaceDir(title), subject, group, session) ); ipcMain.handle('fs:readFiles', (_event, filePathsArray: string[]) => { diff --git a/src/main/recordings.ts b/src/main/recordings.ts new file mode 100644 index 00000000..341d564f --- /dev/null +++ b/src/main/recordings.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; + +/** Suffix for ended-early runs. Neither discovery predicate below matches it. */ +const INCOMPLETE = '.incomplete.csv'; + +const sessionFiles = ( + workspaceDir: string, + subject: string, + group: string, + session: number +) => { + const dir = path.join(workspaceDir, 'Data', subject); + const stem = `${subject}-${group}-${session}`; + return [ + path.join(dir, 'Behavior', `${stem}-behavior.csv`), + path.join(dir, 'EEG', `${stem}-raw.csv`), + ]; +}; + +const incomplete = (file: string) => file.replace(/\.csv$/, INCOMPLETE); + +/** Raw EEG recordings Clean and the workflow badges may offer; never incomplete ones. */ +export const isRawEEGFile = (file: string) => file.endsWith('raw.csv'); + +/** Behavior files Analyze and the workflow badges may offer; never incomplete ones. */ +export const isBehaviorFile = (file: string) => file.endsWith('behavior.csv'); + +/** True when any artifact of this session exists, complete or ended early. */ +export const recordingExists = ( + workspaceDir: string, + subject: string, + group: string, + session: number +) => + sessionFiles(workspaceDir, subject, group, session).some( + (file) => fs.existsSync(file) || fs.existsSync(incomplete(file)) + ); + +/** Renames the session's behavior and raw EEG files to `*.incomplete.csv`. Nothing is deleted. */ +export const markRecordingIncomplete = ( + workspaceDir: string, + subject: string, + group: string, + session: number +) => { + for (const file of sessionFiles(workspaceDir, subject, group, session)) { + if (fs.existsSync(file)) fs.renameSync(file, incomplete(file)); + } +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index ca68dfea..f7c2d325 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -147,6 +147,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ): Promise<boolean> => ipcRenderer.invoke('fs:recordingExists', title, subject, group, session), + markRecordingIncomplete: ( + title: string, + subject: string, + group: string, + session: number + ): Promise<void> => + ipcRenderer.invoke( + 'fs:markRecordingIncomplete', + title, + subject, + group, + session + ), + readFiles: (filePathsArray: string[]): Promise<string[]> => ipcRenderer.invoke('fs:readFiles', filePathsArray), diff --git a/src/renderer/actions/experimentActions.ts b/src/renderer/actions/experimentActions.ts index 060094a0..9de861e5 100644 --- a/src/renderer/actions/experimentActions.ts +++ b/src/renderer/actions/experimentActions.ts @@ -8,12 +8,20 @@ import { } from '../constants/interfaces'; import { ExperimentStateType } from '../reducers/experimentReducer'; +/** How a recorded run ended; an incomplete run is kept but hidden from Clean/Analyze. */ +export type RunOutcome = 'complete' | 'incomplete'; + // ------------------------------------------------------------------------- // Actions export const ExperimentActions = { Start: createAction('START'), - Stop: createAction<{ data: string }, 'STOP'>('STOP'), + Stop: createAction<{ data: string; outcome: RunOutcome }, 'STOP'>('STOP'), + /** Ends the live run early: the runtime unmounts and reports what it recorded. */ + EndRun: createAction('END_RUN'), + SetEscapeHeld: createAction<boolean, 'SET_ESCAPE_HELD'>('SET_ESCAPE_HELD'), + /** Leaves the result screen for the Ready card. */ + DismissRunResult: createAction('DISMISS_RUN_RESULT'), SetType: createAction<EXPERIMENTS, 'SET_TYPE'>('SET_TYPE'), SetExperimentObject: createAction<ExperimentObject, 'SET_EXPERIMENT_OBJECT'>( 'SET_EXPERIMENT_OBJECT' diff --git a/src/renderer/components/AppShell/AppShell.stories.tsx b/src/renderer/components/AppShell/AppShell.stories.tsx index 3aba836e..9259b21f 100644 --- a/src/renderer/components/AppShell/AppShell.stories.tsx +++ b/src/renderer/components/AppShell/AppShell.stories.tsx @@ -2,7 +2,6 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import AppShell from './AppShell'; -import RunBar from './RunBar'; import BlockedAreaPanel from './BlockedAreaEmptyState'; import { AREA_LABELS } from './WorkflowNav'; import { Area, ShellWorkspace } from './types'; @@ -237,32 +236,20 @@ export const RunBehavior: Story = { }, }; -/** - * S10 — Holding Esc says, in words, that letting go keeps the run going. - * Rendered through RunBar: AppShell does not forward `escapeHeld` yet. - */ +/** S10 — Holding Esc says, in words, that letting go keeps the run going. */ export const HoldingEscape: Story = { args: { location: 'collect', workspace: facesHouses, run: { kind: 'eeg', elapsed: '02:14', progress: 'Trial 34 of 120' }, - }, - render: ({ run, workspace, device, deviceName, onEndRun }) => ( - <div className="flex h-screen min-w-[1180px] flex-col bg-white text-ink"> - <RunBar - run={run!} - workspace={workspace} - device={device} - deviceName={deviceName} - onEndRun={onEndRun} - escapeHeld - /> - <div className="flex-1 bg-app px-[56px] pb-[40px] pt-[32px]"> + escapeHeld: true, + children: ( + <div className="px-[56px] pb-[40px] pt-[32px]"> <Placeholder label="experiment runtime (trials) — unchanged" height={300} /> </div> - </div> - ), + ), + }, }; diff --git a/src/renderer/components/AppShell/AppShell.tsx b/src/renderer/components/AppShell/AppShell.tsx index e03ba748..90c0f369 100644 --- a/src/renderer/components/AppShell/AppShell.tsx +++ b/src/renderer/components/AppShell/AppShell.tsx @@ -20,6 +20,8 @@ export interface AppShellProps { onSelectArea?(area: Area): void; onHome?(): void; onEndRun?(): void; + /** Escape is being held during a run; the RunBar says so. */ + escapeHeld?: boolean; /** Opens headset setup from the chip; not offered during a run. */ onDeviceClick?(): void; /** The current screen, rendered on the app gradient under the bar. */ @@ -46,6 +48,7 @@ export default function AppShell({ onSelectArea, onHome, onEndRun, + escapeHeld, onDeviceClick, children, }: AppShellProps) { @@ -59,6 +62,7 @@ export default function AppShell({ device={device} deviceName={deviceName} onEndRun={onEndRun} + escapeHeld={escapeHeld} /> ) : ( <header className="flex h-[64px] min-h-0 items-stretch border-b border-[#e5e5e5] bg-white px-[24px] py-0 text-left"> diff --git a/src/renderer/components/AppShell/RunBar.tsx b/src/renderer/components/AppShell/RunBar.tsx index d30bf1cd..fce7cbad 100644 --- a/src/renderer/components/AppShell/RunBar.tsx +++ b/src/renderer/components/AppShell/RunBar.tsx @@ -9,7 +9,7 @@ interface Props { workspace?: ShellWorkspace; device: DeviceState; deviceName?: string; - /** Should open a confirm in the container before aborting. */ + /** Ends the run immediately — no confirm (plan §1.5); data so far is kept as incomplete. */ onEndRun?(): void; /** Escape is being held; the run ends early once the hold completes. */ escapeHeld?: boolean; diff --git a/src/renderer/components/CollectComponent/PreTestComponent.tsx b/src/renderer/components/CollectComponent/PreTestComponent.tsx index a518ae10..620c8498 100644 --- a/src/renderer/components/CollectComponent/PreTestComponent.tsx +++ b/src/renderer/components/CollectComponent/PreTestComponent.tsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Button } from '../ui/button'; -import Mousetrap from 'mousetrap'; import ViewerComponent from '../ViewerComponent'; import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import PreviewExperimentComponent from '../PreviewExperimentComponent'; @@ -44,13 +43,6 @@ export default function PreTestComponent(props: Props) { const [isPreviewing, setIsPreviewing] = useState(false); const [isSidebarVisible, setIsSidebarVisible] = useState(true); - useEffect(() => { - Mousetrap.bind('esc', props.ExperimentActions.Stop); - return () => { - Mousetrap.unbind('esc'); - }; - }, [props.ExperimentActions]); - function endPreview() { setIsPreviewing(false); } diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index bfdb4a27..981f3dc9 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -6,9 +6,10 @@ import React, { useState, } from 'react'; import { Observable } from 'rxjs'; +import { useNavigate } from 'react-router-dom'; import { Button } from '../ui/button'; import { Card, CardHeader, CardContent } from '../ui/card'; -import { Link } from 'react-router-dom'; +import RunResult from './RunResult'; import InputCollect from '../InputCollect'; import { emitMarker } from '../../utils/eeg'; import { resolveMarkerRegistry } from '../../utils/eeg/markerRegistry'; @@ -28,12 +29,18 @@ import { SignalQualityData, } from '../../constants/interfaces'; import { ExperimentActions as globalExperimentActions } from '../../actions'; +import type { RunOutcome } from '../../actions/experimentActions'; import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; interface Props { type: EXPERIMENTS; title: string; isRunning: boolean; + /** End early was asked for; the runtime unmounts and reports what it recorded. */ + isEnding: boolean; + runOutcome: RunOutcome | null; + /** The run captures EEG (EEG on and a headset connected), not just key presses. */ + recordsEEG: boolean; params: ExperimentParameters; subject: string; experimentObject: ExperimentObject; @@ -49,6 +56,9 @@ const Run: React.FC<Props> = ({ type, title, isRunning, + isEnding, + runOutcome, + recordsEEG, params, subject, experimentObject, @@ -62,10 +72,7 @@ const Run: React.FC<Props> = ({ const [isInputCollectOpen, setIsInputCollectOpen] = useState( subject.length === 0 ); - // A run finished this session — show a completion panel that points forward to - // Clean, instead of silently dropping back to the identical pre-run landing. - const [hasFinished, setHasFinished] = useState(false); - + const navigate = useNavigate(); // 'armed': checks passed, waiting for SPACE; nothing is recorded yet. // 'starting': SPACE pressed, Start dispatched; the gate stays up until the // run is live so the Ready card doesn't flash, and SPACE can't start twice. @@ -155,38 +162,37 @@ const Run: React.FC<Props> = ({ ); const onFinish = useCallback( - (csv) => { - ExperimentActions.Stop({ data: csv }); - setHasFinished(true); - }, + (csv: string) => ExperimentActions.Stop({ data: csv, outcome: 'complete' }), + [ExperimentActions] + ); + const onAbort = useCallback( + (csv: string) => + ExperimentActions.Stop({ data: csv, outcome: 'incomplete' }), [ExperimentActions] ); + const handleRunAnother = useCallback(() => { + ExperimentActions.DismissRunResult(); + setIsInputCollectOpen(true); + }, [ExperimentActions]); - const handleRunAgain = useCallback(() => { - setHasFinished(false); - }, []); + const result = isRunning ? isEnding && 'saving' : runOutcome; return ( - <div className="h-screen p-[3%] bg-app" data-tid="container"> + <div className="h-full p-[3%] bg-app" data-tid="container"> <div className="h-full"> - {!isRunning && hasFinished && ( - <div className="flex flex-col items-center justify-center h-full text-center gap-4"> - <h1 className="m-0">Recording complete 🎉</h1> - <p className="text-gray-600"> - Saved <b>{subject}</b>'s data. Ready to clean and analyze it? - </p> - <div className="flex gap-3 mt-2"> - <Button asChild variant="default"> - <Link to={SCREENS.CLEAN.route}>Clean your data →</Link> - </Button> - <Button variant="secondary" onClick={handleRunAgain}> - Run again - </Button> - </div> - </div> + {result && ( + <RunResult + outcome={result} + modality={recordsEEG ? 'eeg' : 'behavior'} + subject={subject} + onClean={() => navigate(SCREENS.CLEAN.route)} + onAnalyze={() => navigate(SCREENS.ANALYZE.route)} + onRunAnother={handleRunAnother} + onRunAgain={() => ExperimentActions.DismissRunResult()} + /> )} - {!isRunning && !hasFinished && gate !== 'off' && ( + {!isRunning && !runOutcome && gate !== 'off' && ( <div role="dialog" aria-label="Press space to begin" @@ -210,7 +216,7 @@ const Run: React.FC<Props> = ({ </div> )} - {!isRunning && !hasFinished && gate === 'off' && ( + {!isRunning && !runOutcome && gate === 'off' && ( <div className="flex items-center justify-center h-full"> <Card className="w-full max-w-lg"> <CardHeader> @@ -268,7 +274,7 @@ const Run: React.FC<Props> = ({ </div> )} - {isRunning && ( + {isRunning && !isEnding && ( <div className="h-full w-full"> <ExperimentRuntime type={type} @@ -277,6 +283,7 @@ const Run: React.FC<Props> = ({ params={params} eventCallback={eventCallback} onFinish={onFinish} + onAbort={onAbort} onProgress={reportProgress} /> </div> diff --git a/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx new file mode 100644 index 00000000..bbaecb9e --- /dev/null +++ b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CONNECTION_STATUS, EXPERIMENTS } from '../../../constants/constants'; +import Run from '../RunComponent'; + +type RuntimeProps = { onAbort?(csv: string): void }; + +const runtime = vi.hoisted(() => ({ props: null as null | RuntimeProps })); + +vi.mock('lab.js', () => ({})); +vi.mock('../../ExperimentRuntime', async () => { + // vi.mock factories are hoisted above static imports, so React is loaded here. + const { useEffect } = await import('react'); + return { + ExperimentRuntime: (props: RuntimeProps) => { + runtime.props = props; + useEffect(() => () => runtime.props?.onAbort?.('partial'), []); + return <div data-testid="runtime" />; + }, + }; +}); +vi.mock('../../../utils/labjs/functions', () => ({ + getExperimentFromType: () => ({ text: { protocol: {} } }), +})); +vi.mock('../../../utils/eeg', () => ({ emitMarker: vi.fn() })); +vi.mock('../../../utils/eeg/markerRegistry', () => ({ + resolveMarkerRegistry: () => ({}), +})); +vi.mock('../../InputCollect', () => ({ default: () => null })); + +const Stop = vi.fn(); +const props = { + type: EXPERIMENTS.N170, + title: 'Study', + isRunning: true, + isEnding: false, + runOutcome: null, + recordsEEG: true, + params: {} as never, + subject: 'P1', + experimentObject: {} as never, + group: 'A', + session: 1, + isEEGEnabled: true, + connectionStatus: CONNECTION_STATUS.CONNECTED, + ExperimentActions: { Stop, Start: vi.fn(), SetSession: vi.fn() } as never, +}; + +describe('ending a run early', () => { + afterEach(() => vi.clearAllMocks()); + + it('keeps the partial run as incomplete and says it ended early', () => { + const { rerender } = render(<Run {...props} />, { wrapper: MemoryRouter }); + + rerender(<Run {...props} isEnding />); + expect(Stop).toHaveBeenCalledWith({ + data: 'partial', + outcome: 'incomplete', + }); + + rerender(<Run {...props} isRunning={false} runOutcome="incomplete" />); + expect( + screen.getByRole('heading', { name: 'Experiment ended early' }) + ).toBeInTheDocument(); + expect(screen.queryByText(/Recording complete/)).toBeNull(); + }); + + it('a run that recorded no EEG recommends Analyze, even with EEG turned on', () => { + render( + <Run + {...props} + isRunning={false} + runOutcome="complete" + recordsEEG={false} + />, + { wrapper: MemoryRouter } + ); + + expect( + screen.getByRole('button', { name: 'Analyze results →' }) + ).toBeInTheDocument(); + expect(screen.queryByText(/Clean this recording/)).toBeNull(); + }); +}); diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx index 1822066d..6a88fcb0 100644 --- a/src/renderer/components/CollectComponent/index.tsx +++ b/src/renderer/components/CollectComponent/index.tsx @@ -18,6 +18,7 @@ import { HeadsetSetupContext } from '../../containers/AppShellContainer'; import LiveSignalPrep from '../HeadsetSetup/LiveSignalPrep'; import RunComponent from './RunComponent'; import { ExperimentActions, DeviceActions } from '../../actions'; +import type { RunOutcome } from '../../actions/experimentActions'; export interface Props { ExperimentActions: typeof ExperimentActions; @@ -31,6 +32,9 @@ export interface Props { experimentObject: ExperimentObject; signalQualityObservable: Observable<SignalQualityData> | null | undefined; isRunning: boolean; + isEnding: boolean; + runOutcome: RunOutcome | null; + recordsEEG: boolean; params: ExperimentParameters; subject: string; group: string; diff --git a/src/renderer/components/ExperimentRuntime.tsx b/src/renderer/components/ExperimentRuntime.tsx index e284d630..04999bd6 100644 --- a/src/renderer/components/ExperimentRuntime.tsx +++ b/src/renderer/components/ExperimentRuntime.tsx @@ -28,6 +28,11 @@ export interface ExperimentRuntimeProps { /** Emitted at stimulus onset with the trial's condition label and one clock reading. */ eventCallback: (label: string, time: number) => void; onFinish: (csv: string) => void; + /** + * Called once if the runtime is torn down before the study finishes, with the + * trials recorded so far ('' if none). Omitted by Preview, which records nothing. + */ + onAbort?: (csv: string) => void; /** Called as trials start; `null` between trial blocks. */ onProgress?: (progress: ExperimentProgress | null) => void; } @@ -52,6 +57,11 @@ const resolveImport = async ( return { kind: 'labjs', study: JSON.parse(source) as ExperimentObject }; }; +/** + * Picks the runtime for a study. An imported study that is still loading or + * failed has no runtime to report an early exit; the end-run fallback epic + * ends that run. + */ export const ExperimentRuntime: React.FC<Props> = ({ type, experimentObject, diff --git a/src/renderer/components/ImportedExperimentWindow.tsx b/src/renderer/components/ImportedExperimentWindow.tsx index bf705b56..44b3d338 100644 --- a/src/renderer/components/ImportedExperimentWindow.tsx +++ b/src/renderer/components/ImportedExperimentWindow.tsx @@ -35,6 +35,7 @@ export const ImportedExperimentWindow: React.FC< fullScreen = true, eventCallback, onFinish, + onAbort, onProgress, }) => { const hostElementId = useRef( @@ -55,6 +56,7 @@ export const ImportedExperimentWindow: React.FC< }, eventCallback, onFinish, + onAbort, onProgress, }); return host.teardown; @@ -62,7 +64,15 @@ export const ImportedExperimentWindow: React.FC< setError((failure as Error).message); return undefined; } - }, [eventCallback, hostElementId, imported, onFinish, onProgress, source]); + }, [ + eventCallback, + hostElementId, + imported, + onAbort, + onFinish, + onProgress, + source, + ]); if (error) { return ( diff --git a/src/renderer/components/LabjsExperimentWindow.tsx b/src/renderer/components/LabjsExperimentWindow.tsx index b6c0667e..eda77763 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -15,6 +15,12 @@ export type LabjsExperimentWindowProps = ExperimentRuntimeProps & { params: ExperimentParameters; }; +/** + * Normal end → `onFinish(csv)`. Unmount before the end → `onAbort` at once + * with the trials committed so far, then the study is stopped with the + * controller's `jump('abort')` (lab.js 23's own abort, as its debug plugin + * uses). Escape is not handled here — see RunComponent's hold-Escape. + */ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ title, experimentObject, @@ -22,6 +28,7 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ fullScreen = true, eventCallback, onFinish, + onAbort, onProgress, }) => { useEffect(() => { @@ -66,12 +73,14 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ ); } + let finished = false; + // lab.js 23.x moved the datastore and audio context from `options`/the + // controller to `global`; the old paths throw inside lab.js's end sequence. experimentToRun.on('end', () => { - // lab.js 23.x moved the datastore from `options.datastore` to - // `global.datastore` (controller.global). The old path is undefined and - // throws inside the end handler, aborting lab.js's end sequence. - const csv = experimentToRun.global.datastore.exportCsv(); - onFinish(csv); + void experimentToRun.global.audioContext.close(); + if (finished) return; + finished = true; + onFinish(experimentToRun.global.datastore.exportCsv()); }); // TODO: more natural labjs-y way to do this? @@ -79,15 +88,6 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ eventCallback(label, Date.now()); }; - experimentToRun.options.events.keydown = async (e) => { - if (e.code === 'Escape') { - if (experimentToRun) { - await experimentToRun.internals.controller.audioContext.close(); - experimentToRun.end(); - } - } - }; - if (onProgress) { let last = ''; // The controller exists once the root prepares, and emits one 'flip' per @@ -108,16 +108,39 @@ export const LabjsExperimentWindow: React.FC<LabjsExperimentWindowProps> = ({ experimentToRun.run(); return () => { + if (finished) return; + finished = true; + let csv = ''; try { - if (experimentToRun) { - experimentToRun.internals.controller.audioContext.close(); - experimentToRun.end(); - } - } catch (e) { - console.log('Experiment closed before unmount'); + csv = experimentToRun.global.datastore.exportCsv(); + } catch { + // No controller before the study prepares; nothing was recorded. } + onAbort?.(csv); + // A bare root `end()` does not stop lab.js 23 (the flip loop keeps + // iterating), and an abort between a screen's render and show frames + // hangs it, so the abort waits two frames for pending flips to settle. + requestAnimationFrame(() => + requestAnimationFrame(() => { + Promise.resolve() + .then(() => + experimentToRun.internals.controller.jump('abort', { + sender: experimentToRun, + }) + ) + .catch(() => undefined); + }) + ); }; - }, [eventCallback, experimentObject, onFinish, onProgress, params, title]); + }, [ + eventCallback, + experimentObject, + onAbort, + onFinish, + onProgress, + params, + title, + ]); return ( <div diff --git a/src/renderer/components/__tests__/ExperimentRuntime.test.tsx b/src/renderer/components/__tests__/ExperimentRuntime.test.tsx index 7ddc5e66..bb7ccea4 100644 --- a/src/renderer/components/__tests__/ExperimentRuntime.test.tsx +++ b/src/renderer/components/__tests__/ExperimentRuntime.test.tsx @@ -28,9 +28,7 @@ const baseProps = { onFinish: vi.fn(), }; -const importedParams = ( - overrides: Record<string, unknown> = {} -): never => +const importedParams = (overrides: Record<string, unknown> = {}): never => ({ stimuli: [], imported: { @@ -72,7 +70,9 @@ describe('ExperimentRuntime', () => { /> ); await waitFor(() => - expect(screen.getByTestId('jspsych')).toHaveTextContent('initJsPsych({});') + expect(screen.getByTestId('jspsych')).toHaveTextContent( + 'initJsPsych({});' + ) ); expect(readImportedExperimentFile).toHaveBeenCalledWith( 'My_Study', @@ -81,13 +81,18 @@ describe('ExperimentRuntime', () => { }); it('parses an imported lab.js study and hands it to the lab.js runtime', async () => { - readImportedExperimentFile.mockResolvedValue('{"type":"lab.flow.Sequence"}'); + readImportedExperimentFile.mockResolvedValue( + '{"type":"lab.flow.Sequence"}' + ); render( <ExperimentRuntime {...baseProps} type={EXPERIMENTS.IMPORTED} experimentObject={{}} - params={importedParams({ kind: 'labjs', file: 'experiment/study.json' })} + params={importedParams({ + kind: 'labjs', + file: 'experiment/study.json', + })} /> ); await waitFor(() => diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx new file mode 100644 index 00000000..31aed706 --- /dev/null +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LabjsExperimentWindow } from '../LabjsExperimentWindow'; + +// lab.js needs browser APIs jsdom lacks: its canvas module subclasses +// DOMMatrixReadOnly at import, every controller opens an AudioContext, and +// flips read document.timeline for their timestamps. vi.hoisted runs before +// the imports above. +vi.hoisted(() => { + Object.assign(window, { + DOMMatrixReadOnly: class {}, + AudioContext: class { + close = () => Promise.resolve(); + }, + }); + Object.defineProperty(document, 'timeline', { + get: () => ({ currentTime: performance.now() }), + }); +}); + +const study = { + type: 'lab.flow.Sequence', + content: [ + { type: 'lab.html.Screen', content: '<p>first</p>', timeout: 10 }, + { type: 'lab.html.Screen', content: '<p>waiting</p>' }, + ], +}; + +describe('LabjsExperimentWindow', () => { + it('unmounting mid-study reports the trials so far at once, and never onFinish', async () => { + const onFinish = vi.fn(); + const onAbort = vi.fn(); + const { unmount } = render( + <LabjsExperimentWindow + title="Study" + experimentObject={study as never} + params={{} as never} + eventCallback={vi.fn()} + onFinish={onFinish} + onAbort={onAbort} + /> + ); + await screen.findByText('waiting', {}, { timeout: 3000 }); + + unmount(); + + expect(onAbort).toHaveBeenCalledTimes(1); + expect(onAbort.mock.calls[0][0]).toContain('html.Screen'); + expect(onFinish).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index 2e751008..76d0616a 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -20,6 +20,7 @@ import { ExperimentActions } from '../actions'; import { CONNECTION_STATUS, DEVICES } from '../constants/constants'; import { experimentLabel } from '../constants/experimentLabels'; import { RootState } from '../store'; +import { selectRecordsEEG } from '../selectors'; import type { ExperimentProgress } from '../components/ExperimentRuntime'; import HeadsetSetupDialog from '../components/HeadsetSetup/HeadsetSetupDialog'; @@ -64,6 +65,7 @@ export default function AppShellContainer({ const dispatch = useDispatch(); const experiment = useSelector((state: RootState) => state.experiment); const device = useSelector((state: RootState) => state.device); + const recordsEEG = useSelector(selectRecordsEEG); const modality: Modality = experiment.isEEGEnabled ? 'eeg' : 'behavior'; const workspace = experiment.title @@ -135,12 +137,17 @@ export default function AppShellContainer({ deviceName={device.connectedDevice?.name} run={ isRunning - ? { kind: modality, elapsed, progress: formatProgress(progress) } + ? { + kind: recordsEEG ? 'eeg' : 'behavior', + elapsed, + progress: formatProgress(progress), + } : undefined } onSelectArea={(area: Area) => navigate(AREA_ROUTES[area])} onHome={() => navigate(HOME_ROUTE)} - onEndRun={() => dispatch(ExperimentActions.Stop({ data: '' }))} + onEndRun={() => dispatch(ExperimentActions.EndRun())} + escapeHeld={experiment.escapeHeld} onDeviceClick={headsetSetup.openHeadsetSetup} > <RunProgressContext.Provider value={setProgress}> diff --git a/src/renderer/containers/CollectContainer.ts b/src/renderer/containers/CollectContainer.ts index 79d59c2f..f0e37761 100644 --- a/src/renderer/containers/CollectContainer.ts +++ b/src/renderer/containers/CollectContainer.ts @@ -4,6 +4,7 @@ import Collect from '../components/CollectComponent'; import { DeviceActions, ExperimentActions } from '../actions'; import { RootState } from '../store'; import { ExperimentParameters } from '../constants/interfaces'; +import { selectRecordsEEG } from '../selectors'; function mapStateToProps(state: RootState) { return { @@ -12,6 +13,7 @@ function mapStateToProps(state: RootState) { connectedDevice: state.device.connectedDevice, signalQualityObservable: state.device.signalQualityObservable ?? undefined, params: state.experiment.params as ExperimentParameters, + recordsEEG: selectRecordsEEG(state), }; } diff --git a/src/renderer/epics/__tests__/experimentEpics.test.ts b/src/renderer/epics/__tests__/experimentEpics.test.ts index ff8b3cdb..24e933e1 100644 --- a/src/renderer/epics/__tests__/experimentEpics.test.ts +++ b/src/renderer/epics/__tests__/experimentEpics.test.ts @@ -8,9 +8,20 @@ import { DEVICE_AVAILABILITY, DEVICES, } from '../../constants/constants'; +import type { StateObservable } from 'redux-observable'; import type { RootState } from '../../reducers'; -import { saveWorkspaceEpic } from '../experimentEpics'; -import { storeExperimentState } from '../../utils/filesystem/storage'; +import type { EEGData } from '../../constants/interfaces'; +import experimentEpics, { saveWorkspaceEpic } from '../experimentEpics'; +import { + markRecordingIncomplete, + storeBehavioralData, + storeExperimentState, +} from '../../utils/filesystem/storage'; +import { + closeEEGStream, + createEEGWriteStream, + writeEEGData, +} from '../../utils/filesystem/write'; vi.mock('../../utils/filesystem/storage', () => ({ createEEGWriteStream: vi.fn(), @@ -20,9 +31,20 @@ vi.mock('../../utils/filesystem/storage', () => ({ storeExperimentState: vi.fn().mockResolvedValue(undefined), restoreExperimentState: vi.fn(), createWorkspaceDir: vi.fn(), - storeBehavioralData: vi.fn(), + storeBehavioralData: vi.fn().mockResolvedValue(undefined), + markRecordingIncomplete: vi.fn().mockResolvedValue(undefined), getWorkspaceDir: vi.fn(), })); +vi.mock('../../utils/filesystem/write', () => ({ + createEEGWriteStream: vi.fn(), + writeHeader: vi.fn(), + writeEEGData: vi.fn(), + writeEEGEvents: vi.fn().mockResolvedValue(undefined), + closeEEGStream: vi.fn(), +})); +vi.mock('../../utils/eeg/markerRegistry', () => ({ + resolveMarkerRegistry: () => ({ codeToLabel: {} }), +})); const experiment = { type: EXPERIMENTS.CUSTOM, @@ -42,6 +64,9 @@ const experiment = { isRunning: false, isEEGEnabled: true, dateModified: null, + isEnding: false, + escapeHeld: false, + runOutcome: null, }; const rootState = (title: string): RootState => ({ @@ -83,7 +108,7 @@ describe('saveWorkspaceEpic', () => { value: rootState('My_Custom'), subscribe: vi.fn(), lift: vi.fn(), - } as unknown as import('redux-observable').StateObservable<RootState>; + } as unknown as StateObservable<RootState>; const output = saveWorkspaceEpic(actions, state, undefined).subscribe(); @@ -97,3 +122,170 @@ describe('saveWorkspaceEpic', () => { output.unsubscribe(); }); }); + +type Mutable = { + experiment: Record<string, unknown>; + device: Record<string, unknown>; +}; + +const recording = (raw?: Subject<EEGData>) => { + const s = rootState('My_Custom') as unknown as Mutable; + s.experiment.subject = 'P1'; + s.experiment.group = 'A'; + if (raw) { + s.device.connectionStatus = CONNECTION_STATUS.CONNECTED; + s.device.rawObservable = raw; + s.device.connectedDevice = { + name: 'Fixture', + samplingRate: 256, + channels: ['AF7'], + }; + } + return s; +}; + +const runThenStop = async ( + s: Mutable, + outcome: 'complete' | 'incomplete', + afterStop?: () => void +) => { + const actions = new Subject<ExperimentActionType>(); + const state = { value: s } as unknown as StateObservable<RootState>; + const out: ExperimentActionType[] = []; + const sub = experimentEpics(actions, state, undefined).subscribe((a) => + out.push(a) + ); + actions.next(ExperimentActions.Start()); + await vi.waitFor(() => + expect(out).toContainEqual(ExperimentActions.SetIsRunning(true)) + ); + s.experiment.isRunning = true; + actions.next(ExperimentActions.Stop({ data: 'csv', outcome })); + afterStop?.(); + await vi.waitFor(() => + expect(out).toContainEqual(ExperimentActions.SetIsRunning(false)) + ); + sub.unsubscribe(); +}; + +describe('experiment stop', () => { + afterEach(() => vi.clearAllMocks()); + + it('closes the EEG file, then saves behavior, then marks an ended-early run incomplete', async () => { + const calls: string[] = []; + vi.mocked(createEEGWriteStream).mockResolvedValue('stream-1'); + vi.mocked(closeEEGStream).mockImplementation(async (id) => { + calls.push(`close:${id}`); + }); + vi.mocked(storeBehavioralData).mockImplementation(async () => { + calls.push('behavior'); + }); + vi.mocked(markRecordingIncomplete).mockImplementation(async () => { + calls.push('incomplete'); + }); + const raw = new Subject<EEGData>(); + + await runThenStop(recording(raw), 'incomplete', () => + raw.next({ timestamp: 1, data: [1] } as EEGData) + ); + + expect(calls).toEqual(['close:stream-1', 'behavior', 'incomplete']); + expect(writeEEGData).not.toHaveBeenCalled(); + }); + + it('never marks a completed run', async () => { + await runThenStop(recording(), 'complete'); + + expect(storeBehavioralData).toHaveBeenCalledWith( + 'csv', + 'My_Custom', + 'P1', + 'A', + 1 + ); + expect(markRecordingIncomplete).not.toHaveBeenCalled(); + }); + + it('still hides an ended-early run when saving behavior fails', async () => { + vi.mocked(storeBehavioralData).mockRejectedValueOnce(new Error('EACCES')); + + await runThenStop(recording(), 'incomplete'); + + expect(markRecordingIncomplete).toHaveBeenCalledWith( + 'My_Custom', + 'P1', + 'A', + 1 + ); + }); +}); + +describe('ending a run early', () => { + const live = () => { + const s = recording(); + s.experiment.isRunning = true; + const actions = new Subject<ExperimentActionType>(); + const out: ExperimentActionType[] = []; + const sub = experimentEpics( + actions, + { value: s } as unknown as StateObservable<RootState>, + undefined + ).subscribe((a) => out.push(a)); + return { actions, out, sub }; + }; + const escape = (type: 'keydown' | 'keyup') => + window.dispatchEvent(new KeyboardEvent(type, { key: 'Escape' })); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('a tap of Escape never ends the run; holding it does', () => { + vi.useFakeTimers(); + const { out, sub } = live(); + + escape('keydown'); + vi.advanceTimersByTime(400); + escape('keyup'); + vi.advanceTimersByTime(2000); + expect(out).toEqual([ + ExperimentActions.SetEscapeHeld(true), + ExperimentActions.SetEscapeHeld(false), + ]); + + escape('keydown'); + vi.advanceTimersByTime(1000); + expect(out.at(-1)).toEqual(ExperimentActions.EndRun()); + sub.unsubscribe(); + }); + + it('ends the run anyway if the runtime never reports', () => { + vi.useFakeTimers(); + const { actions, out, sub } = live(); + + actions.next(ExperimentActions.EndRun()); + vi.advanceTimersByTime(3000); + + expect(out).toContainEqual( + ExperimentActions.Stop({ data: '', outcome: 'incomplete' }) + ); + sub.unsubscribe(); + }); + + it('adds nothing once the runtime has reported', () => { + vi.useFakeTimers(); + const { actions, out, sub } = live(); + + actions.next(ExperimentActions.EndRun()); + actions.next( + ExperimentActions.Stop({ data: 'partial', outcome: 'incomplete' }) + ); + vi.advanceTimersByTime(3000); + + expect(out).not.toContainEqual( + ExperimentActions.Stop({ data: '', outcome: 'incomplete' }) + ); + sub.unsubscribe(); + }); +}); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index cf7676bd..a25f944a 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -1,13 +1,17 @@ import { combineEpics, Epic, ofType } from 'redux-observable'; -import { of } from 'rxjs'; +import { concat, fromEvent, of, race, timer } from 'rxjs'; import { map, mergeMap, + exhaustMap, filter, + switchMap, + take, takeUntil, debounceTime, tap, } from 'rxjs/operators'; +import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; import { DeviceActions, @@ -18,6 +22,7 @@ import { RouterActions } from '../actions/routerActions'; import { MUSE_CHANNELS, CONNECTION_STATUS } from '../constants/constants'; import { isWorkspaceRoute } from '../components/AppShell/areas'; import { + closeEEGStream, createEEGWriteStream, writeHeader, writeEEGData, @@ -29,6 +34,7 @@ import { restoreExperimentState, createWorkspaceDir, storeBehavioralData, + markRecordingIncomplete, getWorkspaceDir, } from '../utils/filesystem/storage'; import { RootState } from '../reducers'; @@ -65,11 +71,15 @@ const createNewWorkspaceEpic: Epic< mergeMap((actions) => of(...actions)) ); +/** The open raw-EEG write stream of the current run; closed by the stop epic. */ +let activeEEGStream: string | null = null; + const startEpic = (action$, state$) => action$.pipe( filter(isActionOf(ExperimentActions.Start)), filter(() => !state$.value.experiment.isRunning), mergeMap(async () => { + activeEEGStream = null; await createWorkspaceDir(state$.value.experiment.title); if ( state$.value.device.connectionStatus === CONNECTION_STATUS.CONNECTED @@ -84,6 +94,7 @@ const startEpic = (action$, state$) => if (!streamId) { return true; } + activeEEGStream = streamId; writeHeader( streamId, state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS @@ -121,6 +132,13 @@ const startEpic = (action$, state$) => map(ExperimentActions.SetIsRunning) ); +/** + * Finalizes a run exactly once: closes the EEG stream (the raw subscription + * already ended on Stop), writes behavior, and for an ended-early run renames + * both files so Clean and Analyze skip them. Each step runs even if an earlier + * one failed, so a failed behavior write never leaves an ended-early EEG file + * discoverable. Stops arriving meanwhile are ignored. + */ const experimentStopEpic: Epic< ExperimentActionType, ExperimentActionType, @@ -129,20 +147,93 @@ const experimentStopEpic: Epic< action$.pipe( filter(isActionOf(ExperimentActions.Stop)), filter(() => state$.value.experiment.isRunning), - map((action) => action.payload as { data: string }), - map(({ data }) => { - if (!state$.value.experiment.title) { - return; + exhaustMap(async ({ payload: { data, outcome } }) => { + const { title, subject, group, session } = state$.value.experiment; + const streamId = activeEEGStream; + activeEEGStream = null; + const failures: string[] = []; + const step = async (work: () => Promise<void>) => { + try { + await work(); + } catch (error) { + failures.push((error as Error).message); + } + }; + if (streamId) await step(() => closeEEGStream(streamId)); + if (title) { + if (data) + await step(() => + storeBehavioralData(data, title, subject, group, session) + ); + if (outcome === 'incomplete') + await step(() => + markRecordingIncomplete(title, subject, group, session) + ); } - storeBehavioralData( - data, - state$.value.experiment.title, - state$.value.experiment.subject, - state$.value.experiment.group, - state$.value.experiment.session - ); - }), - mergeMap(() => of(ExperimentActions.SetIsRunning(false))) + if (failures.length) + toast.error(`Couldn't finish saving this run: ${failures.join('; ')}`); + return ExperimentActions.SetIsRunning(false); + }) + ); + +/** How long Escape must be held to end a run early; a tap never ends it. */ +const END_EARLY_HOLD_MS = 1000; +/** ponytail: a runtime that never reports on teardown gets this long, then the run ends with no behavior data. */ +const ABORT_FALLBACK_MS = 3000; + +const escapeKey = (type: 'keydown' | 'keyup') => + fromEvent<KeyboardEvent>(window, type, { capture: true }).pipe( + filter((event) => event.key === 'Escape') + ); + +/** + * Holding Escape for END_EARLY_HOLD_MS during a run ends it early, the same as + * the RunBar button; letting go sooner keeps it running. Keys still reach the + * study, whose own presentation is untouched. + */ +const escapeHoldEpic: Epic< + ExperimentActionType, + ExperimentActionType, + RootState +> = (action$, state$) => + escapeKey('keydown').pipe( + filter( + (event) => + !event.repeat && + state$.value.experiment.isRunning && + !state$.value.experiment.isEnding + ), + exhaustMap(() => + concat( + of(ExperimentActions.SetEscapeHeld(true)), + race( + timer(END_EARLY_HOLD_MS).pipe(map(() => ExperimentActions.EndRun())), + escapeKey('keyup').pipe( + take(1), + map(() => ExperimentActions.SetEscapeHeld(false)) + ) + ) + ).pipe( + takeUntil(action$.pipe(filter(isActionOf(ExperimentActions.Stop)))) + ) + ) + ); + +/** Ends an ended-early run with no behavior data if the runtime never reports. */ +const endRunFallbackEpic: Epic< + ExperimentActionType, + ExperimentActionType, + RootState +> = (action$, state$) => + action$.pipe( + filter(isActionOf(ExperimentActions.EndRun)), + filter(() => state$.value.experiment.isRunning), + switchMap(() => + timer(ABORT_FALLBACK_MS).pipe( + takeUntil(action$.pipe(filter(isActionOf(ExperimentActions.Stop)))), + map(() => ExperimentActions.Stop({ data: '', outcome: 'incomplete' })) + ) + ) ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -207,6 +298,8 @@ export default combineEpics( createNewWorkspaceEpic, startEpic, experimentStopEpic, + escapeHoldEpic, + endRunFallbackEpic, autoSaveEpic, saveWorkspaceEpic, navigationCleanupEpic, diff --git a/src/renderer/reducers/__tests__/experimentReducer.test.ts b/src/renderer/reducers/__tests__/experimentReducer.test.ts new file mode 100644 index 00000000..2729d59a --- /dev/null +++ b/src/renderer/reducers/__tests__/experimentReducer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { ExperimentActions } from '../../actions'; +import experimentReducer, { ExperimentStateType } from '../experimentReducer'; + +const initial = experimentReducer(undefined, { type: '@@INIT' }); +const apply = (...actions: Parameters<typeof experimentReducer>[1][]) => + actions.reduce(experimentReducer, initial); + +describe('experimentReducer run lifecycle', () => { + it('a run that finishes as it is ended early is recorded as complete', () => { + const state = apply( + ExperimentActions.SetIsRunning(true), + ExperimentActions.Stop({ data: 'full', outcome: 'complete' }), + ExperimentActions.EndRun(), + ExperimentActions.Stop({ data: '', outcome: 'incomplete' }), + ExperimentActions.SetIsRunning(false) + ); + + expect(state.runOutcome).toBe('complete'); + expect(state.isEnding).toBe(false); + }); + + it('ending early only applies to a live run', () => { + expect(apply(ExperimentActions.EndRun()).isEnding).toBe(false); + expect( + apply(ExperimentActions.SetIsRunning(true), ExperimentActions.EndRun()) + .isEnding + ).toBe(true); + }); + + it('the next run clears the last result', () => { + const state = apply( + ExperimentActions.SetIsRunning(true), + ExperimentActions.Stop({ data: '', outcome: 'incomplete' }), + ExperimentActions.SetIsRunning(false), + ExperimentActions.SetIsRunning(true) + ); + + expect(state.runOutcome).toBeNull(); + }); + + it('reopening a workspace never restores a live run', () => { + const saved = { + ...initial, + isEnding: true, + escapeHeld: true, + runOutcome: 'complete', + } as ExperimentStateType; + + const state = apply(ExperimentActions.SetState(saved)); + + expect(state.runOutcome).toBeNull(); + expect(state.isEnding).toBe(false); + expect(state.escapeHeld).toBe(false); + }); +}); diff --git a/src/renderer/reducers/experimentReducer.ts b/src/renderer/reducers/experimentReducer.ts index 70088466..498c8fa2 100644 --- a/src/renderer/reducers/experimentReducer.ts +++ b/src/renderer/reducers/experimentReducer.ts @@ -1,5 +1,6 @@ import { createReducer } from '@reduxjs/toolkit'; import { ExperimentActions } from '../actions'; +import type { RunOutcome } from '../actions/experimentActions'; import { EXPERIMENTS } from '../constants/constants'; import { ExperimentObject, @@ -24,8 +25,17 @@ export interface ExperimentStateType { readonly isRunning: boolean; readonly isEEGEnabled: boolean; readonly dateModified: number | null; + /** End early was asked for; the runtime is unmounting and reporting its data. */ + readonly isEnding: boolean; + /** Escape is held during a run; the RunBar says so. */ + readonly escapeHeld: boolean; + /** How the last run ended; the first Stop of a run wins. Cleared by the next run. */ + readonly runOutcome: RunOutcome | null; } +/** The live-run fields: never carried across runs or restored from disk. */ +const idleRun = { isEnding: false, escapeHeld: false, runOutcome: null }; + const initialState: ExperimentStateType = { type: EXPERIMENTS.NONE, title: '', @@ -38,6 +48,7 @@ const initialState: ExperimentStateType = { // EEG-on is the app's whole point; opt out for behavior-only runs, not in. isEEGEnabled: true, dateModified: null, + ...idleRun, }; export default createReducer(initialState, (builder) => @@ -98,12 +109,33 @@ export default createReducer(initialState, (builder) => }; }) - .addCase(ExperimentActions.SetIsRunning, (state, action) => { - return { - ...state, - isRunning: action.payload, - }; - }) + .addCase(ExperimentActions.SetIsRunning, (state, action) => ({ + ...state, + isRunning: action.payload, + isEnding: false, + escapeHeld: false, + runOutcome: action.payload ? null : state.runOutcome, + })) + + .addCase(ExperimentActions.Stop, (state, action) => + state.isRunning && !state.runOutcome + ? { ...state, runOutcome: action.payload.outcome } + : state + ) + + .addCase(ExperimentActions.EndRun, (state) => + state.isRunning ? { ...state, isEnding: true, escapeHeld: false } : state + ) + + .addCase(ExperimentActions.SetEscapeHeld, (state, action) => ({ + ...state, + escapeHeld: action.payload, + })) + + .addCase(ExperimentActions.DismissRunResult, (state) => ({ + ...state, + runOutcome: null, + })) .addCase(ExperimentActions.SetEEGEnabled, (state, action) => { return { @@ -112,12 +144,11 @@ export default createReducer(initialState, (builder) => }; }) - .addCase(ExperimentActions.SetState, (state, action) => { - return { - ...state, - ...action.payload, - }; - }) + .addCase(ExperimentActions.SetState, (state, action) => ({ + ...state, + ...action.payload, + ...idleRun, + })) .addCase(ExperimentActions.ExperimentCleanup, (state, action) => { return initialState; diff --git a/src/renderer/selectors.ts b/src/renderer/selectors.ts new file mode 100644 index 00000000..223fad0c --- /dev/null +++ b/src/renderer/selectors.ts @@ -0,0 +1,10 @@ +import { CONNECTION_STATUS } from './constants/constants'; +import type { RootState } from './store'; + +/** + * A run records EEG only with EEG on and a headset connected; "Continue + * anyway" without one records key presses alone. + */ +export const selectRecordsEEG = (state: RootState) => + state.experiment.isEEGEnabled && + state.device.connectionStatus === CONNECTION_STATUS.CONNECTED; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index ba599915..fa70c948 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -79,6 +79,12 @@ declare global { group: string, session: number ) => Promise<boolean>; + markRecordingIncomplete: ( + title: string, + subject: string, + group: string, + session: number + ) => Promise<void>; readFiles: (filePathsArray: string[]) => Promise<string[]>; readFileAsBytes: (filePath: string) => Promise<Uint8Array>; diff --git a/src/renderer/utils/filesystem/storage.ts b/src/renderer/utils/filesystem/storage.ts index 7fb25447..4281f03c 100644 --- a/src/renderer/utils/filesystem/storage.ts +++ b/src/renderer/utils/filesystem/storage.ts @@ -49,6 +49,15 @@ export const storeBehavioralData = ( ): Promise<void> => api().storeBehavioralData(csv, title, subject, group, session); +/** Marks a session ended early: its files leave ordinary Clean/Analyze discovery. */ +export const markRecordingIncomplete = ( + title: string, + subject: string, + group: string, + session: number +): Promise<void> => + api().markRecordingIncomplete(title, subject, group, session); + export const importExperimentFile = ( title: string, sourcePath: string diff --git a/src/renderer/utils/jspsych/__tests__/host.test.ts b/src/renderer/utils/jspsych/__tests__/host.test.ts index 62522032..dd543be8 100644 --- a/src/renderer/utils/jspsych/__tests__/host.test.ts +++ b/src/renderer/utils/jspsych/__tests__/host.test.ts @@ -152,6 +152,7 @@ describe('createJsPsychHost', () => { document.body.innerHTML = '<div id="host"></div>'; let csv = ''; let host: { teardown: () => void } | undefined; + const onAbort = vi.fn(); const finished = new Promise<void>((resolve) => { host = createJsPsychHost( ` @@ -169,11 +170,13 @@ describe('createJsPsychHost', () => { csv = value; resolve(); }, + onAbort, } ); }); await finished; host!.teardown(); + expect(onAbort).not.toHaveBeenCalled(); expect(csv.split('\n')).toHaveLength(3); expect(csv).toContain('1,Face,'); @@ -184,6 +187,64 @@ describe('createJsPsychHost', () => { ).toBeUndefined(); }); + it('teardown mid-run reports the trials so far through onAbort, never onFinish', async () => { + document.body.innerHTML = '<div id="host"></div>'; + const onFinish = vi.fn(); + const aborted = Promise.withResolvers<string>(); + const host = createJsPsychHost( + ` + const jsPsych = initJsPsych({}); + jsPsych.run([ + { type: jsPsychCallFunction, func: () => {}, data: { condition: 'Face' } }, + { type: jsPsychHtmlKeyboardResponse, stimulus: 'waiting', data: { condition: 'House' } }, + ]); + `, + { + hostElementId: 'host', + mapping, + eventCallback: vi.fn(), + onFinish, + onAbort: aborted.resolve, + } + ); + await vi.waitFor(() => + expect(document.getElementById('host')!.textContent).toContain('waiting') + ); + host.teardown(); + + expect(await aborted.promise).toContain('1,Face,'); + expect(onFinish).not.toHaveBeenCalled(); + }); + + it('an abort during a post-trial gap reports the finished trials at once', async () => { + document.body.innerHTML = '<div id="host"></div>'; + const onAbort = vi.fn(); + const scope = window as unknown as { trialSaved?: boolean }; + const host = createJsPsychHost( + ` + const jsPsych = initJsPsych({ on_data_update: () => { window.trialSaved = true; } }); + jsPsych.run([ + { type: jsPsychCallFunction, func: () => {}, data: { condition: 'Face' }, post_trial_gap: 10000 }, + { type: jsPsychCallFunction, func: () => {}, data: { condition: 'House' } }, + ]); + `, + { + hostElementId: 'host', + mapping, + eventCallback: vi.fn(), + onFinish: vi.fn(), + onAbort, + } + ); + await vi.waitFor(() => expect(scope.trialSaved).toBe(true)); + + host.teardown(); + + expect(onAbort).toHaveBeenCalledTimes(1); + expect(onAbort.mock.calls[0][0]).toContain('1,Face,'); + delete scope.trialSaved; + }); + it('re-declaring the same top-level names twice does not throw', () => { document.body.innerHTML = '<div id="host"></div>'; const config = { diff --git a/src/renderer/utils/jspsych/host.ts b/src/renderer/utils/jspsych/host.ts index 41c415f3..a11a4d5c 100644 --- a/src/renderer/utils/jspsych/host.ts +++ b/src/renderer/utils/jspsych/host.ts @@ -23,6 +23,8 @@ export interface JsPsychHostConfig { /** Emitted with the trial's condition label and one clock reading. */ eventCallback: (label: string, time: number) => void; onFinish: (csv: string) => void; + /** Teardown before the timeline finished; receives the trials so far. */ + onAbort?: (csv: string) => void; onProgress?: (progress: ExperimentProgress) => void; } @@ -42,6 +44,7 @@ interface JsPsychInternals { description?: unknown; }; abortExperiment?: (endMessage?: string) => void; + data?: { get: () => { values: () => Record<string, unknown>[] } }; getProgress?: () => { total_trials?: number; current_trial_global?: number }; } @@ -169,17 +172,39 @@ export const createJsPsychHost = ( const scope = window as unknown as Record<string, unknown>; const replaced = new Map<string, unknown>(); let instance: JsPsychInternals | undefined; + let finished = false; + /** Cleared when the file fails to load, so the error screen stays up. */ + let { onAbort } = config; + const route = (csv: string) => { + if (finished) return; + finished = true; + config.onFinish(csv); + }; const install = (key: string, value: unknown) => { if (!replaced.has(key)) replaced.set(key, scope[key]); scope[key] = value; }; + /** + * Aborts a running timeline and restores globals. The trials saved so far are + * reported at once: jsPsych still awaits a post-trial gap after an abort, so + * its own on_finish can arrive many seconds later (and is then ignored). + */ const teardown = () => { - try { - instance?.abortExperiment?.(); - } catch { - // A finished run has nothing left to abort; that is not an error. + if (!finished) { + finished = true; + const trials = instance?.data?.get().values() ?? []; + try { + instance?.abortExperiment?.(); + } catch { + // An instance whose timeline never started has nothing to abort. + } + onAbort?.( + trials.length + ? toBehavioralCsv(normalizeJsPsychTrials(trials, config.mapping)) + : '' + ); } instance = undefined; for (const [key, value] of replaced) { @@ -199,6 +224,7 @@ export const createJsPsychHost = ( instance = initJsPsych( buildJsPsychOptions({ ...config, + onFinish: route, getInstance: () => instance, authorOptions, }) @@ -217,6 +243,7 @@ export const createJsPsychHost = ( // eslint-disable-next-line no-new-func new Function(source)(); } catch (error) { + onAbort = undefined; teardown(); throw new Error( `createJsPsychHost: the imported experiment threw while loading — ${