Files
experiments-database/frontend/tests/ExperimentCalendar.test.jsx
T
Experiments DB Dev d764bc66fb fix: move bar chart inside day modal instead of below calendar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:06:52 -04:00

283 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) {
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).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 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).getAllByText('Rat A').length).toBeGreaterThan(0);
expect(within(dialog).getAllByText('Rat B').length).toBeGreaterThan(0);
});
});
});
// ── Bar chart (inside day modal) ──────────────────────────────────────────────
describe('bar chart inside day modal', () => {
it('renders bar chart inside modal when animals have statuses', 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).getByTestId('bar-chart')).toBeInTheDocument();
});
});
it('does not render bar chart in modal when no animals have overall statuses', async () => {
const dateStr = todayStr();
// Give Rat A a status today so the day is clickable, but use a different animal not in barData
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
// Use animals list with no matching statuses for barData by filtering to empty
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={[]} />,
);
// No days enabled since animals=[] means no bar chart, but day is also not enabled
// Just verify no bar chart on empty statuses/animals
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});