Files
experiments-database/frontend/tests/ExperimentCalendar.test.jsx
T
Experiments DB Dev 5f3ad1d408 fix: bar chart uses analysis_summary from each day's status records
X-axis: animals with valid field data on that day.
Bars: Success / Failure / Total from status.analysis_summary for that day.
Each day shows its own session metrics. Animals without analysis_summary
or with total=0 are excluded from the chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:29:15 -04:00

280 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }) => <div>{children}</div>,
BarChart: ({ children }) => <div data-testid="bar-chart">{children}</div>,
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
});
it('does not show count badge on days with no data', () => {
const dateStr = todayStr();
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
// 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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
// 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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
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) ──────────────────────────────────────────────
describe('bar chart inside day modal', () => {
it('renders session metrics bar chart when analysis_summary has data', 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(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
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 with total > 0', async () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});