import { beforeAll, describe, expect, it } from 'vitest'; import { detect } from '../src/lib/detect'; import { bounds } from '../src/lib/geometry'; import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; import type { DetectParams } from '../src/lib/types'; import { grayImage, inCircle, inRect } from './helpers/images'; let cv: CV; beforeAll(async () => { ({ cv } = await loadOpenCV()); }); const params = (overrides: Partial = {}): DetectParams => ({ threshold: 128, invert: false, seed: null, marginPx: 0, minHolePx: 0, ...overrides, }); /** Light plate x 40..159, y 30..129 with hole r=15 at (100,80), plus a small light blob, on dark ground. */ const plateScene = grayImage(200, 160, (x, y) => { const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15); const blob = inRect(x, y, 170, 140, 185, 152); return plate || blob ? 220 : 30; }); const near = (actual: number, expected: number) => expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2); describe('detect', () => { it('finds the plate and its hole, ignoring the smaller blob', () => { const shape = detect(cv, plateScene, params())!; const b = bounds(shape.outer); near(b.minX, 40); near(b.maxX, 159); near(b.minY, 30); near(b.maxY, 129); expect(shape.holes).toHaveLength(1); }); it('handles a dark piece on a light background with invert', () => { const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30)); expect(detect(cv, inverted, params())).toBeNull(); const b = bounds(detect(cv, inverted, params({ invert: true }))!.outer); near(b.minX, 40); near(b.maxX, 159); }); it('selects the clicked piece even when another piece is larger', () => { const shape = detect(cv, plateScene, params({ seed: { x: 177, y: 146 } }))!; const b = bounds(shape.outer); near(b.minX, 170); near(b.maxX, 184); }); it('falls back to the automatic choice when the seed is on background or in a hole', () => { for (const seed of [{ x: 5, y: 5 }, { x: 100, y: 80 }]) { const b = bounds(detect(cv, plateScene, params({ seed }))!.outer); near(b.minX, 40); } }); it('ignores a seed outside the image', () => { const b = bounds(detect(cv, plateScene, params({ seed: { x: -10, y: 500 } }))!.outer); near(b.minX, 40); }); it('needs a seed for a piece touching the photo border', () => { const framed = grayImage(200, 160, (x, y) => (inRect(x, y, 0, 30, 120, 130) ? 220 : 30)); expect(detect(cv, framed, params())).toBeNull(); const b = bounds(detect(cv, framed, params({ seed: { x: 50, y: 80 } }))!.outer); near(b.minX, 0); near(b.maxX, 119); }); it('returns null when the clicked region is the whole image', () => { const allLight = grayImage(100, 100, () => 220); expect(detect(cv, allLight, params({ seed: { x: 50, y: 50 } }))).toBeNull(); }); it('returns null when nothing passes the threshold', () => { expect(detect(cv, grayImage(100, 100, () => 30), params())).toBeNull(); }); it('applies the margin', () => { const b = bounds(detect(cv, plateScene, params({ marginPx: -5 }))!.outer); near(b.minX, 45); near(b.maxX, 154); }); it('removes single-pixel speckle from the piece', () => { const speckled = grayImage(200, 160, (x, y) => { if (x === 70 && y === 50) return 30; // one dark pixel inside the plate return inRect(x, y, 40, 30, 160, 130) ? 220 : 30; }); expect(detect(cv, speckled, params())!.holes).toHaveLength(0); }); it('fills specks smaller than minHolePx before the margin can grow them', () => { const glare = grayImage(200, 160, (x, y) => { const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15) && !inCircle(x, y, 60, 50, 3); return plate ? 220 : 30; }); expect(detect(cv, glare, params({ marginPx: -6 }))!.holes).toHaveLength(2); expect(detect(cv, glare, params({ marginPx: -6, minHolePx: 10 }))!.holes).toHaveLength(1); }); it('keeps a long thin hole that is under minHolePx in only one direction', () => { const slotted = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) && !inRect(x, y, 60, 60, 120, 66) ? 220 : 30)); expect(detect(cv, slotted, params({ minHolePx: 10 }))!.holes).toHaveLength(1); }); it('does not auto-pick a speck when the real piece touches the border', () => { const framed = grayImage(400, 300, (x, y) => (inRect(x, y, 0, 60, 240, 260) || inRect(x, y, 330, 100, 340, 110) ? 220 : 30)); expect(detect(cv, framed, params())).toBeNull(); }); });