Files
experiments-database/frontend/tests/ExperimentCalendar.test.jsx
T
Experiments DB Dev 4a048fda88 feat: permanent calendar with editable day modal and per-subject bar chart
- Calendar moved out of List/Group tab toggle — always visible below cross-
  subject analytics (ExperimentAnalysisCharts)
- Day modal shows all subjects: existing data as read-only + Edit button,
  missing data as + Add button; both open DailyStatusForm inline
- Status changes/creates propagate up to parent via onStatusChange/onStatusCreate
  and calendar counts recompute locally (no extra API round-trip)
- Bar chart (recharts) at bottom: Total/Success/Failure bars per subject plus
  % complete text, grouped by subject name
- Tests updated: 17 ExperimentCalendar tests, 76 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:30:26 -04:00

268 lines
11 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) {
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,
};
}
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).getByText('Rat A')).toBeInTheDocument();
});
});
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 all animals in the modal (with and without data)', async () => {
const dateStr = todayStr();
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).getByText('Rat A')).toBeInTheDocument();
expect(within(dialog).getByText('Rat B')).toBeInTheDocument(); // shown even without data
});
});
});
// ── Bar chart ─────────────────────────────────────────────────────────────────
describe('bar chart', () => {
it('renders bar chart when animals have statuses', () => {
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} />,
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
it('does not render bar chart when no animals have statuses', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});