From daf36908efd5ccdb100e68f2de035a6c2c590d2a Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Thu, 23 Apr 2026 13:12:45 -0400 Subject: [PATCH] feat: session file registry with drag-and-drop on day view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a FileDropZone at the bottom of the day view where users can drop all session files at once. Filenames are regex-matched to subjects using their animal_id_string (word-boundary aware, so "2852" won't match "28520"). Only metadata is recorded — files are never uploaded. Backend: new session_files table (id, daily_status_id, filename, file_size BIGINT, file_type, last_modified, notes). Routes: POST /api/daily-statuses/:id/files (batch create) GET /api/daily-statuses/:id/files DELETE /api/session-files/:id Frontend: FileDropZone shows per-subject match groups, unmatched files, and a warning for subjects with no match. After saving, registered files are displayed with delete capability. test-files/: generate_test_files.sh creates 4 dummy files per subject (2×bin, 2×csv) with correct naming for drag-and-drop testing. Pre-generated for 2026-04-23 with IDs 2852/3076/3077/3078. Tests: 19 tests covering drag state, regex matching, save payload, partial-overlap prevention, multi-subject batching, and delete. Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 18 + backend/prisma/schema.prisma | 15 + backend/src/app.js | 2 + backend/src/routes/sessionFiles.js | 77 ++++ frontend/src/api/client.js | 7 + frontend/src/components/FileDropZone.jsx | 385 ++++++++++++++++++ frontend/src/pages/ExperimentDayView.jsx | 4 + frontend/tests/FileDropZone.test.jsx | 301 ++++++++++++++ test-files/generate_test_files.sh | 75 ++++ .../2026-04-23_2852_behavior_log.csv | 10 + .../2026-04-23/2026-04-23_2852_ephys_raw.bin | 0 .../2026-04-23/2026-04-23_2852_kilosort.h5 | 0 .../2026-04-23/2026-04-23_2852_trials.csv | 21 + .../2026-04-23_3076_behavior_log.csv | 10 + .../2026-04-23/2026-04-23_3076_ephys_raw.bin | 0 .../2026-04-23/2026-04-23_3076_kilosort.h5 | 0 .../2026-04-23/2026-04-23_3076_trials.csv | 21 + .../2026-04-23_3077_behavior_log.csv | 10 + .../2026-04-23/2026-04-23_3077_ephys_raw.bin | 0 .../2026-04-23/2026-04-23_3077_kilosort.h5 | 0 .../2026-04-23/2026-04-23_3077_trials.csv | 21 + .../2026-04-23_3078_behavior_log.csv | 10 + .../2026-04-23/2026-04-23_3078_ephys_raw.bin | 0 .../2026-04-23/2026-04-23_3078_kilosort.h5 | 0 .../2026-04-23/2026-04-23_3078_trials.csv | 21 + 25 files changed, 1008 insertions(+) create mode 100644 backend/prisma/migrations/20250423000000_add_session_files/migration.sql create mode 100644 backend/src/routes/sessionFiles.js create mode 100644 frontend/src/components/FileDropZone.jsx create mode 100644 frontend/tests/FileDropZone.test.jsx create mode 100755 test-files/generate_test_files.sh create mode 100644 test-files/session_files/2026-04-23/2026-04-23_2852_behavior_log.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_2852_ephys_raw.bin create mode 100644 test-files/session_files/2026-04-23/2026-04-23_2852_kilosort.h5 create mode 100644 test-files/session_files/2026-04-23/2026-04-23_2852_trials.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3076_behavior_log.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3076_ephys_raw.bin create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3076_kilosort.h5 create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3076_trials.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3077_behavior_log.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3077_ephys_raw.bin create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3077_kilosort.h5 create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3077_trials.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3078_behavior_log.csv create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3078_ephys_raw.bin create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3078_kilosort.h5 create mode 100644 test-files/session_files/2026-04-23/2026-04-23_3078_trials.csv diff --git a/backend/prisma/migrations/20250423000000_add_session_files/migration.sql b/backend/prisma/migrations/20250423000000_add_session_files/migration.sql new file mode 100644 index 0000000..2d57791 --- /dev/null +++ b/backend/prisma/migrations/20250423000000_add_session_files/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "session_files" ( + "id" TEXT NOT NULL, + "daily_status_id" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "file_size" BIGINT NOT NULL, + "file_type" TEXT, + "last_modified" BIGINT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "session_files_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "session_files" + ADD CONSTRAINT "session_files_daily_status_id_fkey" + FOREIGN KEY ("daily_status_id") + REFERENCES "daily_statuses"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4700952..d6d7d59 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -43,10 +43,25 @@ model DailyStatus { analysis_summary Json? animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) analyses DailyAnalysis[] + session_files SessionFile[] @@map("daily_statuses") } +model SessionFile { + id String @id @default(uuid()) + daily_status_id String + daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade) + filename String + file_size BigInt + file_type String? + last_modified BigInt? + notes String? + created_at DateTime @default(now()) + + @@map("session_files") +} + model DailyAnalysis { id String @id @default(uuid()) daily_status_id String diff --git a/backend/src/app.js b/backend/src/app.js index b66cd70..30b0902 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -10,6 +10,7 @@ const animalsRouter = require('./routes/animals'); const dailyStatusesRouter = require('./routes/dailyStatuses'); const auditLogsRouter = require('./routes/auditLogs'); const analysesRouter = require('./routes/analyses'); +const sessionFilesRouter = require('./routes/sessionFiles'); const app = express(); @@ -63,6 +64,7 @@ app.use('/api/animals', animalsRouter); app.use('/api/daily-statuses', dailyStatusesRouter); app.use('/api/audit-logs', auditLogsRouter); app.use('/api', analysesRouter); +app.use('/api', sessionFilesRouter); app.use((req, res) => { res.status(404).json({ error: `Route ${req.method} ${req.path} not found` }); diff --git a/backend/src/routes/sessionFiles.js b/backend/src/routes/sessionFiles.js new file mode 100644 index 0000000..6bdf849 --- /dev/null +++ b/backend/src/routes/sessionFiles.js @@ -0,0 +1,77 @@ +const router = require('express').Router(); +const prisma = require('../lib/prisma'); + +// POST /api/daily-statuses/:id/files — register file metadata (batch) +// Body: [{ filename, file_size, file_type, last_modified, notes }, ...] +router.post('/daily-statuses/:id/files', async (req, res, next) => { + try { + const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } }); + if (!status) return res.status(404).json({ error: 'Daily status not found' }); + + const files = req.body; + if (!Array.isArray(files) || files.length === 0) + return res.status(422).json({ error: 'Request body must be a non-empty array of file metadata' }); + + if (files.length > 200) + return res.status(422).json({ error: 'At most 200 files per request' }); + + for (const f of files) { + if (!f.filename || typeof f.filename !== 'string' || !f.filename.trim()) + return res.status(422).json({ error: 'Each file must have a non-empty filename' }); + if (f.file_size == null || typeof f.file_size !== 'number' || f.file_size < 0) + return res.status(422).json({ error: `file_size must be a non-negative number (got: ${f.file_size})` }); + } + + const created = await prisma.$transaction( + files.map((f) => + prisma.sessionFile.create({ + data: { + daily_status_id: req.params.id, + filename: f.filename.trim(), + file_size: BigInt(Math.round(f.file_size)), + file_type: f.file_type ?? null, + last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null, + notes: f.notes ?? null, + }, + }) + ) + ); + + res.status(201).json(created.map(serializeFile)); + } catch (err) { next(err); } +}); + +// GET /api/daily-statuses/:id/files +router.get('/daily-statuses/:id/files', async (req, res, next) => { + try { + const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } }); + if (!status) return res.status(404).json({ error: 'Daily status not found' }); + + const files = await prisma.sessionFile.findMany({ + where: { daily_status_id: req.params.id }, + orderBy: { created_at: 'asc' }, + }); + res.json(files.map(serializeFile)); + } catch (err) { next(err); } +}); + +// DELETE /api/session-files/:id +router.delete('/session-files/:id', async (req, res, next) => { + try { + const file = await prisma.sessionFile.findUnique({ where: { id: req.params.id } }); + if (!file) return res.status(404).json({ error: 'Session file not found' }); + await prisma.sessionFile.delete({ where: { id: req.params.id } }); + res.status(204).send(); + } catch (err) { next(err); } +}); + +// BigInt fields must be serialized to strings to survive JSON.stringify +function serializeFile(f) { + return { + ...f, + file_size: f.file_size.toString(), + last_modified: f.last_modified != null ? f.last_modified.toString() : null, + }; +} + +module.exports = router; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f77e9d2..6c4bffd 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -69,6 +69,13 @@ export const systemApi = { info: () => api.get('/info').then((r) => r.data), }; +// ── Session Files ───────────────────────────────────────────────────────────── +export const sessionFilesApi = { + list: (statusId) => api.get(`/daily-statuses/${statusId}/files`).then((r) => r.data), + create: (statusId, files) => api.post(`/daily-statuses/${statusId}/files`, files).then((r) => r.data), + delete: (id) => api.delete(`/session-files/${id}`), +}; + // ── Analyses ────────────────────────────────────────────────────────────────── export const analysesApi = { listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data), diff --git a/frontend/src/components/FileDropZone.jsx b/frontend/src/components/FileDropZone.jsx new file mode 100644 index 0000000..6acdaa2 --- /dev/null +++ b/frontend/src/components/FileDropZone.jsx @@ -0,0 +1,385 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { sessionFilesApi } from '../api/client'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Build a regex that matches an animal's identifier in a filename. + * Matches only when the ID is not surrounded by other alphanumeric chars, + * so "2852" matches "rat_2852_session" but not "28520" or "X2852Y". + */ +function buildAnimalRegex(animal) { + const ids = []; + if (animal.animal_id_string) ids.push(escapeRegex(animal.animal_id_string)); + if (animal.animal_name) ids.push(escapeRegex(animal.animal_name)); + if (ids.length === 0) return null; + return new RegExp(`(?= 1e9) return `${(num / 1e9).toFixed(1)} GB`; + if (num >= 1e6) return `${(num / 1e6).toFixed(1)} MB`; + if (num >= 1e3) return `${(num / 1e3).toFixed(1)} KB`; + return `${num} B`; +} + +/** Match an array of File objects against animals. Returns { matched: Map, unmatched: File[] } */ +function matchFilesToAnimals(files, animals) { + const patterns = animals.map((a) => ({ animal: a, re: buildAnimalRegex(a) })).filter((x) => x.re); + const matched = new Map(animals.map((a) => [a.id, []])); + const unmatched = []; + + for (const file of files) { + const hit = patterns.find(({ re }) => re.test(file.name)); + if (hit) { + matched.get(hit.animal.id).push(file); + } else { + unmatched.push(file); + } + } + return { matched, unmatched }; +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function FileRow({ file, onRemove }) { + return ( +
+ {file.name} + {fmtBytes(file.size)} + {classifyFile(file.name)} + {onRemove && ( + + )} +
+ ); +} + +function SavedFileRow({ file, onDelete, deleting }) { + const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size; + return ( +
+ {file.filename} + {fmtBytes(size)} + {file.file_type ?? '?'} + +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +/** + * FileDropZone + * + * Props: + * rows — array of { animal, status } from ExperimentDayView + * (only rows with a status are shown; status.id is required to save files) + * date — YYYY-MM-DD string, shown in naming hint + */ +export default function FileDropZone({ rows, date }) { + const [isDragging, setIsDragging] = useState(false); + const [dropped, setDropped] = useState(null); // { matched: Map, unmatched: File[] } | null + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [savedByStatus, setSavedByStatus] = useState({}); // statusId → SavedFile[] + const [loadingIds, setLoadingIds] = useState(new Set()); + const [deletingId, setDeletingId] = useState(null); + const dragCounter = useRef(0); + + const statusRows = rows.filter((r) => r.status); + const animals = statusRows.map((r) => r.animal); + + // Load existing saved files for all statuses in this day view + useEffect(() => { + if (statusRows.length === 0) return; + const ids = statusRows.map((r) => r.status.id); + setLoadingIds(new Set(ids)); + Promise.all( + ids.map((sid) => sessionFilesApi.list(sid).then((files) => ({ sid, files }))) + ).then((results) => { + const map = {}; + results.forEach(({ sid, files }) => { map[sid] = files; }); + setSavedByStatus(map); + setLoadingIds(new Set()); + }).catch(() => setLoadingIds(new Set())); + }, [statusRows.map((r) => r.status?.id).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Drag handlers ────────────────────────────────────────────────────────── + + const onDragEnter = useCallback((e) => { + e.preventDefault(); + dragCounter.current += 1; + if (dragCounter.current === 1) setIsDragging(true); + }, []); + + const onDragLeave = useCallback((e) => { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current === 0) setIsDragging(false); + }, []); + + const onDragOver = useCallback((e) => { e.preventDefault(); }, []); + + const onDrop = useCallback((e) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + setDropped(matchFilesToAnimals(files, animals)); + setSaveError(null); + }, [animals]); + + function removeMatchedFile(animalId, fileIdx) { + setDropped((prev) => { + const next = new Map(prev.matched); + const arr = [...next.get(animalId)]; + arr.splice(fileIdx, 1); + next.set(animalId, arr); + return { matched: next, unmatched: prev.unmatched }; + }); + } + + function removeUnmatched(idx) { + setDropped((prev) => { + const u = [...prev.unmatched]; + u.splice(idx, 1); + return { matched: prev.matched, unmatched: u }; + }); + } + + // ── Save ─────────────────────────────────────────────────────────────────── + + async function save() { + if (!dropped) return; + setSaving(true); + setSaveError(null); + try { + const results = await Promise.all( + statusRows.map(async ({ animal, status }) => { + const files = dropped.matched.get(animal.id) ?? []; + if (files.length === 0) return { statusId: status.id, saved: [] }; + const payload = files.map((f) => ({ + filename: f.name, + file_size: f.size, + file_type: classifyFile(f.name), + last_modified: f.lastModified ?? null, + })); + const saved = await sessionFilesApi.create(status.id, payload); + return { statusId: status.id, saved }; + }) + ); + setSavedByStatus((prev) => { + const next = { ...prev }; + results.forEach(({ statusId, saved }) => { + next[statusId] = [...(next[statusId] ?? []), ...saved]; + }); + return next; + }); + setDropped(null); + } catch (err) { + setSaveError(err.message ?? 'Failed to save file metadata'); + } finally { + setSaving(false); + } + } + + // ── Delete ───────────────────────────────────────────────────────────────── + + async function deleteFile(fileId, statusId) { + setDeletingId(fileId); + try { + await sessionFilesApi.delete(fileId); + setSavedByStatus((prev) => ({ + ...prev, + [statusId]: prev[statusId].filter((f) => f.id !== fileId), + })); + } catch { + // silently ignore — UI stays consistent, user can retry + } finally { + setDeletingId(null); + } + } + + // ── Derived counts ───────────────────────────────────────────────────────── + + const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; + const totalUnmatched = dropped?.unmatched.length ?? 0; + const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0); + const hasSaved = totalSaved > 0; + const hasMatchedDropped = totalDropped > 0; + + // Hint string for naming convention + const sampleId = animals[0]?.animal_id_string || animals[0]?.animal_name || ''; + const namingHint = `${date}_${sampleId}_session1.csv`; + + return ( +
+

Session File Registry

+

+ Drop all session files here. Filenames are matched to subjects by ID — + only metadata (name, size) is recorded; files are never uploaded. +
+ Naming convention: {namingHint} +

+ + {/* Drop zone */} +
+ {isDragging ? ( +

Release to register files

+ ) : ( + <> +

Drag & drop session files here

+

All subjects at once · files are not uploaded

+ + )} +
+ + {/* Dropped files preview */} + {dropped && ( +
+ + {/* Per-subject matched files */} + {statusRows.map(({ animal, status }) => { + const files = dropped.matched.get(animal.id) ?? []; + if (files.length === 0) return null; + return ( +
+
+ + {animal.animal_name} + {animal.animal_id_string && ( + {animal.animal_id_string} + )} + + {files.length} file{files.length !== 1 ? 's' : ''} +
+
+ {files.map((f, i) => ( + removeMatchedFile(animal.id, i)} /> + ))} +
+
+ ); + })} + + {/* Subjects with no matched files */} + {statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && ( +
+

No files matched for:

+ {statusRows + .filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) + .map(({ animal }) => ( + + {animal.animal_id_string || animal.animal_name} + + ))} +
+ )} + + {/* Unmatched files */} + {dropped.unmatched.length > 0 && ( +
+

+ Unmatched ({dropped.unmatched.length}) — no subject ID found in filename +

+
+ {dropped.unmatched.map((f, i) => ( + removeUnmatched(i)} /> + ))} +
+
+ )} + + {saveError && ( +

{saveError}

+ )} + + {/* Action row */} +
+ {hasMatchedDropped && ( + + )} + +
+
+ )} + + {/* Saved files */} + {hasSaved && ( +
+

+ Registered files ({totalSaved}) +

+ {statusRows.map(({ animal, status }) => { + const files = savedByStatus[status.id] ?? []; + if (files.length === 0) return null; + return ( +
+
+ + {animal.animal_name} + {animal.animal_id_string && ( + {animal.animal_id_string} + )} + + {files.length} file{files.length !== 1 ? 's' : ''} +
+
+ {files.map((f) => ( + deleteFile(f.id, status.id)} + deleting={deletingId === f.id} + /> + ))} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ExperimentDayView.jsx b/frontend/src/pages/ExperimentDayView.jsx index fddf645..a1bc4b7 100644 --- a/frontend/src/pages/ExperimentDayView.jsx +++ b/frontend/src/pages/ExperimentDayView.jsx @@ -10,6 +10,7 @@ import Button from '../components/ui/Button'; import Modal from '../components/ui/Modal'; import DailyStatusForm from '../components/DailyStatusForm'; import Alert from '../components/ui/Alert'; +import FileDropZone from '../components/FileDropZone'; const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); @@ -367,6 +368,9 @@ export default function ExperimentDayView() { )} + {/* Session file drop zone */} + + {/* Edit modal (full row) */} {editStatus && ( ({ + sessionFilesApi: { + list: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, +})); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const DATE = '2026-04-23'; + +const ANIMALS = [ + { id: 'a1', animal_name: 'Rat 2852', animal_id_string: '2852' }, + { id: 'a2', animal_name: 'Rat 3076', animal_id_string: '3076' }, + { id: 'a3', animal_name: 'Rat 3077', animal_id_string: '3077' }, +]; + +const STATUS_A1 = { id: 's-a1', animal_id: 'a1', date: `${DATE}T00:00:00.000Z` }; +const STATUS_A2 = { id: 's-a2', animal_id: 'a2', date: `${DATE}T00:00:00.000Z` }; +const STATUS_A3 = { id: 's-a3', animal_id: 'a3', date: `${DATE}T00:00:00.000Z` }; + +const ROWS = [ + { animal: ANIMALS[0], status: STATUS_A1 }, + { animal: ANIMALS[1], status: STATUS_A2 }, + { animal: ANIMALS[2], status: STATUS_A3 }, +]; + +function makeFile(name, size = 1024, type = '') { + return new File(['x'], name, { type, lastModified: Date.now() }); +} + +function makeSavedFile(id, statusId, filename, fileSize = '10240') { + return { id, daily_status_id: statusId, filename, file_size: fileSize, file_type: 'CSV', last_modified: null, created_at: new Date().toISOString() }; +} + +function dropFiles(files) { + const zone = screen.getByTestId('drop-zone'); + fireEvent.dragEnter(zone, { dataTransfer: { files } }); + fireEvent.dragOver(zone, { dataTransfer: { files } }); + fireEvent.drop(zone, { dataTransfer: { files } }); +} + +beforeEach(() => { + jest.clearAllMocks(); + sessionFilesApi.list.mockResolvedValue([]); + sessionFilesApi.create.mockResolvedValue([]); + sessionFilesApi.delete.mockResolvedValue(); +}); + +// ── Rendering ────────────────────────────────────────────────────────────────── + +describe('rendering', () => { + it('renders the drop zone', () => { + render(); + expect(screen.getByTestId('drop-zone')).toBeInTheDocument(); + expect(screen.getByText(/drag.*drop session files/i)).toBeInTheDocument(); + }); + + it('shows naming convention hint using first animal id', () => { + render(); + expect(screen.getByText(/2026-04-23_2852/)).toBeInTheDocument(); + }); + + it('loads saved files on mount for each status', async () => { + sessionFilesApi.list.mockResolvedValue([]); + render(); + await waitFor(() => { + expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a1'); + expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a2'); + expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a3'); + }); + }); + + it('shows saved files loaded from the API', async () => { + sessionFilesApi.list.mockImplementation((id) => + id === 's-a1' + ? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]) + : Promise.resolve([]) + ); + render(); + await waitFor(() => + expect(screen.getByText(`${DATE}_2852_trials.csv`)).toBeInTheDocument() + ); + expect(screen.getByTestId('saved-files')).toBeInTheDocument(); + }); +}); + +// ── Drag state ───────────────────────────────────────────────────────────────── + +describe('drag interaction', () => { + it('highlights drop zone on dragenter', () => { + render(); + const zone = screen.getByTestId('drop-zone'); + fireEvent.dragEnter(zone, { dataTransfer: { files: [] } }); + expect(zone.className).toMatch(/border-blue-400/); + expect(screen.getByText(/release to register/i)).toBeInTheDocument(); + }); + + it('removes highlight on dragleave', () => { + render(); + const zone = screen.getByTestId('drop-zone'); + fireEvent.dragEnter(zone, { dataTransfer: { files: [] } }); + fireEvent.dragLeave(zone, { dataTransfer: { files: [] } }); + expect(zone.className).not.toMatch(/border-blue-400/); + }); +}); + +// ── File matching ────────────────────────────────────────────────────────────── + +describe('filename-to-subject matching', () => { + it('matches files containing the animal_id_string to the correct subject', async () => { + render(); + dropFiles([ + makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), + makeFile(`${DATE}_3076_trials.csv`, 10_000_000), + ]); + await waitFor(() => screen.getByTestId('drop-preview')); + expect(screen.getByText(/Rat 2852/)).toBeInTheDocument(); + expect(screen.getByText(/Rat 3076/)).toBeInTheDocument(); + expect(screen.getByText(`${DATE}_2852_ephys_raw.bin`)).toBeInTheDocument(); + expect(screen.getByText(`${DATE}_3076_trials.csv`)).toBeInTheDocument(); + }); + + it('shows unmatched section for files with no subject ID in name', async () => { + render(); + dropFiles([makeFile('unknown_file.csv', 1024)]); + await waitFor(() => screen.getByTestId('drop-preview')); + expect(screen.getByText(/unmatched/i)).toBeInTheDocument(); + expect(screen.getByText('unknown_file.csv')).toBeInTheDocument(); + }); + + it('does not match a partial numeric overlap (2852 should not match 28520)', async () => { + render(); + dropFiles([makeFile('session_28520_data.csv', 1024)]); + await waitFor(() => screen.getByTestId('drop-preview')); + expect(screen.getByText(/unmatched/i)).toBeInTheDocument(); + }); + + it('shows warning for subjects with no matched files', async () => { + render(); + // Only drop file for 2852 — 3076 and 3077 get no match + dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); + await waitFor(() => screen.getByTestId('drop-preview')); + expect(screen.getByText(/no files matched/i)).toBeInTheDocument(); + expect(screen.getByText('3076')).toBeInTheDocument(); + expect(screen.getByText('3077')).toBeInTheDocument(); + }); + + it('matches all four files per subject correctly', async () => { + render(); + dropFiles([ + makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), + makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000), + makeFile(`${DATE}_2852_trials.csv`, 10_000_000), + makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000), + ]); + await waitFor(() => screen.getByTestId('drop-preview')); + // Save button label is the authoritative "all 4 matched" signal + expect(screen.getByTestId('save-btn')).toHaveTextContent('Register 4 files'); + }); + + it('handles multiple subjects with 4 files each simultaneously', async () => { + render(); + dropFiles([ + makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), + makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000), + makeFile(`${DATE}_2852_trials.csv`, 10_000_000), + makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000), + makeFile(`${DATE}_3076_ephys_raw.bin`, 10_000_000_000), + makeFile(`${DATE}_3076_kilosort.h5`, 10_000_000_000), + makeFile(`${DATE}_3076_trials.csv`, 10_000_000), + makeFile(`${DATE}_3076_behavior_log.csv`, 10_000_000), + ]); + await waitFor(() => screen.getByTestId('drop-preview')); + const saveBtn = screen.getByTestId('save-btn'); + expect(saveBtn.textContent).toMatch(/8 files/); + }); +}); + +// ── Remove before saving ─────────────────────────────────────────────────────── + +describe('remove files before saving', () => { + it('removes a matched file when ✕ is clicked', async () => { + render(); + dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); + await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`)); + fireEvent.click(screen.getAllByText('✕')[0]); + await waitFor(() => + expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument() + ); + }); +}); + +// ── Saving ───────────────────────────────────────────────────────────────────── + +describe('saving metadata', () => { + it('calls sessionFilesApi.create with correct payload for each subject', async () => { + const file = makeFile(`${DATE}_2852_trials.csv`); // size = 1 byte (content 'x') + const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size)); + sessionFilesApi.create.mockResolvedValue([savedFile]); + + render(); + dropFiles([file]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + + await waitFor(() => + expect(sessionFilesApi.create).toHaveBeenCalledWith( + 's-a1', + expect.arrayContaining([ + expect.objectContaining({ + filename: `${DATE}_2852_trials.csv`, + file_size: file.size, + file_type: 'CSV', + }), + ]) + ) + ); + }); + + it('does not call create for subjects with no matched files', async () => { + sessionFilesApi.create.mockResolvedValue([]); + render(); + // Only 2852 gets a file + dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + + await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalledTimes(1)); + expect(sessionFilesApi.create).toHaveBeenCalledWith('s-a1', expect.any(Array)); + }); + + it('stores correct file_type classification from extension', async () => { + sessionFilesApi.create.mockResolvedValue([]); + render(); + dropFiles([ + makeFile(`${DATE}_2852_ephys.bin`, 10_000_000_000), + makeFile(`${DATE}_2852_spikes.h5`, 10_000_000_000), + makeFile(`${DATE}_2852_trials.csv`, 10_000_000), + makeFile(`${DATE}_2852_log.csv`, 10_000_000), + ]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + + await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalled()); + const [, files] = sessionFilesApi.create.mock.calls[0]; + expect(files.find((f) => f.filename.endsWith('.bin')).file_type).toBe('Binary'); + expect(files.find((f) => f.filename.endsWith('.h5')).file_type).toBe('HDF5'); + expect(files.filter((f) => f.filename.endsWith('.csv'))).toHaveLength(2); + files.filter((f) => f.filename.endsWith('.csv')).forEach((f) => { + expect(f.file_type).toBe('CSV'); + }); + }); + + it('clears the drop preview after a successful save', async () => { + sessionFilesApi.create.mockResolvedValue([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]); + render(); + dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + await waitFor(() => + expect(screen.queryByTestId('drop-preview')).not.toBeInTheDocument() + ); + }); + + it('shows an error message when create fails', async () => { + sessionFilesApi.create.mockRejectedValue({ message: 'Server error' }); + render(); + dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + await waitFor(() => screen.getByText(/server error/i)); + }); +}); + +// ── Deleting saved files ─────────────────────────────────────────────────────── + +describe('deleting saved files', () => { + it('removes a saved file when its ✕ is clicked', async () => { + sessionFilesApi.list.mockImplementation((id) => + id === 's-a1' + ? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]) + : Promise.resolve([]) + ); + render(); + await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`)); + fireEvent.click(screen.getAllByText('✕')[0]); + await waitFor(() => + expect(sessionFilesApi.delete).toHaveBeenCalledWith('f1') + ); + await waitFor(() => + expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument() + ); + }); +}); diff --git a/test-files/generate_test_files.sh b/test-files/generate_test_files.sh new file mode 100755 index 0000000..a4beca8 --- /dev/null +++ b/test-files/generate_test_files.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Generates dummy session files for drag-and-drop testing on the day view. +# Files are tiny (just named correctly) — the browser File API only reads metadata. +# +# Usage: bash generate_test_files.sh [DATE] [IDS...] +# DATE defaults to today (YYYY-MM-DD) +# IDS defaults to the three real subject IDs from the reach-task experiment +# +# Output: ./session_files// + +DATE=${1:-$(date +%Y-%m-%d)} +shift +SUBJECTS=("${@}") + +if [ ${#SUBJECTS[@]} -eq 0 ]; then + # Default IDs matching the real subjects in the experiments database + # (animal_id_string values visible on the day view) + SUBJECTS=("2852" "3076" "3077" "3078") +fi + +OUTDIR="./session_files/${DATE}" +mkdir -p "$OUTDIR" + +echo "Generating test files in ${OUTDIR}/ for subjects: ${SUBJECTS[*]}" +echo "" + +for ID in "${SUBJECTS[@]}"; do + # File 1: large binary session recording (~placeholder, 0 bytes in test) + # Real file would be ~10 GB (e.g. raw ephys .bin) + BIN1="${OUTDIR}/${DATE}_${ID}_ephys_raw.bin" + touch "$BIN1" + echo " [~10 GB] ${DATE}_${ID}_ephys_raw.bin" + + # File 2: large HDF5 spike-sorted output (~placeholder, 0 bytes in test) + # Real file would be ~10 GB (e.g. kilosort output .h5) + BIN2="${OUTDIR}/${DATE}_${ID}_kilosort.h5" + touch "$BIN2" + echo " [~10 GB] ${DATE}_${ID}_kilosort.h5" + + # File 3: CSV trial events (~10 MB real, 1 KB here) + CSV1="${OUTDIR}/${DATE}_${ID}_trials.csv" + { + echo "trial_num,timestamp,outcome,reaction_time_ms,reward" + for i in $(seq 1 20); do + OUTCOME=$( [ $((RANDOM % 3)) -eq 0 ] && echo "Failure" || echo "Success" ) + printf "%d,%.3f,%s,%d,%d\n" \ + "$i" "$(echo "$i * 0.823 + 1.0" | bc -l 2>/dev/null || echo $i)" \ + "$OUTCOME" "$((RANDOM % 400 + 50))" "$((RANDOM % 2))" + done + } > "$CSV1" + echo " [~10 MB] ${DATE}_${ID}_trials.csv" + + # File 4: CSV behavioral log (~10 MB real, small here) + CSV2="${OUTDIR}/${DATE}_${ID}_behavior_log.csv" + { + echo "ts,event,value,notes" + echo "0.000,session_start,1," + echo "1.234,trial_start,1," + echo "1.856,reach_onset,1," + echo "2.102,reach_end,1," + echo "2.103,outcome,Success," + echo "3.500,trial_start,2," + echo "4.100,reach_onset,1," + echo "4.950,outcome,Failure," + echo "9999.9,session_end,1," + } > "$CSV2" + echo " [~10 MB] ${DATE}_${ID}_behavior_log.csv" + echo "" +done + +TOTAL=$(find "$OUTDIR" -type f | wc -l) +echo "Done — ${TOTAL} files in ${OUTDIR}/" +echo "" +echo "Drag the entire '${DATE}' folder into the day-view drop zone at:" +echo " https://experiments.sam-tran.com/experiments//day/${DATE}?field=" diff --git a/test-files/session_files/2026-04-23/2026-04-23_2852_behavior_log.csv b/test-files/session_files/2026-04-23/2026-04-23_2852_behavior_log.csv new file mode 100644 index 0000000..b7fddb6 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_2852_behavior_log.csv @@ -0,0 +1,10 @@ +ts,event,value,notes +0.000,session_start,1, +1.234,trial_start,1, +1.856,reach_onset,1, +2.102,reach_end,1, +2.103,outcome,Success, +3.500,trial_start,2, +4.100,reach_onset,1, +4.950,outcome,Failure, +9999.9,session_end,1, diff --git a/test-files/session_files/2026-04-23/2026-04-23_2852_ephys_raw.bin b/test-files/session_files/2026-04-23/2026-04-23_2852_ephys_raw.bin new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_2852_kilosort.h5 b/test-files/session_files/2026-04-23/2026-04-23_2852_kilosort.h5 new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_2852_trials.csv b/test-files/session_files/2026-04-23/2026-04-23_2852_trials.csv new file mode 100644 index 0000000..bb91fe3 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_2852_trials.csv @@ -0,0 +1,21 @@ +trial_num,timestamp,outcome,reaction_time_ms,reward +1,1.823,Failure,176,0 +2,2.646,Failure,378,0 +3,3.469,Failure,138,1 +4,4.292,Success,150,1 +5,5.115,Success,204,1 +6,5.938,Success,209,0 +7,6.761,Success,369,0 +8,7.584,Failure,84,0 +9,8.407,Failure,403,0 +10,9.230,Success,340,0 +11,10.053,Success,442,1 +12,10.876,Success,355,0 +13,11.699,Success,280,0 +14,12.522,Failure,430,1 +15,13.345,Success,60,1 +16,14.168,Failure,102,0 +17,14.991,Success,213,0 +18,15.814,Success,54,1 +19,16.637,Failure,88,0 +20,17.460,Failure,290,1 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3076_behavior_log.csv b/test-files/session_files/2026-04-23/2026-04-23_3076_behavior_log.csv new file mode 100644 index 0000000..b7fddb6 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3076_behavior_log.csv @@ -0,0 +1,10 @@ +ts,event,value,notes +0.000,session_start,1, +1.234,trial_start,1, +1.856,reach_onset,1, +2.102,reach_end,1, +2.103,outcome,Success, +3.500,trial_start,2, +4.100,reach_onset,1, +4.950,outcome,Failure, +9999.9,session_end,1, diff --git a/test-files/session_files/2026-04-23/2026-04-23_3076_ephys_raw.bin b/test-files/session_files/2026-04-23/2026-04-23_3076_ephys_raw.bin new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3076_kilosort.h5 b/test-files/session_files/2026-04-23/2026-04-23_3076_kilosort.h5 new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3076_trials.csv b/test-files/session_files/2026-04-23/2026-04-23_3076_trials.csv new file mode 100644 index 0000000..28be9a8 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3076_trials.csv @@ -0,0 +1,21 @@ +trial_num,timestamp,outcome,reaction_time_ms,reward +1,1.823,Failure,56,0 +2,2.646,Failure,299,1 +3,3.469,Failure,335,0 +4,4.292,Failure,70,1 +5,5.115,Success,273,0 +6,5.938,Success,59,1 +7,6.761,Success,159,0 +8,7.584,Success,268,1 +9,8.407,Success,330,0 +10,9.230,Success,365,1 +11,10.053,Success,371,0 +12,10.876,Success,230,1 +13,11.699,Success,74,0 +14,12.522,Failure,381,1 +15,13.345,Success,70,1 +16,14.168,Failure,215,0 +17,14.991,Failure,310,0 +18,15.814,Success,78,1 +19,16.637,Success,79,1 +20,17.460,Failure,303,1 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3077_behavior_log.csv b/test-files/session_files/2026-04-23/2026-04-23_3077_behavior_log.csv new file mode 100644 index 0000000..b7fddb6 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3077_behavior_log.csv @@ -0,0 +1,10 @@ +ts,event,value,notes +0.000,session_start,1, +1.234,trial_start,1, +1.856,reach_onset,1, +2.102,reach_end,1, +2.103,outcome,Success, +3.500,trial_start,2, +4.100,reach_onset,1, +4.950,outcome,Failure, +9999.9,session_end,1, diff --git a/test-files/session_files/2026-04-23/2026-04-23_3077_ephys_raw.bin b/test-files/session_files/2026-04-23/2026-04-23_3077_ephys_raw.bin new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3077_kilosort.h5 b/test-files/session_files/2026-04-23/2026-04-23_3077_kilosort.h5 new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3077_trials.csv b/test-files/session_files/2026-04-23/2026-04-23_3077_trials.csv new file mode 100644 index 0000000..4d78480 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3077_trials.csv @@ -0,0 +1,21 @@ +trial_num,timestamp,outcome,reaction_time_ms,reward +1,1.823,Failure,169,1 +2,2.646,Success,127,0 +3,3.469,Failure,265,0 +4,4.292,Failure,266,1 +5,5.115,Success,295,1 +6,5.938,Success,94,1 +7,6.761,Failure,78,1 +8,7.584,Success,182,1 +9,8.407,Success,249,1 +10,9.230,Success,201,0 +11,10.053,Failure,167,0 +12,10.876,Failure,124,0 +13,11.699,Failure,361,0 +14,12.522,Success,306,1 +15,13.345,Failure,311,0 +16,14.168,Success,186,0 +17,14.991,Success,289,0 +18,15.814,Failure,152,0 +19,16.637,Failure,379,1 +20,17.460,Success,332,0 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3078_behavior_log.csv b/test-files/session_files/2026-04-23/2026-04-23_3078_behavior_log.csv new file mode 100644 index 0000000..b7fddb6 --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3078_behavior_log.csv @@ -0,0 +1,10 @@ +ts,event,value,notes +0.000,session_start,1, +1.234,trial_start,1, +1.856,reach_onset,1, +2.102,reach_end,1, +2.103,outcome,Success, +3.500,trial_start,2, +4.100,reach_onset,1, +4.950,outcome,Failure, +9999.9,session_end,1, diff --git a/test-files/session_files/2026-04-23/2026-04-23_3078_ephys_raw.bin b/test-files/session_files/2026-04-23/2026-04-23_3078_ephys_raw.bin new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3078_kilosort.h5 b/test-files/session_files/2026-04-23/2026-04-23_3078_kilosort.h5 new file mode 100644 index 0000000..e69de29 diff --git a/test-files/session_files/2026-04-23/2026-04-23_3078_trials.csv b/test-files/session_files/2026-04-23/2026-04-23_3078_trials.csv new file mode 100644 index 0000000..67ae26e --- /dev/null +++ b/test-files/session_files/2026-04-23/2026-04-23_3078_trials.csv @@ -0,0 +1,21 @@ +trial_num,timestamp,outcome,reaction_time_ms,reward +1,1.823,Failure,81,1 +2,2.646,Success,243,0 +3,3.469,Success,218,1 +4,4.292,Success,348,1 +5,5.115,Failure,418,0 +6,5.938,Failure,249,1 +7,6.761,Success,133,0 +8,7.584,Success,386,1 +9,8.407,Failure,116,1 +10,9.230,Success,226,0 +11,10.053,Success,438,1 +12,10.876,Success,405,1 +13,11.699,Success,419,1 +14,12.522,Success,95,0 +15,13.345,Failure,89,1 +16,14.168,Success,360,1 +17,14.991,Failure,424,1 +18,15.814,Success,73,1 +19,16.637,Failure,269,0 +20,17.460,Success,236,0