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:
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
@@ -1044,6 +1045,15 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
# Gate B pass2 results — 2026-09-07
|
||||
|
||||
Evidence collected on gate-b @ 302d694.
|
||||
Evidence collected on gate-b @ 699b6a0.
|
||||
|
||||
Real-fixture harness landed: `frontend/src/__tests__/fixtures/real/` + `real-fixtures.ts` + four product-state test files.
|
||||
Harness loads real PNGs, generates masks, runs the pipeline, and fails-closed when fixtures are absent or unmarked.
|
||||
Dual-proof preserved: proxy-synthetic unit rows remain separate and unchanged.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,17 +14,17 @@ Evidence collected on gate-b @ 302d694.
|
||||
|---|---|---|---|---|---|
|
||||
| TV-1 | Green-sheet chroma-key keeps interior green patch, does not eat border wood | `proxy-synthetic` | pass | vitest.txt:13 | — |
|
||||
| TV-2 | Black-sheet threshold keeps dark interior wood, flood-fill isolates border-only | `proxy-synthetic` | pass | vitest.txt:13 | — |
|
||||
| TV-3 | bdft = sqft × thickness_in; 1 sqft @ 1" = 1 bdft; no divide-by-12 | `product-state` | blocked | — | No real slab fixtures or Ty walk on real wood |
|
||||
| TV-3 | bdft = sqft × thickness_in; 1 sqft @ 1" = 1 bdft; no divide-by-12 | `product-state` | blocked | vitest.txt: skipped (real fixture absent) | Real PNG + metadata needed; harness at `fixtures/real/` |
|
||||
| TV-3 | bdft identity holds on synthetic fixture (proxy proof only) | `proxy-synthetic` | pass | vitest.txt:4–5,12 | — |
|
||||
| TV-4 | Width samples at 6" stations report min, max, average perpendicular to confirmed axis | `proxy-synthetic` | pass | vitest.txt:14–15,20 | — |
|
||||
| TV-5 | Angled-end slab: length axis follows prevailing length; widths stay perpendicular after confirm | `proxy-synthetic` | pass | vitest.txt:17–18,20 | — |
|
||||
| TV-6 | 3:4 crop, slab centered, 80% fill, transparent PNG, shorter side ≥ 1600 when source allows | `product-state` | blocked | — | No real slab photos or Ty walk on real wood |
|
||||
| TV-6 | 3:4 crop, slab centered, 80% fill, transparent PNG, shorter side ≥ 1600 when source allows | `product-state` | blocked | vitest.txt: skipped (real fixture absent) | Real PNG + metadata needed; harness at `fixtures/real/` |
|
||||
| TV-6 | Crop dimensions and fill ratio hold on synthetic fixture (proxy proof only) | `proxy-synthetic` | pass | vitest.txt:80–90,101 | — |
|
||||
| TV-7 | Undersized source warns, refuses upscale, photo not publishable until retake | `product-state` | blocked | — | No real slab photos or Ty walk on real wood |
|
||||
| TV-7 | Undersized source warns, refuses upscale, photo not publishable until retake | `product-state` | blocked | vitest.txt: skipped (real fixture absent) | Real PNG + metadata needed; harness at `fixtures/real/` |
|
||||
| TV-7 | Undersized-source logic holds on synthetic fixture (proxy proof only) | `proxy-synthetic` | pass | vitest.txt:92–99,101 | — |
|
||||
| TV-8 | Pricing: bdft 0.96 × $12.50 → $12.00 recommended; override stored; no rule leaves empty | `proxy-synthetic` | pass | vitest.txt:11 | — |
|
||||
| TV-9 | Thickness 1" rounds up to Woo range using synced attribute term, not hardcoded label | `proxy-synthetic` | pass | vitest.txt:10 | — |
|
||||
| TV-10 | Rounding: 96.04" → 96"; 0.641 sqft → 0.64; 11.525 bdft → 11.53; 12.345 price → 12.35 | `product-state` | blocked | — | No real slab measurements or Ty walk on real wood |
|
||||
| TV-10 | Rounding: 96.04" → 96"; 0.641 sqft → 0.64; 11.525 bdft → 11.53; 12.345 price → 12.35 | `product-state` | blocked | vitest.txt: skipped (metadata placeholder, real: false) | Real measurements from Ty needed; harness at `fixtures/real/` |
|
||||
| TV-10 | Rounding rules hold on synthetic fixture (proxy proof only) | `proxy-synthetic` | pass | vitest.txt:7–8,12 | — |
|
||||
| V-1 | SKU charset `[A-Z0-9-]{3,24}` accepted; invalid chars/rejected length rejected | `proxy-synthetic` | pass | vitest.txt:21–50,79 | — |
|
||||
| V-2 | Title/short_title/description maxLength enforced at field exit and submit | `proxy-synthetic` | pass | vitest.txt:52–68,79 | — |
|
||||
@@ -39,11 +43,17 @@ Evidence collected on gate-b @ 302d694.
|
||||
|
||||
**18 pass / 1 fail / 5 blocked** (24 total rows)
|
||||
|
||||
Real-fixture harness (4 product-state test files + loader + acquisition README) is committed;
|
||||
still blocked pending Ty-supplied PNG photos and real measurements.
|
||||
|
||||
---
|
||||
|
||||
## Unproven / Gate exit
|
||||
|
||||
- **TV-3, TV-6, TV-7, TV-10 product-state**: No real slab fixtures, no Ty walk. Proxy-synthetic rows pass but do not satisfy Gate B exit.
|
||||
- **TV-3, TV-6, TV-7, TV-10 product-state**: Real-fixture harness committed (`fixtures/real/` + `real-fixtures.ts`
|
||||
+ four product-state test files). Harness fails-closed when real PNG photos / real measurements are absent.
|
||||
Still blocked pending Ty-supplied photos and measurements. Proxy-synthetic rows pass but do not satisfy
|
||||
Gate B exit.
|
||||
- **OPS-1 deploy**: No compose-up, no Caddy, no inventory filled. Health endpoints all unreachable (health.txt).
|
||||
- **Walkable UI / E2E**: No Playwright E2E tests exist yet; no browser session captured.
|
||||
- **Real draft create (D-1)**: All 8 TestCreateSlab tests failed with `python-multipart` missing; no slab was created.
|
||||
|
||||
Reference in New Issue
Block a user