Files
SlabUploader/frontend/tests/unit/mask.test.js
T
Ty dd011207a0 feat(m1-2,m1-3): deterministic core + mask keying (TDD, green)
Card 2 (deterministic core) + Card 3 (capture+mask geometry):
- src/lib/core/math.js: scale, sqft, bdft, roundHalfUp, width sampling,
  1/8-inch snap — implemented to docs/CAPTURE.md identities.
- src/lib/core/mask.js: keySheet green/black/auto chroma keys + maskStats.
  - fixed sensitivity scale (0-100, higher = looser = keeps more)
  - fixed inverted green threshold (150 - s)
  - black key drops bright saturated sheets via saturation gate
- tests/unit/math.test.js: corrected 3 wrong expected values to match the
  spec (sqft 21x12in = 1.75 not 17.5; bdft 3.06; sample avg 11.25).
- tests/e2e/mask.spec.js: skeleton, skipped pending M1-5 live UI.

Full unit suite green: 24/24. E2e collects clean.
2026-09-13 23:17:36 -07:00

175 lines
6.5 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 } from 'vitest';
import { keySheet, maskStats } from '$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). */
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;
}
/** 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;
}
/** 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 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);
}
/** 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;
}
const GREEN = { sheet: 'green', sensitivity: 50 };
const BLACK = { sheet: 'black', sensitivity: 50 };
const rect = { x0: 2, y0: 2, rw: 8, rh: 4 };
// --- green key ---------------------------------------------------------------
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);
});
});
// --- 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 -----------------------------------------------------------------
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('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);
});
});