import { describe, it, expect, vi } from 'vitest'; import { offsetMask, featherMask, computeMask, createMaskState, reduceMaskState, dueForRecompute, RECOMPUTE_DELAY_MS, } from '../../src/lib/core/maskState.js'; // M1-3 Mask state — RED first (strict TDD). // // Ground truth: docs/CAPTURE.md §Mask // - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather // - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the // last sensitivity change (debounce). // - Approve blocked when maskStats is empty. // // All functions here are pure (no DOM) so the state is unit-testable in Node; // the live UI (M1-5) drives this machine. // // --- synthetic photo helpers (no DOM) -----------------------------------------\ // function solid(w, h, r, g, b) { const data = new Uint8ClampedArray(w * h * 4); for (let i = 0; i < w * h; i++) { data[i * 4 + 0] = r; data[i * 4 + 1] = g; data[i * 4 + 2] = b; data[i * 4 + 3] = 255; } return data; } function withRect(data, width, x0, y0, w, h, r, g, b) { for (let y = y0; y < y0 + h; y++) { for (let x = x0; x < x0 + w; x++) { const i = (y * width + x) * 4; data[i + 0] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255; } } return data; } // 12 x 8 green sheet with an 8 x 4 brown slab at (2,2). const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40); const DIMS = { width: 12, height: 8 }; // --- offsetMask ------------------------------------------------------------------ // describe('offsetMask (edge offset)', () => { it('offset 0 is a no-op', () => { const mask = new Uint8Array([1, 1, 1, 0, 0, 0]); expect(offsetMask(mask, 3, 2, 0)).toEqual(new Uint8Array([1, 1, 1, 0, 0, 0])); }); it('positive offset erodes the mask boundary', () => { // 1 kept ring of 1s on a 5x1 row: erode 1 -> the single kept pixel // loses its kept neighbours' protection and is removed. const mask = new Uint8Array([0, 0, 1, 0, 0]); const eroded = offsetMask(mask, 5, 1, 1); expect(eroded).toEqual(new Uint8Array([0, 0, 0, 0, 0])); }); it('positive offset erodes a filled block by 1 px ring', () => { // 4x4 all-kept block -> 2x2 after one erosion. const mask = new Uint8Array(16).fill(1); const eroded = offsetMask(mask, 4, 4, 1); // corners + outer ring gone, inner 2x2 kept. expect(eroded[0]).toBe(0); expect(eroded[3]).toBe(0); expect(eroded[4]).toBe(0); expect(eroded[7]).toBe(0); expect(eroded[5]).toBe(1); expect(eroded[6]).toBe(1); expect(eroded[9]).toBe(1); expect(eroded[10]).toBe(1); }); it('negative offset dilates the mask', () => { // single kept pixel grows to a plus shape after one dilation. const mask = new Uint8Array(9); mask[4] = 1; const dilated = offsetMask(mask, 3, 3, -1); expect(dilated[1]).toBe(1); // up expect(dilated[3]).toBe(1); // left expect(dilated[4]).toBe(1); // center expect(dilated[5]).toBe(1); // right expect(dilated[7]).toBe(1); // down expect(dilated[0]).toBe(0); // corners untouched by 4-neighbour dilate }); it('multiple iterations compound', () => { // 6x6 block eroded twice -> 2x2 core. const mask = new Uint8Array(36).fill(1); const twice = offsetMask(mask, 6, 6, 2); let kept = 0; for (let i = 0; i < 36; i++) kept += twice[i]; expect(kept).toBe(4); }); }); // --- featherMask ------------------------------------------------------------------ // describe('featherMask (feather)', () => { it('radius 0 returns identity weights (1 kept, 0 removed)', () => { const mask = new Uint8Array([1, 0, 1]); expect(featherMask(mask, 3, 1, 0)).toEqual(new Float32Array([1, 0, 1])); }); it('interior pixels away from the edge stay fully weighted', () => { // 5x1 row, all kept, feather 1: interior pixel (2,0) has min distance // 2 to any 0 (out-of-bounds is not 0, but row ends are boundary — // boundary pixels have no removed neighbour, so weight stays 1). const mask = new Uint8Array([1, 1, 1, 1, 1]); const w = featherMask(mask, 5, 1, 1); expect(w[2]).toBe(1); }); it('attenuates kept pixels adjacent to removed pixels', () => { // 3x1 row: [0,1,0], feather 1: the kept middle pixel is 1 px from a // removed pixel -> weight (r - dist + 1)/(r + 1) = (1-1+1)/(1+1) = 0.5. const mask = new Uint8Array([0, 1, 0]); const w = featherMask(mask, 3, 1, 1); expect(w[1]).toBeCloseTo(0.5); }); it('attenuation scales with distance (farther = heavier)', () => { // 5x1: [0,1,1,1,0], feather 2: // x=1 dist 1 -> (2-1+1)/3 = 2/3 // x=2 dist 2 -> (2-2+1)/3 = 1/3 // x=3 dist 1 -> 2/3 const mask = new Uint8Array([0, 1, 1, 1, 0]); const w = featherMask(mask, 5, 1, 2); expect(w[1]).toBeCloseTo(2 / 3); expect(w[2]).toBeCloseTo(1 / 3); expect(w[3]).toBeCloseTo(2 / 3); }); }); // --- computeMask -------------------------------------------------------------------- // describe('computeMask (pipeline)', () => { it('keys green and black sheets end-to-end', () => { const green = computeMask(GREEN_PHOTO(), { ...DIMS, sheet: 'green', sensitivity: 50, }); expect(green.stats.kept).toBe(32); expect(green.stats.empty).toBe(false); const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190); const black = computeMask(blackPhoto, { ...DIMS, sheet: 'black', sensitivity: 50, }); expect(black.stats.kept).toBe(32); }); it('edge offset erodes the kept count', () => { const base = computeMask(GREEN_PHOTO(), { ...DIMS, sheet: 'green', sensitivity: 50, }); const eroded = computeMask(GREEN_PHOTO(), { ...DIMS, sheet: 'green', sensitivity: 50, edgeOffset: 1, }); expect(eroded.stats.kept).toBeLessThan(base.stats.kept); }); it('feather does not change the approve stats', () => { const base = computeMask(GREEN_PHOTO(), { ...DIMS, sheet: 'green', sensitivity: 50, }); const feath = computeMask(GREEN_PHOTO(), { ...DIMS, sheet: 'green', sensitivity: 50, feather: 2, }); expect(feath.stats).toEqual(base.stats); }); it('reports empty stats for a sheet-only photo (approve must block)', () => { const res = computeMask(solid(12, 8, 0, 200, 0), { ...DIMS, sheet: 'green', sensitivity: 50, }); expect(res.stats.empty).toBe(true); }); }); // --- state machine -------------------------------------------------------------------- // describe('mask state machine', () => { const fresh = () => createMaskState(DIMS); it('starts with sheet auto, sensitivity 50, knobs 0, not approved, empty stats', () => { const s = fresh(); expect(s.sheet).toBe('auto'); expect(s.sensitivity).toBe(50); expect(s.edgeOffset).toBe(0); expect(s.feather).toBe(0); expect(s.approved).toBe(false); expect(s.stats.empty).toBe(true); }); it('set_sheet updates immediately and flags a pending recompute', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_sheet', sheet: 'green' }); expect(s.sheet).toBe('green'); expect(s.pendingRecompute).toBe(true); }); it('sheet change to the same value is a no-op', () => { const s = fresh(); expect(reduceMaskState(s, { type: 'set_sheet', sheet: 'auto' })).toBe(s); }); it('set_sensitivity updates immediately, clamps 0–100, flags recompute', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_sensitivity', value: 80 }); expect(s.sensitivity).toBe(80); expect(s.pendingRecompute).toBe(true); s = reduceMaskState(s, { type: 'set_sensitivity', value: 250 }); expect(s.sensitivity).toBe(100); s = reduceMaskState(s, { type: 'set_sensitivity', value: -10 }); expect(s.sensitivity).toBe(0); }); it('nudge_sensitivity steps by delta and clamps', () => { let s = fresh(); s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -5 }); expect(s.sensitivity).toBe(45); s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: 5 }); expect(s.sensitivity).toBe(50); s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -999 }); expect(s.sensitivity).toBe(0); }); it('edge offset and feather update immediately and flag recompute', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_edge_offset', value: 2 }); expect(s.edgeOffset).toBe(2); expect(s.pendingRecompute).toBe(true); s = reduceMaskState(s, { type: 'set_feather', value: 3 }); expect(s.feather).toBe(3); expect(s.pendingRecompute).toBe(true); }); it('recompute stores stats, clears pending, records time', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_sensitivity', value: 60 }); s = reduceMaskState(s, { type: 'recompute', stats: { kept: 32, total: 96, empty: false }, at: 1234, }); expect(s.stats).toEqual({ kept: 32, total: 96, empty: false }); expect(s.pendingRecompute).toBe(false); expect(s.lastRecomputeAt).toBe(1234); }); it('approve is blocked while the mask is empty', () => { let s = fresh(); // stats empty by default const before = s; s = reduceMaskState(s, { type: 'approve' }); expect(s.approved).toBe(false); expect(s).toBe(before); // no change at all }); it('approve succeeds once the mask has slab pixels', () => { let s = fresh(); s = reduceMaskState(s, { type: 'recompute', stats: { kept: 32, total: 96, empty: false }, at: 1, }); s = reduceMaskState(s, { type: 'approve' }); expect(s.approved).toBe(true); }); it('debounce: recompute is due only after 2.0 s of idle', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 }); expect(dueForRecompute(s, s.lastRecomputeAt)).toBe(false); expect(dueForRecompute(s, s.lastRecomputeAt + 1999)).toBe(false); expect(RECOMPUTE_DELAY_MS).toBe(2000); expect(dueForRecompute(s, s.lastRecomputeAt + 2000)).toBe(true); }); it('debounce resets on every sensitivity change (timer restarts)', () => { let s = fresh(); s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 }); // User keeps nudging: at t=1999 the earlier change would be due, but // a new change at t=1999 resets the window. s = reduceMaskState(s, { type: 'recompute', stats: s.stats, at: 0 }); s = reduceMaskState(s, { type: 'set_sensitivity', value: 52 }); // 1.9 s after the latest change -> not due; 2.0 s -> due. expect(dueForRecompute(s, 1999)).toBe(false); expect(dueForRecompute(s, 2000)).toBe(true); }); });