From 6dfb427f9174a1b9c70d29004cb971f5e4550291 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Thu, 23 Apr 2026 12:53:54 -0400 Subject: [PATCH] test: add ExperimentDayView test suite (26 tests) Covers loading/error states, breadcrumb, row filtering by selected field (custom and builtin), table structure, subject-name navigation, edit modal open/close/submit, session metrics bar chart visibility, and API call parameters. Co-Authored-By: Claude Sonnet 4.6 --- frontend/tests/ExperimentDayView.test.jsx | 423 ++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 frontend/tests/ExperimentDayView.test.jsx diff --git a/frontend/tests/ExperimentDayView.test.jsx b/frontend/tests/ExperimentDayView.test.jsx new file mode 100644 index 0000000..2ac2c50 --- /dev/null +++ b/frontend/tests/ExperimentDayView.test.jsx @@ -0,0 +1,423 @@ +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) + ); + }); +});