Files
hyperframes/packages/cli/src/utils/layoutAudit.test.ts
Xuanru LiandClaude Opus 4.8 72e2f08f15 feat(lint): dense motion re-sampling for content_overlap (#2746)
* feat(lint): dense motion re-sampling for content_overlap

Transient text-on-text collisions during continuous motion (e.g. an
orbiting label card crossing the center card) overlap for a fraction of
a second that the sparse 9-point layout grid seeks straight past. The
content_overlap detector is correct; it just never gets a sample at the
crossing moment. Rerun ONLY content_overlap on an 8fps grid (text-only,
cheap) when the composition animates; findings feed the existing
persistence tiering unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(lint): unconditional dense content_overlap pass + honor 500ms floor

Round-1 blocker: the dense motion-overlap re-pass was gated on sparse-grid
geometry fingerprints changing, so an animation aliased to the sparse grid
(identical fingerprints, yet colliding between samples) bypassed the pass —
exactly the transient false-negative it was built to catch. Remove the gate:
the dense pass now runs unconditionally (bounded, text-only), driven by the
composition timeline rather than a fingerprint heuristic.

Round-2 follow-ups:
- Persistence-tier drift: at 8fps, occurrences>=2 spans only ~125ms, not the
  ~500ms the design intends, and it short-circuited before the ms floor.
  content_overlap promotion now requires BOTH occurrences>=2 AND a literal
  firstSeen..lastSeen span >= 500ms, so the wall-clock floor is honored at any
  sampling density. Comment block updated to match.
- Sample cap scales to hold a true 8fps grid up to ~75s (raised 120 -> 600)
  with an explicit note that longer comps degrade below 8fps to stay bounded.

Tests:
- Replaced the trivial "warning at every sample" test with a real between-grid
  regression: a collision living only inside (3.5,4.5) — a gap the sparse grid
  seeks past — is detected and, held ~750ms, promoted to error.
- Replaced the now-invalid "skips when static" test with one asserting the
  dense pass runs even when sparse fingerprints are identical (aliased motion).
- Added a tiering regression: two dense occurrences spanning ~125ms stay a
  warning (not error). Both new guards verified red before the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(lint): settle-free geometry seek for dense content_overlap pass

The dense overlap re-pass did up to OVERLAP_MAX_SAMPLES full-settle seeks
(120ms paint settle each, ~72s of pure sleep at the ceiling) even though
collectOverlap only reads getBoundingClientRect geometry, valid
synchronously after the timeline setTime. Add a settle-free
DENSE_GEOMETRY_SEEK_OPTIONS + driver.seekGeometry used only by the dense
loop; the base grid keeps full-settle driver.seek.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(lint): document + cover content_overlap 500ms boundary for sparse callers

The occurrences>=2 AND heldMs>=500 promotion rule is a semantics change for
sparse callers (--samples 20, --at, short comps) whose two samples can land
<500ms apart. Document the change in the tiering comment and add boundary
tests: 499ms span stays warning, 500ms span promotes to error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(lint): make dense content_overlap seek genuinely geometry-only

Per review: DENSE_GEOMETRY_SEEK_OPTIONS only overrode settleMs, still
inheriting animationFrameSettle:double + waitForFontsMs:500 → ~3 frame
waits + font wait per seek → ~30s at the 600-sample cap. Geometry
(getBoundingClientRect) is valid synchronously post-setTime, so drop all
post-seek waits (animationFrameSettle:none, waitForFontsMs:0, settleMs:0).
Add options-level regression locking the geometry-only contract. Also fix
a stale comment name (detectMotionTextOverlap → collectMotionOverlapSamples).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: collapse multi-line comments to single lines

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:29:24 -07:00

395 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.3311.55, capB slams in 11.3511.69.
// The collision window 11.3511.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("keeps a content_overlap that spans under the 500ms floor as a warning, even with 2 occurrences", () => {
// Two dense-pass occurrences ~125ms apart are under the held-duration floor, so occurrences>=2 alone must not promote to error.
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 4.0 },
{ ...issue("content_overlap", "warning"), time: 4.125 },
],
73,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 });
});
it("does NOT promote content_overlap whose two occurrences span exactly 499ms (under the floor)", () => {
// Boundary: a span one millisecond short of the 500ms floor stays a warning — guards the AND-tighten for sparse callers.
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 4.0 },
{ ...issue("content_overlap", "warning"), time: 4.499 },
],
73,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 });
});
it("promotes content_overlap whose two occurrences span exactly 500ms (at the floor)", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 4.0 },
{ ...issue("content_overlap", "warning"), time: 4.5 },
],
73,
);
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.",
};
}