c8f0c5f298
Calendar day clicks now navigate to /experiments/:id/day/:date (with ?field= param). ExperimentDayView shows a sticky-column table of all subjects with data that day, inline edit via modal, and a session-metrics bar chart from analysis_summary. Subject name links to /daily-statuses/:id. Backend adds ?date= filtering to GET /experiments/:id/daily-statuses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
8.6 KiB
React
187 lines
8.6 KiB
React
import React from 'react';
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import ExperimentCalendar from '../src/components/ExperimentCalendar';
|
||
|
||
const mockNavigate = jest.fn();
|
||
jest.mock('react-router-dom', () => ({
|
||
useNavigate: () => mockNavigate,
|
||
}));
|
||
|
||
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 },
|
||
];
|
||
|
||
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,
|
||
analysis_summary: null,
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
// ── Rendering ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('ExperimentCalendar rendering', () => {
|
||
it('renders the current month and year', () => {
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||
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={[]} />);
|
||
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={[]} />);
|
||
const options = screen.getAllByRole('option').map((o) => o.textContent);
|
||
expect(options).toContain('Vitals');
|
||
expect(options).toContain('Weights');
|
||
expect(options).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={[]} />);
|
||
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={[]} />);
|
||
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('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||
makeStatus('a2', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
|
||
];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
|
||
});
|
||
|
||
it('does not show count badge on days with no data', () => {
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('shows a count badge for builtin field (vitals)', () => {
|
||
const dateStr = todayStr();
|
||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
|
||
});
|
||
|
||
it('recomputes counts after field change', () => {
|
||
const dateStr = todayStr();
|
||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||
// 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');
|
||
});
|
||
});
|
||
|
||
// ── Month navigation ──────────────────────────────────────────────────────────
|
||
|
||
describe('month navigation', () => {
|
||
it('moves to the previous month on ‹ click', () => {
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||
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={[]} />);
|
||
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();
|
||
});
|
||
});
|
||
|
||
// ── Day click navigation ──────────────────────────────────────────────────────
|
||
|
||
describe('day click navigation', () => {
|
||
it('navigates to the day view with field param when a day with data is clicked', () => {
|
||
const dateStr = todayStr();
|
||
const statuses = [
|
||
makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||
];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||
expect(mockNavigate).toHaveBeenCalledWith(
|
||
`/experiments/${EXP_ID}/day/${dateStr}?field=ww000000-0000-0000-0000-000000000099`,
|
||
);
|
||
});
|
||
|
||
it('does not navigate when clicking a day without data (button is disabled)', () => {
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||
const dayBtn = screen.getByTestId(`day-${todayStr()}`);
|
||
expect(dayBtn).toBeDisabled();
|
||
fireEvent.click(dayBtn);
|
||
expect(mockNavigate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('includes the currently selected field in the navigation URL', () => {
|
||
const dateStr = todayStr();
|
||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||
// Switch to vitals field first
|
||
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||
expect(mockNavigate).toHaveBeenCalledWith(
|
||
`/experiments/${EXP_ID}/day/${dateStr}?field=vitals`,
|
||
);
|
||
});
|
||
|
||
it('navigates with no field param when selectedField is null (no active fields)', () => {
|
||
const emptyTemplate = [];
|
||
render(<ExperimentCalendar experimentId={EXP_ID} template={emptyTemplate} allStatuses={[]} />);
|
||
// No days have data with empty template, so nothing to click — just verify no crash
|
||
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
|
||
});
|
||
});
|