From 328edce753870c63d194eda7a56b16e84cdad023 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:00:43 -0400 Subject: [PATCH 01/12] feat(main): list and trash ended-early recordings over IPC --- src/main/__tests__/recordings.test.ts | 39 ++++++++++++++++++++++++ src/main/index.ts | 33 ++++++++++++++++++++ src/main/recordings.ts | 29 ++++++++++++++++++ src/preload/index.ts | 6 ++++ src/renderer/types/electron.d.ts | 4 +++ src/renderer/utils/filesystem/storage.ts | 12 ++++++++ 6 files changed, 123 insertions(+) diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts index 91476bc5..8b731253 100644 --- a/src/main/__tests__/recordings.test.ts +++ b/src/main/__tests__/recordings.test.ts @@ -3,7 +3,9 @@ import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + incompleteRecordingFiles, isBehaviorFile, + isIncompleteRawEEGFile, isRawEEGFile, markRecordingIncomplete, recordingExists, @@ -55,4 +57,41 @@ describe('recordings', () => { expect(csvFiles().filter(isBehaviorFile)).toEqual([]); expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true); }); + + it('an ended-early run is listed as incomplete and deletes with its behavior sibling', () => { + write('Data/P1/Behavior/P1-A-1-behavior.csv'); + write('Data/P1/EEG/P1-A-1-raw.csv'); + + markRecordingIncomplete(dir, 'P1', 'A', 1); + + const eeg = path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv'); + expect(csvFiles().filter(isIncompleteRawEEGFile)).toEqual([ + path.join('Data/P1/EEG/P1-A-1-raw.incomplete.csv'), + ]); + expect(incompleteRecordingFiles(dir, eeg)).toEqual([ + eeg, + path.join(dir, 'Data/P1/Behavior/P1-A-1-behavior.incomplete.csv'), + ]); + }); + + it('refuses to delete anything but an ended-early EEG file inside Data', () => { + write('Data/P1/EEG/P1-A-1-raw.csv'); + write('Other/P1-A-1-raw.incomplete.csv'); + + expect(() => + incompleteRecordingFiles(dir, path.join(dir, 'Data/P1/EEG/P1-A-1-raw.csv')) + ).toThrow(); + expect(() => + incompleteRecordingFiles( + dir, + path.join(dir, 'Other/P1-A-1-raw.incomplete.csv') + ) + ).toThrow(); + expect(() => + incompleteRecordingFiles( + dir, + `${dir}/Data/../Other/P1-A-1-raw.incomplete.csv` + ) + ).toThrow(); + }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 938413be..868ecf77 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -45,7 +45,9 @@ import type { } from '../shared/lslTypes'; import { importExperimentFile } from './importExperimentFile'; import { + incompleteRecordingFiles, isBehaviorFile, + isIncompleteRawEEGFile, isRawEEGFile, markRecordingIncomplete, recordingExists, @@ -250,6 +252,24 @@ ipcMain.handle('fs:readWorkspaceRawEEGData', (_event, title) => { } }); +/** Ended-early raw EEG runs, which `fs:readWorkspaceRawEEGData` leaves out. */ +ipcMain.handle('fs:readWorkspaceIncompleteEEGData', (_event, title) => { + try { + const files = fs.readdirSync(getWorkspaceDir(title), { + recursive: true, + }) as string[]; + return files + .filter(isIncompleteRawEEGFile) + .map((filepath) => { + const fullPath = path.join(getWorkspaceDir(title), filepath); + return { name: path.basename(filepath), path: fullPath }; + }); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') console.log(e); + return []; + } +}); + ipcMain.handle('fs:readWorkspaceCleanedEEGData', (_event, title) => { try { const files = fs.readdirSync(getWorkspaceDir(title), { @@ -359,6 +379,19 @@ ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) => shell.trashItem(path.join(workspaces, title)) ); +/** Moves one ended-early run (EEG file and behavior sibling) to the Trash. */ +ipcMain.handle( + 'fs:deleteIncompleteRecording', + async (_event, title: string, eegPath: string) => { + for (const file of incompleteRecordingFiles( + getWorkspaceDir(title), + eegPath + )) { + await shell.trashItem(file); + } + } +); + ipcMain.handle( 'fs:importExperimentFile', (_event, title: string, sourcePath: string) => diff --git a/src/main/recordings.ts b/src/main/recordings.ts index 341d564f..ed085d65 100644 --- a/src/main/recordings.ts +++ b/src/main/recordings.ts @@ -26,6 +26,35 @@ 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'); +/** Ended-early raw EEG recordings, which Clean lists only so they can be deleted. */ +export const isIncompleteRawEEGFile = (file: string) => + file.endsWith(`-raw${INCOMPLETE}`); + +/** + * Files `fs:deleteIncompleteRecording` trashes for one ended-early run: the EEG + * file plus its `Behavior/-behavior.incomplete.csv` sibling when present. + * Throws for anything that is not an ended-early raw EEG file inside + * `/Data/`, since `eegPath` comes from the renderer. + */ +export const incompleteRecordingFiles = ( + workspaceDir: string, + eegPath: string +) => { + const eeg = path.resolve(eegPath); + if ( + !eeg.startsWith(path.resolve(workspaceDir, 'Data') + path.sep) || + !isIncompleteRawEEGFile(eeg) + ) { + throw new Error(`Not an ended-early recording in this workspace: ${eegPath}`); + } + const behavior = path.join( + path.dirname(path.dirname(eeg)), + 'Behavior', + path.basename(eeg).replace(`-raw${INCOMPLETE}`, `-behavior${INCOMPLETE}`) + ); + return fs.existsSync(behavior) ? [eeg, behavior] : [eeg]; +}; + /** True when any artifact of this session exists, complete or ended early. */ export const recordingExists = ( workspaceDir: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index f7c2d325..15cec7a9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readWorkspaceRawEEGData: (title: string) => ipcRenderer.invoke('fs:readWorkspaceRawEEGData', title), + readWorkspaceIncompleteEEGData: (title: string) => + ipcRenderer.invoke('fs:readWorkspaceIncompleteEEGData', title), + readWorkspaceCleanedEEGData: (title: string) => ipcRenderer.invoke('fs:readWorkspaceCleanedEEGData', title), @@ -118,6 +121,9 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteWorkspaceDir: (title: string): Promise => ipcRenderer.invoke('fs:deleteWorkspaceDir', title), + deleteIncompleteRecording: (title: string, eegPath: string): Promise => + ipcRenderer.invoke('fs:deleteIncompleteRecording', title, eegPath), + importExperimentFile: ( title: string, sourcePath: string diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index fa70c948..002b92c9 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -32,6 +32,9 @@ declare global { readWorkspaceRawEEGData: ( title: string ) => Promise>; + readWorkspaceIncompleteEEGData: ( + title: string + ) => Promise>; readWorkspaceCleanedEEGData: ( title: string ) => Promise>; @@ -61,6 +64,7 @@ declare global { rawData: ArrayBuffer ) => Promise; deleteWorkspaceDir: (title: string) => Promise; + deleteIncompleteRecording: (title: string, eegPath: string) => Promise; importExperimentFile: ( title: string, sourcePath: string diff --git a/src/renderer/utils/filesystem/storage.ts b/src/renderer/utils/filesystem/storage.ts index 4281f03c..1a71a500 100644 --- a/src/renderer/utils/filesystem/storage.ts +++ b/src/renderer/utils/filesystem/storage.ts @@ -83,6 +83,12 @@ export const readWorkspaces = (): Promise => api().readWorkspaces(); export const readWorkspaceRawEEGData = (title: string) => api().readWorkspaceRawEEGData(title); +/** Ended-early runs (`*-raw.incomplete.csv`), which the raw listing leaves out. */ +export const readWorkspaceIncompleteEEGData = ( + title: string +): Promise> => + api().readWorkspaceIncompleteEEGData(title); + export const readWorkspaceCleanedEEGData = (title: string) => api().readWorkspaceCleanedEEGData(title); @@ -121,6 +127,12 @@ export const getSubjectNamesFromFiles = (filePaths: string[]): string[] => export const deleteWorkspaceDir = (title: string): Promise => api().deleteWorkspaceDir(title); +/** Moves one ended-early run and its behavior sibling to the Trash. */ +export const deleteIncompleteRecording = ( + title: string, + eegPath: string +): Promise => api().deleteIncompleteRecording(title, eegPath); + /** First session number, starting at `session`, with no behavior or EEG file on disk. */ export const nextFreeSession = async ( title: string, From 1ae05a2a4ecd2068eb493baeebb878b70194ac56 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:01:29 -0400 Subject: [PATCH 02/12] fix(analysis): apply_rejection replaces bad channels so un-flagging is saved --- src/renderer/utils/webworker/utils.py | 6 +++--- tests/analysis/test_apply_rejection.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 17fb59f6..884b7ae0 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -339,14 +339,14 @@ def apply_rejection(epochs, drop_indices, bad_channels): drop_indices : list[int] -- 0-based indices into the CURRENT epochs (same order as get_epochs_arrays produced), the epochs the user marked bad. - bad_channels : list[str] -- channel names to add to info['bads']. + bad_channels : list[str] -- the full set of bad channel names; replaces + info['bads'], so an empty list clears sensors un-flagged since the last save. The result is exactly what MNE produces from epochs.drop(...) / info['bads'] — the science is unchanged; only the UI that chooses the indices is new. Returns epochs (the same, mutated object). """ - if bad_channels: - epochs.info['bads'] = list(bad_channels) + epochs.info['bads'] = list(bad_channels) if drop_indices: epochs.drop(list(drop_indices)) return epochs diff --git a/tests/analysis/test_apply_rejection.py b/tests/analysis/test_apply_rejection.py index 6d296f06..f36719b3 100644 --- a/tests/analysis/test_apply_rejection.py +++ b/tests/analysis/test_apply_rejection.py @@ -65,6 +65,16 @@ def test_empty_args_are_no_ops(): assert result.info["bads"] == [] +def test_bad_channels_replace_existing_bads(): + epochs = _build_epochs() + epochs.info["bads"] = ["TP9"] + + assert utils.apply_rejection(epochs, [], []).info["bads"] == [] + + epochs.info["bads"] = ["TP9"] + assert utils.apply_rejection(epochs, [], ["AF7"]).info["bads"] == ["AF7"] + + def test_returns_the_same_object(): e = _build_epochs().copy() assert utils.apply_rejection(e, [], []) is e From 86b6664bc67e38666081e7b699523156b49ffb05 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:02:05 -0400 Subject: [PATCH 03/12] refactor(pyodide): LoadEpochs loads one recording and clears stale arrays --- src/renderer/actions/pyodideActions.ts | 3 ++- src/renderer/components/CleanComponent/index.tsx | 2 +- src/renderer/epics/pyodideEpics.ts | 5 ++--- src/renderer/reducers/pyodideReducer.ts | 5 +++++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts index 4b194e64..2d6ad57b 100644 --- a/src/renderer/actions/pyodideActions.ts +++ b/src/renderer/actions/pyodideActions.ts @@ -31,7 +31,8 @@ export const PyodideActions = { SetPyodideWorker: createAction( 'SET_PYODIDE_WORKER' ), - LoadEpochs: createAction('LOAD_EPOCHS'), + /** One raw recording path: a flagged sensor belongs to one recording. */ + LoadEpochs: createAction('LOAD_EPOCHS'), LoadCleanedEpochs: createAction( 'LOAD_CLEANED_EPOCHS' ), diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 5a95dec0..9e7466dd 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -189,7 +189,7 @@ export default function Clean(props: Props) { function handleLoadData() { props.ExperimentActions.SetSubject(selectedSubject); - props.PyodideActions.LoadEpochs(selectedFilePaths); + props.PyodideActions.LoadEpochs(selectedFilePaths[0]); setView('review'); setRejectedEpochs(new Set()); setBadChannels(new Set()); diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 8246ce56..82468f90 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -178,12 +178,11 @@ const loadEpochsEpic: Epic = ( action$.pipe( filter(isActionOf(PyodideActions.LoadEpochs)), pluck('payload'), - filter((filePathsArray: string[]) => filePathsArray.length >= 1), - mergeMap(async (filePathsArray) => { + mergeMap(async (filePath) => { const worker = state$.value.pyodide.worker!; // readFiles is async — await before posting csvArray to the worker. // (An unresolved Promise into postMessage throws DataCloneError.) - const csvArray = await readFiles(filePathsArray); + const csvArray = await readFiles([filePath]); // Queue processing messages in order; the worker runs them sequentially. loadCSV(worker, csvArray); filterIIR(worker, 1, 30); diff --git a/src/renderer/reducers/pyodideReducer.ts b/src/renderer/reducers/pyodideReducer.ts index bf160c63..3780b0e3 100644 --- a/src/renderer/reducers/pyodideReducer.ts +++ b/src/renderer/reducers/pyodideReducer.ts @@ -92,6 +92,11 @@ export default createReducer(initialState, (builder) => erpPlot: action.payload, }; }) + .addCase(PyodideActions.LoadEpochs, (state) => ({ + ...state, + epochArrays: null, + suggestedRejections: [], + })) .addCase(PyodideActions.SetEpochArrays, (state, action) => { // New epoch arrays → any prior auto-flag suggestions are stale. return { ...state, epochArrays: action.payload, suggestedRejections: [] }; From 875198794d28ec64fa16ccbaaacbae8ff6a1ce5c Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:02:37 -0400 Subject: [PATCH 04/12] feat(clean): panes say trials; keep FitPane with its ceiling noted --- src/renderer/components/Clean/CleanParts.tsx | 4 ++++ .../components/CleanComponent/EpochReviewer.tsx | 10 +++++----- src/renderer/components/CleanComponent/LiveErpPane.tsx | 4 ++-- .../CleanComponent/__tests__/EpochReviewer.test.tsx | 8 ++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/Clean/CleanParts.tsx b/src/renderer/components/Clean/CleanParts.tsx index 54952b18..4048a1c5 100644 --- a/src/renderer/components/Clean/CleanParts.tsx +++ b/src/renderer/components/Clean/CleanParts.tsx @@ -45,6 +45,10 @@ export function CleanLayout({ * its box — `zoom`, so layout and click targets scale together — so the real * panes use the space they are given instead of sitting at their hand-coded * 640px width. + * + * ponytail: canvases go soft below 1× zoom (the Live ERP sits near 0.6); make + * `EpochReviewer`/`LiveErpPane` draw at their box size (resize-aware canvases) + * and drop this when that matters. */ export function FitPane({ logicalWidth, diff --git a/src/renderer/components/CleanComponent/EpochReviewer.tsx b/src/renderer/components/CleanComponent/EpochReviewer.tsx index 4ff4eb71..9584f50b 100644 --- a/src/renderer/components/CleanComponent/EpochReviewer.tsx +++ b/src/renderer/components/CleanComponent/EpochReviewer.tsx @@ -231,7 +231,7 @@ export default function EpochReviewer({ if (!epochArrays || !meta || meta.n_epochs === 0) { return (
- Load a dataset to see your epochs here 🧠 + Load a dataset to see your trials here 🧠
); } @@ -245,7 +245,7 @@ export default function EpochReviewer({ return (
-

Epochs

+

Trials

diff --git a/src/renderer/components/CleanComponent/LiveErpPane.tsx b/src/renderer/components/CleanComponent/LiveErpPane.tsx index 4dea18dd..0a8f528f 100644 --- a/src/renderer/components/CleanComponent/LiveErpPane.tsx +++ b/src/renderer/components/CleanComponent/LiveErpPane.tsx @@ -17,7 +17,7 @@ import { meanTrace } from './epochArrays'; // │ 0ms │ // └──────────────────────────────────────────────┘ // ■ Condition 1 (42) ■ Condition 2 (39) -// Averaged over 81 epochs — reject noisy ones to clean it up +// Averaged over 81 trials // --------------------------------------------------------------------------- interface Props { @@ -267,7 +267,7 @@ export default function LiveErpPane({

- Averaged over {includedCount} epochs — reject noisy ones to clean it up + Averaged over {includedCount} trials

); diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx index e8c6aa5e..099b9b91 100644 --- a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -46,8 +46,8 @@ describe('EpochReviewer', () => { /> ); - // The overlay for epoch 1 is labelled "Reject epoch 1". - const target = screen.getByLabelText('Reject epoch 1'); + // The overlay for epoch 1 is labelled "Reject trial 1". + const target = screen.getByLabelText('Reject trial 1'); fireEvent.click(target); expect(onToggleEpoch).toHaveBeenCalledTimes(1); @@ -66,7 +66,7 @@ describe('EpochReviewer', () => { /> ); - expect(screen.getByLabelText('Restore epoch 0')).toBeInTheDocument(); + expect(screen.getByLabelText('Restore trial 0')).toBeInTheDocument(); }); it('keeps epoch click targets visually transparent', () => { @@ -80,7 +80,7 @@ describe('EpochReviewer', () => { /> ); - const overlay = screen.getByLabelText('Restore epoch 1'); + const overlay = screen.getByLabelText('Restore trial 1'); expect(overlay.className).toMatch(/bg-transparent/); }); }); From 7ace012e405c55ede734be60a02d5ecc29558743 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:06:09 -0400 Subject: [PATCH 05/12] feat(clean): wire the approved Clean design into the screen --- .../components/Clean/CleanDatasetSelect.tsx | 5 +- src/renderer/components/Clean/CleanPrimer.tsx | 2 +- src/renderer/components/Clean/fixtures.ts | 7 - .../components/CleanComponent/index.tsx | 674 ++++++------------ 4 files changed, 202 insertions(+), 486 deletions(-) diff --git a/src/renderer/components/Clean/CleanDatasetSelect.tsx b/src/renderer/components/Clean/CleanDatasetSelect.tsx index dac050ce..c57ec162 100644 --- a/src/renderer/components/Clean/CleanDatasetSelect.tsx +++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx @@ -127,9 +127,6 @@ export default function CleanDatasetSelect({ /> {recording.subject} {recording.name} - - {recording.duration} - ); @@ -184,7 +181,7 @@ export default function CleanDatasetSelect({ = { 1: { title: 'Leave out noisy trials', - body: 'Every column in the Epochs panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', + body: 'Every column in the Trials panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', pointer: 'Click a noisy trial column to leave it out', }, 2: { diff --git a/src/renderer/components/Clean/fixtures.ts b/src/renderer/components/Clean/fixtures.ts index 01f67ed8..a7f4aa77 100644 --- a/src/renderer/components/Clean/fixtures.ts +++ b/src/renderer/components/Clean/fixtures.ts @@ -14,8 +14,6 @@ export interface RawRecording { subject: string; /** File name shown in the list. */ name: string; - /** How long the run lasted. */ - duration: string; /** Ended-early runs are renamed `*.incomplete.csv` and hidden from ordinary selection. */ incomplete: boolean; } @@ -30,35 +28,30 @@ export const RAW_RECORDINGS: RawRecording[] = [ key: 'P01-A-1', subject: 'P01', name: 'P01-A-1-raw.csv', - duration: '4 min · 84 trials', incomplete: false, }, { key: 'P01-A-2', subject: 'P01', name: 'P01-A-2-raw.csv', - duration: '4 min · 84 trials', incomplete: false, }, { key: 'P02-A-1', subject: 'P02', name: 'P02-A-1-raw.csv', - duration: '3 min · 62 trials', incomplete: false, }, { key: 'P02-A-2', subject: 'P02', name: 'P02-A-2-raw.incomplete.csv', - duration: '2 min · experiment ended early', incomplete: true, }, { key: 'P03-A-1', subject: 'P03', name: 'P03-A-1-raw.incomplete.csv', - duration: '30 s · experiment ended early', incomplete: true, }, ]; diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 9e7466dd..3029acac 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -1,18 +1,20 @@ import React, { useEffect, useRef, useState } from 'react'; import path from 'pathe'; -import { isNil, isString, memoize } from 'lodash'; -import { Button } from '../ui/button'; -import { - EXPERIMENTS, - DEVICES, - PTP_THRESHOLD, - SCREENS, -} from '../../constants/constants'; +import { memoize } from 'lodash'; +import { toast } from 'react-toastify'; +import { PTP_THRESHOLD, SCREENS } from '../../constants/constants'; import { ExperimentParameters } from '../../constants/interfaces'; import { resolveMarkerRegistry } from '../../utils/eeg/markerRegistry'; -import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage'; -import EpochReviewer from './EpochReviewer'; -import LiveErpPane from './LiveErpPane'; +import { + deleteIncompleteRecording, + readWorkspaceIncompleteEEGData, + readWorkspaceRawEEGData, +} from '../../utils/filesystem/storage'; +import { AREA_ROUTES } from '../AppShell/areas'; +import CleanDatasetSelect from '../Clean/CleanDatasetSelect'; +import CleanReview, { CleanConfirm, SaveState } from '../Clean/CleanReview'; +import { PrimerStep } from '../Clean/CleanPrimer'; +import type { RawRecording } from '../Clean/fixtures'; import { PyodideActions, ExperimentActions, @@ -27,527 +29,251 @@ const codeToLabelFor = memoize( ); export interface Props { - type?: EXPERIMENTS; title: string; - deviceType: DEVICES; - epochsInfo: Array<{ - [key: string]: number | string; - }>; epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; PyodideActions: typeof PyodideActions; ExperimentActions: typeof ExperimentActions; - subject: string; - session: number; params: ExperimentParameters | null; suggestedRejections: SuggestedRejection[]; cleanedEpochsSave: { revision: number; ok: boolean }; navigate: (route: string) => void; } -interface DropdownOption { - key: string; - text: string; - value: string; -} - -const CLEAN_STEPS = [ - 'Click a noisy trial (epoch) column to leave it out — click again to bring it back.', - 'Click a sensor name if that one sensor looks bad the whole time.', - 'Auto-flag suggests noisy trials for you. They’re only suggestions, so check them.', - 'Watch the Live ERP update as you leave trials out.', - 'Save the cleaned dataset. Analyze uses it to make your results.', -]; - -/** Plain-language "what is cleaning?" primer; always available, collapsible. */ -function CleanExplainer({ defaultOpen }: { defaultOpen: boolean }) { - return ( -
- - What does cleaning your data mean? 🧹 - -
- Blinks, jaw clenches and loose sensors add big spikes that have nothing - to do with your experiment. Cleaning means finding the trials or sensors - with movement or poor signal and leaving them out before the responses - are averaged. Your original recording stays unchanged. -
-
    - {CLEAN_STEPS.map((step, i) => ( -
  1. - {i + 1}. {step} -
  2. - ))} -
-
- ); -} - +/** + * The Clean screen: pick one complete raw recording (`CleanDatasetSelect`), + * then leave out trials, flag sensors, accept or restore auto-flag suggestions + * and save a cleaned copy (`CleanReview`). Owns exclusion, confirmation and + * save state; loading, cleaning and the disk write go through `PyodideActions`. + */ export default function Clean(props: Props) { + const { epochArrays, cleanedEpochsSave, navigate } = props; const [view, setView] = useState<'select' | 'review'>('select'); - const [subjects, setSubjects] = useState>([]); - const [eegFilePaths, setEegFilePaths] = useState>([ - { key: '', text: '', value: '' }, - ]); - const [selectedSubject, setSelectedSubject] = useState(props.subject); - const [selectedFilePaths, setSelectedFilePaths] = useState>([]); - const [rejectedEpochs, setRejectedEpochs] = useState>(new Set()); + const [recordings, setRecordings] = useState([]); + /** Bumped to list the workspace's recordings again (after a delete). */ + const [listRevision, setListRevision] = useState(0); + const [selected, setSelected] = useState(null); + const [showIncomplete, setShowIncomplete] = useState(false); + const [deletingRecording, setDeletingRecording] = + useState(null); + /** ABSOLUTE indices into `rejectedFor`, including accepted suggestions. */ + const [rejected, setRejected] = useState>(new Set()); + /** The arrays `rejected` indexes; arrays are re-fetched after every load and save, which invalidates the indices. */ + const [rejectedFor, setRejectedFor] = useState(epochArrays); + /** Channel names stay valid across saves, so the flags persist and each save sends the full set. */ const [badChannels, setBadChannels] = useState>(new Set()); const [autoFlagThreshold, setAutoFlagThreshold] = useState( PTP_THRESHOLD.default ); - const [showAutoFlagSettings, setShowAutoFlagSettings] = useState(false); - const [isWaitingForCleanedSave, setIsWaitingForCleanedSave] = useState(false); - const [icons] = useState(() => - props.type === EXPERIMENTS.N170 - ? ['😊', '🏠', '✕', '📖'] - : ['★', '☆', '✕', '📖'] - ); - - // Track previous revision so we can detect changes in cleanedEpochsSave. - const prevRevisionRef = useRef(props.cleanedEpochsSave.revision); + const [saveState, setSaveState] = useState('idle'); + /** Where a settled save goes: stay on Clean, or on to Analyze. */ + const destination = useRef<'clean' | 'analyze'>('clean'); + const [confirm, setConfirm] = useState(null); + const [primerOpen, setPrimerOpen] = useState(true); + const [primerStep, setPrimerStep] = useState(1); + const prevRevision = useRef(cleanedEpochsSave.revision); + + if (rejectedFor !== epochArrays) { + setRejectedFor(epochArrays); + setRejected(new Set()); + } useEffect(() => { let cancelled = false; (async () => { - const workspaceRawData = await readWorkspaceRawEEGData(props.title); + const [raw, incomplete] = await Promise.all([ + readWorkspaceRawEEGData(props.title), + readWorkspaceIncompleteEEGData(props.title), + ]); if (cancelled) return; - setSubjects( - workspaceRawData - .map( - (filepath) => - filepath.path.split(path.sep)[ - filepath.path.split(path.sep).length - 3 - ] - ) - .reduce((acc, curr) => { - if (acc.find((subject) => subject.key === curr)) { - return acc; - } - return acc.concat({ key: curr, text: curr, value: curr }); - }, [] as DropdownOption[]) - ); - setEegFilePaths( - workspaceRawData.map((filepath) => ({ - key: filepath.name, - text: filepath.name, - value: filepath.path, - })) + const listed: Array<{ + file: { name: string; path: string }; + incomplete: boolean; + }> = [ + ...raw.map((file) => ({ file, incomplete: false })), + ...incomplete.map((file) => ({ file, incomplete: true })), + ]; + setRecordings( + listed.map(({ file, incomplete: isIncomplete }) => { + const segments = file.path.split(path.sep); + return { + key: file.path, + name: file.name, + subject: segments[segments.length - 3], + incomplete: isIncomplete, + }; + }) ); })(); return () => { cancelled = true; }; - }, [props.title]); + }, [props.title, listRevision]); useEffect(() => { - if (props.suggestedRejections.length > 0) { - setRejectedEpochs((prev) => { - const next = new Set(prev); - for (const s of props.suggestedRejections) next.add(s.index); - return next; - }); + if (cleanedEpochsSave.revision === prevRevision.current) return; + prevRevision.current = cleanedEpochsSave.revision; + if (!cleanedEpochsSave.ok) { + setSaveState('failed'); + } else if (destination.current === 'analyze') { + navigate(SCREENS.ANALYZE.route); + } else { + setSaveState('saved'); } - }, [props.suggestedRejections]); + }, [cleanedEpochsSave, navigate]); - // componentDidUpdate equivalent: watch cleanedEpochsSave.revision changes. - const { cleanedEpochsSave, navigate } = props; - useEffect(() => { - if ( - isWaitingForCleanedSave && - cleanedEpochsSave.revision !== prevRevisionRef.current - ) { - setIsWaitingForCleanedSave(false); - // On failure the epic has already surfaced a toast; stay put so the - // student doesn't land on an Analyze screen missing their dataset. - if (cleanedEpochsSave.ok) { - navigate(SCREENS.ANALYZE.route); - } - } - prevRevisionRef.current = cleanedEpochsSave.revision; - }, [ - isWaitingForCleanedSave, - cleanedEpochsSave.revision, - cleanedEpochsSave.ok, - navigate, - ]); + const chosen = recordings.find((r) => r.key === selected) ?? null; + const total = epochArrays?.meta.n_epochs ?? 0; + const allRejected = total > 0 && rejected.size >= total; - function handleRecordingChange(e: React.ChangeEvent) { - const filePaths = Array.from(e.target.selectedOptions, (o) => o.value); - setSelectedFilePaths(filePaths); + /** Any exclusion edit makes a finished save stale and retires the primer. */ + function edited() { + setSaveState((state) => (state === 'saving' ? state : 'idle')); + setPrimerOpen(false); } - function handleSubjectChange(e: React.ChangeEvent) { - const { value } = e.target; - if (!isNil(value) && isString(value)) { - setSelectedSubject(value); - setSelectedFilePaths([]); - } + /** Cleans the worker's current epochs, then saves them; `dropIndices` index those epochs. */ + function save(dropIndices: number[]) { + props.PyodideActions.CleanEpochs({ + dropIndices, + badChannels: [...badChannels], + }); + setSaveState('saving'); } - function handleLoadData() { - props.ExperimentActions.SetSubject(selectedSubject); - props.PyodideActions.LoadEpochs(selectedFilePaths[0]); - setView('review'); - setRejectedEpochs(new Set()); + function handleStart() { + if (!chosen) return; + props.ExperimentActions.SetSubject(chosen.subject); + props.PyodideActions.LoadEpochs(chosen.key); setBadChannels(new Set()); + setSaveState('idle'); + setView('review'); } - function handleToggleEpoch(index: number) { - setRejectedEpochs((prev) => { - const next = new Set(prev); - if (next.has(index)) { - next.delete(index); - } else { - next.add(index); - } - return next; - }); + async function handleDeleteConfirm() { + if (!deletingRecording) return; + const { key, name } = deletingRecording; + setDeletingRecording(null); + try { + await deleteIncompleteRecording(props.title, key); + } catch (e) { + toast.error(`Couldn't delete ${name}: ${(e as Error).message}`); + } + setListRevision((n) => n + 1); } function handleToggleChannel(name: string) { + edited(); const next = new Set(badChannels); - const adding = !next.has(name); - if (adding) { - next.add(name); - } else { - next.delete(name); - } + const adding = !next.delete(name); + if (adding) next.add(name); setBadChannels(next); - - // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal — - // informational only; they can still proceed. - if (adding && next.size > 1 && props.epochArrays?.meta.n_channels === 4) { - window.electronAPI.showMessageBox({ - buttons: ['Got it'], - message: - "You've marked more than one bad channel on a 4-channel recording. " + - 'That removes a big chunk of your data — if the signal is really this ' + - 'noisy, consider collecting another dataset.', - }); + if (adding && next.size > 1 && epochArrays?.meta.n_channels === 4) { + setConfirm('dropChannels'); } } - function handleAutoFlag() { - props.PyodideActions.GetSuggestedRejections(autoFlagThreshold); - } - - /** - * Applies the pending selection. Returns false when the student backed out, - * so callers know nothing was dispatched. - * - * `destination` only affects the wording of the one confirmation dialog — - * "Apply exclusions & save" has always applied a partial selection without - * asking, while "Save cleaned dataset & analyze" confirms because it also - * leaves the screen. - */ - async function handleCleanData( - destination: 'clean' | 'analyze' = 'clean' - ): Promise { - const total = props.epochArrays?.meta.n_epochs ?? 0; - const nDropped = rejectedEpochs.size; - const nBadChannels = badChannels.size; - - // Exactly one prompt, whatever the trigger. Rejecting every epoch produces - // an empty dataset that can't be analyzed (and previously wrote a - // degenerate .fif with no error), so that warning always wins. - let confirmation: { confirmLabel: string; message: string } | null = null; - if (total > 0 && nDropped >= total) { - confirmation = { - confirmLabel: 'Reject all anyway', - message: `This will reject all ${total} epochs, leaving nothing to analyze. Are you sure?`, - }; - } else if (destination === 'analyze' && nDropped > 0) { - confirmation = { - confirmLabel: 'Remove selected and analyze', - message: `This will remove ${nDropped} selected epoch${nDropped === 1 ? '' : 's'} before analysis. Continue?`, - }; - } else if (destination === 'analyze' && nBadChannels > 0) { - confirmation = { - confirmLabel: 'Apply and analyze', - message: - 'This will apply flagged bad channels before analysis. Continue?', - }; - } - - if (confirmation) { - const response = await window.electronAPI.showMessageBox({ - buttons: ['Cancel', confirmation.confirmLabel], - message: confirmation.message, - }); - if (response.response !== 1) { - return false; - } - } - - props.PyodideActions.CleanEpochs({ - dropIndices: [...rejectedEpochs], - badChannels: [...badChannels], - }); - // After Clean, raw_epochs is re-fetched with fewer epochs, so the old - // absolute indices no longer apply. - setRejectedEpochs(new Set()); - setBadChannels(new Set()); - return true; - } - - async function handleAnalyzeClick() { - if (!(await handleCleanData('analyze'))) return; - // The cleaned .fif is written by the worker well after CleanEpochs is - // dispatched, and Analyze lists that directory on mount — navigating now - // would show a picker without the dataset that was just produced. - setIsWaitingForCleanedSave(true); - } - - function handleThresholdChange(e: React.ChangeEvent) { - const parsed = parseFloat(e.target.value); - if (!Number.isNaN(parsed)) { - setAutoFlagThreshold(parsed); - } - } - - function renderStats() { - const { epochsInfo, epochArrays } = props; - if (isNil(epochsInfo) || epochsInfo.length === 0) { - return null; + function handleApply() { + destination.current = 'clean'; + if (allRejected) { + setConfirm('rejectAll'); + } else { + save([...rejected]); } - // MNE's "Drop Percentage" only counts epochs already dropped, so it can't - // move while the student is still selecting. Report the pending selection - // alongside it rather than overwriting it with a number that resets to 0 - // after every clean. - const total = epochArrays?.meta.n_epochs ?? 0; - const nSelected = rejectedEpochs.size; - const selectedPercent = - total > 0 ? Math.round((nSelected / total) * 1000) / 10 : 0; - return ( -
- {epochsInfo.map((infoObj, index) => ( - - {icons[index]} - {String(infoObj.name)}:{' '} - {infoObj.value} - - ))} - {nSelected > 0 && ( - - 🚫 - Selected for rejection:{' '} - - {nSelected} ({selectedPercent}%) - - - )} -
- ); } - function renderAnalyzeButton() { - const { epochsInfo } = props; - if (isNil(epochsInfo) || epochsInfo.length === 0) { - return null; + function handleSave() { + destination.current = 'analyze'; + if (allRejected) { + setConfirm('rejectAll'); + } else if (rejected.size > 0) { + setConfirm('removeSelected'); + } else if (badChannels.size > 0) { + setConfirm('applyChannels'); + } else { + save([]); } - const hasSelection = rejectedEpochs.size + badChannels.size > 0; - const isSaving = isWaitingForCleanedSave; - return ( - - ); } - function renderSelect(filteredFilePaths: DropdownOption[]) { - return ( -
-

Clean your data

-

- Remove the noisy bits of a recording and save a cleaned copy. Analyze - uses that cleaned copy to make your results. -

- -

Select Subject

- -

Select Recordings

- - -
- ); + function handleConfirmAccept() { + setConfirm(null); + if (confirm !== 'dropChannels') save([...rejected]); } - function renderReview( - codeToLabel: Record, - suggestedRejections: SuggestedRejection[] - ) { - const hasEpochs = !isNil(props.epochArrays); - const nRecordings = selectedFilePaths.length; + if (view === 'select') { return ( - <> -
- -

Clean your data

- - {selectedSubject} · {nRecordings} recording - {nRecordings === 1 ? '' : 's'} - -
- - - -
- - - -
{renderAnalyzeButton()}
-
- - {showAutoFlagSettings && ( -
- -
- More flags - - Fewer flags -
-

- Flag epochs whose peak-to-peak amplitude exceeds{' '} - {autoFlagThreshold} µV. -

-
- )} - {suggestedRejections.length > 0 && ( -
-

- Flagged {suggestedRejections.length}{' '} - {suggestedRejections.length === 1 ? 'epoch' : 'epochs'} -

-
    - {suggestedRejections.slice(0, 3).map((s, i) => ( -
  • {s.reason}
  • - ))} -
-
- )} - -
{renderStats()}
- - {hasEpochs ? ( -
- - -
- ) : ( -
- Loading your epochs… 🧠 -
- )} - + void handleDeleteConfirm()} + onDeleteCancel={() => setDeletingRecording(null)} + onStart={handleStart} + /> ); } - const filteredFilePaths = eegFilePaths.filter((filepath) => { - const strVal = filepath.value; - const subjectFromFilepath = strVal.split(path.sep)[ - strVal.split(path.sep).length - 3 - ]; - return selectedSubject === subjectFromFilepath; - }); - - const codeToLabel = codeToLabelFor(props.params); - const { suggestedRejections } = props; - return ( -
- {view === 'select' - ? renderSelect(filteredFilePaths) - : renderReview(codeToLabel, suggestedRejections)} -
+ { + edited(); + setRejected((prev) => { + const next = new Set(prev); + if (!next.delete(index)) next.add(index); + return next; + }); + }} + onToggleChannel={handleToggleChannel} + autoFlagThreshold={autoFlagThreshold} + onThresholdChange={setAutoFlagThreshold} + suggestions={props.suggestedRejections.map((s) => ({ + ...s, + accepted: rejected.has(s.index), + }))} + onAcceptSuggestion={(index) => { + edited(); + setRejected((prev) => new Set(prev).add(index)); + }} + onRestoreSuggestion={(index) => { + edited(); + setRejected((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + }} + onSuggest={() => + props.PyodideActions.GetSuggestedRejections(autoFlagThreshold) + } + saveState={saveState} + onApply={handleApply} + onSave={handleSave} + onRetrySave={() => save([])} + onGoToAnalyze={() => navigate(SCREENS.ANALYZE.route)} + onGoToCollect={() => navigate(AREA_ROUTES.collect)} + onBackToSelection={() => setView('select')} + confirm={confirm} + onConfirmAccept={handleConfirmAccept} + onConfirmCancel={() => setConfirm(null)} + primerOpen={primerOpen} + primerStep={primerStep} + onPrimerOpenChange={setPrimerOpen} + onPrimerStepChange={setPrimerStep} + /> ); } From a90a4a272501cd9e23b117e3887ace5298935b63 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:06:30 -0400 Subject: [PATCH 06/12] feat(shell): refresh workflow badges when a cleaned save lands --- src/renderer/components/AppShell/WorkspaceAreaGate.tsx | 7 ++++++- src/renderer/components/AppShell/useWorkspaceProgress.ts | 9 ++++++--- src/renderer/containers/AppShellContainer.tsx | 6 +++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/AppShell/WorkspaceAreaGate.tsx b/src/renderer/components/AppShell/WorkspaceAreaGate.tsx index c082692b..d7cf0f83 100644 --- a/src/renderer/components/AppShell/WorkspaceAreaGate.tsx +++ b/src/renderer/components/AppShell/WorkspaceAreaGate.tsx @@ -35,7 +35,12 @@ export default function WorkspaceAreaGate({ area, children }: Props) { const navigate = useNavigate(); const modality = experiment.isEEGEnabled ? 'eeg' : 'behavior'; - const { counts } = useWorkspaceProgress(experiment.title, modality, pathname); + const { counts } = useWorkspaceProgress( + experiment.title, + modality, + pathname, + 0 + ); if (counts === null) return null; diff --git a/src/renderer/components/AppShell/useWorkspaceProgress.ts b/src/renderer/components/AppShell/useWorkspaceProgress.ts index 3cb8acc9..2d3a7bfa 100644 --- a/src/renderer/components/AppShell/useWorkspaceProgress.ts +++ b/src/renderer/components/AppShell/useWorkspaceProgress.ts @@ -47,12 +47,15 @@ export function summarize( /** * Reads the workspace folder and derives AppShell's `badges` / `nextArea`. - * Refreshes on navigation; an unreadable or missing folder counts as zero. + * Refreshes on navigation and whenever `refresh` changes (e.g. a cleaned save + * lands without leaving the screen); an unreadable or missing folder counts as + * zero. */ export function useWorkspaceProgress( title: string | undefined, modality: Modality, - pathname: string + pathname: string, + refresh: number ): WorkspaceProgress { const [progress, setProgress] = useState(EMPTY); @@ -83,7 +86,7 @@ export function useWorkspaceProgress( return () => { cancelled = true; }; - }, [title, modality, pathname]); + }, [title, modality, pathname, refresh]); return progress; } diff --git a/src/renderer/containers/AppShellContainer.tsx b/src/renderer/containers/AppShellContainer.tsx index 76d0616a..9cb4578b 100644 --- a/src/renderer/containers/AppShellContainer.tsx +++ b/src/renderer/containers/AppShellContainer.tsx @@ -66,6 +66,9 @@ export default function AppShellContainer({ const experiment = useSelector((state: RootState) => state.experiment); const device = useSelector((state: RootState) => state.device); const recordsEEG = useSelector(selectRecordsEEG); + const cleanedSaveRevision = useSelector( + (state: RootState) => state.pyodide.cleanedEpochsSave.revision + ); const modality: Modality = experiment.isEEGEnabled ? 'eeg' : 'behavior'; const workspace = experiment.title @@ -79,7 +82,8 @@ export default function AppShellContainer({ const { badges, next } = useWorkspaceProgress( workspace?.name, modality, - pathname + pathname, + cleanedSaveRevision ); const { isRunning } = experiment; From f458d16abaa700db302043fa9ce005d497e8cb98 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:07:27 -0400 Subject: [PATCH 07/12] test(clean): pin single-recording load, suggestion review and save flow --- .../__tests__/CleanRejections.test.tsx | 197 +++++++++++------- 1 file changed, 121 insertions(+), 76 deletions(-) diff --git a/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx b/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx index 00b5092d..5e7732f3 100644 --- a/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx +++ b/src/renderer/components/CleanComponent/__tests__/CleanRejections.test.tsx @@ -1,23 +1,22 @@ import React from 'react'; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { EXPERIMENTS, DEVICES } from '../../../constants/constants'; import type { SuggestedRejection } from '../../../actions'; import Clean, { Props as CleanProps } from '../index'; -// We mock the children to capture the `rejected` prop sent to EpochReviewer. -let mockRejected: Set = new Set(); +// The mocked reviewer captures the exclusion state Clean passes down and its +// toggle callbacks, so tests can click trials/sensors without a canvas. +let reviewer: { + rejected: Set; + badChannels: Set; + onToggleEpoch(index: number): void; + onToggleChannel(name: string): void; +}; vi.mock('../EpochReviewer', () => ({ - default: (props: { rejected: Set }) => { - mockRejected = props.rejected; + default: (props: typeof reviewer) => { + reviewer = props; return
; }, })); @@ -28,12 +27,24 @@ vi.mock('../LiveErpPane', () => ({ vi.mock('lab.js', () => ({})); +const RECORDING = '/ws/Data/P1/EEG/P1-A-1-raw.csv'; + vi.mock('../../../utils/filesystem/storage', () => ({ readWorkspaceRawEEGData: vi.fn(async () => [ - { name: 'session_1.fif', path: '/sub-01/session_1.fif' }, + { name: 'P1-A-1-raw.csv', path: RECORDING }, ]), + readWorkspaceIncompleteEEGData: vi.fn(async () => []), + deleteIncompleteRecording: vi.fn(), })); +vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } +); + const fakeEpochArrays = { buffer: new ArrayBuffer(8), meta: { @@ -46,80 +57,114 @@ const fakeEpochArrays = { }, }; -const baseProps: Record = { - type: EXPERIMENTS.N170, - title: 'Test_Experiment', - deviceType: DEVICES.MUSE, - epochsInfo: [{ name: 'N170', value: 100 }], - epochArrays: fakeEpochArrays, - PyodideActions: { LoadEpochs: vi.fn() }, - ExperimentActions: { SetSubject: vi.fn() }, - subject: '', - session: 0, - params: null, - suggestedRejections: [] as SuggestedRejection[], - cleanedEpochsSave: { revision: 0, ok: false }, - navigate: vi.fn(), -}; +let props: CleanProps; + +beforeEach(() => { + props = { + title: 'Test_Experiment', + epochArrays: fakeEpochArrays, + PyodideActions: { + LoadEpochs: vi.fn(), + CleanEpochs: vi.fn(), + GetSuggestedRejections: vi.fn(), + }, + ExperimentActions: { SetSubject: vi.fn() }, + params: null, + suggestedRejections: [] as SuggestedRejection[], + cleanedEpochsSave: { revision: 0, ok: false }, + navigate: vi.fn(), + } as unknown as CleanProps; +}); -describe('Clean suggestedRejections merge', () => { - beforeEach(() => { - mockRejected = new Set(); +const ui = (overrides: Partial = {}) => ( + + + +); + +async function startCleaning() { + const view = render(ui()); + fireEvent.click(await screen.findByRole('radio', { name: /P1-A-1-raw.csv/ })); + fireEvent.click(screen.getByRole('button', { name: 'Start cleaning' })); + return view; +} + +describe('Clean', () => { + it('loads the one chosen recording', async () => { + await startCleaning(); + + expect(props.ExperimentActions.SetSubject).toHaveBeenCalledWith('P1'); + expect(props.PyodideActions.LoadEpochs).toHaveBeenCalledWith(RECORDING); + expect(screen.getByTestId('epoch-reviewer')).toBeInTheDocument(); }); - it('merges suggestedRejections indices into rejectedEpochs', async () => { - const { rerender } = render( - - - + it('never applies suggestions on their own; Accept and Restore do', async () => { + const { rerender } = await startCleaning(); + + rerender( + ui({ suggestedRejections: [{ index: 2, reason: '212 µV at Fp1' }] }) ); + expect(reviewer.rejected.size).toBe(0); - // The mount effect reads workspace data and sets subjects/file paths. - // After that, the user selects a file and loads the dataset. - // The file-path multi-select fires onChange via handleRecordingChange. - // Wait for the mount effect to populate the select options. + fireEvent.click(screen.getByRole('button', { name: 'Accept' })); + expect([...reviewer.rejected]).toEqual([2]); - await waitFor(() => { - expect(screen.getByText('Start cleaning →')).toBeInTheDocument(); - }); + fireEvent.click(screen.getByRole('button', { name: 'Restore' })); + expect(reviewer.rejected.size).toBe(0); + }); - // Select the first (and only) file path. - const select = screen.getByRole('listbox') as HTMLSelectElement; - const option = screen.getByRole('option', { - name: 'session_1.fif', - }) as HTMLOptionElement; - option.selected = true; - await act(async () => { - fireEvent.change(select); - }); + it('new epoch arrays clear left-out trials but keep flagged sensors', async () => { + const { rerender } = await startCleaning(); + act(() => reviewer.onToggleEpoch(1)); + act(() => reviewer.onToggleChannel('Fp1')); + expect([...reviewer.rejected]).toEqual([1]); - // Click "Start cleaning" to switch to review view. - await act(async () => { - fireEvent.click(screen.getByText('Start cleaning →')); - }); + rerender(ui({ epochArrays: { ...fakeEpochArrays } })); - // Now EpochReviewer should be rendered. - expect(screen.getByTestId('epoch-reviewer')).toBeInTheDocument(); + expect(reviewer.rejected.size).toBe(0); + expect([...reviewer.badChannels]).toEqual(['Fp1']); + }); - // Rerender with suggestedRejections. - const suggestions: SuggestedRejection[] = [ - { index: 2, reason: 'High peak-to-peak' }, - { index: 5, reason: 'Muscle artifact' }, - ]; - rerender( - - - + it('Save & analyze confirms the removal, saves, then navigates once saved', async () => { + const { rerender } = await startCleaning(); + act(() => reviewer.onToggleEpoch(0)); + act(() => reviewer.onToggleEpoch(2)); + + fireEvent.click( + screen.getByRole('button', { name: 'Save cleaned dataset & analyze' }) + ); + expect(screen.getByText('Remove the selected trials?')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Remove selected and analyze' }) ); - // The componentDidUpdate in the class (and later the useEffect in the - // function component) merges the new indices into rejectedEpochs. - expect(mockRejected.has(2)).toBe(true); - expect(mockRejected.has(5)).toBe(true); - // The set should not contain an index that was never suggested (1). - expect(mockRejected.size).toBe(2); + expect(props.PyodideActions.CleanEpochs).toHaveBeenCalledWith({ + dropIndices: [0, 2], + badChannels: [], + }); + expect(props.navigate).not.toHaveBeenCalled(); + + rerender(ui({ cleanedEpochsSave: { revision: 1, ok: true } })); + expect(props.navigate).toHaveBeenCalledWith('/analyze'); + }); + + it('Try again after a failed save does not drop the trials a second time', async () => { + const { rerender } = await startCleaning(); + act(() => reviewer.onToggleEpoch(1)); + act(() => reviewer.onToggleChannel('Fp1')); + + fireEvent.click(screen.getByRole('button', { name: 'Apply exclusions' })); + expect(props.PyodideActions.CleanEpochs).toHaveBeenLastCalledWith({ + dropIndices: [1], + badChannels: ['Fp1'], + }); + + rerender(ui({ cleanedEpochsSave: { revision: 1, ok: false } })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(props.PyodideActions.CleanEpochs).toHaveBeenLastCalledWith({ + dropIndices: [], + badChannels: ['Fp1'], + }); }); }); From 54fa86dd34cd4f2af5072245276dcfb4c77add81 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:36:00 -0400 Subject: [PATCH 08/12] chore: format WS6 changes; note dev-playtest gotchas in learnings --- .llms/learnings.md | 12 ++++++++++++ src/main/__tests__/recordings.test.ts | 5 ++++- src/main/recordings.ts | 4 +++- src/renderer/components/CleanComponent/index.tsx | 5 ++++- src/renderer/types/electron.d.ts | 5 ++++- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index 05ef0913..c9ee94a5 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -431,3 +431,15 @@ deadlock the flip (the previous screen's `lock` frame is cancelled), so wait ~700 ms before each automated response. Native `showMessageBox` dialogs can be auto-answered by re-registering `dialog:showMessage` from that inspector (`process.getBuiltinModule('module').createRequire(...)('electron')`). + +## Dev playtests: worktrees lack Pyodide, and a Vite dep reload empties the workspace + +`src/renderer/utils/webworker/src/` is gitignored, so a fresh git worktree has +no Pyodide runtime: `pyodide://` requests fail with `ERR_FILE_NOT_FOUND` in the +main log and Clean never gets epochs. Clone it from the main checkout +(`cp -Rc
/src/renderer/utils/webworker/src …`) when the installer scripts +match. Separately, the first visit to Collect can trigger Vite's "optimized +dependencies changed. reloading": Redux resets but the hash route stays on +`/collect`, so a run started then has `title: ''` and writes to +`~/BrainWaves_Workspaces/Data/`. Reopen the workspace from Home after any dev +reload before recording. diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts index 8b731253..246f9877 100644 --- a/src/main/__tests__/recordings.test.ts +++ b/src/main/__tests__/recordings.test.ts @@ -79,7 +79,10 @@ describe('recordings', () => { write('Other/P1-A-1-raw.incomplete.csv'); expect(() => - incompleteRecordingFiles(dir, path.join(dir, 'Data/P1/EEG/P1-A-1-raw.csv')) + incompleteRecordingFiles( + dir, + path.join(dir, 'Data/P1/EEG/P1-A-1-raw.csv') + ) ).toThrow(); expect(() => incompleteRecordingFiles( diff --git a/src/main/recordings.ts b/src/main/recordings.ts index ed085d65..75cb593a 100644 --- a/src/main/recordings.ts +++ b/src/main/recordings.ts @@ -45,7 +45,9 @@ export const incompleteRecordingFiles = ( !eeg.startsWith(path.resolve(workspaceDir, 'Data') + path.sep) || !isIncompleteRawEEGFile(eeg) ) { - throw new Error(`Not an ended-early recording in this workspace: ${eegPath}`); + throw new Error( + `Not an ended-early recording in this workspace: ${eegPath}` + ); } const behavior = path.join( path.dirname(path.dirname(eeg)), diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 3029acac..523d182f 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -218,7 +218,10 @@ export default function Clean(props: Props) { return ( Promise; deleteWorkspaceDir: (title: string) => Promise; - deleteIncompleteRecording: (title: string, eegPath: string) => Promise; + deleteIncompleteRecording: ( + title: string, + eegPath: string + ) => Promise; importExperimentFile: ( title: string, sourcePath: string From 7441b017a5e5a45fe8db60ee7e9e706a6477c233 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 16:43:39 -0400 Subject: [PATCH 09/12] fix(clean): keep flagged sensors visible, scroll suggestions, harden delete and title paths, block saves before load and edits during save --- src/main/__tests__/recordings.test.ts | 26 +++++++++++- src/main/index.ts | 10 ++++- src/main/recordings.ts | 42 ++++++++++++++----- src/renderer/components/Clean/CleanReview.tsx | 30 +++++++++---- .../__tests__/EpochReviewer.test.tsx | 15 ------- .../components/CleanComponent/index.tsx | 18 ++++---- src/renderer/utils/webworker/utils.py | 11 +++-- tests/analysis/test_epoch_arrays.py | 13 ++++++ 8 files changed, 118 insertions(+), 47 deletions(-) diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts index 246f9877..f2d12a43 100644 --- a/src/main/__tests__/recordings.test.ts +++ b/src/main/__tests__/recordings.test.ts @@ -13,7 +13,9 @@ import { let dir: string; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')); + dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-')) + ); }); afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); @@ -97,4 +99,26 @@ describe('recordings', () => { ) ).toThrow(); }); + + it('refuses symlinks out of Data, the wrong depth, and directories', () => { + write('Other/P1-A-1-raw.incomplete.csv'); + write('Data/P1-A-1-raw.incomplete.csv'); + fs.mkdirSync(path.join(dir, 'Data/P1/EEG/P1-A-2-raw.incomplete.csv'), { + recursive: true, + }); + fs.symlinkSync( + path.join(dir, 'Other/P1-A-1-raw.incomplete.csv'), + path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv') + ); + + for (const rel of [ + 'Data/P1/EEG/P1-A-1-raw.incomplete.csv', + 'Data/P1-A-1-raw.incomplete.csv', + 'Data/P1/EEG/P1-A-2-raw.incomplete.csv', + ]) { + expect(() => + incompleteRecordingFiles(dir, path.join(dir, rel)) + ).toThrow(); + } + }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 868ecf77..5b22e619 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -121,7 +121,13 @@ const getStimulusFileAccess = () => { return stimulusFileAccess; }; -const getWorkspaceDir = (title: string) => path.join(workspaces, title); +/** A workspace's folder; `title` comes from the renderer, so it must be one path segment. */ +const getWorkspaceDir = (title: string) => { + if (!title || title === '.' || title === '..' || title !== path.basename(title)) { + throw new Error(`Invalid workspace title: ${title}`); + } + return path.join(workspaces, title); +}; const mkdirPathSync = (dirPath: string) => fs.mkdirSync(dirPath, { recursive: true }); @@ -376,7 +382,7 @@ ipcMain.handle( ); ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) => - shell.trashItem(path.join(workspaces, title)) + shell.trashItem(getWorkspaceDir(title)) ); /** Moves one ended-early run (EEG file and behavior sibling) to the Trash. */ diff --git a/src/main/recordings.ts b/src/main/recordings.ts index 75cb593a..e4bfa461 100644 --- a/src/main/recordings.ts +++ b/src/main/recordings.ts @@ -33,28 +33,48 @@ export const isIncompleteRawEEGFile = (file: string) => /** * Files `fs:deleteIncompleteRecording` trashes for one ended-early run: the EEG * file plus its `Behavior/-behavior.incomplete.csv` sibling when present. - * Throws for anything that is not an ended-early raw EEG file inside - * `/Data/`, since `eegPath` comes from the renderer. + * `eegPath` comes from the renderer, so after resolving symlinks it must be a + * regular file at exactly `/Data//EEG/-raw.incomplete.csv` + * (and the sibling a regular file in that subject's `Behavior/`); otherwise throws. */ export const incompleteRecordingFiles = ( workspaceDir: string, eegPath: string ) => { - const eeg = path.resolve(eegPath); - if ( - !eeg.startsWith(path.resolve(workspaceDir, 'Data') + path.sep) || - !isIncompleteRawEEGFile(eeg) - ) { + const reject = (): never => { throw new Error( `Not an ended-early recording in this workspace: ${eegPath}` ); + }; + const dataDir = fs.realpathSync(path.join(workspaceDir, 'Data')); + const eeg = fs.realpathSync(eegPath); + const [subject, folder, file, ...rest] = path + .relative(dataDir, eeg) + .split(path.sep); + if ( + rest.length > 0 || + !file || + subject === '..' || + folder !== 'EEG' || + !isIncompleteRawEEGFile(file) || + !fs.statSync(eeg).isFile() + ) { + reject(); } + const behaviorDir = path.join(dataDir, subject, 'Behavior'); const behavior = path.join( - path.dirname(path.dirname(eeg)), - 'Behavior', - path.basename(eeg).replace(`-raw${INCOMPLETE}`, `-behavior${INCOMPLETE}`) + behaviorDir, + file.replace(`-raw${INCOMPLETE}`, `-behavior${INCOMPLETE}`) ); - return fs.existsSync(behavior) ? [eeg, behavior] : [eeg]; + if (!fs.existsSync(behavior)) return [eeg]; + const realBehavior = fs.realpathSync(behavior); + if ( + path.dirname(realBehavior) !== behaviorDir || + !fs.statSync(realBehavior).isFile() + ) { + reject(); + } + return [eeg, realBehavior]; }; /** True when any artifact of this session exists, complete or ended early. */ diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx index bf0492ec..d1cefa15 100644 --- a/src/renderer/components/Clean/CleanReview.tsx +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -50,6 +50,8 @@ export interface CleanReviewProps { onRestoreSuggestion(index: number): void; onSuggest(): void; saveState: SaveState; + /** Disables Save and Apply, e.g. until the recording's trials have loaded. */ + saveDisabled?: boolean; onApply(): void; onSave(): void; onRetrySave(): void; @@ -79,6 +81,7 @@ export default function CleanReview(props: CleanReviewProps) { const acceptedCount = props.suggestions.filter((s) => s.accepted).length; const kept = total - props.rejected.size; const { dataset } = props; + const busy = props.saveState === 'saving'; const rail = ( <> @@ -103,6 +106,7 @@ export default function CleanReview(props: CleanReviewProps) { value={props.autoFlagThreshold} aria-valuetext={`${props.autoFlagThreshold} µV peak-to-peak`} onChange={(e) => props.onThresholdChange(Number(e.target.value))} + disabled={busy} className="flex-1 accent-brand" /> Fewer @@ -168,7 +172,12 @@ export default function CleanReview(props: CleanReviewProps) { -
@@ -190,10 +199,15 @@ export default function CleanReview(props: CleanReviewProps) { )} {props.saveState === 'idle' && (
- -
@@ -281,7 +295,7 @@ export default function CleanReview(props: CleanReviewProps) { {props.suggestions.length > 0 && (
Suggested by auto-flag @@ -289,7 +303,7 @@ export default function CleanReview(props: CleanReviewProps) { Suggestions, not decisions — you decide.
-
    +
      {props.suggestions.map((suggestion) => (
    • Trial {suggestion.index} - + {suggestion.reason} {suggestion.accepted ? ( <> - ✓ Left out (from a suggestion) + ✓ Left out - +
      More flags - + {total === 0 ? (
      Counts show up once the trials are loaded. @@ -175,7 +175,7 @@ export default function CleanReview(props: CleanReviewProps) {
      -
      )} {props.saveState === 'idle' && (
      - -
      diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx index 67da68e6..9cb4bff3 100644 --- a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -46,7 +46,6 @@ describe('EpochReviewer', () => { /> ); - // The overlay for epoch 1 is labelled "Reject trial 1". const target = screen.getByLabelText('Reject trial 1'); fireEvent.click(target); diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 1e2c6074..8cc86e64 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -267,7 +267,6 @@ export default function Clean(props: Props) { props.PyodideActions.GetSuggestedRejections(autoFlagThreshold) } saveState={saveState} - saveDisabled={status !== 'ready'} onApply={handleApply} onSave={handleSave} onRetrySave={() => save([])} diff --git a/src/renderer/containers/CleanContainer.ts b/src/renderer/containers/CleanContainer.ts index 80de3766..762a2bdc 100644 --- a/src/renderer/containers/CleanContainer.ts +++ b/src/renderer/containers/CleanContainer.ts @@ -8,13 +8,9 @@ import { RootState } from '../store'; function mapStateToProps(state: RootState) { return { - type: state.experiment.type, title: state.experiment.title, - subject: state.experiment.subject, group: state.experiment.group, - session: state.experiment.session, params: state.experiment.params, - deviceType: state.device.deviceType, ...state.pyodide, }; } diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 8cc6ae37..28296275 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -278,12 +278,12 @@ def plot_conditions(epochs, palette, ch_ind=0, conditions=OrderedDict(), def get_epochs_arrays(epochs, out_path): """Serialize epoch data to a float32 buffer file plus a metadata dict. - Writes the raw EEG epoch samples (Marker/stim channel excluded) as a flat - little-endian float32 buffer to `out_path` and returns metadata describing - the buffer's shape and per-epoch/per-channel labels. Channels in - `info['bads']` stay in the buffer so the viewer can show them flagged (and - the student can un-flag them). `out_path` is a Pyodide MEMFS path in-app and - a real filesystem path in the native tests. + Writes the raw EEG epoch samples (Marker/stim channel excluded) in + microvolts as a flat little-endian float32 buffer to `out_path` and returns + metadata describing the buffer's shape and per-epoch/per-channel labels. + Channels in `info['bads']` stay in the buffer so the viewer can show them + flagged (and the student can un-flag them). `out_path` is a Pyodide MEMFS + path in-app and a real filesystem path in the native tests. # buffer (float32, C-order): epoch0[ch0[t0..tN] ch1[..] ..] epoch1[..] .. # byte length == n_epochs * n_channels * n_times * 4 @@ -300,12 +300,7 @@ def get_epochs_arrays(epochs, out_path): meta : dict Buffer metadata (see keys below). """ - # EEG only — the Marker channel is type 'stim' (set in load_data), so - # pick_types(eeg=True) drops it while keeping the EEG channels in order; - # exclude=[] keeps bad channels, which pick_types drops by default. picks = pick_types(epochs.info, eeg=True, exclude=[]) - # get_data() is volts (load_data scales eeg uV -> V). This buffer drives the - # epoch viewer, which works in microvolts, so convert back to uV here. data = epochs.get_data(picks=picks) * 1e6 # (n_epochs, n_channels, n_times) data = np.ascontiguousarray(data.astype(np.float32)) @@ -361,7 +356,7 @@ def suggest_rejections(epochs, threshold_uv): For each epoch, compute the per-channel peak-to-peak (max-min over time) on the EEG channels only (Marker/stim excluded), take the worst channel, and if it exceeds threshold_uv microvolts, suggest that epoch. Advisory only — the UI - pre-marks these but the user can override; the real drop goes through + requires the user to accept each one; the real drop goes through apply_rejection so the saved data stays MNE-exact. Returns list[dict] with keys: index (int, 0-based into the CURRENT epochs, From 36f0708996ff29cc2e03c8efef1b9333b657bd24 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 19:58:08 -0400 Subject: [PATCH 12/12] test(main): normalize Windows separators in recordings test (vitest aliases path to pathe) --- src/main/__tests__/recordings.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts index 2bdedf3e..401b339e 100644 --- a/src/main/__tests__/recordings.test.ts +++ b/src/main/__tests__/recordings.test.ts @@ -24,10 +24,11 @@ const write = (rel: string) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, 'x'); }; +/** Workspace CSVs relative to `dir`, with `/` separators (tests alias `path` to `pathe`). */ const csvFiles = () => - (fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) => - f.endsWith('.csv') - ); + (fs.readdirSync(dir, { recursive: true }) as string[]) + .map(path.normalize) + .filter((f) => f.endsWith('.csv')); describe('recordings', () => { it('complete recordings are discovered', () => { @@ -71,7 +72,7 @@ describe('recordings', () => { expect(csvFiles().filter(isIncompleteRawEEGFile)).toEqual([ path.join('Data/P1/EEG/P1-A-1-raw.incomplete.csv'), ]); - expect(incompleteRecordingFiles(dir, eeg)).toEqual([ + expect(incompleteRecordingFiles(dir, eeg).map(path.normalize)).toEqual([ eeg, path.join(dir, 'Data/P1/Behavior/P1-A-1-behavior.incomplete.csv'), path.join(dir, 'Data/P1/EEG/P1-A-1-events.json'),