Files
hyperframes/packages/parsers/src/gsapWriter.acorn.test.ts
T

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();
});
});