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(); expect(screen.getByTestId('drop-zone')).toBeInTheDocument(); expect(screen.getByText(/drag.*drop session files/i)).toBeInTheDocument(); }); it('shows naming convention hint using first animal id', () => { render(); expect(screen.getByText(/2026-04-23_2852/)).toBeInTheDocument(); }); it('loads saved files on mount for each status', async () => { sessionFilesApi.list.mockResolvedValue([]); render(); await waitFor(() => { expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a1'); expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a2'); expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a3'); }); }); it('shows saved files loaded from the API', async () => { sessionFilesApi.list.mockImplementation((id) => id === 's-a1' ? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]) : Promise.resolve([]) ); render(); await waitFor(() => expect(screen.getByText(`${DATE}_2852_trials.csv`)).toBeInTheDocument() ); expect(screen.getByTestId('saved-files')).toBeInTheDocument(); }); }); // ── Drag state ───────────────────────────────────────────────────────────────── describe('drag interaction', () => { it('highlights drop zone on dragenter', () => { render(); const zone = screen.getByTestId('drop-zone'); fireEvent.dragEnter(zone, { dataTransfer: { files: [] } }); expect(zone.className).toMatch(/border-blue-400/); expect(screen.getByText(/release to register/i)).toBeInTheDocument(); }); it('removes highlight on dragleave', () => { render(); const zone = screen.getByTestId('drop-zone'); fireEvent.dragEnter(zone, { dataTransfer: { files: [] } }); fireEvent.dragLeave(zone, { dataTransfer: { files: [] } }); expect(zone.className).not.toMatch(/border-blue-400/); }); }); // ── File matching ────────────────────────────────────────────────────────────── describe('filename-to-subject matching', () => { it('matches files containing the animal_id_string to the correct subject', async () => { render(); dropFiles([ makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), makeFile(`${DATE}_3076_trials.csv`, 10_000_000), ]); await waitFor(() => screen.getByTestId('drop-preview')); expect(screen.getByText(/Rat 2852/)).toBeInTheDocument(); expect(screen.getByText(/Rat 3076/)).toBeInTheDocument(); expect(screen.getByText(`${DATE}_2852_ephys_raw.bin`)).toBeInTheDocument(); expect(screen.getByText(`${DATE}_3076_trials.csv`)).toBeInTheDocument(); }); it('shows unmatched section for files with no subject ID in name', async () => { render(); dropFiles([makeFile('unknown_file.csv', 1024)]); await waitFor(() => screen.getByTestId('drop-preview')); expect(screen.getByText(/unmatched/i)).toBeInTheDocument(); expect(screen.getByText('unknown_file.csv')).toBeInTheDocument(); }); it('does not match a partial numeric overlap (2852 should not match 28520)', async () => { render(); dropFiles([makeFile('session_28520_data.csv', 1024)]); await waitFor(() => screen.getByTestId('drop-preview')); expect(screen.getByText(/unmatched/i)).toBeInTheDocument(); }); // ── 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(); 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(); 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(); 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(); // 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(); 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(); // Only drop file for 2852 — 3076 and 3077 get no match dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); await waitFor(() => screen.getByTestId('drop-preview')); expect(screen.getByText(/no files matched/i)).toBeInTheDocument(); expect(screen.getByText('3076')).toBeInTheDocument(); expect(screen.getByText('3077')).toBeInTheDocument(); }); it('matches all four files per subject correctly', async () => { render(); dropFiles([ makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000), makeFile(`${DATE}_2852_trials.csv`, 10_000_000), makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000), ]); await waitFor(() => screen.getByTestId('drop-preview')); // Save button label is the authoritative "all 4 matched" signal expect(screen.getByTestId('save-btn')).toHaveTextContent('Register 4 files'); }); it('handles multiple subjects with 4 files each simultaneously', async () => { render(); dropFiles([ makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000), makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000), makeFile(`${DATE}_2852_trials.csv`, 10_000_000), makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000), makeFile(`${DATE}_3076_ephys_raw.bin`, 10_000_000_000), makeFile(`${DATE}_3076_kilosort.h5`, 10_000_000_000), makeFile(`${DATE}_3076_trials.csv`, 10_000_000), makeFile(`${DATE}_3076_behavior_log.csv`, 10_000_000), ]); await waitFor(() => screen.getByTestId('drop-preview')); const saveBtn = screen.getByTestId('save-btn'); expect(saveBtn.textContent).toMatch(/8 files/); }); }); // ── Remove before saving ─────────────────────────────────────────────────────── describe('remove files before saving', () => { it('removes a matched file when ✕ is clicked', async () => { render(); dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`)); fireEvent.click(screen.getAllByText('✕')[0]); await waitFor(() => expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument() ); }); }); // ── Saving ───────────────────────────────────────────────────────────────────── describe('saving metadata', () => { it('calls sessionFilesApi.create with correct payload for each subject', async () => { const file = makeFile(`${DATE}_2852_trials.csv`); // size = 1 byte (content 'x') const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size)); sessionFilesApi.create.mockResolvedValue([savedFile]); render(); dropFiles([file]); await waitFor(() => screen.getByTestId('save-btn')); fireEvent.click(screen.getByTestId('save-btn')); await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalledWith( 's-a1', expect.arrayContaining([ expect.objectContaining({ filename: `${DATE}_2852_trials.csv`, file_size: file.size, file_type: 'CSV', }), ]) ) ); }); it('does not call create for subjects with no matched files', async () => { sessionFilesApi.create.mockResolvedValue([]); render(); // Only 2852 gets a file dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); await waitFor(() => screen.getByTestId('save-btn')); fireEvent.click(screen.getByTestId('save-btn')); await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalledTimes(1)); expect(sessionFilesApi.create).toHaveBeenCalledWith('s-a1', expect.any(Array)); }); it('stores correct file_type classification from extension', async () => { sessionFilesApi.create.mockResolvedValue([]); render(); dropFiles([ makeFile(`${DATE}_2852_ephys.bin`, 10_000_000_000), makeFile(`${DATE}_2852_spikes.h5`, 10_000_000_000), makeFile(`${DATE}_2852_trials.csv`, 10_000_000), makeFile(`${DATE}_2852_log.csv`, 10_000_000), ]); await waitFor(() => screen.getByTestId('save-btn')); fireEvent.click(screen.getByTestId('save-btn')); await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalled()); const [, files] = sessionFilesApi.create.mock.calls[0]; expect(files.find((f) => f.filename.endsWith('.bin')).file_type).toBe('Binary'); expect(files.find((f) => f.filename.endsWith('.h5')).file_type).toBe('HDF5'); expect(files.filter((f) => f.filename.endsWith('.csv'))).toHaveLength(2); files.filter((f) => f.filename.endsWith('.csv')).forEach((f) => { expect(f.file_type).toBe('CSV'); }); }); it('clears the drop preview after a successful save', async () => { sessionFilesApi.create.mockResolvedValue([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]); render(); dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); await waitFor(() => screen.getByTestId('save-btn')); fireEvent.click(screen.getByTestId('save-btn')); await waitFor(() => expect(screen.queryByTestId('drop-preview')).not.toBeInTheDocument() ); }); it('shows an error message when create fails', async () => { sessionFilesApi.create.mockRejectedValue({ message: 'Server error' }); render(); dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]); await waitFor(() => screen.getByTestId('save-btn')); fireEvent.click(screen.getByTestId('save-btn')); await waitFor(() => screen.getByText(/server error/i)); }); }); // ── Deleting saved files ─────────────────────────────────────────────────────── describe('deleting saved files', () => { it('removes a saved file when its ✕ is clicked', async () => { sessionFilesApi.list.mockImplementation((id) => id === 's-a1' ? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]) : Promise.resolve([]) ); render(); await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`)); fireEvent.click(screen.getAllByText('✕')[0]); await waitFor(() => expect(sessionFilesApi.delete).toHaveBeenCalledWith('f1') ); await waitFor(() => expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument() ); }); });