deckhearth/test/components/ScanDisambiguationDialog.test.js

170 lines
5.5 KiB
JavaScript
Raw Permalink Normal View History

convoy: render-test regression-lock for ScanDisambiguationDialog (PR #144) PR #144 (`31da384`, 2026-06-13) shipped a runtime `ReferenceError: useFocusTrap is not defined` to production because the component called the hook without importing it. The sibling `enable-no-undef-eslint-rule` convoy closes that bug class at LINT time. This PR locks the same regression at RENDER time so the bug would still fail CI even if the lint rule were dropped or disabled. ## What changes - `test/components/ScanDisambiguationDialog.test.js` — 8 tests: 1. `renders without crashing (PR #144 regression-lock)` — the direct lock-in. Mutation-tested: commenting out the `useFocusTrap` import causes all 8 tests to fail with the same `ReferenceError` shape that hit prod. 2. `returns null when disambiguation is falsy` 3. ARIA shape (`role`, `aria-modal`, `aria-labelledby`) 4. One button per candidate with accessible labels 5. `onPick` callback receives the selected candidate 6. Vision-hint branch renders when provided 7. Submitting state disables the "send for review" button 8. `onCancel` callback fires on Cancel click ## Why vitest + jsdom and not Playwright smoke | Path | Catches PR #144 | Setup | Runtime | |------|-----------------|-------|---------| | Playwright smoke | ✓ if disambiguation mounts in the smoke run | High (auth bypass, stable multi-candidate fixture image) | ~10s + browser | | Vitest render | ✓ directly — render-throw → test fail | Low | <100ms | Re-scoped the queued `scanner-disambiguation-smoke-test` task to the vitest shape because a render test catches the exact same bug class at 1/100th the cost and matches the existing `test/components/*.test.js` pattern (`Modal.test.js`, `ScannedCardItem.test.js`, etc.). A Playwright disambiguation smoke is still useful as integration-layer coverage and is queued as `scanner-disambiguation-playwright-smoke`. ## Verification - [x] `npm run test:run` — 26 files / 131 tests pass (up from 25/123) - [x] Mutation test: with `useFocusTrap` import commented out, all 8 tests fail with `ReferenceError`. With import restored, all pass. ## Test plan - [ ] CI on this PR green - [ ] Squash + merge - [ ] Smoke test post-merge: scan a card that triggers disambiguation in prod and confirm no console errors (the original PR #144 bug shape) ## Convoy doc `.convoys/scanner-disambiguation-render-test.md` documents D1 (cover the early-return branch explicitly), D2 (`fireEvent` not `userEvent`), D3 (do NOT mock `useFocusTrap` — the missing-hook is exactly what we're locking), and the two queued follow-ups (`add-component-render-smoke-pattern`, `scanner-disambiguation-playwright-smoke`). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 02:20:21 -04:00
// @vitest-environment jsdom
//
// Regression lock for PR #144 (`31da384`, 2026-06-13) — `useFocusTrap`
// was called by this component but never imported, shipping a runtime
// `ReferenceError: useFocusTrap is not defined` to production. The
// `enable-no-undef-eslint-rule` convoy closes that bug class at lint
// time; this file additionally exercises the component at render time
// so the regression is locked in BOTH static-analysis AND
// dynamic-execution paths. A future identical bug (call a hook without
// importing it) would fail this test even if someone disabled the
// lint rule.
//
// The "renders without crashing" test is intentionally the FIRST
// assertion — it's the cheapest failure mode and most directly tied
// to the original PR #144 regression.
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import ScanDisambiguationDialog from '../../components/ScanDisambiguationDialog';
const baseFixture = {
message: 'Multiple matches found. Pick one.',
candidates: [
{
id: 'card-1',
name: 'Lightning Bolt',
set_name: 'Alpha',
set_code: 'LEA',
card_number: '161',
image_url: 'https://cards.scryfall.io/normal/front/1/1.jpg',
},
{
id: 'card-2',
name: 'Lightning Bolt',
set_name: 'Beta',
set_code: 'LEB',
card_number: '161',
// intentionally no image — exercises the placeholder branch
},
],
};
const noop = () => {};
describe('ScanDisambiguationDialog', () => {
afterEach(() => cleanup());
// ── Regression-lock for PR #144 ─────────────────────────────────────
// If `useFocusTrap` (or any other imported identifier) is missing,
// React's render throws `ReferenceError` from inside the function
// component, and this assertion fails. Do NOT relax this test to
// accept thrown errors — that's the entire point.
it('renders without crashing when given a disambiguation (PR #144 regression-lock)', () => {
expect(() => {
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
}).not.toThrow();
});
it('returns null when disambiguation prop is falsy', () => {
const { container } = render(
<ScanDisambiguationDialog
disambiguation={null}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('renders dialog with the correct ARIA shape', () => {
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.getAttribute('aria-labelledby')).toBe('disambiguation-title');
expect(screen.getByText('Which card is this?')).toBeTruthy();
expect(screen.getByText(baseFixture.message)).toBeTruthy();
});
it('renders one button per candidate with accessible labels', () => {
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
const buttons = screen.getAllByRole('button', { name: /Select Lightning Bolt/i });
expect(buttons).toHaveLength(2);
expect(buttons[0].getAttribute('aria-label')).toContain('Alpha');
expect(buttons[1].getAttribute('aria-label')).toContain('Beta');
});
it('calls onPick with the selected candidate', () => {
const onPick = vi.fn();
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={false}
onPick={onPick}
onNotInCatalog={noop}
onCancel={noop}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Select Lightning Bolt, Alpha/i }));
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith(baseFixture.candidates[0]);
});
it('renders the vision hint when one is provided', () => {
render(
<ScanDisambiguationDialog
disambiguation={{ ...baseFixture, visionHint: 'LEA' }}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
expect(screen.getByText(/Vision detected set: LEA/i)).toBeTruthy();
});
it('disables the "send for review" button while submitting and shows in-flight copy', () => {
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={true}
onPick={noop}
onNotInCatalog={noop}
onCancel={noop}
/>
);
const reviewBtn = screen.getByRole('button', { name: /Submitting/i });
expect(reviewBtn.hasAttribute('disabled')).toBe(true);
});
it('calls onCancel when the Cancel button is clicked', () => {
const onCancel = vi.fn();
render(
<ScanDisambiguationDialog
disambiguation={baseFixture}
submittingReview={false}
onPick={noop}
onNotInCatalog={noop}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByRole('button', { name: /^Cancel$/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});