52d5fca5d1
Each saved file now records three additional fields:
matched_identifier — the literal animal_id_string or animal_name
substring that matched in the filename
animal_id_string_snap — snapshot of animal.animal_id_string at
registration time
animal_name_snap — snapshot of animal.animal_name at
registration time
This lets future consumers (scripts, exports, re-matching) identify
the subject from the file record alone, even if animal data changes.
The matched identifier is also shown in the drop preview UI (↳ 2852).
Tests: 25 total (+2 for new payload fields).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
17 KiB
React
376 lines
17 KiB
React
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();
|
|
});
|
|
|
|
// ── animal_name matching ───────────────────────────────────────────────────
|
|
|
|
it('matches by animal_name when the name appears literally in the filename', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Homer', animal_id_string: null };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
dropFiles([makeFile(`${DATE}_Homer_session.csv`)]);
|
|
await waitFor(() => screen.getByTestId('drop-preview'));
|
|
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
|
expect(screen.getByText(`${DATE}_Homer_session.csv`)).toBeInTheDocument();
|
|
});
|
|
|
|
it('matches multi-word animal_name with underscores as separator in filename', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
|
|
await waitFor(() => screen.getByTestId('drop-preview'));
|
|
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('matches multi-word animal_name with dashes as separator in filename', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
dropFiles([makeFile(`${DATE}-Rat-A-session.bin`)]);
|
|
await waitFor(() => screen.getByTestId('drop-preview'));
|
|
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('matches by animal_name even when animal_id_string is also set (OR logic)', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: '2852' };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
// File uses name, not ID
|
|
dropFiles([makeFile(`${DATE}_Rat_A_ephys.bin`)]);
|
|
await waitFor(() => screen.getByTestId('drop-preview'));
|
|
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not match a partial name overlap (Rat should not match Rationale)', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Rat', animal_id_string: null };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
dropFiles([makeFile('Rationale_study_data.csv')]);
|
|
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 including matching metadata', async () => {
|
|
const file = makeFile(`${DATE}_2852_trials.csv`);
|
|
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',
|
|
matched_identifier: '2852',
|
|
animal_id_string_snap: '2852',
|
|
animal_name_snap: 'Rat 2852',
|
|
}),
|
|
])
|
|
)
|
|
);
|
|
});
|
|
|
|
it('sets matched_identifier to animal_name when file matched by name', async () => {
|
|
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
|
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
|
sessionFilesApi.create.mockResolvedValue([]);
|
|
render(<FileDropZone rows={[row]} date={DATE} />);
|
|
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
|
|
await waitFor(() => screen.getByTestId('save-btn'));
|
|
fireEvent.click(screen.getByTestId('save-btn'));
|
|
await waitFor(() =>
|
|
expect(sessionFilesApi.create).toHaveBeenCalledWith(
|
|
's-b1',
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
matched_identifier: 'Rat A',
|
|
animal_id_string_snap: null,
|
|
animal_name_snap: 'Rat A',
|
|
}),
|
|
])
|
|
)
|
|
);
|
|
});
|
|
|
|
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()
|
|
);
|
|
});
|
|
});
|