feat(gate-b): add product-state fixture harness for TV-3/6/7/10
Real-fixture path and harness for product-state evidence on TV-3 bdft identity, TV-6 crop/PNG rules, TV-7 undersized warn/retake, and TV-10 rounding. - fixtures/real/: acquisition README, placeholder metadata JSON templates - fixtures/real-fixtures.ts: loader that decodes real PNGs, generates masks, runs the pipeline, fails-closed when absent or unmarked - tv3-real.test.ts / tv6-real.test.ts / tv7-real.test.ts / tv10-real.test.ts: product-state tests that skip when real fixtures are absent - Updated RESULTS.md with real-fixture harness note - Added pngjs devDependency for PNG decoding in Node.js Dual-proof preserved: proxy-synthetic unit rows remain separate and unchanged. Gate B is not green; product-state rows are blocked pending Ty-supplied photos.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Real fixture harness — product-state evidence for TV-3, TV-6, TV-7, TV-10.
|
||||
*
|
||||
* Loads real PNG photos from `fixtures/real/`, decodes them, runs the
|
||||
* pipeline to generate masks, and returns fixture data for product-state tests.
|
||||
*
|
||||
* When real PNG files are absent, every fixture returns `"blocked"` — the test
|
||||
* harness fails-closed; it never substitutes synthetic data.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PNG } from "pngjs";
|
||||
import type { RgbaImage, MaskBuffer, LengthAxis } from "../../lib/pipeline/types.js";
|
||||
import { defaultKnobs, removeSheetBackground } from "../../lib/pipeline/mask.js";
|
||||
import { findSlabBounds } from "../../lib/pipeline/crop.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REAL_DIR = join(__dirname, "real");
|
||||
|
||||
function realPath(filename: string): string {
|
||||
return join(REAL_DIR, filename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Decode a PNG file into an RgbaImage. Throws when file missing or corrupt. */
|
||||
function decodePng(filePath: string): RgbaImage {
|
||||
const data = readFileSync(filePath);
|
||||
const png = PNG.sync.read(data);
|
||||
return {
|
||||
width: png.width,
|
||||
height: png.height,
|
||||
data: png.data, // Uint8ClampedArray-compatible (Uint8Array is fine for tests)
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a binary mask by running the pipeline's sheet-removal on the image. */
|
||||
function generateMask(image: RgbaImage): MaskBuffer {
|
||||
const result = removeSheetBackground(image, defaultKnobs());
|
||||
return result.mask;
|
||||
}
|
||||
|
||||
/** Build a simple right-pointing axis for the slab's bounding box. */
|
||||
function buildAxis(mask: MaskBuffer, width: number, height: number): LengthAxis {
|
||||
const bounds = findSlabBounds(mask, width, height);
|
||||
const center = {
|
||||
x: (bounds.left + bounds.right) / 2,
|
||||
y: (bounds.top + bounds.bottom) / 2,
|
||||
};
|
||||
return {
|
||||
center,
|
||||
direction: { x: 1, y: 0 },
|
||||
lengthPx: bounds.widthPx,
|
||||
widthPx: bounds.heightPx,
|
||||
corners: [
|
||||
{ x: bounds.left, y: bounds.top },
|
||||
{ x: bounds.right, y: bounds.top },
|
||||
{ x: bounds.right, y: bounds.bottom },
|
||||
{ x: bounds.left, y: bounds.bottom },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture loaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RealFixtureResult {
|
||||
/** The decoded real image. */
|
||||
image: RgbaImage;
|
||||
/** Auto-generated binary mask from the real image. */
|
||||
mask: MaskBuffer;
|
||||
/** Length axis derived from the mask. */
|
||||
axis: LengthAxis;
|
||||
/** User-measured length in inches. */
|
||||
lengthIn: number;
|
||||
/** User-measured thickness in inches. */
|
||||
thicknessIn: number;
|
||||
/** Sqft computed from the real mask. */
|
||||
computedSqft: number;
|
||||
}
|
||||
|
||||
/** Attempt to load the TV-3 real slab fixture. Returns "blocked" when PNG absent or not marked real. */
|
||||
export function loadTv3RealFixture(): "blocked" | RealFixtureResult {
|
||||
const pngPath = realPath("tv3-slab.png");
|
||||
const jsonPath = realPath("tv3-slab.json");
|
||||
|
||||
if (!existsSync(pngPath)) return "blocked";
|
||||
if (!existsSync(jsonPath)) return "blocked";
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
if (meta.real !== true) return "blocked";
|
||||
|
||||
const image = decodePng(pngPath);
|
||||
const mask = generateMask(image);
|
||||
const axis = buildAxis(mask, image.width, image.height);
|
||||
|
||||
const scale = axis.lengthPx / meta.lengthIn;
|
||||
const maskPixelCount = countMaskPixels(mask, image.width, image.height);
|
||||
const sqft = (maskPixelCount / (scale * scale)) / 144;
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
axis,
|
||||
lengthIn: meta.lengthIn,
|
||||
thicknessIn: meta.thicknessIn,
|
||||
computedSqft: Math.round(sqft * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Attempt to load the TV-6 real slab fixture. Returns "blocked" when PNG absent or not marked real. */
|
||||
export function loadTv6RealFixture(): "blocked" | RealFixtureResult {
|
||||
const pngPath = realPath("tv6-slab.png");
|
||||
const jsonPath = realPath("tv6-slab.json");
|
||||
|
||||
if (!existsSync(pngPath)) return "blocked";
|
||||
if (!existsSync(jsonPath)) return "blocked";
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
if (meta.real !== true) return "blocked";
|
||||
|
||||
const image = decodePng(pngPath);
|
||||
const mask = generateMask(image);
|
||||
const axis = buildAxis(mask, image.width, image.height);
|
||||
|
||||
const scale = axis.lengthPx / meta.lengthIn;
|
||||
const maskPixelCount = countMaskPixels(mask, image.width, image.height);
|
||||
const sqft = (maskPixelCount / (scale * scale)) / 144;
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
axis,
|
||||
lengthIn: meta.lengthIn,
|
||||
thicknessIn: meta.thicknessIn,
|
||||
computedSqft: Math.round(sqft * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Attempt to load the TV-7 real slab fixture. Returns "blocked" when PNG absent or not marked real. */
|
||||
export function loadTv7RealFixture(): "blocked" | RealFixtureResult {
|
||||
const pngPath = realPath("tv7-slab.png");
|
||||
const jsonPath = realPath("tv7-slab.json");
|
||||
|
||||
if (!existsSync(pngPath)) return "blocked";
|
||||
if (!existsSync(jsonPath)) return "blocked";
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
if (meta.real !== true) return "blocked";
|
||||
|
||||
const image = decodePng(pngPath);
|
||||
const mask = generateMask(image);
|
||||
const axis = buildAxis(mask, image.width, image.height);
|
||||
|
||||
const scale = axis.lengthPx / meta.lengthIn;
|
||||
const maskPixelCount = countMaskPixels(mask, image.width, image.height);
|
||||
const sqft = (maskPixelCount / (scale * scale)) / 144;
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
axis,
|
||||
lengthIn: meta.lengthIn,
|
||||
thicknessIn: meta.thicknessIn,
|
||||
computedSqft: Math.round(sqft * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-10 rounding fixture: real measurements from Ty, not synthetic cartoons.
|
||||
* Returns "blocked" when metadata is absent or not marked real; does not need a PNG file.
|
||||
*/
|
||||
export interface RealRoundingFixture {
|
||||
lengthRaw: number;
|
||||
lengthExpected: number;
|
||||
sqftRaw: number;
|
||||
sqftExpected: number;
|
||||
bdftRaw: number;
|
||||
bdftExpected: number;
|
||||
priceRaw: number;
|
||||
priceExpected: number;
|
||||
}
|
||||
|
||||
export function loadTv10RealFixture(): "blocked" | RealRoundingFixture {
|
||||
const jsonPath = realPath("tv10-slab.json");
|
||||
|
||||
if (!existsSync(jsonPath)) return "blocked";
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
if (meta.real !== true) return "blocked";
|
||||
|
||||
return {
|
||||
lengthRaw: meta.measurements[0].raw,
|
||||
lengthExpected: meta.measurements[0].expected,
|
||||
sqftRaw: meta.measurements[1].raw,
|
||||
sqftExpected: meta.measurements[1].expected,
|
||||
bdftRaw: meta.measurements[2].raw,
|
||||
bdftExpected: meta.measurements[2].expected,
|
||||
priceRaw: meta.measurements[3].raw,
|
||||
priceExpected: meta.measurements[3].expected,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countMaskPixels(mask: MaskBuffer, width: number, height: number): number {
|
||||
let count = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (mask[y * width + x]! >= 128) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Real Slab Fixtures
|
||||
|
||||
These are **real slab photo samples** used for `product-state` test evidence.
|
||||
They are not synthetic; they are actual photographs of physical lumber slabs.
|
||||
|
||||
## Acquisition path
|
||||
|
||||
To populate this directory with real samples, Ty follows this procedure:
|
||||
|
||||
1. **Source** — A physical slab with a uniform green or black sheet backing
|
||||
(the sheet must be uniform enough for `detectSheet` to auto-resolve).
|
||||
2. **Photo** — Top-down shot, lens parallel to the broad face. Phone camera
|
||||
is fine; higher resolution is better for TV-6 (≥ 1600 px shorter side after
|
||||
3:4 crop at 80% fill).
|
||||
3. **Measure** — Record the following for each sample:
|
||||
- `lengthIn` — prevailing length in inches (to nearest 1/8")
|
||||
- `thicknessIn` — thickness pick in inches (to nearest 1/8")
|
||||
- `sqft` — projected top-down area from the mask (computed by the pipeline)
|
||||
4. **File naming**:
|
||||
- `tv3-slab.png` — A slab suitable for TV-3 (any size; known dimensions)
|
||||
- `tv6-slab.png` — A slab large enough that the 3:4 crop ≥ 1600 px shorter side
|
||||
- `tv7-slab.png` — A small slab that triggers the undersized warning after crop
|
||||
5. **Metadata** — Create a matching `*-metadata.json` file alongside each PNG
|
||||
with the measured values (see the JSON schema below).
|
||||
6. **License** — Photos are TyTech production samples; no external copyright.
|
||||
|
||||
## Metadata schema
|
||||
|
||||
Each `*-metadata.json` follows this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "ty-lab",
|
||||
"lengthIn": 96,
|
||||
"thicknessIn": 1.5,
|
||||
"sqft": 0.64,
|
||||
"expectedBdft": 0.96,
|
||||
"photoNotes": "Walnut slab on green sheet, top-down, 2026-09"
|
||||
}
|
||||
```
|
||||
|
||||
- `lengthIn` / `thicknessIn` — measured with a tape, stored to 1/8" precision
|
||||
- `sqft` — computed from the mask of this exact photo (pipeline `checkSourceSize` / `findSlabBounds`)
|
||||
- `expectedBdft` — `sqft × thicknessIn` rounded to 2 decimals; the value the
|
||||
`product-state` test asserts against
|
||||
|
||||
## What counts as product-state
|
||||
|
||||
- A real photograph of a real slab
|
||||
- Measurements taken from that slab (tape measure, caliper)
|
||||
- The pipeline running on the actual image file
|
||||
|
||||
## What does NOT count
|
||||
|
||||
- Procedurally drawn fake images (those are `proxy-synthetic`)
|
||||
- Numbers invented without a physical measurement source
|
||||
- "Real-looking" synthetic fixtures masquerading as product evidence
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"source": "placeholder-template",
|
||||
"real": false,
|
||||
"measurements": [
|
||||
{ "raw": 96.04, "expected": 96, "field": "lengthIn" },
|
||||
{ "raw": 0.641, "expected": 0.64, "field": "sqft" },
|
||||
{ "raw": 11.525, "expected": 11.53, "field": "bdft" },
|
||||
{ "raw": 12.345, "expected": 12.35, "field": "price" }
|
||||
],
|
||||
"photoNotes": "PLACEHOLDER: replace with real Ty measurements and set real: true"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"source": "placeholder-template",
|
||||
"real": false,
|
||||
"lengthIn": 96,
|
||||
"thicknessIn": 1.5,
|
||||
"sqft": 0.64,
|
||||
"expectedBdft": 0.96,
|
||||
"photoNotes": "PLACEHOLDER: replace with real Ty measurements and set real: true"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"source": "placeholder-template",
|
||||
"real": false,
|
||||
"lengthIn": 96,
|
||||
"thicknessIn": 1.5,
|
||||
"sqft": 0.64,
|
||||
"expectedBdft": 0.96,
|
||||
"photoNotes": "PLACEHOLDER: replace with real Ty measurements and set real: true"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"source": "placeholder-template",
|
||||
"real": false,
|
||||
"lengthIn": 48,
|
||||
"thicknessIn": 1.0,
|
||||
"sqft": 0.15,
|
||||
"expectedBdft": 0.15,
|
||||
"photoNotes": "PLACEHOLDER: replace with real Ty measurements and set real: true"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TV-10 product-state: rounding on real measurements.
|
||||
*
|
||||
* Evidence class: `product-state`
|
||||
*
|
||||
* Real measurements from Ty, not synthetic cartoons.
|
||||
* Fails-closed when metadata is absent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
roundLengthIn,
|
||||
roundBdft,
|
||||
roundPrice,
|
||||
roundSqft,
|
||||
} from "../lib/pipeline/units.js";
|
||||
import { loadTv10RealFixture } from "./fixtures/real-fixtures.js";
|
||||
|
||||
describe("TV-10 product-state: rounding on real measurements", () => {
|
||||
const fixture = loadTv10RealFixture();
|
||||
|
||||
if (fixture === "blocked") {
|
||||
it.skip("real fixture absent — blocked; no product-state evidence", () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
it("rounds real length to nearest 1/8\"", () => {
|
||||
const length = roundLengthIn(fixture.lengthRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10-product-state",
|
||||
length: { raw: fixture.lengthRaw, rounded: length, expected: fixture.lengthExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(length).toBe(fixture.lengthExpected);
|
||||
});
|
||||
|
||||
it("rounds real sqft half-up to 2 decimals", () => {
|
||||
const sqft = roundSqft(fixture.sqftRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10-product-state",
|
||||
sqft: { raw: fixture.sqftRaw, rounded: sqft, expected: fixture.sqftExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sqft).toBe(fixture.sqftExpected);
|
||||
});
|
||||
|
||||
it("rounds real bdft half-up to 2 decimals", () => {
|
||||
const bdft = roundBdft(fixture.bdftRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10-product-state",
|
||||
bdft: { raw: fixture.bdftRaw, rounded: bdft, expected: fixture.bdftExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bdft).toBe(fixture.bdftExpected);
|
||||
});
|
||||
|
||||
it("rounds real price half-up to 2 decimals", () => {
|
||||
const price = roundPrice(fixture.priceRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10-product-state",
|
||||
price: { raw: fixture.priceRaw, rounded: price, expected: fixture.priceExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(price).toBe(fixture.priceExpected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* TV-3 product-state: bdft identity on real slab fixtures.
|
||||
*
|
||||
* Evidence class: `product-state`
|
||||
*
|
||||
* Real slab photo + measurements from a physical slab.
|
||||
* Fails-closed when real PNG/metadata is absent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeBdft,
|
||||
roundBdft,
|
||||
} from "../lib/pipeline/units.js";
|
||||
import { loadTv3RealFixture } from "./fixtures/real-fixtures.js";
|
||||
|
||||
describe("TV-3 product-state: bdft = sqft × thickness_in on real slab", () => {
|
||||
const fixture = loadTv3RealFixture();
|
||||
|
||||
if (fixture === "blocked") {
|
||||
it.skip("real fixture absent — blocked; no product-state evidence", () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
it("bdft = sqft × thickness_in with no divide-by-12 (real slab measurements)", () => {
|
||||
const { lengthIn, thicknessIn, computedSqft } = fixture;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-3-product-state",
|
||||
lengthIn,
|
||||
thicknessIn,
|
||||
computedSqft,
|
||||
bdft: computeBdft(computedSqft, thicknessIn),
|
||||
identity_1sqft_1in: computeBdft(1, 1),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(computeBdft(computedSqft, thicknessIn)).toBe(roundBdft(computedSqft * thicknessIn));
|
||||
expect(computeBdft(computedSqft, thicknessIn)).toBeCloseTo(
|
||||
computedSqft * thicknessIn,
|
||||
4,
|
||||
);
|
||||
// Identity: 1 sqft at 1 inch = 1 bdft
|
||||
expect(computeBdft(1, 1)).toBe(1);
|
||||
// No divide-by-12
|
||||
expect(computeBdft(computedSqft, thicknessIn)).not.toBeCloseTo(
|
||||
(computedSqft * thicknessIn) / 12,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it("bdft is monotonic in thickness for the real slab", () => {
|
||||
const { computedSqft, thicknessIn } = fixture;
|
||||
const base = computeBdft(computedSqft, thicknessIn);
|
||||
expect(computeBdft(computedSqft, thicknessIn + 0.125)).toBeGreaterThan(base);
|
||||
expect(computeBdft(computedSqft, thicknessIn + 0.01)).toBeGreaterThanOrEqual(base);
|
||||
});
|
||||
|
||||
it("bdft is monotonic in sqft for the real slab", () => {
|
||||
const { computedSqft, thicknessIn } = fixture;
|
||||
const base = computeBdft(computedSqft, thicknessIn);
|
||||
expect(computeBdft(computedSqft + 0.01, thicknessIn)).toBeGreaterThan(base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* TV-6 product-state: 3:4 crop, 80% fill, transparent PNG, ≥1600px on real slab.
|
||||
*
|
||||
* Evidence class: `product-state`
|
||||
*
|
||||
* Real slab photo cropped through the pipeline.
|
||||
* Fails-closed when real PNG is absent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASPECT_H,
|
||||
ASPECT_W,
|
||||
FILL_TARGET,
|
||||
OUTPUT_PX_MIN,
|
||||
} from "../lib/pipeline/constants.js";
|
||||
import {
|
||||
computeCropTarget,
|
||||
cropTo34,
|
||||
findSlabBounds,
|
||||
} from "../lib/pipeline/crop.js";
|
||||
import { loadTv6RealFixture } from "./fixtures/real-fixtures.js";
|
||||
|
||||
describe("TV-6 product-state: 3:4 crop, 80% fill, ≥1600px on real slab", () => {
|
||||
const fixture = loadTv6RealFixture();
|
||||
|
||||
if (fixture === "blocked") {
|
||||
it.skip("real fixture absent — blocked; no product-state evidence", () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
it("computes crop target preserving 3:4 aspect and 80% fill on real slab", () => {
|
||||
const { mask, image, lengthIn } = fixture;
|
||||
const bounds = findSlabBounds(mask, image.width, image.height);
|
||||
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
|
||||
expect(Math.abs(target.width / target.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Fill at limiting dimension should equal FILL_TARGET
|
||||
const slabAspect = bounds.widthPx / bounds.heightPx;
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
const fillAtLimitingDim = slabAspect >= targetAspect
|
||||
? bounds.heightPx / target.height
|
||||
: bounds.widthPx / target.width;
|
||||
|
||||
expect(fillAtLimitingDim).toBeCloseTo(FILL_TARGET, 3);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6-product-state",
|
||||
slabW: bounds.widthPx,
|
||||
slabH: bounds.heightPx,
|
||||
targetW: target.width,
|
||||
targetH: target.height,
|
||||
fillAtLimitingDim,
|
||||
aspect: target.width / target.height,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("crops real slab to 3:4 with 80% fill, transparent PNG, shorter side ≥ 1600", () => {
|
||||
const { image, mask, axis, lengthIn } = fixture;
|
||||
|
||||
const result = cropTo34(image, mask, axis, lengthIn);
|
||||
|
||||
expect(result.width).toBeGreaterThan(0);
|
||||
expect(result.height).toBeGreaterThan(0);
|
||||
|
||||
// 3:4 aspect
|
||||
expect(Math.abs(result.width / result.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Shorter side ≥ OUTPUT_PX_MIN
|
||||
const shorterSide = Math.min(result.width, result.height);
|
||||
expect(shorterSide).toBeGreaterThanOrEqual(OUTPUT_PX_MIN);
|
||||
expect(result.meetsMinPx).toBe(true);
|
||||
|
||||
// Transparent margins
|
||||
const marginAlpha = result.image.data[3];
|
||||
expect(marginAlpha).toBe(0);
|
||||
|
||||
// Slab pixels opaque
|
||||
const slabCenterX = Math.round(result.width / 2);
|
||||
const slabCenterY = Math.round(result.height / 2);
|
||||
const slabAlpha = result.image.data[(slabCenterY * result.width + slabCenterX) * 4 + 3]!;
|
||||
expect(slabAlpha).toBeGreaterThan(0);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6-product-state",
|
||||
frameW: result.width,
|
||||
frameH: result.height,
|
||||
shorterSide,
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
marginAlpha,
|
||||
slabAlpha,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TV-7 product-state: undersized source warns, no upscale, not publishable.
|
||||
*
|
||||
* Evidence class: `product-state`
|
||||
*
|
||||
* Real small slab photo that triggers the undersized warning.
|
||||
* Fails-closed when real PNG is absent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OUTPUT_PX_MIN } from "../lib/pipeline/constants.js";
|
||||
import {
|
||||
computeCropTarget,
|
||||
cropTo34,
|
||||
findSlabBounds,
|
||||
checkSourceSize,
|
||||
} from "../lib/pipeline/crop.js";
|
||||
import { loadTv7RealFixture } from "./fixtures/real-fixtures.js";
|
||||
|
||||
describe("TV-7 product-state: undersized-source warn/retake on real slab", () => {
|
||||
const fixture = loadTv7RealFixture();
|
||||
|
||||
if (fixture === "blocked") {
|
||||
it.skip("real fixture absent — blocked; no product-state evidence", () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
it("detects undersized source on real slab and refuses upscale", () => {
|
||||
const { mask, image } = fixture;
|
||||
const bounds = findSlabBounds(mask, image.width, image.height);
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
|
||||
expect(Math.min(target.width, target.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7-product-state",
|
||||
slabW: bounds.widthPx,
|
||||
slabH: bounds.heightPx,
|
||||
targetW: target.width,
|
||||
targetH: target.height,
|
||||
shorterSide: Math.min(target.width, target.height),
|
||||
outputPxMin: OUTPUT_PX_MIN,
|
||||
undersized: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cropTo34 returns meetsMinPx=false and no upscale for undersized real slab", () => {
|
||||
const { image, mask, axis, lengthIn } = fixture;
|
||||
|
||||
const result = cropTo34(image, mask, axis, lengthIn);
|
||||
|
||||
expect(result.meetsMinPx).toBe(false);
|
||||
expect(Math.min(result.width, result.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
|
||||
// No upscale: output ≤ source
|
||||
expect(result.width).toBeLessThanOrEqual(image.width);
|
||||
expect(result.height).toBeLessThanOrEqual(image.height);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7-product-state",
|
||||
sourceW: image.width,
|
||||
sourceH: image.height,
|
||||
outputW: result.width,
|
||||
outputH: result.height,
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("checkSourceSize returns 'undersized' for the real small slab", () => {
|
||||
const { mask, image } = fixture;
|
||||
const status = checkSourceSize(mask, image.width, image.height);
|
||||
expect(status).toBe("undersized");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user