mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* feat(cli): coordinate-frame layout findings in check Four production compositions shipped with 100-600px layout drift, each a different coordinate-frame confusion the check graded info or missed entirely: viewport pixels written as container left/top, gsap x/y treated as absolute position, a -350px margin fighting flex centering, and stage-relative path coords drawn into a nested SVG. Three new layout findings close the class: - positioned_out_of_parent: an absolute/fixed element rendering mostly outside its positioning ancestor (warning) — the parent needs no overflow clipping, which is what let container_overflow miss it. - box_out_of_canvas: a painted panel breaching the canvas (warning) — text is canvas_overflow's, media is frame_out_of_frame's, painted boxes were nobody's. - connector_detached: a connector path whose endpoints land far from every anchorable element (warning) — measured coordinates drawn into an SVG with a different origin. canvas_overflow additionally promotes from info to warning when held across samples AND the breach exceeds 5% of the canvas. All three are persistence-tiered and respect data-layout-allow-overflow. Verified against the four incident compositions: every one now surfaces its drift as held warnings (previously: info or silence). * fix(cli): harden coordinate-frame findings against review false positives Reworks all three findings after two-lens review (adversarial FP hunt in real Chrome + maintainer pass): - escaped_container (was positioned_out_of_parent): uses offsetParent (transform-aware, skips fixed-as-canvas), exempts fully-detached callouts within an attachment allowance while still flagging touching-but-mostly-outside drift. - panel_out_of_canvas (was box_out_of_canvas): paint alone qualifies (flat solid panels were a false negative), fully off-canvas rects are parked entrances and stay silent, pointer-events:none marks decorative layers, hero-sized breaches warn while small bleeds stay info. - connector_detached: endpoints via getPointAtLength + getScreenCTM (viewBox, preserveAspectRatio, group transforms, every command type), defs/marker/clipPath subtrees skipped, word-boundary connector naming, containment tier limited to opaque non-ancestor targets (a text-bearing wrapper contains its own diagram's endpoints). - canvas_overflow promotion requires partial visibility — a fully off-canvas rect is a parked entrance, not drift. Verified: the four incident compositions still surface their drift as held warnings; the review's false-positive repros (fixed HUD, callout, parked entrance, corner bleed, marker arrowheads, g-transform and viewBox-scaled connectors) are clean at warning level. Docs and the CLI skill reference now describe the coordinate-frame findings. * fix(cli): panel ownership is geometric — direct-text panels were a silent false negative A painted panel whose direct text stays in-bounds while its box breaches the canvas produced neither finding: canvas_overflow measures the text range and panel_out_of_canvas skipped every own-text element. Skip the panel finding only when the element's own text ALSO breaches (that geometry belongs to canvas_overflow); pin the message/fixHint wording of all three findings with positive assertions; document the SVG-internal anchor blind spot. * fix(cli): classify panel decoration by paint kind, not pointer-events pointer-events:none exempted the framed-painting incident's gold frame layers — hero content that happens to disable hit-testing. Decoration is now gradient-only paint (spotlights, textures, vignettes); url() images, solid fills and borders are content regardless of pointer-events. * fix(cli): add fixHint to the test-local AuditIssue shape * fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance A gradient with any solid stop (alpha >= 0.6) is content — heroes and cards painted with linear-gradient were invisible under the blanket gradient exemption; all-translucent stops (spotlights, vignettes) stay decoration. The text-ownership check now uses the audit tolerance that canvas_overflow itself fires at, making the contract strict-mutex: any text breach past that tolerance cedes the element, so a shallow 20px text breach no longer double-reports.
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildLayoutSampleTimes,
|
||
buildTransitionSampleTimes,
|
||
computeOverflow,
|
||
overflowValueClips,
|
||
collapseStaticLayoutIssues,
|
||
limitLayoutIssues,
|
||
mergeSampleTimes,
|
||
summarizeLayoutIssues,
|
||
formatLayoutIssue,
|
||
type LayoutIssue,
|
||
} from "./layoutAudit.js";
|
||
|
||
describe("buildTransitionSampleTimes (#1380)", () => {
|
||
it("samples boundaries plus the midpoint of each segment between them", () => {
|
||
// The #1380 repro: capA fades out 11.33–11.55, capB slams in 11.35–11.69.
|
||
// The collision window 11.35–11.55 only shows both captions half-visible
|
||
// away from the exact boundaries — the midpoints land inside it.
|
||
const result = buildTransitionSampleTimes({
|
||
duration: 20,
|
||
boundaries: [11.33, 11.55, 11.35, 11.69],
|
||
});
|
||
expect(result.times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 11.69]);
|
||
expect(result.dropped).toBe(0);
|
||
});
|
||
|
||
it("drops boundaries outside the composition and dedupes repeats", () => {
|
||
const result = buildTransitionSampleTimes({
|
||
duration: 10,
|
||
boundaries: [2, 2, -1, 10.5, NaN, 4],
|
||
});
|
||
expect(result.times).toEqual([2, 3, 4]);
|
||
expect(result.dropped).toBe(0);
|
||
});
|
||
|
||
it("returns an empty list without a valid duration", () => {
|
||
expect(buildTransitionSampleTimes({ duration: 0, boundaries: [1, 2] })).toEqual({
|
||
times: [],
|
||
dropped: 0,
|
||
});
|
||
});
|
||
|
||
it("samples every collected boundary when no cap is given", () => {
|
||
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
|
||
const result = buildTransitionSampleTimes({ duration: 10, boundaries });
|
||
// 200 boundaries + 199 segment midpoints, all distinct after rounding.
|
||
expect(result.times.length).toBe(399);
|
||
expect(result.dropped).toBe(0);
|
||
});
|
||
|
||
it("caps only on explicit request, reporting the omitted count and keeping the extremes", () => {
|
||
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
|
||
const result = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 });
|
||
expect(result.times.length).toBeLessThanOrEqual(40);
|
||
expect(result.dropped).toBe(399 - result.times.length);
|
||
expect(result.times[0]).toBe(0);
|
||
expect(result.times[result.times.length - 1]).toBeCloseTo(9.95, 3);
|
||
});
|
||
|
||
it("merges with even-spacing samples into one deduplicated ascending list", () => {
|
||
expect(mergeSampleTimes([1, 3, 5], [3, 2.5, 7])).toEqual([1, 2.5, 3, 5, 7]);
|
||
});
|
||
});
|
||
|
||
describe("layoutAudit helpers", () => {
|
||
it("samples the whole duration using stable midpoint timestamps", () => {
|
||
expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]);
|
||
});
|
||
|
||
it("prefers explicit timestamps and keeps them inside the composition duration", () => {
|
||
expect(buildLayoutSampleTimes({ duration: 10, samples: 5, at: [0, 2.5, 12, -1, NaN] })).toEqual(
|
||
[0, 2.5],
|
||
);
|
||
});
|
||
|
||
it("computes per-side overflow beyond a tolerance", () => {
|
||
const overflow = computeOverflow(
|
||
{ left: 88, top: 102, right: 231, bottom: 181, width: 143, height: 79 },
|
||
{ left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 },
|
||
2,
|
||
);
|
||
|
||
expect(overflow).toEqual({ left: 12, right: 11 });
|
||
});
|
||
|
||
it("returns no overflow when the subject only exceeds the box within tolerance", () => {
|
||
const overflow = computeOverflow(
|
||
{ left: 99, top: 100, right: 221, bottom: 180, width: 122, height: 80 },
|
||
{ left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 },
|
||
2,
|
||
);
|
||
|
||
expect(overflow).toBeNull();
|
||
});
|
||
|
||
it("summarizes errors and warnings separately", () => {
|
||
const issues: LayoutIssue[] = [
|
||
issue("text_box_overflow", "error"),
|
||
issue("canvas_overflow", "warning"),
|
||
issue("clipped_text", "error"),
|
||
];
|
||
|
||
expect(summarizeLayoutIssues(issues)).toEqual({
|
||
ok: false,
|
||
errorCount: 2,
|
||
warningCount: 1,
|
||
infoCount: 0,
|
||
issueCount: 3,
|
||
});
|
||
});
|
||
|
||
it("tracks info findings separately from warnings and errors", () => {
|
||
expect(summarizeLayoutIssues([issue("canvas_overflow", "info")])).toEqual({
|
||
ok: true,
|
||
errorCount: 0,
|
||
warningCount: 0,
|
||
infoCount: 1,
|
||
issueCount: 1,
|
||
});
|
||
});
|
||
|
||
it("collapses repeated static issues across sampled timestamps", () => {
|
||
const collapsed = collapseStaticLayoutIssues([
|
||
{ ...issue("text_box_overflow", "error"), time: 1 },
|
||
{ ...issue("text_box_overflow", "error"), time: 3 },
|
||
{ ...issue("text_box_overflow", "error"), time: 5 },
|
||
]);
|
||
|
||
expect(collapsed).toHaveLength(1);
|
||
expect(collapsed[0]).toMatchObject({
|
||
time: 1,
|
||
firstSeen: 1,
|
||
lastSeen: 5,
|
||
occurrences: 3,
|
||
});
|
||
});
|
||
|
||
it("formats issues with timestamp, selector, container, and fix hint", () => {
|
||
const formatted = formatLayoutIssue({
|
||
...issue("text_box_overflow", "error"),
|
||
time: 3.25,
|
||
selector: "#headline",
|
||
containerSelector: ".bubble",
|
||
text: "Quarterly plan",
|
||
overflow: { right: 18, bottom: 7 },
|
||
fixHint: "Increase container padding or reduce font-size.",
|
||
});
|
||
|
||
expect(formatted).toContain("t=3.25s");
|
||
expect(formatted).toContain("#headline");
|
||
expect(formatted).toContain("inside .bubble");
|
||
expect(formatted).toContain("right 18px, bottom 7px");
|
||
expect(formatted).toContain("Fix: Increase container padding");
|
||
});
|
||
|
||
it("formats collapsed issue time ranges", () => {
|
||
const formatted = formatLayoutIssue({
|
||
...issue("text_box_overflow", "error"),
|
||
time: 1,
|
||
firstSeen: 1,
|
||
lastSeen: 5,
|
||
occurrences: 3,
|
||
});
|
||
|
||
expect(formatted).toContain("t=1-5s (3 samples)");
|
||
});
|
||
|
||
// The clip rule that suppresses the odometer/ticker false positive: text
|
||
// spilling past an `overflow:hidden` reel window is the mechanism, not a bug.
|
||
it("treats clipping overflow values as masking (suppress) and visible as not (still report)", () => {
|
||
// Intended clipping — the queued digit rows of a reel are masked here.
|
||
expect(overflowValueClips("hidden")).toBe(true);
|
||
expect(overflowValueClips("clip")).toBe(true);
|
||
expect(overflowValueClips("auto")).toBe(true);
|
||
expect(overflowValueClips("scroll")).toBe(true);
|
||
// Genuine overflow — nothing masks the text, so it must STILL be reported.
|
||
expect(overflowValueClips("visible")).toBe(false);
|
||
expect(overflowValueClips("clip visible")).toBe(false);
|
||
expect(overflowValueClips("")).toBe(false);
|
||
expect(overflowValueClips(null)).toBe(false);
|
||
expect(overflowValueClips(undefined)).toBe(false);
|
||
});
|
||
|
||
it("limits returned issues by severity before truncating", () => {
|
||
const limited = limitLayoutIssues(
|
||
[
|
||
{ ...issue("canvas_overflow", "info"), time: 1 },
|
||
{ ...issue("text_box_overflow", "error"), time: 2 },
|
||
],
|
||
1,
|
||
);
|
||
|
||
expect(limited).toMatchObject({
|
||
totalIssueCount: 2,
|
||
truncated: true,
|
||
issues: [{ code: "text_box_overflow", severity: "error" }],
|
||
});
|
||
});
|
||
});
|
||
|
||
// #U10: held-duration severity tiering on top of the existing collapse step.
|
||
// Sample counts below (9) mirror the CLI's default grid so the "1 sample =
|
||
// entrance/exit transient, 2+ adjacent samples = held" framing in the
|
||
// approach doc lines up with the numbers used here.
|
||
describe("persistence-tiered severity (#U10)", () => {
|
||
it("demotes a content_overlap seen at only one sample among several to info", () => {
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[{ ...issue("content_overlap", "warning"), time: 3 }],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed).toHaveLength(1);
|
||
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
|
||
});
|
||
|
||
it("promotes content_overlap held across >= 2 adjacent samples to error", () => {
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...issue("content_overlap", "warning"), time: 3 },
|
||
{ ...issue("content_overlap", "warning"), time: 3.6 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed).toHaveLength(1);
|
||
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
|
||
});
|
||
|
||
it("promotes a held, canvas-scale canvas_overflow breach from info to warning", () => {
|
||
const breach = {
|
||
...issue("canvas_overflow", "info"),
|
||
overflow: { top: 140 },
|
||
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
|
||
};
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...breach, time: 1 },
|
||
{ ...breach, time: 3 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed).toHaveLength(1);
|
||
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 });
|
||
});
|
||
|
||
it("keeps a held, large but fully off-canvas canvas_overflow at info — a parked entrance, not drift", () => {
|
||
const breach = {
|
||
...issue("canvas_overflow", "info"),
|
||
rect: { left: 2200, top: 300, right: 2800, bottom: 700, width: 600, height: 400 },
|
||
overflow: { right: 880 },
|
||
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
|
||
};
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...breach, time: 1 },
|
||
{ ...breach, time: 3 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 2 });
|
||
});
|
||
|
||
it("demotes single-sample coordinate-frame findings to info", () => {
|
||
for (const code of [
|
||
"escaped_container",
|
||
"panel_out_of_canvas",
|
||
"connector_detached",
|
||
] as const) {
|
||
const collapsed = collapseStaticLayoutIssues([{ ...issue(code, "warning"), time: 3 }], 9);
|
||
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
|
||
}
|
||
});
|
||
|
||
it("keeps a held but small canvas_overflow at info", () => {
|
||
const breach = {
|
||
...issue("canvas_overflow", "info"),
|
||
overflow: { top: 30 },
|
||
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
|
||
};
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...breach, time: 1 },
|
||
{ ...breach, time: 3 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 2 });
|
||
});
|
||
|
||
it("does not demote a finding held at every sample — persistence, not a single hit", () => {
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...issue("text_box_overflow", "error"), time: 1 },
|
||
{ ...issue("text_box_overflow", "error"), time: 3 },
|
||
{ ...issue("text_box_overflow", "error"), time: 5 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed).toHaveLength(1);
|
||
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 });
|
||
});
|
||
|
||
it("does not promote held codes without a promotion rule — container_overflow keeps its severity", () => {
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[
|
||
{ ...issue("container_overflow", "warning"), time: 3 },
|
||
{ ...issue("container_overflow", "warning"), time: 3.6 },
|
||
],
|
||
9,
|
||
);
|
||
|
||
expect(collapsed[0]).toMatchObject({ severity: "warning" });
|
||
});
|
||
|
||
it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => {
|
||
const collapsed = collapseStaticLayoutIssues(
|
||
[{ ...issue("content_overlap", "warning"), time: 3 }],
|
||
1,
|
||
);
|
||
|
||
expect(collapsed[0]).toMatchObject({ severity: "warning" });
|
||
});
|
||
|
||
it("infers the sample count from distinct issue times when none is given", () => {
|
||
// Two distinct times among the raw issues imply a multi-sample run even
|
||
// without an explicit count, so the single-occurrence group still demotes.
|
||
const collapsed = collapseStaticLayoutIssues([
|
||
{ ...issue("content_overlap", "warning"), time: 3 },
|
||
{ ...issue("text_box_overflow", "error"), time: 5 },
|
||
]);
|
||
|
||
const overlap = collapsed.find((found) => found.code === "content_overlap");
|
||
expect(overlap).toMatchObject({ severity: "info" });
|
||
});
|
||
});
|
||
|
||
function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue {
|
||
return {
|
||
code,
|
||
severity,
|
||
time: 1,
|
||
selector: ".label",
|
||
message: "Layout issue",
|
||
rect: { left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20 },
|
||
overflow: { right: 8 },
|
||
fixHint: "Adjust layout.",
|
||
};
|
||
}
|