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.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user