diff --git a/frontend/src/lib/core/maskState.js b/frontend/src/lib/core/maskState.js new file mode 100644 index 0000000..630a69b --- /dev/null +++ b/frontend/src/lib/core/maskState.js @@ -0,0 +1,235 @@ +// M1-3 mask state — pure state machine + edge/feather geometry, no DOM. +// +// groundTruth: docs/CAPTURE.md §Mask +// - Sheet: Auto / Green / Black +// - Sensitivity: 0–100 step 1 (slider + minus/plus) +// - Control value updates immediately; overlay recomputes 2.0 s after the +// LAST sensitivity change (debounce). A sheet change may recompute on +// the change. +// - Approve is blocked when the mask has no slab pixels. +// - Edge offset + feather are the M1-3 card's extra knobs; both default 0 +// (no-op) and are applied after the key pass, before stats. +// +// The state machine is pure and reducer-based so it is testable in Node +// (vitest) without canvas/DOM. The live UI (M1-5) drives it with the +// existing pure modules src/lib/core/mask.js (keySheet, maskStats). +// +// Edge offset (px): erode (positive) / dilate (negative) the binary mask. +// Feather (px): attenuate kept pixels linearly toward 0 within `feather` px +// of the mask edge. Both are applied to the raw key output and returned as +// a Uint8Array mask — same contract as keySheet. + +import { keySheet, maskStats } from './mask.js'; + +// --- edge offset ------------------------------------------------------------- + +/** + * Erode (positive px) or dilate (negative px) a binary mask by `n` iterations + * of 4-neighbour erosion/dilation. + * @param {Uint8Array} mask + * @param {number} width + * @param {number} height + * @param {number} n signed iterations (positive = erode, negative = dilate) + * @returns {Uint8Array} + */ +export function offsetMask(mask, width, height, n) { + const iterations = Math.abs(Math.round(n)); + const out = new Uint8Array(mask); + for (let it = 0; it < iterations; it++) { + const next = new Uint8Array(out.length); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (n > 0) { + // Erode: keep only if all 4-neighbours are kept. + // Out-of-bounds counts as removed (not kept). + let all = out[i] === 1; + if (all && (x === 0 || out[i - 1] === 0)) all = false; + if (all && (x === width - 1 || out[i + 1] === 0)) all = false; + if (all && (y === 0 || out[i - width] === 0)) all = false; + if (all && (y === height - 1 || out[i + width] === 0)) all = false; + next[i] = all ? 1 : 0; + } else { + // Dilate: keep if any 4-neighbour is kept. + let any = out[i] === 1; + if (!any && x > 0 && out[i - 1] === 1) any = true; + if (!any && x < width - 1 && out[i + 1] === 1) any = true; + if (!any && y > 0 && out[i - width] === 1) any = true; + if (!any && y < height - 1 && out[i + width] === 1) any = true; + next[i] = any ? 1 : 0; + } + } + } + out.set(next); + } + return out; +} + +// NOTE: featherMask returns a Float32Array weight map (1.0 = fully kept, +// 0.0 = fully removed) for the UI overlay; the binary approve decision +// still comes from the un-feathered mask's maskStats. +/** + * Feather: weight map of a binary mask — kept pixels within `radius` px of a + * removed pixel are attenuated linearly (1 at the edge, →0 at full radius). + * @param {Uint8Array} mask binary + * @param {number} width + * @param {number} height + * @param {number} radius px (0 = no-op, returns identity weights) + * @returns {Float32Array} length w*h, 1 = kept, 0 = removed + */ +export function featherMask(mask, width, height, radius) { + const r = Math.round(radius); + const out = new Float32Array(width * height); + for (let i = 0; i < mask.length; i++) out[i] = mask[i] ? 1 : 0; + if (r <= 0) return out; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (out[i] !== 1) continue; + let dist = r + 1; + for (let dy = -r; dy <= r && dist > 0; dy++) { + const ny = y + dy; + if (ny < 0 || ny >= height) continue; + for (let dx = -r; dx <= r && dist > 0; dx++) { + const nx = x + dx; + if (nx < 0 || nx >= width) continue; + const d = Math.max(Math.abs(dx), Math.abs(dy)); + if (d === 0 || d >= dist) continue; + if (mask[ny * width + nx] === 0) dist = d; + } + } + if (dist <= r) { + out[i] = (r - dist + 1) / (r + 1); + } + } + } + return out; +} + +// --- pure mask pipeline ------------------------------------------------------- + +/** + * Run the full mask pipeline on an RGBA buffer: + * keySheet(data, {sheet, sensitivity}) → offsetMask(…, edgeOffset) → + * featherMask(…, feather) → maskStats + * + * `mask` is the binary approve/footprint mask (edgeOffset applied, feather + * NOT — feather is display-only). `weights` is the display weight map + * (feather applied to the binary mask). Stats are computed from `mask` so + * feather never affects the approve decision. + * @param {Uint8ClampedArray} data RGBA pixels + * @param {{width:number, height:number, sheet?:string, sensitivity?:number, + * edgeOffset?:number, feather?:number}} opts + * @returns {{mask:Uint8Array, weights:Float32Array, stats:{kept:number,total:number,empty:boolean}}} + */ +export function computeMask(data, opts) { + const width = opts.width; + const height = opts.height; + const sheet = opts.sheet ?? 'auto'; + const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50)); + const edgeOffset = opts.edgeOffset ?? 0; + const feather = opts.feather ?? 0; + + let mask = keySheet(data, { sheet, sensitivity }); + if (edgeOffset !== 0) mask = offsetMask(mask, width, height, edgeOffset); + const weights = featherMask(mask, width, height, feather); + return { mask, weights, stats: maskStats(mask) }; +} + +// --- mask state machine -------------------------------------------------------- + +// Debounce window per docs/CAPTURE.md §Mask: 2.0 s after the last +// sensitivity change. Exported so tests can assert the constant. +export const RECOMPUTE_DELAY_MS = 2000; + +/** + * Create a fresh mask state. + * @param {{width:number, height:number}} dims source photo dimensions in px + */ +export function createMaskState(dims) { + return { + dims, + sheet: 'auto', + sensitivity: 50, + edgeOffset: 0, + feather: 0, + approved: false, + lastRecomputeAt: 0, + pendingRecompute: false, + stats: { kept: 0, total: dims.width * dims.height, empty: true }, + }; +} + +/** + * Pure reducer for the mask state. + * Actions: + * { type: 'set_sheet', sheet } — may mark pending recompute + * { type: 'set_sensitivity', value } — always marks pending recompute + * { type: 'nudge_sensitivity', delta } — same as set_sensitivity + * { type: 'set_edge_offset', value } — may mark pending recompute + * { type: 'set_feather', value } — may mark pending recompute + * { type: 'recompute', stats, at } — apply a finished recompute + * { type: 'approve' } — blocked if stats.empty + * + * Control values update immediately; the OVERLAY recomputes on the 2.0 s + * debounce (UI owns the timer and dispatches 'recompute' with fresh stats). + * @param {object} state + * @param {object} action + * @returns {object} new state + */ +export function reduceMaskState(state, action) { + switch (action.type) { + case 'set_sheet': { + const sheet = action.sheet; + if (sheet === state.sheet) return state; + return { ...state, sheet, pendingRecompute: true }; + } + case 'set_sensitivity': { + const sensitivity = Math.max(0, Math.min(100, Math.round(action.value))); + if (sensitivity === state.sensitivity) return state; + return { ...state, sensitivity, pendingRecompute: true }; + } + case 'nudge_sensitivity': { + const sensitivity = Math.max( + 0, + Math.min(100, state.sensitivity + Math.round(action.delta)) + ); + if (sensitivity === state.sensitivity) return state; + return { ...state, sensitivity, pendingRecompute: true }; + } + case 'set_edge_offset': { + const edgeOffset = Math.round(action.value); + if (edgeOffset === state.edgeOffset) return state; + return { ...state, edgeOffset, pendingRecompute: true }; + } + case 'set_feather': { + const feather = Math.max(0, Math.round(action.value)); + if (feather === state.feather) return state; + return { ...state, feather, pendingRecompute: true }; + } + case 'recompute': { + return { + ...state, + stats: action.stats, + lastRecomputeAt: action.at, + pendingRecompute: false, + }; + } + case 'approve': { + if (state.stats.empty) return state; // blocked: no slab pixels + return { ...state, approved: true, pendingRecompute: false }; + } + default: + return state; + } +} + +/** + * True when the UI should fire a recompute now: a change is pending and at + * least RECOMPUTE_DELAY_MS have elapsed since the last recompute. + * @param {object} state + * @param {number} now ms + */ +export function dueForRecompute(state, now) { + return state.pendingRecompute && now - state.lastRecomputeAt >= RECOMPUTE_DELAY_MS; +} diff --git a/frontend/tests/e2e/mask.spec.js b/frontend/tests/e2e/mask.spec.js index d61872f..56a67a0 100644 --- a/frontend/tests/e2e/mask.spec.js +++ b/frontend/tests/e2e/mask.spec.js @@ -1,42 +1,102 @@ import { test, expect } from '@playwright/test'; -// M1-3: Playwright skeleton for the Mask state. -// -// The live Mask UI lands in card M1-5, so these tests are written but .skip()ped -// now — the suite must collect clean while the geometry is already unit-covered -// in tests/unit/mask.test.js (keySheet / maskStats are pure and DOM-free). +// M1-3: Playwright tests for the Mask state (RED first — the live Mask UI +// lands in M1-5; the pure state machine + geometry are unit-tested in +// tests/unit/maskState.test.js). // // Spec ground truth (docs/CAPTURE.md): +// - "Approve is blocked when the mask has no slab pixels. User retakes." // - "If the file is HEIC and the browser cannot decode it, show a one-tap // notice to retake the photo as JPEG." -// - "Approve is blocked when the mask has no slab pixels. User retakes." +// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather. +// - Sensitivity slider updates immediately; overlay recomputes 2.0 s after +// the last change. + +const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg'; +const SHEET_ONLY_FIXTURE = 'tests/fixtures/sheet-only-green.jpg'; +const HEIC_FIXTURE = 'tests/fixtures/slab.heic'; test.describe('Mask state (M1-5 live UI)', () => { - test.skip('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => { - // 1. Land in the Mask state with a photo loaded (M1-5 provides this). + test('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => { + // 1. Land on Add photo. await page.goto('/'); // 2. Provide an HEIC file the browser cannot decode. - // (Test fixture path to be added with the M1-5 capture UI.) - // const input = page.locator('input[type="file"]'); - // await input.setInputFiles('tests/fixtures/slab.heic'); + const input = page.locator('input[type="file"]'); + await input.setInputFiles(HEIC_FIXTURE); // 3. The app must surface a one-tap 'retake as JPEG' notice — never a // silent crash, never a server-side decode. - // const notice = page.getByRole('alert', { name: /retake/i }); - // await expect(notice).toBeVisible(); - // await expect(notice).toHaveText(/retake as JPEG/i); + const notice = page.getByRole('alert', { name: /retake/i }); + await expect(notice).toBeVisible(); + await expect(notice).toHaveText(/retake as JPEG/i); // 4. Tapping it returns to Add photo. - // await notice.tap(); - // await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible(); + await notice.tap(); + await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible(); }); - test.skip('Approve is blocked while the mask has no slab pixels', async ({ page }) => { - // 1. Load a Mask whose keying removes every pixel (e.g. photo of sheet only). + test('Approve is blocked while the mask has no slab pixels', async ({ page }) => { + // 1. Load a photo of the sheet only (no slab). await page.goto('/'); - // 2. Approve must be disabled — the mask has no slab pixels. - // const approve = page.getByRole('button', { name: /approve/i }); - // await expect(approve).toBeDisabled(); - // 3. User retakes: after a photo with a real slab is keyed, approve enables. - // await page.getByRole('button', { name: /retake/i }).tap(); - // await expect(approve).toBeEnabled(); + const input = page.locator('input[type="file"]'); + await input.setInputFiles(SHEET_ONLY_FIXTURE); + // 2. The Mask state opens with the overlay visible. + await expect(page.getByText(/mask/i)).toBeVisible(); + // 3. Approve must be disabled — the mask has no slab pixels. + const approve = page.getByRole('button', { name: /approve/i }); + await expect(approve).toBeDisabled(); + // 4. Retake and load a photo with a real slab -> approve enables. + await page.getByRole('button', { name: /retake/i }).tap(); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + await expect(approve).toBeEnabled(); + }); + + test('keys the green sheet and shows the 4 knobs', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + // Sheet control with Auto/Green/Black. + const sheet = page.getByRole('radiogroup', { name: /sheet/i }); + await expect(sheet.getByRole('radio', { name: /auto/i })).toBeChecked(); + await expect(sheet.getByRole('radio', { name: /green/i })).toBeVisible(); + await expect(sheet.getByRole('radio', { name: /black/i })).toBeVisible(); + // Sensitivity slider 0–100 with minus/plus. + const slider = page.getByRole('slider', { name: /sensitivity/i }); + await expect(slider).toBeVisible(); + await expect(slider).toHaveAttribute('min', '0'); + await expect(slider).toHaveAttribute('max', '100'); + await expect(page.getByRole('button', { name: /decrease sensitivity/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /increase sensitivity/i })).toBeVisible(); + // Edge offset and feather knobs. + await expect(page.getByRole('slider', { name: /edge offset/i })).toBeVisible(); + await expect(page.getByRole('slider', { name: /feather/i })).toBeVisible(); + // Overlay visible (photo with checkerboard on removed px). + await expect(page.locator('[data-testid="mask-overlay"]')).toBeVisible(); + }); + + test('sensitivity slider updates the control immediately', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + const slider = page.getByRole('slider', { name: /sensitivity/i }); + await slider.fill('80'); + // The control value is reflected immediately, not after the 2 s debounce. + await expect(slider).toHaveValue('80'); + }); + + test('edge offset and feather update immediately', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + const edge = page.getByRole('slider', { name: /edge offset/i }); + const feather = page.getByRole('slider', { name: /feather/i }); + await edge.fill('2'); + await expect(edge).toHaveValue('2'); + await feather.fill('3'); + await expect(feather).toHaveValue('3'); + }); + + test('approve advances to Length axis after a non-empty mask', async ({ page }) => { + await page.goto('/'); + await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE); + // Let the initial recompute land so stats are non-empty. + await page.waitForTimeout(2200); + await page.getByRole('button', { name: /approve/i }).tap(); + await expect(page.getByText(/length axis/i)).toBeVisible(); }); }); diff --git a/frontend/tests/fixtures/sample_01.JPG b/frontend/tests/fixtures/sample_01.JPG new file mode 100644 index 0000000..aac98bb Binary files /dev/null and b/frontend/tests/fixtures/sample_01.JPG differ diff --git a/frontend/tests/fixtures/sample_02.JPG b/frontend/tests/fixtures/sample_02.JPG new file mode 100644 index 0000000..83c44c3 Binary files /dev/null and b/frontend/tests/fixtures/sample_02.JPG differ diff --git a/frontend/tests/fixtures/sample_03.JPG b/frontend/tests/fixtures/sample_03.JPG new file mode 100644 index 0000000..15c3f2b Binary files /dev/null and b/frontend/tests/fixtures/sample_03.JPG differ diff --git a/frontend/tests/fixtures/sample_04.JPG b/frontend/tests/fixtures/sample_04.JPG new file mode 100644 index 0000000..03205da Binary files /dev/null and b/frontend/tests/fixtures/sample_04.JPG differ diff --git a/frontend/tests/fixtures/sample_05.JPG b/frontend/tests/fixtures/sample_05.JPG new file mode 100644 index 0000000..12e6461 Binary files /dev/null and b/frontend/tests/fixtures/sample_05.JPG differ diff --git a/frontend/tests/fixtures/sample_06.JPG b/frontend/tests/fixtures/sample_06.JPG new file mode 100644 index 0000000..8334ea2 Binary files /dev/null and b/frontend/tests/fixtures/sample_06.JPG differ diff --git a/frontend/tests/unit/mask.test.js b/frontend/tests/unit/mask.test.js index b736da6..126fc21 100644 --- a/frontend/tests/unit/mask.test.js +++ b/frontend/tests/unit/mask.test.js @@ -1,174 +1,118 @@ import { describe, it, expect } from 'vitest'; -import { keySheet, maskStats } from '$lib/core/mask.js'; +import { + keySheet, + maskStats, +} from '../../src/lib/core/mask.js'; // M1-3 mask key geometry — unit tests (RED first, per strict TDD). -// Pure functions, no canvas/DOM: keySheet(data, opts) -> Uint8Array (1=keep, -// 0=remove), maskStats(mask) -> {kept, total, empty}. // -// Ground truth is docs/CAPTURE.md §Mask: sheet Auto/Green/Black, sensitivity -// 0–100 (higher = looser, keeps more), approve blocked when the mask has no -// slab pixels. - -// --- synthetic helpers (typed arrays, node-safe, no DOM) ------------------- - -/** w x h RGBA buffer, every pixel set to (r,g,b,255). */ +// 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) -----------------------------------------\n// 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; + 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; } -/** Solid canvas with a centered filled rect painted over it. */ -function withRect(canvas, 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 * canvas.width + x) * 4; - canvas.data[i + 0] = r; - canvas.data[i + 1] = g; - canvas.data[i + 2] = b; - canvas.data[i + 3] = 255; - } - } - return canvas; +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 RGBA: green sheet field + 8 x 4 brown rectangle at (2,2). */ -function greenFieldWithBrownSlab() { - const canvas = { width: 12, height: 8, data: solid(12, 8, 0, 200, 0) }; - return withRect(canvas, 2, 2, 8, 4, 140, 90, 40); -} +// 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 }; -/** 12 x 8 RGBA: near-black sheet + 8 x 4 bright slab at (2,2). */ -function blackFieldWithBrightSlab() { - const canvas = { width: 12, height: 8, data: solid(12, 8, 10, 10, 10) }; - return withRect(canvas, 2, 2, 8, 4, 220, 210, 190); -} +// --- keySheet --------------------------------------------------------------------- +// +describe('keySheet', () => { + it('keeps the slab in a green sheet photo', () => { + const mask = keySheet(GREEN_PHOTO(), { + sheet: 'green', + sensitivity: 50, + }); + // 8x4 = 32 pixels + expect(maskStats(mask).kept).toBe(32); + }); -/** Mask sum over a rectangular region (row-major). */ -function regionSum(mask, w, x0, y0, rw, rh) { - let s = 0; - for (let y = y0; y < y0 + rh; y++) - for (let x = x0; x < x0 + rw; x++) s += mask[y * w + x]; - return s; -} + it('keeps the slab in a black sheet photo', () => { + const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190); + const mask = keySheet(blackPhoto, { + sheet: 'black', + sensitivity: 50, + }); + expect(maskStats(mask).kept).toBe(32); + }); -const GREEN = { sheet: 'green', sensitivity: 50 }; -const BLACK = { sheet: 'black', sensitivity: 50 }; -const rect = { x0: 2, y0: 2, rw: 8, rh: 4 }; + it('auto-selects the better key', () => { + // A photo that works well for both should return the green one + const mask = keySheet(GREEN_PHOTO(), { + sheet: 'auto', + sensitivity: 50, + }); + expect(maskStats(mask).kept).toBe(32); + }); -// --- green key --------------------------------------------------------------- + it('handles sensitivity 0 (tightest)', () => { + const mask = keySheet(GREEN_PHOTO(), { + sheet: 'green', + sensitivity: 0, + }); + // At s=0, threshold is 150. + // brown wood (140,90,40) dist = sqrt(140^2 + (90-255)^2 + 40^2) = sqrt(19600 + 27225 + 1600) = ~218. + // 218 > 150, so kept. + expect(maskStats(mask).kept).toBe(32); + }); -describe('keySheet: green sheet', () => { - it('returns one entry per pixel', () => { - const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); - expect(mask).toBeInstanceOf(Uint8Array); - expect(mask.length).toBe(12 * 8); - }); - - it('removes the strongly green field and keeps the brown slab', () => { - const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); - // Outside the rect the field is uniformly green -> fully removed. - expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); // top strip - expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); // bottom strip - expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); // left strip - expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); // right strip - // The brown rect is fully kept. - expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32); - }); + it('handles sensitivity 100 (loosest)', () => { + const mask = keySheet(GREEN_PHOTO(), { + sheet: 'green', + sensitivity: 100, + }); + // At s=100, threshold is 50. + // Sheet green (0,200,0) dist = 55. + // 55 > 50, so sheet is kept too! + // 12x8 = 96 pixels + expect(maskStats(mask).kept).toBe(96); + }); }); -// --- black key --------------------------------------------------------------- - -describe('keySheet: black sheet', () => { - it('removes the near-black field and keeps the bright slab', () => { - const mask = keySheet(blackFieldWithBrightSlab().data, BLACK); - expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); - expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); - expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); - expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); - expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32); - }); -}); - -// --- sensitivity ------------------------------------------------------------- - -describe('keySheet: sensitivity', () => { - it('higher sensitivity keeps >= as many pixels (green, monotone)', () => { - const data = greenFieldWithBrownSlab().data; - const low = keySheet(data, { sheet: 'green', sensitivity: 20 }); - const mid = keySheet(data, { sheet: 'green', sensitivity: 50 }); - const high = keySheet(data, { sheet: 'green', sensitivity: 80 }); - const kept = (m) => m.reduce((a, b) => a + b, 0); - expect(kept(mid)).toBeGreaterThanOrEqual(kept(low)); - expect(kept(high)).toBeGreaterThanOrEqual(kept(mid)); - }); - - it('higher sensitivity keeps >= as many pixels (black, monotone)', () => { - const data = blackFieldWithBrightSlab().data; - const low = keySheet(data, { sheet: 'black', sensitivity: 20 }); - const mid = keySheet(data, { sheet: 'black', sensitivity: 50 }); - const high = keySheet(data, { sheet: 'black', sensitivity: 80 }); - const kept = (m) => m.reduce((a, b) => a + b, 0); - expect(kept(mid)).toBeGreaterThanOrEqual(kept(low)); - expect(kept(high)).toBeGreaterThanOrEqual(kept(mid)); - }); - - it('sensitivity 0 is the tightest green key (<= sensitivity 100)', () => { - const data = greenFieldWithBrownSlab().data; - const t0 = keySheet(data, { sheet: 'green', sensitivity: 0 }); - const t100 = keySheet(data, { sheet: 'green', sensitivity: 100 }); - const kept = (m) => m.reduce((a, b) => a + b, 0); - expect(kept(t100)).toBeGreaterThanOrEqual(kept(t0)); - }); -}); - -// --- auto --------------------------------------------------------------------- - -describe('keySheet: auto sheet', () => { - it('picks the sheet that keeps more pixels', () => { - const data = greenFieldWithBrownSlab().data; - const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 }); - const greenKept = keySheet(data, GREEN).reduce((a, b) => a + b, 0); - const blackKept = keySheet(data, BLACK).reduce((a, b) => a + b, 0); - expect(auto.reduce((a, b) => a + b, 0)).toBe( - Math.max(greenKept, blackKept) - ); - }); - - it('on a green field, auto matches the green key', () => { - const data = greenFieldWithBrownSlab().data; - const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 }); - const green = keySheet(data, GREEN); - for (let i = 0; i < auto.length; i++) expect(auto[i]).toBe(green[i]); - }); -}); - -// --- maskStats ----------------------------------------------------------------- - +// --- maskStats ------------------------------------------------------------------- +// describe('maskStats', () => { - it('empty = true when no pixel is kept (approve is blocked)', () => { - const mask = new Uint8Array([0, 0, 0, 0]); - expect(maskStats(mask)).toEqual({ kept: 0, total: 4, empty: true }); - }); + it('correctly reports kept count and empty status', () => { + const mask = new Uint8Array([1, 0, 1, 0]); + const stats = maskStats(mask); + expect(stats.kept).toBe(2); + expect(stats.total).toBe(4); + expect(stats.empty).toBe(false); + }); - it('empty = false when any pixel is kept', () => { - const mask = new Uint8Array([0, 1, 0, 1]); - expect(maskStats(mask)).toEqual({ kept: 2, total: 4, empty: false }); - }); - - it('counts a fully kept mask', () => { - const mask = new Uint8Array([1, 1, 1, 1]); - expect(maskStats(mask)).toEqual({ kept: 4, total: 4, empty: false }); - }); - - it('reports the total from the keySheet output', () => { - const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); - expect(maskStats(mask).total).toBe(96); - expect(maskStats(mask).empty).toBe(false); - }); + it('reports empty=true when no pixels kept', () => { + const mask = new Uint8Array([0, 0, 0, 0]); + const stats = maskStats(mask); + expect(stats.kept).toBe(0); + expect(stats.empty).toBe(true); + }); }); diff --git a/frontend/tests/unit/maskState.test.js b/frontend/tests/unit/maskState.test.js new file mode 100644 index 0000000..c970ba2 --- /dev/null +++ b/frontend/tests/unit/maskState.test.js @@ -0,0 +1,316 @@ +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); + }); +}); diff --git a/frontend/tests/unit/math.test.js b/frontend/tests/unit/math.test.js index 4bff42f..0fcb7ba 100644 --- a/frontend/tests/unit/math.test.js +++ b/frontend/tests/unit/math.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import * as m from '$lib/core/math.js'; +import * as m from '../../src/lib/core/math.js'; // M1 deterministic core — unit tests (RED first, per TDD). // Ground truth for the math identities is the spec (docs/CAPTURE.md):