import React from 'react'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import ExperimentCalendar from '../src/components/ExperimentCalendar'; import { experimentsApi } from '../src/api/client'; jest.mock('../src/api/client', () => ({ experimentsApi: { getCalendar: jest.fn(), }, })); jest.mock('recharts', () => ({ ResponsiveContainer: ({ children }) =>
{children}
, BarChart: ({ children }) =>
{children}
, Bar: () => null, XAxis: () => null, YAxis: () => null, CartesianGrid: () => null, Tooltip: () => null, Legend: () => null, })); const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const TEMPLATE = [ { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, { fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false }, { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true }, ]; 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' }, ]; function todayStr() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function makeStatus(animalId, date, customFields = {}, vitals = null, analysisSummary = null) { return { id: `s-${animalId}-${date}`, animal_id: animalId, date: `${date}T00:00:00.000Z`, experiment_description: null, vitals, treatment: null, notes: null, custom_fields: customFields, analysis_summary: analysisSummary, }; } beforeEach(() => { jest.clearAllMocks(); }); // ── Rendering ────────────────────────────────────────────────────────────────── describe('ExperimentCalendar rendering', () => { it('renders the current month and year', () => { render( , ); const now = new Date(); const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); expect(screen.getByText(monthLabel)).toBeInTheDocument(); }); it('renders day-of-week headers', () => { render( , ); for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) { expect(screen.getByText(d)).toBeInTheDocument(); } }); it('renders the field selector with active template fields only', () => { render( , ); expect(screen.getByTestId('field-select')).toBeInTheDocument(); const options = screen.getAllByRole('option'); const labels = options.map((o) => o.textContent); expect(labels).toContain('Vitals'); expect(labels).toContain('Weights'); expect(labels).not.toContain('Notes'); // inactive }); }); // ── Default field selection ──────────────────────────────────────────────────── describe('default field selection', () => { it('defaults to the field whose label contains "weight"', () => { render( , ); expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099'); }); it('defaults to first field if none contains "weight"', () => { const noWeightTemplate = [ { fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true }, { fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, ]; render( , ); expect(screen.getByTestId('field-select')).toHaveValue('treatment'); }); }); // ── Count badges (computed from allStatuses) ─────────────────────────────────── describe('count badges', () => { it('shows a count badge for a day where the selected field is non-empty', () => { const dateStr = todayStr(); const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }), ]; render( , ); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2'); }); it('does not show count badge on days with no data', () => { const dateStr = todayStr(); render( , ); expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); }); it('shows a count badge for builtin field (vitals)', () => { const dateStr = todayStr(); const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')]; render( , ); // Switch to vitals field fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); }); }); // ── Month navigation ────────────────────────────────────────────────────────── describe('month navigation', () => { it('moves to the previous month on ‹ click', () => { render( , ); const now = new Date(); const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' }); fireEvent.click(screen.getByLabelText('Previous month')); expect(screen.getByText(prevLabel)).toBeInTheDocument(); }); it('moves to the next month on › click', () => { render( , ); const now = new Date(); const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' }); fireEvent.click(screen.getByLabelText('Next month')); expect(screen.getByText(nextLabel)).toBeInTheDocument(); }); it('recomputes counts after field change', () => { const dateStr = todayStr(); // Only has vitals, not weights const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')]; render( , ); // Default is weights — no badge expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); // Switch to vitals — badge appears fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); }); }); // ── Day click → modal ───────────────────────────────────────────────────────── describe('day click modal', () => { it('opens a modal when a day with data is clicked', async () => { const dateStr = todayStr(); const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), ]; render( , ); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { const dialog = screen.getByRole('dialog'); expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); }); }); it('shows subject value in read-only view after clicking day', async () => { const dateStr = todayStr(); const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), ]; render( , ); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => expect(screen.getByText('325')).toBeInTheDocument()); }); it('does not open a modal when clicking a day without data', () => { const dateStr = todayStr(); render( , ); const dayBtn = screen.getByTestId(`day-${dateStr}`); expect(dayBtn).toBeDisabled(); fireEvent.click(dayBtn); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('shows only animals with data — excludes subjects with no record that day', async () => { const dateStr = todayStr(); // Only Rat A has a record; Rat B does not const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }), ]; render( , ); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { const dialog = screen.getByRole('dialog'); expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0); }); }); }); // ── Bar chart (inside day modal) ────────────────────────────────────────────── // // The bar chart reads from status.analysis_summary — a pre-computed field on // each DailyStatus record containing trial-level session metrics: // { total: number, counts: { Success: number, Failure: number }, success_rate: number } // // X-axis: animals that have a valid (non-empty) value for the chosen calendar // field on THAT specific day. Each day can show a different set of animals. // Bars: Total / Success / Failure from analysis_summary for that animal on that day. // // Chart is hidden when no animal on that day has analysis_summary.total > 0. // Do NOT compute success/failure from the field value itself — always use analysis_summary. describe('bar chart inside day modal', () => { it('renders session metrics bar chart when at least one animal has analysis_summary', async () => { const dateStr = todayStr(); const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }; const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary), ]; render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument(); }); }); it('hides bar chart when no animal has analysis_summary (null or total=0)', async () => { const dateStr = todayStr(); const statuses = [ // Has field data (so day is clickable) but no analysis_summary makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), ]; render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => screen.getByRole('dialog')); expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); }); it('hides bar chart when analysis_summary exists but total is 0', async () => { const dateStr = todayStr(); const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null }; const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, emptySummary), ]; render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => screen.getByRole('dialog')); expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); }); it('shows bar chart for multiple animals each with their own analysis_summary', async () => { const dateStr = todayStr(); const summaryA = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }; const summaryB = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }; const statuses = [ makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summaryA), makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }, null, summaryB), ]; render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => { expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument(); }); }); it('excludes from chart animals whose field value is empty on that day, even with analysis_summary', async () => { const dateStr = todayStr(); const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }; const statuses = [ // Rat A: has weight AND analysis_summary → appears in chart makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary), // Rat B: has analysis_summary but NO weight → not in day view at all, not in chart makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, {}, null, summary), ]; render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); await waitFor(() => screen.getByRole('dialog')); // Chart shows because Rat A has data expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); // Rat B not shown in day view (no weight value) const dialog = screen.getByRole('dialog'); expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0); }); });