import React from 'react'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import ExperimentDayView from '../src/pages/ExperimentDayView'; import { experimentsApi } from '../src/api/client'; // ── Mocks ────────────────────────────────────────────────────────────────────── const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ useParams: jest.fn(), useSearchParams: jest.fn(), useNavigate: () => mockNavigate, Link: ({ to, children, ...props }) => {children}, })); const { useParams, useSearchParams } = require('react-router-dom'); jest.mock('../src/api/client', () => ({ experimentsApi: { get: jest.fn(), getDayStatuses: jest.fn(), }, })); // DailyStatusForm is a complex form component; stub it to keep tests focused jest.mock('../src/components/DailyStatusForm', () => function MockDailyStatusForm({ onSuccess, onCancel }) { return (
); } ); // recharts uses ResizeObserver which is not available in jsdom jest.mock('recharts', () => ({ ResponsiveContainer: ({ children }) =>
{children}
, BarChart: ({ children }) =>
{children}
, Bar: () => null, XAxis: () => null, YAxis: () => null, CartesianGrid: () => null, Tooltip: () => null, Legend: () => null, })); // ── Fixtures ─────────────────────────────────────────────────────────────────── const EXP_ID = 'exp-0001-0000-0000-0000-000000000001'; const DATE_STR = '2026-04-23'; const FIELD_ID = 'ww000000-0000-0000-0000-000000000099'; const TEMPLATE = [ { fieldId: '00000000-0000-0000-0000-000000000001', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, { fieldId: FIELD_ID, key: 'weight', label: 'Weight', type: 'text', builtin: false, active: true }, { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: false }, ]; const ANIMALS = [ { id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' }, { id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' }, { id: 'a3000000-0000-0000-0000-000000000003', animal_name: 'Rat C', animal_id_string: 'R003' }, ]; const EXPERIMENT = { id: EXP_ID, title: 'Reach Task Study', template: TEMPLATE, animals: ANIMALS, }; function makeStatus(animalId, overrides = {}) { return { id: `status-${animalId}`, animal_id: animalId, date: `${DATE_STR}T00:00:00.000Z`, experiment_description: null, vitals: null, treatment: null, notes: null, custom_fields: {}, analysis_summary: null, ...overrides, }; } function makeSearchParams(field = FIELD_ID) { const params = new URLSearchParams(field ? `field=${field}` : ''); return [params, jest.fn()]; } // ── Setup ────────────────────────────────────────────────────────────────────── beforeEach(() => { jest.clearAllMocks(); useParams.mockReturnValue({ id: EXP_ID, date: DATE_STR }); useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID)); experimentsApi.get.mockResolvedValue(EXPERIMENT); experimentsApi.getDayStatuses.mockResolvedValue([]); }); // ── Loading & error states ──────────────────────────────────────────────────── describe('loading and error states', () => { it('shows a loading indicator while data is being fetched', () => { experimentsApi.get.mockReturnValue(new Promise(() => {})); // never resolves render(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); it('shows an error message when the API call fails', async () => { experimentsApi.get.mockRejectedValue({ message: 'Network error' }); render(); await waitFor(() => expect(screen.getByText(/network error/i)).toBeInTheDocument()); }); it('shows empty state when no data for the day', async () => { render(); await waitFor(() => expect(screen.getByText(/no data recorded for this day/i)).toBeInTheDocument() ); }); }); // ── Breadcrumb ──────────────────────────────────────────────────────────────── describe('breadcrumb', () => { it('renders dashboard, experiment title, and formatted date', async () => { render(); await waitFor(() => screen.getByText('Reach Task Study')); expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Reach Task Study' })).toHaveAttribute('href', `/experiments/${EXP_ID}`); expect(screen.getAllByText('April 23, 2026').length).toBeGreaterThan(0); }); }); // ── Row filtering by selected field ────────────────────────────────────────── describe('row filtering', () => { it('shows animals that have a non-empty value for the selected field', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }), makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: { [FIELD_ID]: '310' } }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByText('Rat A')).toBeInTheDocument(); expect(screen.getByText('Rat B')).toBeInTheDocument(); }); it('excludes animals whose selected field value is empty', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }), makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: {} }), // no weight ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.queryByText('Rat B')).not.toBeInTheDocument(); }); it('excludes animals whose selected field value is null', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: null } }), ]); render(); await waitFor(() => expect(screen.getByText(/no data recorded/i)).toBeInTheDocument() ); }); it('excludes animals with no status record for that day', async () => { // Only Rat A has a status; Rat B and C have no record returned experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.queryByText('Rat B')).not.toBeInTheDocument(); expect(screen.queryByText('Rat C')).not.toBeInTheDocument(); }); it('shows all animals with a status record when no field filter is set', async () => { useSearchParams.mockReturnValue(makeSearchParams(null)); experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001'), makeStatus('a2000000-0000-0000-0000-000000000002'), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByText('Rat A')).toBeInTheDocument(); expect(screen.getByText('Rat B')).toBeInTheDocument(); }); it('filters by builtin field (vitals)', async () => { useSearchParams.mockReturnValue(makeSearchParams('vitals')); experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: 'HR 72' }), makeStatus('a2000000-0000-0000-0000-000000000002', { vitals: null }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.queryByText('Rat B')).not.toBeInTheDocument(); }); }); // ── Table structure ─────────────────────────────────────────────────────────── describe('table structure', () => { beforeEach(() => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: 'HR 72', custom_fields: { [FIELD_ID]: '325' }, }), ]); }); it('renders a column header for each active template field', async () => { render(); await waitFor(() => screen.getByText('Rat A')); // Active fields: Vitals, Weight. Notes is inactive. expect(screen.getByText('Vitals')).toBeInTheDocument(); expect(screen.getByText('Weight')).toBeInTheDocument(); expect(screen.queryByText('Notes')).not.toBeInTheDocument(); }); it('renders field values in the correct cells', async () => { render(); await waitFor(() => screen.getByText('325')); expect(screen.getByText('HR 72')).toBeInTheDocument(); expect(screen.getByText('325')).toBeInTheDocument(); }); it('renders dash for empty field values', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: null, custom_fields: { [FIELD_ID]: '325' }, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByText('—')).toBeInTheDocument(); }); it('renders the subject name with animal_id_string alongside', async () => { render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByText('R001')).toBeInTheDocument(); }); it('renders an Edit button for each row', async () => { render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); }); }); // ── Subject name navigation ─────────────────────────────────────────────────── describe('subject name click', () => { it('navigates to /daily-statuses/:id when subject name is clicked', async () => { const statusId = 'status-a1000000-0000-0000-0000-000000000001'; experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }), ]); render(); await waitFor(() => screen.getByText('Rat A')); fireEvent.click(screen.getByText('Rat A')); expect(mockNavigate).toHaveBeenCalledWith(`/daily-statuses/${statusId}`); }); }); // ── Edit modal ──────────────────────────────────────────────────────────────── describe('edit modal', () => { beforeEach(() => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }), ]); }); it('opens the edit modal when Edit button is clicked', async () => { render(); await waitFor(() => screen.getByText('Rat A')); fireEvent.click(screen.getByRole('button', { name: /edit/i })); await waitFor(() => expect(screen.getByTestId('daily-status-form')).toBeInTheDocument() ); }); it('closes the edit modal when Cancel is clicked', async () => { render(); await waitFor(() => screen.getByText('Rat A')); fireEvent.click(screen.getByRole('button', { name: /edit/i })); await waitFor(() => screen.getByTestId('daily-status-form')); fireEvent.click(screen.getByRole('button', { name: /cancel/i })); await waitFor(() => expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument() ); }); it('closes the edit modal and updates the row after successful submit', async () => { const originalStatus = makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, }); experimentsApi.getDayStatuses.mockResolvedValue([originalStatus]); render(); await waitFor(() => screen.getByText('Rat A')); fireEvent.click(screen.getByRole('button', { name: /edit/i })); await waitFor(() => screen.getByTestId('daily-status-form')); // Submit with updated status fireEvent.click(screen.getByRole('button', { name: /submit/i })); await waitFor(() => expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument() ); }); }); // ── Session metrics bar chart ───────────────────────────────────────────────── // // Bar chart reads from status.analysis_summary — pre-computed trial-level // session metrics: { total, counts: { Success, Failure }, success_rate } // X-axis: animals visible in the table (have non-empty selected field value). // Hidden when no visible animal has analysis_summary.total > 0. describe('session metrics bar chart', () => { it('renders bar chart when at least one visible animal has analysis_summary', async () => { const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }; experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, analysis_summary: summary, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); }); it('hides bar chart when no visible animal has analysis_summary', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, analysis_summary: null, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); }); it('hides bar chart when analysis_summary.total is 0', async () => { const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null }; experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, analysis_summary: emptySummary, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); }); it('excludes from chart animals that are filtered out by the selected field', async () => { // Rat A: has field value + analysis_summary → in table and chart // Rat B: NO field value, has analysis_summary → excluded from both const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }; experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, analysis_summary: summary, }), makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: {}, analysis_summary: summary, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); // Chart still renders because Rat A has data expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); // Rat B not in table expect(screen.queryByText('Rat B')).not.toBeInTheDocument(); }); it('shows bar chart for multiple animals each with their own analysis_summary', async () => { experimentsApi.getDayStatuses.mockResolvedValue([ makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' }, analysis_summary: { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }, }), makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: { [FIELD_ID]: '310' }, analysis_summary: { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }, }), ]); render(); await waitFor(() => screen.getByText('Rat A')); expect(screen.getByText('Rat B')).toBeInTheDocument(); expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); }); }); // ── API call parameters ─────────────────────────────────────────────────────── describe('API calls', () => { it('calls getDayStatuses with the experiment id and date from the URL', async () => { render(); await waitFor(() => expect(experimentsApi.getDayStatuses).toHaveBeenCalledWith(EXP_ID, DATE_STR) ); }); it('calls experimentsApi.get with the experiment id', async () => { render(); await waitFor(() => expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID) ); }); });