import { describe, expect, it } from "vitest"; import { arcPathFromMotionPathValue, hasNonHoldTweenForElement, readRuntimeKeyframes, } from "./gsapRuntimeKeyframes"; // Build a fake preview iframe whose runtime timeline holds the given child tweens // and resolves `selector` to `el`. function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement { const timeline = { getChildren: () => children, duration: () => 14.6, ...(now != null ? { time: () => now } : {}), }; return { contentWindow: { __timelines: { "index.html": timeline } }, contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) }, } as unknown as HTMLIFrameElement; } describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => { const el = { id: "puck-b" }; const holdSet = { targets: () => [el], // `data` is the STUDIO_HOLD_MARKER sentinel ("hf-hold") from core's gsapParser. // TODO(core follow-up): re-export STUDIO_HOLD_MARKER via the @hyperframes/core/ // gsap-parser subpath so this fixture can import the const instead of the literal. vars: { x: 0, y: 0, data: "hf-hold" }, duration: () => 0, startTime: () => 0, }; const kfTween = { targets: () => [el], vars: { keyframes: [ { x: 0, y: 0 }, { x: -180, y: -60 }, { x: -320, y: 40 }, { x: -460, y: -20 }, ], duration: 3.4, ease: "power1.inOut", }, duration: () => 3.4, startTime: () => 1.0, }; it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => { const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b"); expect(read?.keyframes).toHaveLength(4); }); it("returns null when the element only has a zero-duration set (no real motion)", () => { expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull(); }); }); describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => { const el = { id: "puck-a" }; // Two non-overlapping gesture recordings → two separate keyframed tweens. const gestureA = { targets: () => [el], vars: { keyframes: [ { x: 0, y: 0 }, { x: -100, y: 50 }, ], duration: 2.03, }, duration: () => 2.03, startTime: () => 1.033, // range [1.033, 3.063] }; const gestureB = { targets: () => [el], vars: { keyframes: [ { x: 10, y: 10 }, { x: 20, y: 20 }, { x: 30, y: 30 }, ], duration: 1.129, }, duration: () => 1.129, startTime: () => 3.342, // range [3.342, 4.471] }; it("playhead inside the SECOND tween reads the second tween (not the first)", () => { const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a"); expect(read?.keyframes).toHaveLength(3); // gestureB }); it("playhead inside the FIRST tween reads the first tween", () => { const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a"); expect(read?.keyframes).toHaveLength(2); // gestureA }); it("playhead outside every range falls back to the first keyframed tween", () => { const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a"); expect(read?.keyframes).toHaveLength(2); // gestureA (first) }); }); describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => { // An animated element with a position tween AND a (longer) size tween, both at // start 0 — the shape the per-keyframe size resize produces. const el = { id: "dot-a" }; const positionTween = { targets: () => [el], vars: { keyframes: [ { x: -1252, y: -394 }, { x: 244, y: -316 }, ], duration: 2.4, }, duration: () => 2.4, startTime: () => 0, // range [0, 2.4] }; const sizeTween = { targets: () => [el], vars: { keyframes: [ { width: 120, height: 96 }, { width: 325, height: 300 }, ], duration: 3.243, }, duration: () => 3.243, startTime: () => 0, // range [0, 3.243] — outlives the position tween }; it("playhead past the position range (2.4–3.243s) still returns the position tween, not size", () => { // Without the filter the size tween (the only one containing the playhead) // would win and blank the path. const read = readRuntimeKeyframes( fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a", undefined, ["x", "y"], ); expect(read?.keyframes[0]?.properties).toHaveProperty("x"); }); it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => { const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a"); expect(read?.keyframes[0]?.properties).toHaveProperty("width"); }); }); describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => { const el = { id: "puck-b" }; const holdSet = { targets: () => [el], vars: { x: 0, y: 0, data: "hf-hold" }, duration: () => 0, startTime: () => 0, }; const liveTween = { targets: () => [el], vars: { x: -120, y: 40, duration: 1 }, duration: () => 1, startTime: () => 1, }; it("true when a non-hold tween targets the element", () => { expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true); }); it("false when only a zero-duration hold/set remains (post delete-all)", () => { expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false); }); it("false when the element has no tweens at all", () => { expect(hasNonHoldTweenForElement(fakeIframe(el, []), "#puck-b")).toBe(false); }); }); describe("hasNonHoldTweenForElement — channel-scoped (position vs sibling rotation)", () => { const el = { id: "puck-c" }; const rotationTween = { targets: () => [el], vars: { keyframes: { "0%": { rotation: 0 }, "100%": { rotation: 90 } }, duration: 1 }, duration: () => 1, startTime: () => 0, }; const positionTween = { targets: () => [el], vars: { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 40 } }, duration: 1 }, duration: () => 1, startTime: () => 0, }; it("unfiltered: a rotation-only keyframed tween counts as non-hold", () => { expect(hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c")).toBe(true); }); it("position-scoped: a sibling rotation-only tween is NOT a live position tween", () => { // The primary fix: a keyframed rotation must not route a static position hold // into the keyframe branch. Scoping to position channels returns false here. expect( hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c", undefined, ["x", "y"]), ).toBe(false); }); it("position-scoped: a real keyframed position tween still counts", () => { expect( hasNonHoldTweenForElement(fakeIframe(el, [positionTween]), "#puck-c", undefined, ["x", "y"]), ).toBe(true); }); }); describe("arcPathFromMotionPathValue", () => { it("builds arc config from object form { path, curviness }", () => { const arc = arcPathFromMotionPathValue({ path: [ { x: 0, y: 0 }, { x: 100, y: -50 }, { x: 200, y: 0 }, { x: 300, y: 80 }, ], curviness: 2, }); expect(arc?.enabled).toBe(true); expect(arc?.segments).toHaveLength(3); // 4 waypoints → 3 segments expect(arc?.segments.every((s) => s.curviness === 2)).toBe(true); }); it("builds arc config from bare array form (default curviness 1)", () => { const arc = arcPathFromMotionPathValue([ { x: 0, y: 0 }, { x: 50, y: 50 }, ]); expect(arc?.enabled).toBe(true); expect(arc?.segments).toHaveLength(1); expect(arc?.segments[0]!.curviness).toBe(1); }); it("carries autoRotate", () => { const arc = arcPathFromMotionPathValue({ path: [ { x: 0, y: 0 }, { x: 10, y: 10 }, ], autoRotate: true, }); expect(arc?.autoRotate).toBe(true); }); it("returns undefined for fewer than 2 points, missing path, or string path", () => { expect(arcPathFromMotionPathValue({ path: [{ x: 0, y: 0 }] })).toBeUndefined(); expect(arcPathFromMotionPathValue({ curviness: 2 })).toBeUndefined(); expect(arcPathFromMotionPathValue({ path: "M0 0 L10 10" })).toBeUndefined(); expect(arcPathFromMotionPathValue(null)).toBeUndefined(); }); });