mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
* 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>
395 lines
14 KiB
TypeScript
395 lines
14 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("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.",
|
||
};
|
||
}
|