mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## What Brief description of the change. ## Why Why is this change needed? ## How How was this implemented? Any notable design decisions? ## Test plan How was this tested? - [ ] Unit tests added/updated - [ ] Manual testing performed - [ ] Documentation updated (if applicable)
257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
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();
|
||
});
|
||
});
|