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)
+ );
+ });
+});