mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
/**
|
|
* T6c — acorn write path with magic-string offset-splice.
|
|
*
|
|
* Verifies that each write op touches only the intended byte span and leaves
|
|
* every other character identical to the original source.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
addAnimationToScript,
|
|
addKeyframeToScript,
|
|
convertToKeyframesFromScript,
|
|
removeAnimationFromScript,
|
|
removeKeyframeFromScript,
|
|
updateAnimationInScript,
|
|
updateKeyframeInScript,
|
|
} from "./gsapWriterAcorn.js";
|
|
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
|
import { parseGsapScript } from "./gsapParser.js";
|
|
import { validateCompositionGsap } from "./gsapSerialize.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture scripts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SCRIPT_A = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
const SCRIPT_B = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0);
|
|
tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
const SCRIPT_C = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
|
|
.from(".b", { opacity: 0, duration: 0.3 }, 0.5);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed)
|
|
const SCRIPT_D = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// No-op identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — no-op identity", () => {
|
|
it("updateAnimationInScript with empty updates returns identical script", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {});
|
|
expect(result).toBe(SCRIPT_A);
|
|
});
|
|
|
|
it("updateAnimationInScript with unknown ID returns identical script", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" });
|
|
expect(result).toBe(SCRIPT_A);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// updateAnimationInScript
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — updateAnimationInScript", () => {
|
|
it("updates ease value in-place", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
|
ease: "power2.in",
|
|
});
|
|
expect(result).toContain('"power2.in"');
|
|
expect(result).not.toContain('"power3.out"');
|
|
// Preamble + postamble unchanged
|
|
expect(result).toContain("var tl = gsap.timeline({ paused: true });");
|
|
expect(result).toContain('window.__timelines["t"] = tl;');
|
|
});
|
|
|
|
it("updates duration value in-place", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
|
duration: 1.2,
|
|
});
|
|
expect(result).toContain("duration: 1.2");
|
|
expect(result).not.toContain("duration: 0.5");
|
|
expect(result).toContain('"power3.out"');
|
|
});
|
|
|
|
it("updates position arg in-place", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
|
position: 0.5,
|
|
});
|
|
expect(result).toContain("}, 0.5)");
|
|
expect(result).not.toContain("}, 0.2)");
|
|
expect(result).toContain("opacity: 1");
|
|
});
|
|
|
|
it("inserts ease when property was absent", () => {
|
|
const noEase = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
const result = updateAnimationInScript(noEase, "#hero-to-200-visual", {
|
|
ease: "power3.out",
|
|
});
|
|
expect(result).toContain('ease: "power3.out"');
|
|
// Duration, opacity, position unchanged
|
|
expect(result).toContain("duration: 0.5");
|
|
expect(result).toContain("opacity: 1");
|
|
expect(result).toContain("}, 0.2)");
|
|
});
|
|
|
|
it("updates fromTo — ease on toVars", () => {
|
|
const fromTo = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
|
window.__timelines["t"] = tl;`;
|
|
// ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual
|
|
const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", {
|
|
ease: "back.out",
|
|
});
|
|
expect(result).toContain('"back.out"');
|
|
expect(result).not.toContain('"power3.out"');
|
|
expect(result).toContain("opacity: 0");
|
|
});
|
|
|
|
it("byte-identity outside edited ease span", () => {
|
|
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
|
ease: "power2.in",
|
|
});
|
|
const oldEaseStart = SCRIPT_A.indexOf('"power3.out"');
|
|
const newEaseStart = result.indexOf('"power2.in"');
|
|
// Everything before the ease value is identical
|
|
expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart));
|
|
// Everything after the ease value close-quote is identical
|
|
const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length);
|
|
const newAfter = result.slice(newEaseStart + '"power2.in"'.length);
|
|
expect(newAfter).toBe(oldAfter);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// removeAnimationFromScript
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — removeAnimationFromScript", () => {
|
|
it("removes a standalone tween statement", () => {
|
|
const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual");
|
|
expect(result).not.toContain("power3.out");
|
|
expect(result).toContain("power3.in");
|
|
expect(result).toContain('window.__timelines["t"] = tl;');
|
|
});
|
|
|
|
it("removes last chain link (outer call)", () => {
|
|
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
|
|
// Remove .b (outermost call = last in source)
|
|
const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual");
|
|
expect(result).toContain('.from(".a"');
|
|
expect(result).not.toContain('.from(".b"');
|
|
// The statement should still end with ; (no dangling chain)
|
|
expect(result).toContain("}, 0);");
|
|
});
|
|
|
|
it("removes inner chain link", () => {
|
|
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
|
|
// Remove .a (innermost call = first in source)
|
|
const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual");
|
|
expect(result).not.toContain('.from(".a"');
|
|
expect(result).toContain('.from(".b"');
|
|
// Chain is still rooted at tl (whitespace between tl and .from is valid JS)
|
|
expect(result).toMatch(/tl[\s.]*from\("\.b"/);
|
|
});
|
|
|
|
it("unknown ID returns script unchanged", () => {
|
|
const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id");
|
|
expect(result).toBe(SCRIPT_A);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// addAnimationToScript
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — addAnimationToScript", () => {
|
|
it("inserts new tween after last existing tween", () => {
|
|
const { script: result } = addAnimationToScript(SCRIPT_A, {
|
|
targetSelector: "#new",
|
|
method: "to",
|
|
position: 0.5,
|
|
duration: 0.3,
|
|
properties: { x: 100 },
|
|
});
|
|
expect(result).toContain('tl.to("#new"');
|
|
expect(result).toContain("x: 100");
|
|
expect(result).toContain("duration: 0.3");
|
|
// Original content preserved
|
|
expect(result).toContain('tl.to("#hero"');
|
|
expect(result).toContain('window.__timelines["t"] = tl;');
|
|
// New tween comes after hero tween
|
|
expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"'));
|
|
});
|
|
|
|
it("returns a non-empty stable id for the new animation", () => {
|
|
const { id } = addAnimationToScript(SCRIPT_A, {
|
|
targetSelector: "#new",
|
|
method: "to",
|
|
position: 0.5,
|
|
duration: 0.3,
|
|
properties: { x: 100 },
|
|
});
|
|
expect(id).toBeTruthy();
|
|
expect(typeof id).toBe("string");
|
|
});
|
|
|
|
it("inserts after timeline declaration when script has no tweens", () => {
|
|
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
|
const { script: result } = addAnimationToScript(empty, {
|
|
targetSelector: "#hero",
|
|
method: "to",
|
|
position: 0,
|
|
duration: 0.5,
|
|
properties: { opacity: 1 },
|
|
});
|
|
expect(result).toContain('tl.to("#hero"');
|
|
// Inserted after timeline declaration
|
|
expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline"));
|
|
});
|
|
|
|
it("inserts a global gsap.set BEFORE the timeline declaration", () => {
|
|
// A base set emitted after the tween calls is wiped by GSAP's from()-init
|
|
// revert on the first backwards render (studio soft-reload rebind) — it
|
|
// must precede the timeline so the from() records it as pre-tween state.
|
|
const script = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
const { script: result, id } = addAnimationToScript(script, {
|
|
targetSelector: "#hero",
|
|
method: "set",
|
|
position: 0,
|
|
properties: { x: 267, y: 20 },
|
|
global: true,
|
|
});
|
|
expect(result).toContain('gsap.set("#hero", { x: 267, y: 20 });');
|
|
expect(result.indexOf('gsap.set("#hero"')).toBeLessThan(result.indexOf("gsap.timeline"));
|
|
expect(id).toBeTruthy();
|
|
// Round-trip: the id resolves back to the inserted set
|
|
const updated = updateAnimationInScript(result, id, { properties: { x: 300, y: 20 } });
|
|
expect(updated).toContain("x: 300");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy global-set relocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — updateAnimationInScript relocates legacy trailing global set", () => {
|
|
const LEGACY = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
|
gsap.set("#hero", { x: 267, y: 20 });
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
it("moves a post-timeline global set above the declaration when updated", () => {
|
|
const result = updateAnimationInScript(LEGACY, "#hero-set-0-position", {
|
|
properties: { x: 300, y: 20 },
|
|
});
|
|
expect(result).toContain("x: 300");
|
|
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
|
|
expect(result).toContain('window.__timelines["t"] = tl;');
|
|
});
|
|
|
|
it("leaves an already-hoisted global set in place", () => {
|
|
const hoisted = `\
|
|
gsap.set("#hero", { x: 267, y: 20 });
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
const result = updateAnimationInScript(hoisted, "#hero-set-0-position", {
|
|
properties: { x: 300, y: 20 },
|
|
});
|
|
expect(result).toContain("x: 300");
|
|
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyframe write ops
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("T6c — keyframe write ops", () => {
|
|
it("updateKeyframeInScript replaces keyframe value at given percentage", () => {
|
|
// Update 50% from { opacity: 0.7 } to { opacity: 0.5 }
|
|
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 });
|
|
expect(result).toContain("opacity: 0.5");
|
|
expect(result).not.toContain("opacity: 0.7");
|
|
// Other keyframes unchanged
|
|
expect(result).toContain('"0%": { opacity: 0 }');
|
|
expect(result).toContain('"100%": { opacity: 1 }');
|
|
});
|
|
|
|
it("updateKeyframeInScript preserves bytes outside the edited value", () => {
|
|
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, {
|
|
opacity: 0.9,
|
|
});
|
|
// The 50% keyframe is untouched
|
|
expect(result).toContain('"50%": { opacity: 0.7 }');
|
|
// Duration and position are unchanged
|
|
expect(result).toContain("duration: 0.5");
|
|
expect(result).toContain("}, 0.2)");
|
|
});
|
|
|
|
it("updateKeyframeInScript edits ARRAY-form keyframes by percentage→index (the #shuttle case)", () => {
|
|
// Array-form keyframes carry no explicit percentages; GSAP distributes 4 of
|
|
// them evenly → 0 / 33.3 / 66.7 / 100. Dragging the 2nd motion-path node
|
|
// (pct 33.3) must rewrite array index 1 — not no-op (regression: array form
|
|
// bailed the ObjectExpression check, so the drag committed nothing).
|
|
const script =
|
|
"const tl = gsap.timeline();\n" +
|
|
'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);';
|
|
const result = updateKeyframeInScript(script, "#shuttle-to-5200-position", 33.3, {
|
|
x: 503,
|
|
y: 642,
|
|
});
|
|
expect(result).not.toBe(script); // actually changed (not a no-op)
|
|
expect(result).toContain("x: 503");
|
|
expect(result).toContain("y: 642");
|
|
expect(result).not.toContain("x: 520"); // index 1 replaced
|
|
// Sibling array entries untouched.
|
|
expect(result).toContain("{ x: 0, y: 0 }");
|
|
expect(result).toContain("{ x: 1040, y: 0 }");
|
|
expect(result).toContain("{ x: 1480, y: 160 }");
|
|
});
|
|
|
|
it("updateAnimationInScript apply-to-all sets easeEach and strips per-keyframe eases", () => {
|
|
const script =
|
|
"const tl = gsap.timeline();\n" +
|
|
'tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 50, ease: "power2.in" }, "100%": { x: 100, ease: "back.out" }, easeEach: "none" }, duration: 1 }, 0);';
|
|
const id = parseGsapScript(script).animations[0]!.id;
|
|
const result = updateAnimationInScript(script, id, {
|
|
easeEach: "power2.out",
|
|
resetKeyframeEases: true,
|
|
});
|
|
// easeEach updated to the chosen ease …
|
|
expect(result).toContain('easeEach: "power2.out"');
|
|
// … and every per-keyframe override is gone, so all segments use easeEach.
|
|
expect(result).not.toContain('ease: "power2.in"');
|
|
expect(result).not.toContain('ease: "back.out"');
|
|
// keyframe property values are preserved.
|
|
const kf = parseGsapScript(result).animations[0]!.keyframes!;
|
|
expect(kf.easeEach).toBe("power2.out");
|
|
expect(kf.keyframes.every((k) => k.ease === undefined)).toBe(true);
|
|
expect(kf.keyframes.map((k) => k.properties.x)).toEqual([0, 50, 100]);
|
|
});
|
|
|
|
it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => {
|
|
const script =
|
|
"const tl = gsap.timeline();\n" +
|
|
'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);';
|
|
const result = addKeyframeToScript(script, "#shuttle-to-5200-position", 50, { x: 780, y: 60 });
|
|
expect(result).not.toBe(script); // not a no-op
|
|
expect(result).toContain('"50%"'); // converted to percentage-object form
|
|
expect(result).toContain("x: 780");
|
|
// Original even-distribution stops preserved as percentage keys.
|
|
expect(result).toContain('"0%"');
|
|
expect(result).toContain('"100%"');
|
|
});
|
|
|
|
it("addKeyframeToScript inserts new percentage in sorted order", () => {
|
|
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 });
|
|
expect(result).toContain('"25%"');
|
|
expect(result).toContain("opacity: 0.3");
|
|
// Original keyframes preserved
|
|
expect(result).toContain('"0%": { opacity: 0 }');
|
|
expect(result).toContain('"50%": { opacity: 0.7 }');
|
|
// 25% appears before 50% in the string
|
|
expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"'));
|
|
});
|
|
|
|
it("addKeyframeToScript converts a flat tween and round-trips three sane points", () => {
|
|
const script = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { x: 420, duration: 1 }, 0);
|
|
window.__timelines["t"] = tl;`;
|
|
const animationId = parseGsapScriptAcorn(script).animations[0]!.id;
|
|
|
|
const result = addKeyframeToScript(script, animationId, 50, { x: 210 });
|
|
const reparsed = parseGsapScriptAcorn(result);
|
|
|
|
expect(reparsed.animations).toHaveLength(1);
|
|
expect(reparsed.animations[0]?.keyframes?.keyframes).toEqual([
|
|
{ percentage: 0, properties: { x: 0 } },
|
|
{ percentage: 50, properties: { x: 210 } },
|
|
{ percentage: 100, properties: { x: 420 } },
|
|
]);
|
|
expect(validateCompositionGsap(result)).toEqual({ valid: true, errors: [], warnings: [] });
|
|
});
|
|
|
|
it("addKeyframeToScript replaces value when percentage already exists", () => {
|
|
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 });
|
|
expect(result).toContain("opacity: 0.99");
|
|
expect(result).not.toContain("opacity: 0.7");
|
|
// Only one "50%" in the result
|
|
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
|
});
|
|
|
|
it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => {
|
|
// 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity.
|
|
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 });
|
|
expect(result).toContain("opacity: 0.7");
|
|
expect(result).toContain("x: 100");
|
|
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
|
});
|
|
|
|
it("removeKeyframeFromScript removes the target percentage", () => {
|
|
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
|
|
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
|
|
expect(result).not.toContain('"50%"');
|
|
expect(result).toContain('"0%"');
|
|
expect(result).toContain('"100%"');
|
|
});
|
|
|
|
it("updateKeyframeInScript on unknown id returns script unchanged", () => {
|
|
const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 });
|
|
expect(result).toBe(SCRIPT_D);
|
|
});
|
|
});
|
|
|
|
describe("T6c — convertToKeyframesFromScript: global gsap.set", () => {
|
|
const SCRIPT_GLOBAL_SET = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
gsap.set("#card", { rotationX: 50, rotationY: 20 });
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
it("re-roots a global gsap.set onto the timeline (tl.to + position), not gsap.to", () => {
|
|
const animId = parseGsapScript(SCRIPT_GLOBAL_SET).animations[0].id;
|
|
const result = convertToKeyframesFromScript(SCRIPT_GLOBAL_SET, animId, undefined, 4);
|
|
// Off-timeline gsap.to would fire once at load and be unseekable; must be tl.to.
|
|
expect(result).toMatch(/tl\.to\(\s*"#card"/);
|
|
expect(result).not.toMatch(/gsap\.to\(/);
|
|
expect(result).toContain("keyframes:");
|
|
const reparsed = parseGsapScript(result).animations[0];
|
|
expect(reparsed.keyframes).toBeTruthy();
|
|
expect(reparsed.global).toBeFalsy();
|
|
});
|
|
});
|