Files
SlabUploader/frontend/tests/unit/maskState.test.js
T
Ty 7e2a1845cf 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
2026-09-14 00:02:36 -07:00

317 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});