fix: resolve DailyStatusForm test stall and add label accessibility
- Use module-level EMPTY_TEMPLATE constant as default prop to prevent useEffect infinite loop (new [] each render changed identity every cycle) - Add htmlFor to date and dynamic field labels for proper a11y association - Add role=alert to date error paragraph so tests can find it - Update tests to pass a template with builtin fields (required by template- driven component design) and mock experimentsApi - Add --max-old-space-size=8192 to test scripts to prevent OOM on full suite All 72 frontend tests now pass across 7 suites. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "jest --runInBand --forceExit",
|
"test": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit",
|
||||||
"test:coverage": "jest --runInBand --forceExit --coverage",
|
"test:coverage": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit --coverage",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Alert from './ui/Alert';
|
|||||||
import { dailyStatusesApi, experimentsApi } from '../api/client';
|
import { dailyStatusesApi, experimentsApi } from '../api/client';
|
||||||
|
|
||||||
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
const EMPTY_TEMPLATE = [];
|
||||||
|
|
||||||
// Maps field width % to a 12-column grid span
|
// Maps field width % to a 12-column grid span
|
||||||
function widthToSpan(pct) {
|
function widthToSpan(pct) {
|
||||||
@@ -28,7 +29,7 @@ function getValue(status, field) {
|
|||||||
return status.custom_fields?.[field.fieldId] ?? '';
|
return status.custom_fields?.[field.fieldId] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DailyStatusForm({ existing, animalId, experimentId, template = [], onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
|
export default function DailyStatusForm({ existing, animalId, experimentId, template = EMPTY_TEMPLATE, onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
|
||||||
const today = format(new Date(), 'yyyy-MM-dd');
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
// Active fields from template (date is always first and handled separately)
|
// Active fields from template (date is always first and handled separately)
|
||||||
@@ -142,10 +143,10 @@ export default function DailyStatusForm({ existing, animalId, experimentId, temp
|
|||||||
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
||||||
{/* Date — always full width */}
|
{/* Date — always full width */}
|
||||||
<div className="col-span-12 flex items-center gap-2">
|
<div className="col-span-12 flex items-center gap-2">
|
||||||
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
|
<label htmlFor="date" className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
|
||||||
<div className="w-36">
|
<div className="w-36">
|
||||||
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
||||||
{errors.date && <p className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
|
{errors.date && <p role="alert" className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ export default function DailyStatusForm({ existing, animalId, experimentId, temp
|
|||||||
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
|
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
|
||||||
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
|
<label htmlFor={field.key} className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
|
||||||
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
|
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -9,17 +9,27 @@ jest.mock('../src/api/client', () => ({
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
experimentsApi: {
|
||||||
|
updateTemplate: jest.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ANIMAL_ID = 'animal-001';
|
const ANIMAL_ID = 'animal-001';
|
||||||
const onSuccess = jest.fn();
|
const onSuccess = jest.fn();
|
||||||
const onCancel = jest.fn();
|
const onCancel = jest.fn();
|
||||||
|
|
||||||
|
const BUILTIN_TEMPLATE = [
|
||||||
|
{ fieldId: 'f1', key: 'vitals', label: 'Vitals', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||||
|
{ fieldId: 'f2', key: 'treatment', label: 'Treatment', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||||
|
{ fieldId: 'f3', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||||
|
{ fieldId: 'f4', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
describe('DailyStatusForm', () => {
|
describe('DailyStatusForm', () => {
|
||||||
it('renders date field (required) and optional fields', () => {
|
it('renders date field (required) and optional fields', () => {
|
||||||
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
|
||||||
@@ -28,13 +38,13 @@ describe('DailyStatusForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('pre-fills date with today by default', () => {
|
it('pre-fills date with today by default', () => {
|
||||||
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
|
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when date is cleared', async () => {
|
it('shows error when date is cleared', async () => {
|
||||||
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
const dateInput = screen.getByLabelText(/date/i);
|
const dateInput = screen.getByLabelText(/date/i);
|
||||||
await userEvent.clear(dateInput);
|
await userEvent.clear(dateInput);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
||||||
@@ -46,11 +56,10 @@ describe('DailyStatusForm', () => {
|
|||||||
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
|
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
|
||||||
client.dailyStatusesApi.create.mockResolvedValue(status);
|
client.dailyStatusesApi.create.mockResolvedValue(status);
|
||||||
|
|
||||||
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
|
||||||
const dateInput = screen.getByLabelText(/date/i);
|
const dateInput = screen.getByLabelText(/date/i);
|
||||||
await userEvent.clear(dateInput);
|
fireEvent.change(dateInput, { target: { value: '2024-06-01' } });
|
||||||
await userEvent.type(dateInput, '2024-06-01');
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
|
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
|
||||||
|
|
||||||
@@ -76,6 +85,7 @@ describe('DailyStatusForm', () => {
|
|||||||
render(
|
render(
|
||||||
<DailyStatusForm
|
<DailyStatusForm
|
||||||
existing={existing}
|
existing={existing}
|
||||||
|
template={BUILTIN_TEMPLATE}
|
||||||
animalId={ANIMAL_ID}
|
animalId={ANIMAL_ID}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -86,7 +96,7 @@ describe('DailyStatusForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onCancel when Cancel is clicked', () => {
|
it('calls onCancel when Cancel is clicked', () => {
|
||||||
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
expect(onCancel).toHaveBeenCalled();
|
expect(onCancel).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user