Move Layer-1/Layer-2 identify flow, outcome resolution, disambiguation refine helpers, and scan-for-review API calls into lib/scanner-card-identify.js. Remove unused manaSymbolSettings state from CameraScanner. Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
4.2 KiB
JavaScript
147 lines
4.2 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
buildOcrMetaFromIdentifyResult,
|
|
buildScannedCardPayload,
|
|
candidateMatchesSetHint,
|
|
resolveDisambiguationRefineAction,
|
|
resolveIdentifyOutcome,
|
|
} from '../../lib/scanner-card-identify.js';
|
|
|
|
describe('candidateMatchesSetHint', () => {
|
|
it('matches when set code equals hint', () => {
|
|
expect(candidateMatchesSetHint({ set_name: 'Base Set', set_code: 'BS' }, null, 'BS')).toBe(true);
|
|
});
|
|
|
|
it('returns true when no hint is provided', () => {
|
|
expect(candidateMatchesSetHint({ set_name: 'Any Set' }, null, null)).toBe(true);
|
|
});
|
|
|
|
it('returns false for unrelated sets', () => {
|
|
expect(candidateMatchesSetHint({ set_name: 'Jungle', set_code: 'JU' }, 'Base Set', null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveIdentifyOutcome', () => {
|
|
it('returns emit when a single card is matched', () => {
|
|
const card = { id: 1, name: 'Pikachu', mana_cost: '1R' };
|
|
const outcome = resolveIdentifyOutcome({
|
|
isCard: true,
|
|
card,
|
|
ocr: { confidence: 90, rawText: 'Pikachu' },
|
|
});
|
|
|
|
expect(outcome).toMatchObject({
|
|
type: 'emit',
|
|
cardStatus: 'confirmed',
|
|
card,
|
|
});
|
|
expect(outcome.ocrMeta.confidence).toBe(90);
|
|
});
|
|
|
|
it('returns disambiguation when multiple printings match', () => {
|
|
const matches = [{ id: 1, name: 'Lightning Bolt' }, { id: 2, name: 'Lightning Bolt' }];
|
|
const outcome = resolveIdentifyOutcome({
|
|
isCard: true,
|
|
needsUserSelection: true,
|
|
matches,
|
|
message: 'Pick one',
|
|
});
|
|
|
|
expect(outcome).toEqual({
|
|
type: 'disambiguation',
|
|
cardStatus: 'confirmed',
|
|
matches,
|
|
ocrMeta: expect.objectContaining({ abilities: [] }),
|
|
message: 'Pick one',
|
|
});
|
|
});
|
|
|
|
it('returns error when the frame is not a card', () => {
|
|
expect(resolveIdentifyOutcome({ isCard: false, reason: 'Blurry' })).toEqual({
|
|
type: 'error',
|
|
cardStatus: 'negative',
|
|
message: 'Blurry',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildScannedCardPayload', () => {
|
|
it('maps catalog fields and OCR metadata into the scanner queue shape', () => {
|
|
const payload = buildScannedCardPayload(
|
|
{
|
|
id: 42,
|
|
name: 'Seel',
|
|
set_name: 'Perfect Order',
|
|
set_code: 'PO',
|
|
card_number: '015/208',
|
|
game: 'pokemon',
|
|
card_type: 'Pokemon',
|
|
rarity: 'Common',
|
|
image_url: 'https://example.com/seel.jpg',
|
|
},
|
|
{
|
|
imageData: 'data:image/jpeg;base64,abc',
|
|
scanImageUrl: 'https://blob.example/seel.jpg',
|
|
ocrMeta: { rawText: 'Seel', confidence: 88, abilities: ['Freeze-Dry'] },
|
|
}
|
|
);
|
|
|
|
expect(payload).toMatchObject({
|
|
name: 'Seel',
|
|
set: 'Perfect Order',
|
|
databaseId: 42,
|
|
scanImageUrl: 'https://blob.example/seel.jpg',
|
|
ocrText: 'Seel',
|
|
confidence: 88,
|
|
abilities: ['Freeze-Dry'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveDisambiguationRefineAction', () => {
|
|
const candidates = [
|
|
{ id: 1, name: 'Bolt', set_name: 'Alpha', set_code: 'LEA' },
|
|
{ id: 2, name: 'Bolt', set_name: 'Beta', set_code: 'LEB' },
|
|
];
|
|
|
|
it('auto-picks when vision narrows to one printing', () => {
|
|
const action = resolveDisambiguationRefineAction(
|
|
{
|
|
matches: [{ id: 1 }],
|
|
ocr: { setCode: 'LEA' },
|
|
},
|
|
{ candidates }
|
|
);
|
|
|
|
expect(action).toEqual({ type: 'pick', candidate: candidates[0] });
|
|
});
|
|
|
|
it('requests admin review path when set hint misses the catalog', () => {
|
|
const action = resolveDisambiguationRefineAction(
|
|
{
|
|
matches: [{}],
|
|
ocr: { setName: 'Unknown Set', cardName: 'Bolt' },
|
|
},
|
|
{ candidates }
|
|
);
|
|
|
|
expect(action.type).toBe('catalog_gap');
|
|
expect(action.submitPayload.candidateCardIds).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
describe('buildOcrMetaFromIdentifyResult', () => {
|
|
it('prefers top-level OCR fields over nested card OCR', () => {
|
|
expect(
|
|
buildOcrMetaFromIdentifyResult({
|
|
ocr: { confidence: 95, rawText: 'From top' },
|
|
card: { ocr: { confidence: 50, rawText: 'From card', abilities: ['Flying'] }, hp: 4 },
|
|
})
|
|
).toMatchObject({
|
|
confidence: 95,
|
|
rawText: 'From top',
|
|
abilities: ['Flying'],
|
|
hp: 4,
|
|
});
|
|
});
|
|
});
|