feat: session file registry with drag-and-drop on day view
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -43,10 +43,25 @@ model DailyStatus {
|
|||||||
analysis_summary Json?
|
analysis_summary Json?
|
||||||
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
||||||
analyses DailyAnalysis[]
|
analyses DailyAnalysis[]
|
||||||
|
session_files SessionFile[]
|
||||||
|
|
||||||
@@map("daily_statuses")
|
@@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 {
|
model DailyAnalysis {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
daily_status_id String
|
daily_status_id String
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const animalsRouter = require('./routes/animals');
|
|||||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||||
const auditLogsRouter = require('./routes/auditLogs');
|
const auditLogsRouter = require('./routes/auditLogs');
|
||||||
const analysesRouter = require('./routes/analyses');
|
const analysesRouter = require('./routes/analyses');
|
||||||
|
const sessionFilesRouter = require('./routes/sessionFiles');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ app.use('/api/animals', animalsRouter);
|
|||||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||||
app.use('/api/audit-logs', auditLogsRouter);
|
app.use('/api/audit-logs', auditLogsRouter);
|
||||||
app.use('/api', analysesRouter);
|
app.use('/api', analysesRouter);
|
||||||
|
app.use('/api', sessionFilesRouter);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -69,6 +69,13 @@ export const systemApi = {
|
|||||||
info: () => api.get('/info').then((r) => r.data),
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Analyses ──────────────────────────────────────────────────────────────────
|
||||||
export const analysesApi = {
|
export const analysesApi = {
|
||||||
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
|
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
|
||||||
|
|||||||
@@ -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(`(?<![a-zA-Z0-9])(${ids.join('|')})(?![a-zA-Z0-9])`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyFile(filename) {
|
||||||
|
const ext = filename.split('.').pop().toLowerCase();
|
||||||
|
const MAP = { csv: 'CSV', tsv: 'TSV', h5: 'HDF5', hdf5: 'HDF5', nwb: 'NWB', bin: 'Binary', dat: 'Binary', raw: 'Binary', npy: 'NumPy', npz: 'NumPy', mat: 'MATLAB', pkl: 'Pickle' };
|
||||||
|
return MAP[ext] ?? 'Binary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(n) {
|
||||||
|
const num = typeof n === 'bigint' ? Number(n) : Number(n);
|
||||||
|
if (num >= 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<animalId, File[]>, 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 (
|
||||||
|
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||||
|
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
|
||||||
|
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
|
||||||
|
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
|
||||||
|
{onRemove && (
|
||||||
|
<button type="button" onClick={onRemove}
|
||||||
|
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedFileRow({ file, onDelete, deleting }) {
|
||||||
|
const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||||
|
<span className="font-mono text-gray-600 truncate min-w-0 flex-1">{file.filename}</span>
|
||||||
|
<span className="text-gray-400 shrink-0">{fmtBytes(size)}</span>
|
||||||
|
<span className="text-gray-400 shrink-0 w-12 text-right">{file.file_type ?? '?'}</span>
|
||||||
|
<button type="button" onClick={onDelete} disabled={deleting}
|
||||||
|
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1 disabled:opacity-40">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 || '<id>';
|
||||||
|
const namingHint = `${date}_${sampleId}_session1.csv`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session File Registry</h3>
|
||||||
|
<p className="text-xs text-gray-400 mb-4">
|
||||||
|
Drop all session files here. Filenames are matched to subjects by ID —
|
||||||
|
only metadata (name, size) is recorded; files are never uploaded.
|
||||||
|
<br />
|
||||||
|
Naming convention: <span className="font-mono text-gray-500">{namingHint}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
data-testid="drop-zone"
|
||||||
|
className={[
|
||||||
|
'border-2 border-dashed rounded-xl px-6 py-10 text-center transition-all cursor-default select-none',
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-gray-50 hover:border-gray-300',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{isDragging ? (
|
||||||
|
<p className="text-blue-600 font-medium text-sm">Release to register files</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-400 text-sm">Drag & drop session files here</p>
|
||||||
|
<p className="text-gray-300 text-xs mt-1">All subjects at once · files are not uploaded</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropped files preview */}
|
||||||
|
{dropped && (
|
||||||
|
<div className="mt-4 space-y-4" data-testid="drop-preview">
|
||||||
|
|
||||||
|
{/* Per-subject matched files */}
|
||||||
|
{statusRows.map(({ animal, status }) => {
|
||||||
|
const files = dropped.matched.get(animal.id) ?? [];
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{animal.animal_name}
|
||||||
|
{animal.animal_id_string && (
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<FileRow key={f.name + i} file={f} onRemove={() => removeMatchedFile(animal.id, i)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Subjects with no matched files */}
|
||||||
|
{statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && (
|
||||||
|
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4">
|
||||||
|
<p className="text-xs font-semibold text-amber-700 mb-1">No files matched for:</p>
|
||||||
|
{statusRows
|
||||||
|
.filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0)
|
||||||
|
.map(({ animal }) => (
|
||||||
|
<span key={animal.id} className="inline-block text-xs text-amber-600 bg-amber-100 rounded px-2 py-0.5 mr-1 mb-1 font-mono">
|
||||||
|
{animal.animal_id_string || animal.animal_name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unmatched files */}
|
||||||
|
{dropped.unmatched.length > 0 && (
|
||||||
|
<div className="bg-gray-50 border border-dashed border-gray-200 rounded-xl p-4">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 mb-2">
|
||||||
|
Unmatched ({dropped.unmatched.length}) — no subject ID found in filename
|
||||||
|
</p>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{dropped.unmatched.map((f, i) => (
|
||||||
|
<FileRow key={f.name + i} file={f} onRemove={() => removeUnmatched(i)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p className="text-xs text-red-500 px-1">{saveError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
{hasMatchedDropped && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
data-testid="save-btn"
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : `Register ${totalDropped} file${totalDropped !== 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDropped(null); setSaveError(null); }}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved files */}
|
||||||
|
{hasSaved && (
|
||||||
|
<div className="mt-6 space-y-3" data-testid="saved-files">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Registered files ({totalSaved})
|
||||||
|
</p>
|
||||||
|
{statusRows.map(({ animal, status }) => {
|
||||||
|
const files = savedByStatus[status.id] ?? [];
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{animal.animal_name}
|
||||||
|
{animal.animal_id_string && (
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{files.map((f) => (
|
||||||
|
<SavedFileRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
onDelete={() => deleteFile(f.id, status.id)}
|
||||||
|
deleting={deletingId === f.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button';
|
|||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import DailyStatusForm from '../components/DailyStatusForm';
|
import DailyStatusForm from '../components/DailyStatusForm';
|
||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
|
import FileDropZone from '../components/FileDropZone';
|
||||||
|
|
||||||
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
|
||||||
@@ -367,6 +368,9 @@ export default function ExperimentDayView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Session file drop zone */}
|
||||||
|
<FileDropZone rows={rows} date={date} />
|
||||||
|
|
||||||
{/* Edit modal (full row) */}
|
{/* Edit modal (full row) */}
|
||||||
{editStatus && (
|
{editStatus && (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
|
import FileDropZone from '../src/components/FileDropZone';
|
||||||
|
import { sessionFilesApi } from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
expect(screen.getByText(/2026-04-23_2852/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads saved files on mount for each status', async () => {
|
||||||
|
sessionFilesApi.list.mockResolvedValue([]);
|
||||||
|
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
// 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(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
// 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(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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(<FileDropZone rows={ROWS} date={DATE} />);
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Executable
+75
@@ -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>/
|
||||||
|
|
||||||
|
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/<id>/day/${DATE}?field=<fieldId>"
|
||||||
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user