dc46af8d31
Clicking any table cell opens an inline input (text or textarea per field type). Enter or blur saves; Escape cancels. Unchanged values skip the API call. Custom fields merge with existing custom_fields; builtin fields use their key directly. Prev/next buttons at the bottom navigate to adjacent days that have data for the selected field, preserving the ?field= param. Day list is fetched via getCalendar and sorted chronologically. Tests: 41 total (added 15 new tests for cell editing and day nav). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
640 lines
26 KiB
React
640 lines
26 KiB
React
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
|
import ExperimentDayView from '../src/pages/ExperimentDayView';
|
|
|
|
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
|
|
|
const mockNavigate = jest.fn();
|
|
jest.mock('react-router-dom', () => ({
|
|
useParams: jest.fn(),
|
|
useSearchParams: jest.fn(),
|
|
useNavigate: () => mockNavigate,
|
|
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
|
|
}));
|
|
const { useParams, useSearchParams } = require('react-router-dom');
|
|
const { experimentsApi, dailyStatusesApi } = require('../src/api/client');
|
|
|
|
jest.mock('../src/api/client', () => ({
|
|
experimentsApi: {
|
|
get: jest.fn(),
|
|
getDayStatuses: jest.fn(),
|
|
getCalendar: jest.fn(),
|
|
},
|
|
dailyStatusesApi: {
|
|
update: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// DailyStatusForm is a complex form component; stub it to keep tests focused
|
|
jest.mock('../src/components/DailyStatusForm', () =>
|
|
function MockDailyStatusForm({ onSuccess, onCancel }) {
|
|
return (
|
|
<div data-testid="daily-status-form">
|
|
<button onClick={() => onSuccess({ id: 'updated-id' })}>Submit</button>
|
|
<button onClick={onCancel}>Cancel</button>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
// recharts uses ResizeObserver which is not available in jsdom
|
|
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,
|
|
}));
|
|
|
|
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
|
|
|
const EXP_ID = 'exp-0001-0000-0000-0000-000000000001';
|
|
const DATE_STR = '2026-04-23';
|
|
const FIELD_ID = 'ww000000-0000-0000-0000-000000000099';
|
|
|
|
const TEMPLATE = [
|
|
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
|
{ fieldId: FIELD_ID, key: 'weight', label: 'Weight', type: 'text', builtin: false, active: true },
|
|
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: false },
|
|
];
|
|
|
|
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' },
|
|
{ id: 'a3000000-0000-0000-0000-000000000003', animal_name: 'Rat C', animal_id_string: 'R003' },
|
|
];
|
|
|
|
const EXPERIMENT = {
|
|
id: EXP_ID,
|
|
title: 'Reach Task Study',
|
|
template: TEMPLATE,
|
|
animals: ANIMALS,
|
|
};
|
|
|
|
function makeStatus(animalId, overrides = {}) {
|
|
return {
|
|
id: `status-${animalId}`,
|
|
animal_id: animalId,
|
|
date: `${DATE_STR}T00:00:00.000Z`,
|
|
experiment_description: null,
|
|
vitals: null,
|
|
treatment: null,
|
|
notes: null,
|
|
custom_fields: {},
|
|
analysis_summary: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSearchParams(field = FIELD_ID) {
|
|
const params = new URLSearchParams(field ? `field=${field}` : '');
|
|
return [params, jest.fn()];
|
|
}
|
|
|
|
// ── Setup ──────────────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
useParams.mockReturnValue({ id: EXP_ID, date: DATE_STR });
|
|
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
|
|
experimentsApi.get.mockResolvedValue(EXPERIMENT);
|
|
experimentsApi.getDayStatuses.mockResolvedValue([]);
|
|
experimentsApi.getCalendar.mockResolvedValue({ field: FIELD_ID, days: {} });
|
|
dailyStatusesApi.update.mockResolvedValue({});
|
|
});
|
|
|
|
// ── Loading & error states ────────────────────────────────────────────────────
|
|
|
|
describe('loading and error states', () => {
|
|
it('shows a loading indicator while data is being fetched', () => {
|
|
experimentsApi.get.mockReturnValue(new Promise(() => {})); // never resolves
|
|
render(<ExperimentDayView />);
|
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows an error message when the API call fails', async () => {
|
|
experimentsApi.get.mockRejectedValue({ message: 'Network error' });
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => expect(screen.getByText(/network error/i)).toBeInTheDocument());
|
|
});
|
|
|
|
it('shows empty state when no data for the day', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/no data recorded for this day/i)).toBeInTheDocument()
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Breadcrumb ────────────────────────────────────────────────────────────────
|
|
|
|
describe('breadcrumb', () => {
|
|
it('renders dashboard, experiment title, and formatted date', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Reach Task Study'));
|
|
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
|
|
expect(screen.getByRole('link', { name: 'Reach Task Study' })).toHaveAttribute('href', `/experiments/${EXP_ID}`);
|
|
expect(screen.getAllByText('April 23, 2026').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ── Row filtering by selected field ──────────────────────────────────────────
|
|
|
|
describe('row filtering', () => {
|
|
it('shows animals that have a non-empty value for the selected field', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: { [FIELD_ID]: '310' } }),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
|
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
|
});
|
|
|
|
it('excludes animals whose selected field value is empty', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: {} }), // no weight
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('excludes animals whose selected field value is null', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: null } }),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/no data recorded/i)).toBeInTheDocument()
|
|
);
|
|
});
|
|
|
|
it('excludes animals with no status record for that day', async () => {
|
|
// Only Rat A has a status; Rat B and C have no record returned
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Rat C')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows all animals with a status record when no field filter is set', async () => {
|
|
useSearchParams.mockReturnValue(makeSearchParams(null));
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001'),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002'),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
|
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
|
});
|
|
|
|
it('filters by builtin field (vitals)', async () => {
|
|
useSearchParams.mockReturnValue(makeSearchParams('vitals'));
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: 'HR 72' }),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002', { vitals: null }),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Table structure ───────────────────────────────────────────────────────────
|
|
|
|
describe('table structure', () => {
|
|
beforeEach(() => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
vitals: 'HR 72',
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('renders a column header for each active template field', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
// Active fields: Vitals, Weight. Notes is inactive.
|
|
expect(screen.getByText('Vitals')).toBeInTheDocument();
|
|
expect(screen.getByText('Weight')).toBeInTheDocument();
|
|
expect(screen.queryByText('Notes')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders field values in the correct cells', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
expect(screen.getByText('HR 72')).toBeInTheDocument();
|
|
expect(screen.getByText('325')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders dash for empty field values', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
vitals: null,
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByText('—')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the subject name with animal_id_string alongside', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByText('R001')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders an Edit button for each row', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Subject name navigation ───────────────────────────────────────────────────
|
|
|
|
describe('subject name click', () => {
|
|
it('navigates to /daily-statuses/:id when subject name is clicked', async () => {
|
|
const statusId = 'status-a1000000-0000-0000-0000-000000000001';
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
fireEvent.click(screen.getByText('Rat A'));
|
|
expect(mockNavigate).toHaveBeenCalledWith(`/daily-statuses/${statusId}`);
|
|
});
|
|
});
|
|
|
|
// ── Edit modal ────────────────────────────────────────────────────────────────
|
|
|
|
describe('edit modal', () => {
|
|
beforeEach(() => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
|
]);
|
|
});
|
|
|
|
it('opens the edit modal when Edit button is clicked', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId('daily-status-form')).toBeInTheDocument()
|
|
);
|
|
});
|
|
|
|
it('closes the edit modal when Cancel is clicked', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
await waitFor(() => screen.getByTestId('daily-status-form'));
|
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
|
|
);
|
|
});
|
|
|
|
it('closes the edit modal and updates the row after successful submit', async () => {
|
|
const originalStatus = makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
});
|
|
experimentsApi.getDayStatuses.mockResolvedValue([originalStatus]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
await waitFor(() => screen.getByTestId('daily-status-form'));
|
|
// Submit with updated status
|
|
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Session metrics bar chart ─────────────────────────────────────────────────
|
|
//
|
|
// Bar chart reads from status.analysis_summary — pre-computed trial-level
|
|
// session metrics: { total, counts: { Success, Failure }, success_rate }
|
|
// X-axis: animals visible in the table (have non-empty selected field value).
|
|
// Hidden when no visible animal has analysis_summary.total > 0.
|
|
|
|
describe('session metrics bar chart', () => {
|
|
it('renders bar chart when at least one visible animal has analysis_summary', async () => {
|
|
const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 };
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
analysis_summary: summary,
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides bar chart when no visible animal has analysis_summary', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
analysis_summary: null,
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('hides bar chart when analysis_summary.total is 0', async () => {
|
|
const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null };
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
analysis_summary: emptySummary,
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('excludes from chart animals that are filtered out by the selected field', async () => {
|
|
// Rat A: has field value + analysis_summary → in table and chart
|
|
// Rat B: NO field value, has analysis_summary → excluded from both
|
|
const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 };
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
analysis_summary: summary,
|
|
}),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002', {
|
|
custom_fields: {},
|
|
analysis_summary: summary,
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
// Chart still renders because Rat A has data
|
|
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
|
// Rat B not in table
|
|
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows bar chart for multiple animals each with their own analysis_summary', async () => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
analysis_summary: { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 },
|
|
}),
|
|
makeStatus('a2000000-0000-0000-0000-000000000002', {
|
|
custom_fields: { [FIELD_ID]: '310' },
|
|
analysis_summary: { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 },
|
|
}),
|
|
]);
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
|
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── API call parameters ───────────────────────────────────────────────────────
|
|
|
|
describe('API calls', () => {
|
|
it('calls getDayStatuses with the experiment id and date from the URL', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() =>
|
|
expect(experimentsApi.getDayStatuses).toHaveBeenCalledWith(EXP_ID, DATE_STR)
|
|
);
|
|
});
|
|
|
|
it('calls experimentsApi.get with the experiment id', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() =>
|
|
expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID)
|
|
);
|
|
});
|
|
|
|
it('calls getCalendar with experiment id and selected field for day navigation', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() =>
|
|
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, FIELD_ID)
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Inline cell editing ───────────────────────────────────────────────────────
|
|
|
|
describe('inline cell editing', () => {
|
|
const STATUS_ID = 'status-a1000000-0000-0000-0000-000000000001';
|
|
|
|
beforeEach(() => {
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
vitals: 'HR 72',
|
|
}),
|
|
]);
|
|
dailyStatusesApi.update.mockResolvedValue(
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '330' },
|
|
vitals: 'HR 72',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('shows an input when a cell value is clicked', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
fireEvent.click(screen.getByText('325'));
|
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows an input when a dash (empty cell) is clicked', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('Rat A'));
|
|
// Vitals is shown as "HR 72" but weight is selected, click on a dash cell in vitals column
|
|
// After the status above vitals = 'HR 72' (not empty), let's use a status without vitals
|
|
experimentsApi.getDayStatuses.mockResolvedValue([
|
|
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
|
custom_fields: { [FIELD_ID]: '325' },
|
|
vitals: null,
|
|
}),
|
|
]);
|
|
// Re-render
|
|
const { unmount } = render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getAllByText('Rat A'));
|
|
const dashCell = screen.getAllByText('—')[0];
|
|
fireEvent.click(dashCell);
|
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
unmount();
|
|
});
|
|
|
|
it('saves a custom field value on Enter key', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
fireEvent.click(screen.getByText('325'));
|
|
const input = screen.getByRole('textbox');
|
|
fireEvent.change(input, { target: { value: '330' } });
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
await waitFor(() =>
|
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
|
STATUS_ID,
|
|
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '330' }) })
|
|
)
|
|
);
|
|
});
|
|
|
|
it('saves on blur', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
fireEvent.click(screen.getByText('325'));
|
|
const input = screen.getByRole('textbox');
|
|
fireEvent.change(input, { target: { value: '328' } });
|
|
fireEvent.blur(input);
|
|
await waitFor(() =>
|
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
|
STATUS_ID,
|
|
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '328' }) })
|
|
)
|
|
);
|
|
});
|
|
|
|
it('cancels edit on Escape without saving', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
fireEvent.click(screen.getByText('325'));
|
|
const input = screen.getByRole('textbox');
|
|
fireEvent.change(input, { target: { value: '999' } });
|
|
fireEvent.keyDown(input, { key: 'Escape' });
|
|
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
|
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('skips the API call when the value is unchanged', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('325'));
|
|
fireEvent.click(screen.getByText('325'));
|
|
const input = screen.getByRole('textbox');
|
|
// value unchanged — blur without editing
|
|
fireEvent.blur(input);
|
|
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('saves a builtin field (vitals) with the field key as payload key', async () => {
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText('HR 72'));
|
|
fireEvent.click(screen.getByText('HR 72'));
|
|
const input = screen.getByRole('textbox');
|
|
fireEvent.change(input, { target: { value: 'HR 80' } });
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
await waitFor(() =>
|
|
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
|
STATUS_ID,
|
|
expect.objectContaining({ vitals: 'HR 80' })
|
|
)
|
|
);
|
|
// custom_fields must NOT be in the payload for a builtin field
|
|
const payload = dailyStatusesApi.update.mock.calls[0][1];
|
|
expect(payload).not.toHaveProperty('custom_fields');
|
|
});
|
|
});
|
|
|
|
// ── Prev / next day navigation ────────────────────────────────────────────────
|
|
|
|
describe('prev/next day navigation', () => {
|
|
it('shows no navigation buttons when there are no other days', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { [DATE_STR]: 2 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
|
|
expect(screen.queryByText(/← April/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/April.*→/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a previous day button when an earlier day has data', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/April 22, 2026/));
|
|
expect(screen.getByText(/← April 22, 2026/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a next day button when a later day has data', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/April 24, 2026/));
|
|
expect(screen.getByText(/April 24, 2026 →/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows both prev and next buttons when surrounded by days with data', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { '2026-04-21': 1, [DATE_STR]: 2, '2026-04-25': 3 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/April 21, 2026/));
|
|
expect(screen.getByText(/← April 21, 2026/)).toBeInTheDocument();
|
|
expect(screen.getByText(/April 25, 2026 →/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('navigates to the previous day with the field param preserved', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/← April 22, 2026/));
|
|
fireEvent.click(screen.getByText(/← April 22, 2026/));
|
|
expect(mockNavigate).toHaveBeenCalledWith(
|
|
`/experiments/${EXP_ID}/day/2026-04-22?field=${FIELD_ID}`
|
|
);
|
|
});
|
|
|
|
it('navigates to the next day with the field param preserved', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/April 24, 2026 →/));
|
|
fireEvent.click(screen.getByText(/April 24, 2026 →/));
|
|
expect(mockNavigate).toHaveBeenCalledWith(
|
|
`/experiments/${EXP_ID}/day/2026-04-24?field=${FIELD_ID}`
|
|
);
|
|
});
|
|
|
|
it('only shows adjacent days, not all days (prev is immediately before in sorted list)', async () => {
|
|
experimentsApi.getCalendar.mockResolvedValue({
|
|
field: FIELD_ID,
|
|
days: { '2026-04-10': 1, '2026-04-20': 2, [DATE_STR]: 3, '2026-04-30': 1 },
|
|
});
|
|
render(<ExperimentDayView />);
|
|
await waitFor(() => screen.getByText(/April 20, 2026/));
|
|
// Prev should be April 20 (immediately before), not April 10
|
|
expect(screen.getByText(/← April 20, 2026/)).toBeInTheDocument();
|
|
expect(screen.queryByText(/April 10/)).not.toBeInTheDocument();
|
|
// Next should be April 30 (immediately after)
|
|
expect(screen.getByText(/April 30, 2026 →/)).toBeInTheDocument();
|
|
});
|
|
});
|