// fallow-ignore-file code-duplication /** * WS-C — getElementTimings / setElementTiming / setHold tests. * * Tests the session-layer wiring for the new typed methods. * happy-dom can't do GSAP seek/layout so we test DOM attribute reads and * dispatch behavior directly. */ import { describe, it, expect } from "vitest"; import { openComposition } from "./session.js"; // ─── Fixtures ───────────────────────────────────────────────────────────────── /** Duration-authored clip (data-duration preferred by handleSetTiming). */ const DURATION_AUTHORED_HTML = `

Hello

World

`.trim(); /** End-authored clip (data-end only, no data-duration). */ const END_AUTHORED_HTML = `

Hello

`.trim(); /** Both data-duration and data-end (data-duration wins). */ const BOTH_ATTRS_HTML = `

Hello

`.trim(); /** Has a GSAP script with addLabel. */ const GSAP_LABEL_HTML = `
`.trim(); const GSAP_TEMPLATE_LABEL_HTML = `
`.trim(); // ─── getElementTimings — duration-authored clips ────────────────────────────── describe("getElementTimings — duration-authored clips", () => { it("reads enterAt = data-start, exitAt = data-start + data-duration", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const timings = comp.getElementTimings(); expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 }); expect(timings["hf-sub"]).toMatchObject({ enterAt: 2, exitAt: 4 }); }); it("returns empty labels array when no GSAP script", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const timings = comp.getElementTimings(); expect(timings["hf-title"]?.labels).toEqual([]); }); }); // ─── getElementTimings — end-authored clips ─────────────────────────────────── describe("getElementTimings — end-authored clips", () => { it("falls back to data-end − data-start when no data-duration", async () => { const comp = await openComposition(END_AUTHORED_HTML); const timings = comp.getElementTimings(); // enterAt = 1, exitAt = 4 (from data-end = 4, data-start = 1, duration = 3) expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 4 }); }); }); // ─── getElementTimings — data-duration wins over data-end ──────────────────── describe("getElementTimings — data-duration wins over data-end", () => { it("uses data-duration when both data-duration and data-end are present", async () => { const comp = await openComposition(BOTH_ATTRS_HTML); const timings = comp.getElementTimings(); // data-duration=3 wins; exitAt = 0+3=3, NOT from data-end=99 expect(timings["hf-title"]).toMatchObject({ enterAt: 0, exitAt: 3 }); }); }); // ─── getElementTimings — labels from GSAP script ───────────────────────────── describe("getElementTimings — GSAP labels", () => { it("returns labels whose position falls within [enterAt, exitAt]", async () => { const comp = await openComposition(GSAP_LABEL_HTML); const timings = comp.getElementTimings(); // hf-box: enterAt=0, exitAt=5; labels "intro"@0.5 and "outro"@4.0 are both in range const box = timings["hf-box"]; expect(box?.labels).toContain("intro"); expect(box?.labels).toContain("outro"); }); it("parses labels fresh — no stale cache after mutation", async () => { const comp = await openComposition(GSAP_LABEL_HTML); const before = comp.getElementTimings()["hf-box"]?.labels ?? []; expect(before).toContain("intro"); // Move the element so timing changes; labels should still parse fresh comp.setTiming("hf-box", { start: 0, duration: 5 }); // no-op but triggers re-parse const after = comp.getElementTimings()["hf-box"]?.labels ?? []; expect(after).toContain("intro"); }); it("reads labels from GSAP scripts inside composition templates", async () => { const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML); const labels = comp.getElementTimings()["hf-box"]?.labels ?? []; expect(labels).toContain("template-label"); }); }); // ─── getElementTimings — relative data-start references ────────────────────── /** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */ const RELATIVE_START_HTML = `

Intro

Outro

Right after

`.trim(); describe("getElementTimings — relative data-start references", () => { it("resolves 'ref + offset' against the referenced element's resolved end", async () => { const comp = await openComposition(RELATIVE_START_HTML); const timings = comp.getElementTimings(); // hf-intro: enterAt=1, exitAt=4 expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 }); // hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6 expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 }); }); it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => { const comp = await openComposition(RELATIVE_START_HTML); const timings = comp.getElementTimings(); expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 }); }); it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => { const html = `

`.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 }); }); it("resolves a chained reference (A -> B -> C) through the recursive resolver", async () => { const html = `

A

B

C

`.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 }); // hf-b: "hf-a" (no offset) = a's exitAt (2) expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 }); // hf-c: "hf-b + 1" = b's exitAt (5) + 1 = 6 expect(timings["hf-c"]).toMatchObject({ enterAt: 6, exitAt: 7 }); }); it("terminates (not an infinite loop) on a direct self-reference", async () => { const html = `

`.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); // Cyclic references are invalid. The shared contract leaves the reference // unresolved and this snapshot adapter applies its documented zero fallback. expect(timings["hf-self"]).toMatchObject({ enterAt: 0, exitAt: 2 }); expect(Number.isFinite(timings["hf-self"]?.enterAt)).toBe(true); }); it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", async () => { const html = `

