feat(cli): --frame-check accepts a severity/seek/tol spec

The pipeline already carried FrameCheckOptions; only the flag was
boolean, which meant a pipeline caller tuning severity or seek points
would have them silently dropped — the two sides only agreed because
today's caller happens to match the defaults. Bare --frame-check keeps
the defaults; the value form mirrors --caption-zone's grammar, freezing
the contract before a release pins it.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 18:04:09 -04:00
parent 67cdf0fdb7
commit 659cb6a236
3 changed files with 76 additions and 22 deletions
+16
View File
@@ -1112,6 +1112,22 @@ describe("check pipeline", () => {
});
});
describe("frame-check flag grammar", () => {
it("keeps bare --frame-check on defaults and parses the value form", async () => {
const { parseFrameCheck } = await import("./check.js");
expect(parseFrameCheck(undefined)).toBeUndefined();
expect(parseFrameCheck(true)).toEqual({});
expect(parseFrameCheck("")).toEqual({});
expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({
severity: "error",
seek: [0.25, 0.75],
tol: 4,
});
expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check");
});
});
describe("contrast persistence", () => {
it("demotes a single-sample contrast failure to warning but gates held failures", async () => {
const driver = fakeDriver({
+47 -5
View File
@@ -15,7 +15,7 @@ import {
type CheckReport,
type CheckSection,
} from "../utils/checkPipeline.js";
import type { CaptionZoneOptions } from "../utils/checkTypes.js";
import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js";
export const examples: Example[] = [
["Run the full verification gate", "hyperframes check"],
@@ -109,10 +109,9 @@ export function createCheckCommand(
'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)',
},
"frame-check": {
type: "boolean",
type: "string",
description:
"Use as --frame-check (boolean/no value; tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge))",
default: false,
'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune',
},
},
async run({ args }) {
@@ -161,12 +160,55 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
strict: args.strict === true,
snapshots: args.snapshots === true,
captionZone: parseCaptionZone(args["caption-zone"]),
frameCheck: args["frame-check"] === true ? {} : undefined,
frameCheck: parseFrameCheck(args["frame-check"]),
};
}
const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol
// options survive the migration instead of being silently dropped by a
// boolean flag (bare --frame-check keeps today's defaults).
export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined {
if (value === undefined || value === null || value === false) return undefined;
if (value === true || value === "") return {};
if (typeof value !== "string") throw frameCheckError();
const fields = parseFrameCheckFields(value);
const severity = captionSeverity(fields.get("severity"));
const seek = captionSeeks(fields.get("seek"));
const tol = parseFrameCheckTolerance(fields.get("tol"));
return {
...(severity ? { severity } : {}),
...(seek ? { seek } : {}),
...(tol !== undefined ? { tol } : {}),
};
}
function parseFrameCheckFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const { key, entry } = parseCaptionField(part);
if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError();
fields.set(key, entry);
}
return fields;
}
function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const tol = Number.parseFloat(raw);
if (!Number.isFinite(tol) || tol < 0) throw frameCheckError();
return tol;
}
function frameCheckError(): Error {
return new Error(
'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)',
);
}
function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
if (value === undefined || value === null) return undefined;
const fields = parseCaptionFields(captionZoneString(value));
+13 -17
View File
@@ -70,21 +70,25 @@ function installSessionMock(page: ReturnType<typeof fakePage>): void {
);
}
function mountCanvasFixture(inner = ""): void {
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">${inner}</div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
}
it("carries raw browser geometry through the page driver and pipeline", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(100)
.mockReturnValueOnce(160)
.mockReturnValueOnce(200)
.mockReturnValueOnce(240);
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">
mountCanvasFixture(`
<section data-composition-file="scenes/hero.html">
<img id="hero-image" data-layout-name="hero" src="data:image/png;base64,AA==" />
</section>
</div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
`);
installRects();
const page = fakePage();
installSessionMock(page);
@@ -120,13 +124,9 @@ it("round-trips the browser script's raw contrast candidates back into finish",
// candidate (width/height) makes every sample rect NaN and the audit
// silently reports zero checked elements as green.
vi.spyOn(Date, "now").mockReturnValue(100);
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">
mountCanvasFixture(`
<div id="headline">Readable copy</div>
</div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
`);
const root = document.querySelector("[data-composition-id]");
const headline = document.querySelector("#headline");
if (!root || !headline) throw new Error("Contrast fixture failed to mount");
@@ -215,11 +215,7 @@ it("round-trips the browser script's raw contrast candidates back into finish",
it("carries validate's clip-duration audit into the runtime findings", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360"></div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
mountCanvasFixture();
const validateModule = await import("../commands/validate.js");
vi.mocked(validateModule.auditClipDurations).mockResolvedValue([
{