import { describe, expect, it } from 'vitest'; import { convertToVideoCoordinates, mergeDetectedShapesIntoTrackedCards, rectanglesOverlap, refineCardCornersFromEdges, selectCardsReadyForVerification, TRACK_STALE_MS, } from '../../lib/scanner-card-detection.js'; describe('rectanglesOverlap', () => { it('returns true when rectangles mostly overlap', () => { const a = { x: 0, y: 0, width: 100, height: 140 }; const b = { x: 10, y: 10, width: 100, height: 140 }; expect(rectanglesOverlap(a, b, 0.3)).toBe(true); }); it('returns false when rectangles do not touch', () => { const a = { x: 0, y: 0, width: 50, height: 70 }; const b = { x: 200, y: 200, width: 50, height: 70 }; expect(rectanglesOverlap(a, b)).toBe(false); }); }); describe('convertToVideoCoordinates', () => { it('scales detection canvas coords to video pixel space', () => { const canvas = { width: 320, height: 240 }; const video = { videoWidth: 1280, videoHeight: 960 }; expect(convertToVideoCoordinates({ x: 10, y: 20, width: 100, height: 140 }, canvas, video)).toEqual({ x: 40, y: 80, width: 400, height: 560, }); }); }); describe('mergeDetectedShapesIntoTrackedCards', () => { it('creates a new tracked card for an unmatched shape', () => { const now = 1_000_000; const shape = { x: 10, y: 20, width: 100, height: 140, score: 80, corners: [ { x: 10, y: 20 }, { x: 110, y: 22 }, { x: 108, y: 160 }, { x: 12, y: 158 }, ], }; const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([], [shape], 1, now); expect(cards).toHaveLength(1); expect(cards[0]).toMatchObject({ id: 1, status: 'detecting', stableCount: 1, scanAttempts: 0, bounds: { x: 10, y: 20, width: 100, height: 140 }, corners: shape.corners, }); expect(nextCardId).toBe(2); }); it('updates an overlapping tracked card instead of creating a duplicate', () => { const now = 1_000_000; const existing = { id: 5, bounds: { x: 10, y: 20, width: 100, height: 140 }, status: 'detecting', firstSeen: now - 500, lastSeen: now - 100, stableCount: 3, scanAttempts: 0, }; const moved = { x: 12, y: 22, width: 100, height: 140, score: 82 }; const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([existing], [moved], 6, now); expect(cards).toHaveLength(1); expect(cards[0].id).toBe(5); expect(cards[0].stableCount).toBe(4); expect(cards[0].lastSeen).toBe(now); expect(nextCardId).toBe(6); }); it('drops cards not seen within TRACK_STALE_MS', () => { const now = 10_000; const stale = { id: 1, bounds: { x: 0, y: 0, width: 50, height: 70 }, status: 'detecting', firstSeen: now - TRACK_STALE_MS - 1, lastSeen: now - TRACK_STALE_MS - 1, stableCount: 2, scanAttempts: 0, }; const { cards } = mergeDetectedShapesIntoTrackedCards([stale], [], 2, now); expect(cards).toHaveLength(0); }); }); describe('selectCardsReadyForVerification', () => { it('returns detecting cards that are stable long enough', () => { const now = 10_000; const ready = { id: 1, status: 'detecting', stableCount: 3, scanAttempts: 0, firstSeen: now - 900, }; const tooFresh = { id: 2, status: 'detecting', stableCount: 3, scanAttempts: 0, firstSeen: now - 500, }; expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]); }); it('excludes cards that already attempted verification', () => { const now = 10_000; const retried = { id: 1, status: 'detecting', stableCount: 8, scanAttempts: 1, firstSeen: now - 5000, }; expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0); }); }); describe('refineCardCornersFromEdges', () => { it('picks strong edge pixels near each quadrant corner', () => { const width = 120; const height = 168; const edges = new Uint8Array(width * height); const mark = (x, y) => { edges[y * width + x] = 255; }; mark(8, 10); mark(110, 12); mark(108, 156); mark(10, 154); const corners = refineCardCornersFromEdges(edges, width, height, { x: 5, y: 8, width: 110, height: 150, }); expect(corners[0]).toEqual({ x: 8, y: 10 }); expect(corners[1]).toEqual({ x: 110, y: 12 }); expect(corners[2]).toEqual({ x: 108, y: 156 }); expect(corners[3]).toEqual({ x: 10, y: 154 }); }); });