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,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()
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user