M1-3: Capture + Mask state (TDD)

- Add maskState.js: sheet Auto/Green/Black, sensitivity, edge offset, feather
- Overlay = slab photo + checkerboard on removed px
- Debounced recompute 2.0s after last sensitivity change
- Approve blocked when maskStats empty
- HEIC shows retake notice
- Refactored mask.test.js + extended e2e mask.spec.js
- 43/43 unit tests green
This commit is contained in:
Ty
2026-09-14 00:02:36 -07:00
parent dd011207a0
commit 7e2a1845cf
11 changed files with 735 additions and 180 deletions
+235
View File
@@ -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;
}
+84 -24
View File
@@ -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();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

+99 -155
View File
@@ -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);
});
});
+316
View File
@@ -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);
});
});
+1 -1
View File
@@ -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):