From f29a3ad462f8dc34dc7717cb5041fe498da20430 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 14:34:26 -0400 Subject: [PATCH 1/9] feat(recordings): mark ended-early runs incomplete, excluded from discovery --- src/main/__tests__/recordings.test.ts | 58 ++++++++++++++++++++++++ src/main/index.ts | 29 +++++++----- src/main/recordings.ts | 50 ++++++++++++++++++++ src/preload/index.ts | 14 ++++++ src/renderer/types/electron.d.ts | 6 +++ src/renderer/utils/filesystem/storage.ts | 9 ++++ 6 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 src/main/__tests__/recordings.test.ts create mode 100644 src/main/recordings.ts 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 => ipcRenderer.invoke('fs:recordingExists', title, subject, group, session), + markRecordingIncomplete: ( + title: string, + subject: string, + group: string, + session: number + ): Promise => + ipcRenderer.invoke( + 'fs:markRecordingIncomplete', + title, + subject, + group, + session + ), + readFiles: (filePathsArray: string[]): Promise => ipcRenderer.invoke('fs:readFiles', filePathsArray), 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; + markRecordingIncomplete: ( + title: string, + subject: string, + group: string, + session: number + ) => Promise; readFiles: (filePathsArray: string[]) => Promise; readFileAsBytes: (filePath: string) => Promise; 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 => 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 => + api().markRecordingIncomplete(title, subject, group, session); + export const importExperimentFile = ( title: string, sourcePath: string From 9f388ceb258140a011b563d2121ab00184fd4648 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 14:35:55 -0400 Subject: [PATCH 2/9] feat(run): Stop carries outcome; close EEG stream and mark incomplete runs --- package-lock.json | 5 - package.json | 1 - src/renderer/actions/experimentActions.ts | 5 +- .../CollectComponent/PreTestComponent.tsx | 10 +- .../CollectComponent/RunComponent.tsx | 2 +- src/renderer/containers/AppShellContainer.tsx | 4 +- .../epics/__tests__/experimentEpics.test.ts | 114 +++++++++++++++++- src/renderer/epics/experimentEpics.ts | 45 +++++-- 8 files changed, 151 insertions(+), 35 deletions(-) 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/renderer/actions/experimentActions.ts b/src/renderer/actions/experimentActions.ts index 060094a0..8845b2c9 100644 --- a/src/renderer/actions/experimentActions.ts +++ b/src/renderer/actions/experimentActions.ts @@ -8,12 +8,15 @@ 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'), SetType: createAction('SET_TYPE'), SetExperimentObject: createAction( 'SET_EXPERIMENT_OBJECT' 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..4d56c5ac 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -156,7 +156,7 @@ const Run: React.FC = ({ const onFinish = useCallback( (csv) => { - ExperimentActions.Stop({ data: csv }); + ExperimentActions.Stop({ data: csv, outcome: 'complete' }); setHasFinished(true); }, [ExperimentActions] diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index 2e751008..3d402289 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -140,7 +140,9 @@ export default function AppShellContainer({ } onSelectArea={(area: Area) => navigate(AREA_ROUTES[area])} onHome={() => navigate(HOME_ROUTE)} - onEndRun={() => dispatch(ExperimentActions.Stop({ data: '' }))} + onEndRun={() => + dispatch(ExperimentActions.Stop({ data: '', outcome: 'incomplete' })) + } onDeviceClick={headsetSetup.openHeadsetSetup} > diff --git a/src/renderer/epics/__tests__/experimentEpics.test.ts b/src/renderer/epics/__tests__/experimentEpics.test.ts index ff8b3cdb..733813d9 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, @@ -83,7 +105,7 @@ describe('saveWorkspaceEpic', () => { value: rootState('My_Custom'), subscribe: vi.fn(), lift: vi.fn(), - } as unknown as import('redux-observable').StateObservable; + } as unknown as StateObservable; const output = saveWorkspaceEpic(actions, state, undefined).subscribe(); @@ -97,3 +119,87 @@ describe('saveWorkspaceEpic', () => { output.unsubscribe(); }); }); + +type Mutable = { + experiment: Record; + device: Record; +}; + +const recording = (raw?: Subject) => { + 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(); + const state = { value: s } as unknown as StateObservable; + 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(); + + 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(); + }); +}); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index cf7676bd..49772c5b 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -3,11 +3,13 @@ import { of } from 'rxjs'; import { map, mergeMap, + exhaustMap, filter, takeUntil, debounceTime, tap, } from 'rxjs/operators'; +import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; import { DeviceActions, @@ -18,6 +20,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 +32,7 @@ import { restoreExperimentState, createWorkspaceDir, storeBehavioralData, + markRecordingIncomplete, getWorkspaceDir, } from '../utils/filesystem/storage'; import { RootState } from '../reducers'; @@ -65,11 +69,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 +92,7 @@ const startEpic = (action$, state$) => if (!streamId) { return true; } + activeEEGStream = streamId; writeHeader( streamId, state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS @@ -121,6 +130,11 @@ 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. Stops arriving meanwhile are ignored. + */ const experimentStopEpic: Epic< ExperimentActionType, ExperimentActionType, @@ -129,20 +143,25 @@ 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; + try { + if (streamId) await closeEEGStream(streamId); + if (title) { + if (data) + await storeBehavioralData(data, title, subject, group, session); + if (outcome === 'incomplete') + await markRecordingIncomplete(title, subject, group, session); + } + } catch (error) { + toast.error( + `Couldn't finish saving this run: ${(error as Error).message}` + ); } - storeBehavioralData( - data, - state$.value.experiment.title, - state$.value.experiment.subject, - state$.value.experiment.group, - state$.value.experiment.session - ); - }), - mergeMap(() => of(ExperimentActions.SetIsRunning(false))) + return ExperimentActions.SetIsRunning(false); + }) ); // eslint-disable-next-line @typescript-eslint/no-explicit-any From f6a5470d50444e0e31419c20bda063f1a0e2e556 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 14:37:31 -0400 Subject: [PATCH 3/9] feat(runtime): separate abort callback; Escape no longer ends lab.js studies --- src/renderer/components/ExperimentRuntime.tsx | 23 +++++++- .../components/ImportedExperimentWindow.tsx | 14 ++++- .../components/LabjsExperimentWindow.tsx | 58 ++++++++++++------- .../__tests__/ExperimentRuntime.test.tsx | 39 +++++++++++++ .../utils/jspsych/__tests__/host.test.ts | 32 ++++++++++ src/renderer/utils/jspsych/host.ts | 21 +++++-- 6 files changed, 159 insertions(+), 28 deletions(-) diff --git a/src/renderer/components/ExperimentRuntime.tsx b/src/renderer/components/ExperimentRuntime.tsx index e284d630..78a20dfa 100644 --- a/src/renderer/components/ExperimentRuntime.tsx +++ b/src/renderer/components/ExperimentRuntime.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EXPERIMENTS } from '../constants/constants'; import { ExperimentObject, @@ -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. While an imported study is loading or failed, + * no inner runtime exists, so the dispatcher itself reports `onAbort('')` on + * teardown. + */ export const ExperimentRuntime: React.FC = ({ type, experimentObject, @@ -79,6 +89,17 @@ export const ExperimentRuntime: React.FC = ({ }; }, [imported, runtime.title]); + const waitingRef = useRef(false); + waitingRef.current = Boolean(imported) && !resolved; + const onAbortRef = useRef(runtime.onAbort); + onAbortRef.current = runtime.onAbort; + useEffect( + () => () => { + if (waitingRef.current) onAbortRef.current?.(''); + }, + [] + ); + if (imported) { if (error) { return ( diff --git a/src/renderer/components/ImportedExperimentWindow.tsx b/src/renderer/components/ImportedExperimentWindow.tsx index bf705b56..fbc647c1 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,14 +56,23 @@ export const ImportedExperimentWindow: React.FC< }, eventCallback, onFinish, + onAbort, onProgress, }); return host.teardown; } catch (failure) { setError((failure as Error).message); - return undefined; + return () => onAbort?.(''); } - }, [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..755bf447 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -15,6 +15,11 @@ export type LabjsExperimentWindowProps = ExperimentRuntimeProps & { params: ExperimentParameters; }; +/** + * Normal end → `onFinish(csv)`; unmount before the end → lab.js `end()` → + * `onAbort(partial csv)`. Escape is not handled here — see RunComponent's + * hold-Escape. + */ export const LabjsExperimentWindow: React.FC = ({ title, experimentObject, @@ -22,6 +27,7 @@ export const LabjsExperimentWindow: React.FC = ({ fullScreen = true, eventCallback, onFinish, + onAbort, onProgress, }) => { useEffect(() => { @@ -66,12 +72,21 @@ export const LabjsExperimentWindow: React.FC = ({ ); } + let finished = false; + let aborting = false; + const partialCsv = () => { + try { + return experimentToRun.global.datastore.exportCsv(); + } catch { + return ''; + } + }; + // lab.js 23.x moved the datastore from `options.datastore` to + // `global.datastore`; the old path throws 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); + finished = true; + if (aborting) onAbort?.(partialCsv()); + else onFinish(experimentToRun.global.datastore.exportCsv()); }); // TODO: more natural labjs-y way to do this? @@ -79,15 +94,6 @@ export const LabjsExperimentWindow: React.FC = ({ 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 @@ -109,15 +115,25 @@ export const LabjsExperimentWindow: React.FC = ({ return () => { try { - if (experimentToRun) { - experimentToRun.internals.controller.audioContext.close(); - experimentToRun.end(); - } - } catch (e) { - console.log('Experiment closed before unmount'); + experimentToRun.internals.controller.audioContext.close(); + } catch { + // No controller before the study prepares; nothing to close. } + if (finished) return; + aborting = true; + Promise.resolve() + .then(() => experimentToRun.end()) + .catch(() => onAbort?.('')); }; - }, [eventCallback, experimentObject, onFinish, onProgress, params, title]); + }, [ + eventCallback, + experimentObject, + onAbort, + onFinish, + onProgress, + params, + title, + ]); return (
{ expect(screen.getByRole('alert')).toHaveTextContent('ENOENT: gone') ); }); + + it('reports an abort itself when torn down before an imported study loads', () => { + readImportedExperimentFile.mockReturnValue(new Promise(() => undefined)); + const onAbort = vi.fn(); + const { unmount } = render( + + ); + + unmount(); + + expect(onAbort).toHaveBeenCalledWith(''); + }); + + it('leaves abort reporting to the inner runtime once it is mounted', async () => { + readImportedExperimentFile.mockResolvedValue( + 'const jsPsych = initJsPsych({});' + ); + const onAbort = vi.fn(); + const { unmount } = render( + + ); + await screen.findByTestId('jspsych'); + + unmount(); + + expect(onAbort).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/utils/jspsych/__tests__/host.test.ts b/src/renderer/utils/jspsych/__tests__/host.test.ts index 62522032..bc5c1f20 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 = '
'; let csv = ''; let host: { teardown: () => void } | undefined; + const onAbort = vi.fn(); const finished = new Promise((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,35 @@ describe('createJsPsychHost', () => { ).toBeUndefined(); }); + it('teardown mid-run reports the trials so far through onAbort, never onFinish', async () => { + document.body.innerHTML = '
'; + const onFinish = vi.fn(); + const aborted = Promise.withResolvers(); + 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('re-declaring the same top-level names twice does not throw', () => { document.body.innerHTML = '
'; const config = { diff --git a/src/renderer/utils/jspsych/host.ts b/src/renderer/utils/jspsych/host.ts index 41c415f3..33bfafb4 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; } @@ -169,6 +171,13 @@ export const createJsPsychHost = ( const scope = window as unknown as Record; const replaced = new Map(); let instance: JsPsychInternals | undefined; + let finished = false; + let aborting = false; + const route = (csv: string) => { + finished = true; + if (aborting) config.onAbort?.(csv); + else config.onFinish(csv); + }; const install = (key: string, value: unknown) => { if (!replaced.has(key)) replaced.set(key, scope[key]); @@ -176,10 +185,13 @@ export const createJsPsychHost = ( }; const teardown = () => { - try { - instance?.abortExperiment?.(); - } catch { - // A finished run has nothing left to abort; that is not an error. + if (!finished) { + aborting = true; + try { + instance?.abortExperiment?.(); + } catch { + config.onAbort?.(''); + } } instance = undefined; for (const [key, value] of replaced) { @@ -199,6 +211,7 @@ export const createJsPsychHost = ( instance = initJsPsych( buildJsPsychOptions({ ...config, + onFinish: route, getInstance: () => instance, authorOptions, }) From 4bc9f49fd1fbc86e2a6df68e94836179417fda58 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 14:41:03 -0400 Subject: [PATCH 4/9] feat(run): end early via RunBar or held Escape; ended-early result screen --- .../components/AppShell/AppShell.stories.tsx | 25 +--- src/renderer/components/AppShell/AppShell.tsx | 4 + src/renderer/components/AppShell/RunBar.tsx | 2 +- .../CollectComponent/RunComponent.tsx | 135 ++++++++++++++---- .../__tests__/RunComponent.test.tsx | 121 ++++++++++++++++ src/renderer/containers/AppShellContainer.tsx | 34 ++++- 6 files changed, 266 insertions(+), 55 deletions(-) create mode 100644 src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx 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 }) => ( -
- -
+ escapeHeld: true, + children: ( +
-
- ), + ), + }, }; 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} /> ) : (
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/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 4d56c5ac..29d7c362 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -3,12 +3,14 @@ import React, { useContext, useEffect, useMemo, + useRef, 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'; @@ -21,13 +23,18 @@ import { import { ExperimentRuntime } from '../ExperimentRuntime'; import { nextFreeSession } from '../../utils/filesystem/storage'; import { getExperimentFromType } from '../../utils/labjs/functions'; -import { RunProgressContext } from '../../containers/AppShellContainer'; +import { + EndRunContext, + EscapeHeldContext, + RunProgressContext, +} from '../../containers/AppShellContainer'; import { ExperimentParameters, ExperimentObject, SignalQualityData, } from '../../constants/interfaces'; import { ExperimentActions as globalExperimentActions } from '../../actions'; +import type { RunOutcome } from '../../actions/experimentActions'; import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; interface Props { @@ -45,6 +52,11 @@ interface Props { signalQualityObservable?: Observable | null; } +/** 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 Run: React.FC = ({ type, title, @@ -62,9 +74,14 @@ const Run: React.FC = ({ 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); + /** Set when a run settles; picks the result screen until the next run. */ + const [outcome, setOutcome] = useState(null); + /** End early was asked for; the runtime is unmounted and reports its partial data. */ + const [ending, setEnding] = useState(false); + const settled = useRef(false); + const registerEndRun = useContext(EndRunContext); + const setEscapeHeld = useContext(EscapeHeldContext); + const navigate = useNavigate(); // 'armed': checks passed, waiting for SPACE; nothing is recorded yet. // 'starting': SPACE pressed, Start dispatched; the gate stays up until the @@ -116,7 +133,12 @@ const Run: React.FC = ({ ]); useEffect(() => { - if (isRunning) setGate('off'); + if (!isRunning) { + setEnding(false); + return; + } + setGate('off'); + settled.current = false; }, [isRunning]); useEffect(() => { @@ -154,39 +176,89 @@ const Run: React.FC = ({ [isEEGEnabled, registry] ); - const onFinish = useCallback( - (csv) => { - ExperimentActions.Stop({ data: csv, outcome: 'complete' }); - setHasFinished(true); + const settle = useCallback( + (csv: string, result: RunOutcome) => { + if (settled.current) return; + settled.current = true; + ExperimentActions.Stop({ data: csv, outcome: result }); + setOutcome(result); }, [ExperimentActions] ); + const onFinish = useCallback( + (csv: string) => settle(csv, 'complete'), + [settle] + ); + const onAbort = useCallback( + (csv: string) => settle(csv, 'incomplete'), + [settle] + ); + const endEarly = useCallback(() => setEnding(true), []); + + useEffect(() => { + if (!isRunning) return undefined; + registerEndRun(endEarly); + return () => registerEndRun(null); + }, [isRunning, endEarly, registerEndRun]); + + useEffect(() => { + if (!isRunning) return undefined; + let hold: number | undefined; + const release = () => { + window.clearTimeout(hold); + setEscapeHeld(false); + }; + const down = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.repeat) return; + window.clearTimeout(hold); + setEscapeHeld(true); + hold = window.setTimeout(() => { + setEscapeHeld(false); + endEarly(); + }, END_EARLY_HOLD_MS); + }; + const up = (e: KeyboardEvent) => { + if (e.key === 'Escape') release(); + }; + window.addEventListener('keydown', down, true); + window.addEventListener('keyup', up, true); + return () => { + release(); + window.removeEventListener('keydown', down, true); + window.removeEventListener('keyup', up, true); + }; + }, [isRunning, endEarly, setEscapeHeld]); - const handleRunAgain = useCallback(() => { - setHasFinished(false); + useEffect(() => { + if (!ending) return undefined; + const fallback = window.setTimeout(() => onAbort(''), ABORT_FALLBACK_MS); + return () => window.clearTimeout(fallback); + }, [ending, onAbort]); + + const handleRunAgain = useCallback(() => setOutcome(null), []); + const handleRunAnother = useCallback(() => { + setOutcome(null); + setIsInputCollectOpen(true); }, []); + const result = isRunning ? ending && 'saving' : outcome; + return ( -
+
- {!isRunning && hasFinished && ( -
-

Recording complete 🎉

-

- Saved {subject}'s data. Ready to clean and analyze it? -

-
- - -
-
+ {result && ( + navigate(SCREENS.CLEAN.route)} + onAnalyze={() => navigate(SCREENS.ANALYZE.route)} + onRunAnother={handleRunAnother} + onRunAgain={handleRunAgain} + /> )} - {!isRunning && !hasFinished && gate !== 'off' && ( + {!isRunning && !outcome && gate !== 'off' && (
= ({
)} - {!isRunning && !hasFinished && gate === 'off' && ( + {!isRunning && !outcome && gate === 'off' && (
@@ -268,7 +340,7 @@ const Run: React.FC = ({
)} - {isRunning && ( + {isRunning && !ending && (
= ({ params={params} eventCallback={eventCallback} onFinish={onFinish} + onAbort={onAbort} onProgress={reportProgress} />
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..36b5437b --- /dev/null +++ b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CONNECTION_STATUS, EXPERIMENTS } from '../../../constants/constants'; +import { EndRunContext } from '../../../containers/AppShellContainer'; +import Run from '../RunComponent'; + +type RuntimeProps = { + onFinish(csv: string): void; + 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(() => () => props.onAbort?.('partial'), [props]); + return
; + }, + }; +}); +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, + 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, +}; + +let endRun: (() => void) | null = null; +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + { + endRun = end; + }} + > + {children} + + +); + +describe('ending a run early', () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + endRun = null; + }); + + it('keeps the partial run as incomplete and says it ended early', () => { + const { rerender } = render(, { wrapper }); + + act(() => endRun!()); + expect(Stop).toHaveBeenCalledWith({ + data: 'partial', + outcome: 'incomplete', + }); + + rerender(); + expect( + screen.getByRole('heading', { name: 'Experiment ended early' }) + ).toBeInTheDocument(); + expect(screen.queryByText(/Recording complete/)).toBeNull(); + }); + + it('a tap of Escape never ends the run; holding it does', () => { + vi.useFakeTimers(); + render(, { wrapper }); + + fireEvent.keyDown(window, { key: 'Escape' }); + act(() => { + vi.advanceTimersByTime(400); + }); + fireEvent.keyUp(window, { key: 'Escape' }); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(Stop).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: 'Escape' }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(Stop).toHaveBeenCalledWith({ + data: 'partial', + outcome: 'incomplete', + }); + }); + + it('a run that finishes as it is ended early is recorded once, as complete', () => { + render(, { wrapper }); + + act(() => runtime.props!.onFinish('full')); + act(() => endRun!()); + + expect(Stop).toHaveBeenCalledTimes(1); + expect(Stop).toHaveBeenCalledWith({ data: 'full', outcome: 'complete' }); + }); +}); diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index 3d402289..d5e6c0cb 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -1,8 +1,10 @@ import React, { createContext, ReactNode, + useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { useSelector, useDispatch } from 'react-redux'; @@ -28,6 +30,16 @@ export const RunProgressContext = createContext< (progress: ExperimentProgress | null) => void >(() => undefined); +/** Lets the running Collect screen receive the RunBar's "End experiment early". */ +export const EndRunContext = createContext<(end: (() => void) | null) => void>( + () => undefined +); + +/** Lets the running Collect screen show the RunBar's hold-Escape status. */ +export const EscapeHeldContext = createContext<(held: boolean) => void>( + () => undefined +); + export interface HeadsetSetupApi { /** Opens pairing at "Which headset?" (or Connected); never starts a search. */ openHeadsetSetup(): void; @@ -100,6 +112,11 @@ export default function AppShellContainer({ ); const [elapsed, setElapsed] = useState('00:00'); const [progress, setProgress] = useState(null); + const [escapeHeld, setEscapeHeld] = useState(false); + const endRun = useRef<(() => void) | null>(null); + const registerEndRun = useCallback((end: (() => void) | null) => { + endRun.current = end; + }, []); useEffect(() => { setProgress(null); if (!isRunning) { @@ -141,14 +158,23 @@ export default function AppShellContainer({ onSelectArea={(area: Area) => navigate(AREA_ROUTES[area])} onHome={() => navigate(HOME_ROUTE)} onEndRun={() => - dispatch(ExperimentActions.Stop({ data: '', outcome: 'incomplete' })) + endRun.current + ? endRun.current() + : dispatch( + ExperimentActions.Stop({ data: '', outcome: 'incomplete' }) + ) } + escapeHeld={escapeHeld} onDeviceClick={headsetSetup.openHeadsetSetup} > - - {children} - + + + + {children} + + + Date: Thu, 24 Sep 2026 14:49:46 -0400 Subject: [PATCH 5/9] fix(labjs): abort via controller jump so an ended-early run keeps its trials --- .../components/LabjsExperimentWindow.tsx | 42 +++++++-------- .../__tests__/LabjsExperimentWindow.test.tsx | 51 +++++++++++++++++++ 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx diff --git a/src/renderer/components/LabjsExperimentWindow.tsx b/src/renderer/components/LabjsExperimentWindow.tsx index 755bf447..c2ee4030 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -16,7 +16,8 @@ export type LabjsExperimentWindowProps = ExperimentRuntimeProps & { }; /** - * Normal end → `onFinish(csv)`; unmount before the end → lab.js `end()` → + * Normal end → `onFinish(csv)`; unmount before the end → the controller's + * `jump('abort')` (lab.js 23's own abort, as its debug plugin uses) → * `onAbort(partial csv)`. Escape is not handled here — see RunComponent's * hold-Escape. */ @@ -74,19 +75,14 @@ export const LabjsExperimentWindow: React.FC = ({ let finished = false; let aborting = false; - const partialCsv = () => { - try { - return experimentToRun.global.datastore.exportCsv(); - } catch { - return ''; - } - }; - // lab.js 23.x moved the datastore from `options.datastore` to - // `global.datastore`; the old path throws inside lab.js's end sequence. + // 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', () => { finished = true; - if (aborting) onAbort?.(partialCsv()); - else onFinish(experimentToRun.global.datastore.exportCsv()); + const csv = experimentToRun.global.datastore.exportCsv(); + if (aborting) onAbort?.(csv); + else onFinish(csv); + void experimentToRun.global.audioContext.close(); }); // TODO: more natural labjs-y way to do this? @@ -114,16 +110,22 @@ export const LabjsExperimentWindow: React.FC = ({ experimentToRun.run(); return () => { - try { - experimentToRun.internals.controller.audioContext.close(); - } catch { - // No controller before the study prepares; nothing to close. - } if (finished) return; aborting = true; - Promise.resolve() - .then(() => experimentToRun.end()) - .catch(() => onAbort?.('')); + // 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(() => onAbort?.('')); + }) + ); }; }, [ eventCallback, diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx new file mode 100644 index 00000000..c692a9a4 --- /dev/null +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -0,0 +1,51 @@ +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: '

first

', timeout: 10 }, + { type: 'lab.html.Screen', content: '

waiting

' }, + ], +}; + +describe('LabjsExperimentWindow', () => { + it('unmounting mid-study reports the trials so far through onAbort, never onFinish', async () => { + const onFinish = vi.fn(); + const aborted = Promise.withResolvers(); + const { unmount } = render( + + ); + await screen.findByText('waiting', {}, { timeout: 3000 }); + + unmount(); + + expect((await aborted.promise).split('\n').length).toBeGreaterThan(1); + expect(onFinish).not.toHaveBeenCalled(); + }); +}); From 759bf76fbac9abca725979b74045d7ceefb3fdf5 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 15:06:10 -0400 Subject: [PATCH 6/9] fix(run): per-run settlement, immediate jsPsych abort data, independent finalize steps --- .../CollectComponent/RunComponent.tsx | 13 ++++--- .../__tests__/RunComponent.test.tsx | 18 ++++++++++ .../epics/__tests__/experimentEpics.test.ts | 13 +++++++ src/renderer/epics/experimentEpics.ts | 34 ++++++++++++------- .../utils/jspsych/__tests__/host.test.ts | 29 ++++++++++++++++ src/renderer/utils/jspsych/host.ts | 28 ++++++++++----- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 29d7c362..84ecff12 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -78,7 +78,6 @@ const Run: React.FC = ({ const [outcome, setOutcome] = useState(null); /** End early was asked for; the runtime is unmounted and reports its partial data. */ const [ending, setEnding] = useState(false); - const settled = useRef(false); const registerEndRun = useContext(EndRunContext); const setEscapeHeld = useContext(EscapeHeldContext); const navigate = useNavigate(); @@ -138,7 +137,6 @@ const Run: React.FC = ({ return; } setGate('off'); - settled.current = false; }, [isRunning]); useEffect(() => { @@ -176,14 +174,19 @@ const Run: React.FC = ({ [isEEGEnabled, registry] ); + /** One token per running period; a report from an earlier run's runtime never settles a later one. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useMemo(() => ({ settled: false }), [isRunning]); + const currentRun = useRef(run); + currentRun.current = run; const settle = useCallback( (csv: string, result: RunOutcome) => { - if (settled.current) return; - settled.current = true; + if (run.settled || currentRun.current !== run) return; + run.settled = true; ExperimentActions.Stop({ data: csv, outcome: result }); setOutcome(result); }, - [ExperimentActions] + [run, ExperimentActions] ); const onFinish = useCallback( (csv: string) => settle(csv, 'complete'), diff --git a/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx index 36b5437b..54ff2257 100644 --- a/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx +++ b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx @@ -118,4 +118,22 @@ describe('ending a run early', () => { expect(Stop).toHaveBeenCalledTimes(1); expect(Stop).toHaveBeenCalledWith({ data: 'full', outcome: 'complete' }); }); + + it('a late report from an earlier run never ends the next one', () => { + const { rerender } = render(, { wrapper }); + const firstRun = runtime.props!; + act(() => firstRun.onAbort!('first')); + rerender(); + rerender(); + + act(() => firstRun.onAbort!('late')); + act(() => firstRun.onFinish('late')); + + expect(Stop).toHaveBeenCalledTimes(1); + act(() => runtime.props!.onFinish('second')); + expect(Stop).toHaveBeenLastCalledWith({ + data: 'second', + outcome: 'complete', + }); + }); }); diff --git a/src/renderer/epics/__tests__/experimentEpics.test.ts b/src/renderer/epics/__tests__/experimentEpics.test.ts index 733813d9..1f50ea92 100644 --- a/src/renderer/epics/__tests__/experimentEpics.test.ts +++ b/src/renderer/epics/__tests__/experimentEpics.test.ts @@ -202,4 +202,17 @@ describe('experiment stop', () => { ); 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 + ); + }); }); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index 49772c5b..4d0daf88 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -133,7 +133,9 @@ const startEpic = (action$, state$) => /** * 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. Stops arriving meanwhile are ignored. + * 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, @@ -147,19 +149,27 @@ const experimentStopEpic: Epic< const { title, subject, group, session } = state$.value.experiment; const streamId = activeEEGStream; activeEEGStream = null; - try { - if (streamId) await closeEEGStream(streamId); - if (title) { - if (data) - await storeBehavioralData(data, title, subject, group, session); - if (outcome === 'incomplete') - await markRecordingIncomplete(title, subject, group, session); + const failures: string[] = []; + const step = async (work: () => Promise) => { + try { + await work(); + } catch (error) { + failures.push((error as Error).message); } - } catch (error) { - toast.error( - `Couldn't finish saving this run: ${(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) + ); } + if (failures.length) + toast.error(`Couldn't finish saving this run: ${failures.join('; ')}`); return ExperimentActions.SetIsRunning(false); }) ); diff --git a/src/renderer/utils/jspsych/__tests__/host.test.ts b/src/renderer/utils/jspsych/__tests__/host.test.ts index bc5c1f20..dd543be8 100644 --- a/src/renderer/utils/jspsych/__tests__/host.test.ts +++ b/src/renderer/utils/jspsych/__tests__/host.test.ts @@ -216,6 +216,35 @@ describe('createJsPsychHost', () => { expect(onFinish).not.toHaveBeenCalled(); }); + it('an abort during a post-trial gap reports the finished trials at once', async () => { + document.body.innerHTML = '
'; + 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 = '
'; const config = { diff --git a/src/renderer/utils/jspsych/host.ts b/src/renderer/utils/jspsych/host.ts index 33bfafb4..6ce137b8 100644 --- a/src/renderer/utils/jspsych/host.ts +++ b/src/renderer/utils/jspsych/host.ts @@ -44,6 +44,7 @@ interface JsPsychInternals { description?: unknown; }; abortExperiment?: (endMessage?: string) => void; + data?: { get: () => { values: () => Record[] } }; getProgress?: () => { total_trials?: number; current_trial_global?: number }; } @@ -172,11 +173,10 @@ export const createJsPsychHost = ( const replaced = new Map(); let instance: JsPsychInternals | undefined; let finished = false; - let aborting = false; const route = (csv: string) => { + if (finished) return; finished = true; - if (aborting) config.onAbort?.(csv); - else config.onFinish(csv); + config.onFinish(csv); }; const install = (key: string, value: unknown) => { @@ -184,14 +184,26 @@ export const createJsPsychHost = ( scope[key] = value; }; - const teardown = () => { + /** + * 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 = (report = true) => { if (!finished) { - aborting = true; + finished = true; + const trials = instance?.data?.get().values() ?? []; try { instance?.abortExperiment?.(); } catch { - config.onAbort?.(''); + // An instance whose timeline never started has nothing to abort. } + if (report) + config.onAbort?.( + trials.length + ? toBehavioralCsv(normalizeJsPsychTrials(trials, config.mapping)) + : '' + ); } instance = undefined; for (const [key, value] of replaced) { @@ -230,7 +242,7 @@ export const createJsPsychHost = ( // eslint-disable-next-line no-new-func new Function(source)(); } catch (error) { - teardown(); + teardown(false); throw new Error( `createJsPsychHost: the imported experiment threw while loading — ${ (error as Error).message @@ -238,5 +250,5 @@ export const createJsPsychHost = ( ); } - return { teardown }; + return { teardown: () => teardown() }; }; From adaae379771a64915e218e94928ad111d2afc694 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 15:17:02 -0400 Subject: [PATCH 7/9] fix(labjs): report ended-early trials at once instead of after lab.js unwinds --- .../components/LabjsExperimentWindow.tsx | 27 +++++++++++-------- .../__tests__/LabjsExperimentWindow.test.tsx | 9 ++++--- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/LabjsExperimentWindow.tsx b/src/renderer/components/LabjsExperimentWindow.tsx index c2ee4030..eda77763 100644 --- a/src/renderer/components/LabjsExperimentWindow.tsx +++ b/src/renderer/components/LabjsExperimentWindow.tsx @@ -16,10 +16,10 @@ export type LabjsExperimentWindowProps = ExperimentRuntimeProps & { }; /** - * Normal end → `onFinish(csv)`; unmount before the end → the controller's - * `jump('abort')` (lab.js 23's own abort, as its debug plugin uses) → - * `onAbort(partial csv)`. Escape is not handled here — see RunComponent's - * hold-Escape. + * 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 = ({ title, @@ -74,15 +74,13 @@ export const LabjsExperimentWindow: React.FC = ({ } let finished = false; - let aborting = 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', () => { - finished = true; - const csv = experimentToRun.global.datastore.exportCsv(); - if (aborting) onAbort?.(csv); - else 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? @@ -111,7 +109,14 @@ export const LabjsExperimentWindow: React.FC = ({ return () => { if (finished) return; - aborting = true; + finished = true; + let csv = ''; + try { + 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. @@ -123,7 +128,7 @@ export const LabjsExperimentWindow: React.FC = ({ sender: experimentToRun, }) ) - .catch(() => onAbort?.('')); + .catch(() => undefined); }) ); }; diff --git a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx index c692a9a4..31aed706 100644 --- a/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx +++ b/src/renderer/components/__tests__/LabjsExperimentWindow.test.tsx @@ -28,9 +28,9 @@ const study = { }; describe('LabjsExperimentWindow', () => { - it('unmounting mid-study reports the trials so far through onAbort, never onFinish', async () => { + it('unmounting mid-study reports the trials so far at once, and never onFinish', async () => { const onFinish = vi.fn(); - const aborted = Promise.withResolvers(); + const onAbort = vi.fn(); const { unmount } = render( { params={{} as never} eventCallback={vi.fn()} onFinish={onFinish} - onAbort={aborted.resolve} + onAbort={onAbort} /> ); await screen.findByText('waiting', {}, { timeout: 3000 }); unmount(); - expect((await aborted.promise).split('\n').length).toBeGreaterThan(1); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(onAbort.mock.calls[0][0]).toContain('html.Screen'); expect(onFinish).not.toHaveBeenCalled(); }); }); From da61f8bdc3a126fa62d264f15d32982be2384d9a Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 15:17:31 -0400 Subject: [PATCH 8/9] docs: WS5 early exit learnings and TODOS --- .llms/learnings.md | 30 ++++++++++++++++++++++++++++++ TODOS.md | 3 +++ 2 files changed, 33 insertions(+) diff --git a/.llms/learnings.md b/.llms/learnings.md index 35192ea5..abb9fe74 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -350,3 +350,33 @@ 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`. + +`RunComponent` settles once per run (a token per running period, so a late +report from an earlier run's runtime is ignored; a 3 s fallback covers a runtime +that never reports) and dispatches `Stop({ data, outcome })`. 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. 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. From c5c278838667584be4bf164c627977350f1b416b Mon Sep 17 00:00:00 2001 From: jdpigeon <morrisondano@gmail.com> Date: Thu, 24 Sep 2026 15:42:26 -0400 Subject: [PATCH 9/9] refactor(run): end-early lifecycle in the store and epics; EEG-aware result copy --- .llms/learnings.md | 24 ++-- src/renderer/actions/experimentActions.ts | 5 + .../CollectComponent/RunComponent.tsx | 115 ++++-------------- .../__tests__/RunComponent.test.tsx | 101 ++++----------- .../components/CollectComponent/index.tsx | 4 + src/renderer/components/ExperimentRuntime.tsx | 19 +-- .../components/ImportedExperimentWindow.tsx | 2 +- .../__tests__/ExperimentRuntime.test.tsx | 56 ++------- src/renderer/containers/AppShellContainer.tsx | 45 ++----- src/renderer/containers/CollectContainer.ts | 2 + .../epics/__tests__/experimentEpics.test.ts | 73 +++++++++++ src/renderer/epics/experimentEpics.ts | 66 +++++++++- .../__tests__/experimentReducer.test.ts | 56 +++++++++ src/renderer/reducers/experimentReducer.ts | 55 +++++++-- src/renderer/selectors.ts | 10 ++ src/renderer/utils/jspsych/host.ts | 20 +-- 16 files changed, 359 insertions(+), 294 deletions(-) create mode 100644 src/renderer/reducers/__tests__/experimentReducer.test.ts create mode 100644 src/renderer/selectors.ts diff --git a/.llms/learnings.md b/.llms/learnings.md index abb9fe74..c2b66b8c 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -365,15 +365,21 @@ 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`. -`RunComponent` settles once per run (a token per running period, so a late -report from an earlier run's runtime is ignored; a 3 s fallback covers a runtime -that never reports) and dispatches `Stop({ data, outcome })`. 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. Before this, "End experiment early" wrote the partial run as a -normal file and `closeEEGStream` was never called. +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 diff --git a/src/renderer/actions/experimentActions.ts b/src/renderer/actions/experimentActions.ts index 8845b2c9..9de861e5 100644 --- a/src/renderer/actions/experimentActions.ts +++ b/src/renderer/actions/experimentActions.ts @@ -17,6 +17,11 @@ export type RunOutcome = 'complete' | 'incomplete'; export const ExperimentActions = { Start: createAction('START'), 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/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 84ecff12..981f3dc9 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -3,7 +3,6 @@ import React, { useContext, useEffect, useMemo, - useRef, useState, } from 'react'; import { Observable } from 'rxjs'; @@ -23,11 +22,7 @@ import { import { ExperimentRuntime } from '../ExperimentRuntime'; import { nextFreeSession } from '../../utils/filesystem/storage'; import { getExperimentFromType } from '../../utils/labjs/functions'; -import { - EndRunContext, - EscapeHeldContext, - RunProgressContext, -} from '../../containers/AppShellContainer'; +import { RunProgressContext } from '../../containers/AppShellContainer'; import { ExperimentParameters, ExperimentObject, @@ -41,6 +36,11 @@ 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; @@ -52,15 +52,13 @@ interface Props { signalQualityObservable?: Observable<SignalQualityData> | null; } -/** 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 Run: React.FC<Props> = ({ type, title, isRunning, + isEnding, + runOutcome, + recordsEEG, params, subject, experimentObject, @@ -74,14 +72,7 @@ const Run: React.FC<Props> = ({ const [isInputCollectOpen, setIsInputCollectOpen] = useState( subject.length === 0 ); - /** Set when a run settles; picks the result screen until the next run. */ - const [outcome, setOutcome] = useState<RunOutcome | null>(null); - /** End early was asked for; the runtime is unmounted and reports its partial data. */ - const [ending, setEnding] = useState(false); - const registerEndRun = useContext(EndRunContext); - const setEscapeHeld = useContext(EscapeHeldContext); 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. @@ -132,11 +123,7 @@ const Run: React.FC<Props> = ({ ]); useEffect(() => { - if (!isRunning) { - setEnding(false); - return; - } - setGate('off'); + if (isRunning) setGate('off'); }, [isRunning]); useEffect(() => { @@ -174,77 +161,21 @@ const Run: React.FC<Props> = ({ [isEEGEnabled, registry] ); - /** One token per running period; a report from an earlier run's runtime never settles a later one. */ - // eslint-disable-next-line react-hooks/exhaustive-deps - const run = useMemo(() => ({ settled: false }), [isRunning]); - const currentRun = useRef(run); - currentRun.current = run; - const settle = useCallback( - (csv: string, result: RunOutcome) => { - if (run.settled || currentRun.current !== run) return; - run.settled = true; - ExperimentActions.Stop({ data: csv, outcome: result }); - setOutcome(result); - }, - [run, ExperimentActions] - ); const onFinish = useCallback( - (csv: string) => settle(csv, 'complete'), - [settle] + (csv: string) => ExperimentActions.Stop({ data: csv, outcome: 'complete' }), + [ExperimentActions] ); const onAbort = useCallback( - (csv: string) => settle(csv, 'incomplete'), - [settle] + (csv: string) => + ExperimentActions.Stop({ data: csv, outcome: 'incomplete' }), + [ExperimentActions] ); - const endEarly = useCallback(() => setEnding(true), []); - - useEffect(() => { - if (!isRunning) return undefined; - registerEndRun(endEarly); - return () => registerEndRun(null); - }, [isRunning, endEarly, registerEndRun]); - - useEffect(() => { - if (!isRunning) return undefined; - let hold: number | undefined; - const release = () => { - window.clearTimeout(hold); - setEscapeHeld(false); - }; - const down = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || e.repeat) return; - window.clearTimeout(hold); - setEscapeHeld(true); - hold = window.setTimeout(() => { - setEscapeHeld(false); - endEarly(); - }, END_EARLY_HOLD_MS); - }; - const up = (e: KeyboardEvent) => { - if (e.key === 'Escape') release(); - }; - window.addEventListener('keydown', down, true); - window.addEventListener('keyup', up, true); - return () => { - release(); - window.removeEventListener('keydown', down, true); - window.removeEventListener('keyup', up, true); - }; - }, [isRunning, endEarly, setEscapeHeld]); - - useEffect(() => { - if (!ending) return undefined; - const fallback = window.setTimeout(() => onAbort(''), ABORT_FALLBACK_MS); - return () => window.clearTimeout(fallback); - }, [ending, onAbort]); - - const handleRunAgain = useCallback(() => setOutcome(null), []); const handleRunAnother = useCallback(() => { - setOutcome(null); + ExperimentActions.DismissRunResult(); setIsInputCollectOpen(true); - }, []); + }, [ExperimentActions]); - const result = isRunning ? ending && 'saving' : outcome; + const result = isRunning ? isEnding && 'saving' : runOutcome; return ( <div className="h-full p-[3%] bg-app" data-tid="container"> @@ -252,16 +183,16 @@ const Run: React.FC<Props> = ({ {result && ( <RunResult outcome={result} - modality={isEEGEnabled ? 'eeg' : 'behavior'} + modality={recordsEEG ? 'eeg' : 'behavior'} subject={subject} onClean={() => navigate(SCREENS.CLEAN.route)} onAnalyze={() => navigate(SCREENS.ANALYZE.route)} onRunAnother={handleRunAnother} - onRunAgain={handleRunAgain} + onRunAgain={() => ExperimentActions.DismissRunResult()} /> )} - {!isRunning && !outcome && gate !== 'off' && ( + {!isRunning && !runOutcome && gate !== 'off' && ( <div role="dialog" aria-label="Press space to begin" @@ -285,7 +216,7 @@ const Run: React.FC<Props> = ({ </div> )} - {!isRunning && !outcome && gate === 'off' && ( + {!isRunning && !runOutcome && gate === 'off' && ( <div className="flex items-center justify-center h-full"> <Card className="w-full max-w-lg"> <CardHeader> @@ -343,7 +274,7 @@ const Run: React.FC<Props> = ({ </div> )} - {isRunning && !ending && ( + {isRunning && !isEnding && ( <div className="h-full w-full"> <ExperimentRuntime type={type} diff --git a/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx index 54ff2257..bbaecb9e 100644 --- a/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx +++ b/src/renderer/components/CollectComponent/__tests__/RunComponent.test.tsx @@ -1,15 +1,11 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CONNECTION_STATUS, EXPERIMENTS } from '../../../constants/constants'; -import { EndRunContext } from '../../../containers/AppShellContainer'; import Run from '../RunComponent'; -type RuntimeProps = { - onFinish(csv: string): void; - onAbort?(csv: string): void; -}; +type RuntimeProps = { onAbort?(csv: string): void }; const runtime = vi.hoisted(() => ({ props: null as null | RuntimeProps })); @@ -20,7 +16,7 @@ vi.mock('../../ExperimentRuntime', async () => { return { ExperimentRuntime: (props: RuntimeProps) => { runtime.props = props; - useEffect(() => () => props.onAbort?.('partial'), [props]); + useEffect(() => () => runtime.props?.onAbort?.('partial'), []); return <div data-testid="runtime" />; }, }; @@ -39,6 +35,9 @@ const props = { type: EXPERIMENTS.N170, title: 'Study', isRunning: true, + isEnding: false, + runOutcome: null, + recordsEEG: true, params: {} as never, subject: 'P1', experimentObject: {} as never, @@ -49,91 +48,39 @@ const props = { ExperimentActions: { Stop, Start: vi.fn(), SetSession: vi.fn() } as never, }; -let endRun: (() => void) | null = null; -const wrapper = ({ children }: { children: React.ReactNode }) => ( - <MemoryRouter> - <EndRunContext.Provider - value={(end) => { - endRun = end; - }} - > - {children} - </EndRunContext.Provider> - </MemoryRouter> -); - describe('ending a run early', () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - endRun = null; - }); + afterEach(() => vi.clearAllMocks()); it('keeps the partial run as incomplete and says it ended early', () => { - const { rerender } = render(<Run {...props} />, { wrapper }); + const { rerender } = render(<Run {...props} />, { wrapper: MemoryRouter }); - act(() => endRun!()); + rerender(<Run {...props} isEnding />); expect(Stop).toHaveBeenCalledWith({ data: 'partial', outcome: 'incomplete', }); - rerender(<Run {...props} isRunning={false} />); + rerender(<Run {...props} isRunning={false} runOutcome="incomplete" />); expect( screen.getByRole('heading', { name: 'Experiment ended early' }) ).toBeInTheDocument(); expect(screen.queryByText(/Recording complete/)).toBeNull(); }); - it('a tap of Escape never ends the run; holding it does', () => { - vi.useFakeTimers(); - render(<Run {...props} />, { wrapper }); - - fireEvent.keyDown(window, { key: 'Escape' }); - act(() => { - vi.advanceTimersByTime(400); - }); - fireEvent.keyUp(window, { key: 'Escape' }); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(Stop).not.toHaveBeenCalled(); - - fireEvent.keyDown(window, { key: 'Escape' }); - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(Stop).toHaveBeenCalledWith({ - data: 'partial', - outcome: 'incomplete', - }); - }); - - it('a run that finishes as it is ended early is recorded once, as complete', () => { - render(<Run {...props} />, { wrapper }); - - act(() => runtime.props!.onFinish('full')); - act(() => endRun!()); - - expect(Stop).toHaveBeenCalledTimes(1); - expect(Stop).toHaveBeenCalledWith({ data: 'full', outcome: 'complete' }); - }); + 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 } + ); - it('a late report from an earlier run never ends the next one', () => { - const { rerender } = render(<Run {...props} />, { wrapper }); - const firstRun = runtime.props!; - act(() => firstRun.onAbort!('first')); - rerender(<Run {...props} isRunning={false} />); - rerender(<Run {...props} isRunning />); - - act(() => firstRun.onAbort!('late')); - act(() => firstRun.onFinish('late')); - - expect(Stop).toHaveBeenCalledTimes(1); - act(() => runtime.props!.onFinish('second')); - expect(Stop).toHaveBeenLastCalledWith({ - data: 'second', - outcome: 'complete', - }); + 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 78a20dfa..04999bd6 100644 --- a/src/renderer/components/ExperimentRuntime.tsx +++ b/src/renderer/components/ExperimentRuntime.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EXPERIMENTS } from '../constants/constants'; import { ExperimentObject, @@ -58,9 +58,9 @@ const resolveImport = async ( }; /** - * Picks the runtime for a study. While an imported study is loading or failed, - * no inner runtime exists, so the dispatcher itself reports `onAbort('')` on - * teardown. + * 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, @@ -89,17 +89,6 @@ export const ExperimentRuntime: React.FC<Props> = ({ }; }, [imported, runtime.title]); - const waitingRef = useRef(false); - waitingRef.current = Boolean(imported) && !resolved; - const onAbortRef = useRef(runtime.onAbort); - onAbortRef.current = runtime.onAbort; - useEffect( - () => () => { - if (waitingRef.current) onAbortRef.current?.(''); - }, - [] - ); - if (imported) { if (error) { return ( diff --git a/src/renderer/components/ImportedExperimentWindow.tsx b/src/renderer/components/ImportedExperimentWindow.tsx index fbc647c1..44b3d338 100644 --- a/src/renderer/components/ImportedExperimentWindow.tsx +++ b/src/renderer/components/ImportedExperimentWindow.tsx @@ -62,7 +62,7 @@ export const ImportedExperimentWindow: React.FC< return host.teardown; } catch (failure) { setError((failure as Error).message); - return () => onAbort?.(''); + return undefined; } }, [ eventCallback, diff --git a/src/renderer/components/__tests__/ExperimentRuntime.test.tsx b/src/renderer/components/__tests__/ExperimentRuntime.test.tsx index 946782c3..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(() => @@ -109,43 +114,4 @@ describe('ExperimentRuntime', () => { expect(screen.getByRole('alert')).toHaveTextContent('ENOENT: gone') ); }); - - it('reports an abort itself when torn down before an imported study loads', () => { - readImportedExperimentFile.mockReturnValue(new Promise(() => undefined)); - const onAbort = vi.fn(); - const { unmount } = render( - <ExperimentRuntime - {...baseProps} - onAbort={onAbort} - type={EXPERIMENTS.IMPORTED} - experimentObject={{} as never} - params={importedParams()} - /> - ); - - unmount(); - - expect(onAbort).toHaveBeenCalledWith(''); - }); - - it('leaves abort reporting to the inner runtime once it is mounted', async () => { - readImportedExperimentFile.mockResolvedValue( - 'const jsPsych = initJsPsych({});' - ); - const onAbort = vi.fn(); - const { unmount } = render( - <ExperimentRuntime - {...baseProps} - onAbort={onAbort} - type={EXPERIMENTS.IMPORTED} - experimentObject={{} as never} - params={importedParams()} - /> - ); - await screen.findByTestId('jspsych'); - - unmount(); - - expect(onAbort).not.toHaveBeenCalled(); - }); }); diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index d5e6c0cb..76d0616a 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -1,10 +1,8 @@ import React, { createContext, ReactNode, - useCallback, useEffect, useMemo, - useRef, useState, } from 'react'; import { useSelector, useDispatch } from 'react-redux'; @@ -22,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'; @@ -30,16 +29,6 @@ export const RunProgressContext = createContext< (progress: ExperimentProgress | null) => void >(() => undefined); -/** Lets the running Collect screen receive the RunBar's "End experiment early". */ -export const EndRunContext = createContext<(end: (() => void) | null) => void>( - () => undefined -); - -/** Lets the running Collect screen show the RunBar's hold-Escape status. */ -export const EscapeHeldContext = createContext<(held: boolean) => void>( - () => undefined -); - export interface HeadsetSetupApi { /** Opens pairing at "Which headset?" (or Connected); never starts a search. */ openHeadsetSetup(): void; @@ -76,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 @@ -112,11 +102,6 @@ export default function AppShellContainer({ ); const [elapsed, setElapsed] = useState('00:00'); const [progress, setProgress] = useState<ExperimentProgress | null>(null); - const [escapeHeld, setEscapeHeld] = useState(false); - const endRun = useRef<(() => void) | null>(null); - const registerEndRun = useCallback((end: (() => void) | null) => { - endRun.current = end; - }, []); useEffect(() => { setProgress(null); if (!isRunning) { @@ -152,29 +137,23 @@ 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={() => - endRun.current - ? endRun.current() - : dispatch( - ExperimentActions.Stop({ data: '', outcome: 'incomplete' }) - ) - } - escapeHeld={escapeHeld} + onEndRun={() => dispatch(ExperimentActions.EndRun())} + escapeHeld={experiment.escapeHeld} onDeviceClick={headsetSetup.openHeadsetSetup} > <RunProgressContext.Provider value={setProgress}> - <EndRunContext.Provider value={registerEndRun}> - <EscapeHeldContext.Provider value={setEscapeHeld}> - <HeadsetSetupContext.Provider value={headsetSetup}> - {children} - </HeadsetSetupContext.Provider> - </EscapeHeldContext.Provider> - </EndRunContext.Provider> + <HeadsetSetupContext.Provider value={headsetSetup}> + {children} + </HeadsetSetupContext.Provider> </RunProgressContext.Provider> <HeadsetSetupDialog open={setupOpen} 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 1f50ea92..24e933e1 100644 --- a/src/renderer/epics/__tests__/experimentEpics.test.ts +++ b/src/renderer/epics/__tests__/experimentEpics.test.ts @@ -64,6 +64,9 @@ const experiment = { isRunning: false, isEEGEnabled: true, dateModified: null, + isEnding: false, + escapeHeld: false, + runOutcome: null, }; const rootState = (title: string): RootState => ({ @@ -216,3 +219,73 @@ describe('experiment stop', () => { ); }); }); + +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 4d0daf88..a25f944a 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -1,10 +1,12 @@ 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, @@ -174,6 +176,66 @@ const experimentStopEpic: Epic< }) ); +/** 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 const autoSaveEpic: Epic<any, ExperimentActionType, RootState> = ( action$ // RouterActions union requires any here @@ -236,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/utils/jspsych/host.ts b/src/renderer/utils/jspsych/host.ts index 6ce137b8..a11a4d5c 100644 --- a/src/renderer/utils/jspsych/host.ts +++ b/src/renderer/utils/jspsych/host.ts @@ -173,6 +173,8 @@ export const createJsPsychHost = ( 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; @@ -189,7 +191,7 @@ export const createJsPsychHost = ( * 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 = (report = true) => { + const teardown = () => { if (!finished) { finished = true; const trials = instance?.data?.get().values() ?? []; @@ -198,12 +200,11 @@ export const createJsPsychHost = ( } catch { // An instance whose timeline never started has nothing to abort. } - if (report) - config.onAbort?.( - trials.length - ? toBehavioralCsv(normalizeJsPsychTrials(trials, config.mapping)) - : '' - ); + onAbort?.( + trials.length + ? toBehavioralCsv(normalizeJsPsychTrials(trials, config.mapping)) + : '' + ); } instance = undefined; for (const [key, value] of replaced) { @@ -242,7 +243,8 @@ export const createJsPsychHost = ( // eslint-disable-next-line no-new-func new Function(source)(); } catch (error) { - teardown(false); + onAbort = undefined; + teardown(); throw new Error( `createJsPsychHost: the imported experiment threw while loading — ${ (error as Error).message @@ -250,5 +252,5 @@ export const createJsPsychHost = ( ); } - return { teardown: () => teardown() }; + return { teardown }; };