`.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); // Both invalid starts get the same deterministic fallback; results no // longer depend on document traversal order. expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 }); expect(timings["hf-b"]).toMatchObject({ enterAt: 0, exitAt: 3 }); expect(Number.isFinite(timings["hf-a"]?.enterAt)).toBe(true); expect(Number.isFinite(timings["hf-b"]?.enterAt)).toBe(true); }); it("resolves a colliding bare id to the TOP-LEVEL match, not a same-scope sibling", async () => { // Bare ids have no scope syntax — resolveScoped's bare-id rule prefers the // canonical top-level match when one exists, same as the runtime's own (also // global, not scope-aware) resolver. Both the outer document AND the sub-comp // author an element with the SAME bare id "hf-intro" — a genuine collision. const html = `

Outer intro

Inner intro (same bare id)

Inner outro

`.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); // "hf-intro + 1", authored on an element INSIDE the sub-comp, still resolves // against the OUTER hf-intro (exitAt=10) — 10 + 1 = 11 — not the same-scope // inner hf-intro (exitAt=1, which would give 2). This pins current behavior; // it is a real authoring footgun, not a claim that it's the ideal semantics. expect(timings["hf-host/hf-outro"]).toMatchObject({ enterAt: 11, exitAt: 12 }); }); }); // ─── setElementTiming — sparse map + batched dispatch ──────────────────────── describe("setElementTiming", () => { it("applies sparse timing map to multiple elements", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); comp.setElementTiming({ "hf-title": { start: 1, duration: 2 }, "hf-sub": { start: 4 }, }); const timings = comp.getElementTimings(); expect(timings["hf-title"]).toMatchObject({ enterAt: 1, exitAt: 3 }); expect(timings["hf-sub"]).toMatchObject({ enterAt: 4 }); }); it("emits exactly one patch event for multiple entries (batched)", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const patches: unknown[] = []; comp.on("patch", (e) => patches.push(e)); comp.setElementTiming({ "hf-title": { start: 0.5 }, "hf-sub": { start: 3.0 }, }); // One batch → one patch event expect(patches).toHaveLength(1); }); it("is a no-op for empty map", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const patches: unknown[] = []; comp.on("patch", (e) => patches.push(e)); comp.setElementTiming({}); expect(patches).toHaveLength(0); }); it("respects data-duration vs data-end preference on write", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); // Before: hf-title has data-duration=3, data-start=0 comp.setElementTiming({ "hf-title": { duration: 5 } }); const timings = comp.getElementTimings(); // Should read back the new duration expect(timings["hf-title"]).toMatchObject({ exitAt: 5 }); }); it("can be undone as a single step", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const before = comp.getElementTimings()["hf-title"]; comp.setElementTiming({ "hf-title": { start: 2 } }); comp.undo(); const after = comp.getElementTimings()["hf-title"]; expect(after?.enterAt).toBe(before?.enterAt); }); it("setElementTiming inverse restores original timing", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const originalTimings = comp.getElementTimings(); comp.setElementTiming({ "hf-title": { start: 10, duration: 1 }, "hf-sub": { start: 12, duration: 1 }, }); comp.undo(); const restored = comp.getElementTimings(); expect(restored["hf-title"]).toEqual(originalTimings["hf-title"]); expect(restored["hf-sub"]).toEqual(originalTimings["hf-sub"]); }); }); // ─── setHold — typed wrapper ────────────────────────────────────────────────── describe("setHold — typed method", () => { it("dispatches the setHold op (regression: existing op unchanged)", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); const patches: unknown[] = []; comp.on("patch", (e) => patches.push(e)); comp.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" }); // Should emit a patch expect(patches).toHaveLength(1); }); it("setHold writes data-hold-start / data-hold-end / data-hold-fill attrs", async () => { const comp = await openComposition(DURATION_AUTHORED_HTML); comp.setHold("hf-title", { start: 1.0, end: 2.0, fill: "loop" }); // Verify via serialize (attrs are in the HTML output) const html = comp.serialize(); expect(html).toContain('data-hold-start="1"'); expect(html).toContain('data-hold-end="2"'); expect(html).toContain('data-hold-fill="loop"'); }); it("setHold typed method equals dispatch({type:setHold})", async () => { // Run typed method path const comp1 = await openComposition(DURATION_AUTHORED_HTML); comp1.setHold("hf-title", { start: 0.5, end: 2.5, fill: "freeze" }); const html1 = comp1.serialize(); // Run raw dispatch path const comp2 = await openComposition(DURATION_AUTHORED_HTML); comp2.dispatch({ type: "setHold", target: "hf-title", hold: { start: 0.5, end: 2.5, fill: "freeze" }, }); const html2 = comp2.serialize(); expect(html1).toBe(html2); }); });