Files
experiments-database/frontend/tests/FileDropZone.test.jsx
T
Experiments DB Dev 66e79c1780 fix: match filenames against both animal_id_string and animal_name
Multi-word names ("Rat A") now allow any word separator in the
filename — space, underscore, or dash — so "Rat_A_session.csv"
and "Rat-A-data.bin" both match. Single-word names and numeric IDs
are unchanged. Partial overlaps still rejected ("Rat" won't hit
"Rationale"). Tests: 5 new name-based match cases (24 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:33:10 -04:00

351 lines
16 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 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()
);
});
});