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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
Reference in New Issue
Block a user