test(capture): require mask alpha in inventory PNG compose
Why
Calculate showed accurate numbers but the inventory PNG kept the sheet
background. Measure already uses approvedMask.bitset; encode did not.
Scope
Add composeInventoryFrame and inventory.test.ts alpha/crop proofs.
Wire encodeInventoryPng({ bitmap, mask, source }) at calculate().
This commit still paints the slabBounds AABB without the bitset gate so
the alpha contract fails until the follow-up fix.
Verification
node --import tsx --test src/png/inventory.test.ts
alpha contract fails (sheet green leaks); 3:4 / 80% / floor pass.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
|
||||
const rgba = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = paint(x, y);
|
||||
const i = (y * w + x) * 4;
|
||||
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return { width: w, height: h, rgba };
|
||||
};
|
||||
|
||||
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
|
||||
({ bitset, slabBounds });
|
||||
|
||||
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
|
||||
const W = 8, H = 8;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
|
||||
bitset[2 * W + 2] = 0;
|
||||
bitset[3 * W + 3] = 0;
|
||||
const SLAB = [200, 40, 20, 255] as const;
|
||||
const SHEET = [0, 255, 0, 255] as const;
|
||||
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 1600, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
let transparent = 0;
|
||||
for (let i = 0; i < out.rgba.length; i += 4) {
|
||||
const a = out.rgba[i + 3];
|
||||
if (a === 0) {
|
||||
transparent++;
|
||||
assert.equal(out.rgba[i], 0);
|
||||
assert.equal(out.rgba[i + 1], 0);
|
||||
assert.equal(out.rgba[i + 2], 0);
|
||||
continue;
|
||||
}
|
||||
opaque++;
|
||||
assert.equal(a, 255);
|
||||
assert.deepEqual(
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
|
||||
[SLAB[0], SLAB[1], SLAB[2]],
|
||||
'opaque pixel must be slab RGB, never sheet green',
|
||||
);
|
||||
}
|
||||
assert.ok(opaque > 0, 'slab pixels are opaque');
|
||||
assert.ok(transparent > 0, 'frame has transparent pixels');
|
||||
});
|
||||
|
||||
test('3:4 frame at the floor: 2000×1800 slab → 1600×2133', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.width, 1600);
|
||||
assert.equal(out.height, 2133);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
});
|
||||
|
||||
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 100, y: 100, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
let minx = out.width, maxx = -1, miny = out.height, maxy = -1;
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
if (out.rgba[(y * out.width + x) * 4 + 3] !== 0) {
|
||||
if (x < minx) minx = x;
|
||||
if (x > maxx) maxx = x;
|
||||
if (y < miny) miny = y;
|
||||
if (y > maxy) maxy = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
const xs = [minx, maxx], ys = [miny, maxy];
|
||||
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||
assert.ok(Math.abs(wExtent - 1280) <= 2, `width extent ${wExtent} ≈ 1280 (0.8×1600)`);
|
||||
assert.ok(Math.abs(hExtent - 1152) <= 2, `height extent ${hExtent} ≈ 1152 (0.8×1800×0.64)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW < 1600', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 1600, `outW ${out.width}`);
|
||||
});
|
||||
Reference in New Issue
Block a user