170 lines
5.5 KiB
JavaScript
170 lines
5.5 KiB
JavaScript
|
|
// @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);
|
||
|
|
});
|
||
|
|
});
|