/**
* Phase 3b — GSAP mutation handler tests.
*
* Verifies the 8 parser-backed ops: addGsapTween, setGsapTween, removeGsapTween,
* setGsapKeyframe, addGsapKeyframe, removeGsapKeyframe, addLabel, removeLabel.
*/
import { describe, it, expect } from "vitest";
import { parseMutable } from "./model.js";
import { applyOp, validateOp } from "./mutate.js";
import { applyPatchesToDocument } from "./apply-patches.js";
import { serializeDocument } from "./serialize.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const GSAP_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5, ease: "power2.out" }, 0.2);
window.__timelines["t"] = tl;`;
const KF_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
function makeHtml(script: string) {
return `
`.trim();
}
function fresh(script = GSAP_SCRIPT) {
return parseMutable(makeHtml(script));
}
function getScript(parsed: ReturnType): string {
const doc = serializeDocument(parsed);
const m = /
`);
}
it("retargets post-split tween to newId", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "hero",
newId: "hero-2",
splitTime: 3,
elementStart: 0,
elementDuration: 4,
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("#hero-2");
});
it("spanning tween produces fromTo on new element", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "hero",
newId: "hero-2",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain(".fromTo(");
expect(newScript).toContain("#hero-2");
});
it("no-op when originalId not found", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "nonexistent",
newId: "x",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
});
expect(result.forward).toHaveLength(0);
});
});
// ─── setTiming — per-tween GSAP shift/scale (review #3) ───────────────────────
describe("setTiming — GSAP sync shifts/scales each tween (not absolute)", () => {
// Two staggered tweens on ONE element: positions 2.0 and 5.0, clip [2, 7].
const STAGGER_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 2);
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 5);
window.__timelines["t"] = tl;`;
function freshStagger() {
return parseMutable(``);
}
function gsapPatch(result: ReturnType): string {
const v = result.forward
.map((p) => p.value)
.find((val) => typeof val === "string" && val.includes("tl."));
return typeof v === "string" ? v : "";
}
it("moving the clip +1 shifts BOTH tweens by the delta, preserving the stagger", () => {
const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", start: 3 });
const script = gsapPatch(result);
// 2.0 → 3.0 and 5.0 → 6.0 — NOT both collapsed onto the new absolute start.
expect(script).toContain("{ x: 100, duration: 1 }, 3)");
expect(script).toContain("{ x: 200, duration: 1 }, 6)");
// The stagger gap (3s) is preserved; durations are untouched.
expect(script).not.toContain("duration: 5");
});
it("resizing the clip x2 scales each tween's duration by the ratio (not full clip)", () => {
// duration 5 → 10 (ratio 2); positions remap about the clip start (2).
const result = applyOp(freshStagger(), { type: "setTiming", target: "hf-box", duration: 10 });
const script = gsapPatch(result);
// pos 2 (offset 0) stays 2; pos 5 → 2 + (5-2)*2 = 8. durations 1 → 2.
expect(script).toContain("{ x: 100, duration: 2 }, 2)");
expect(script).toContain("{ x: 200, duration: 2 }, 8)");
// The bug blew every duration up to the full clip duration (10).
expect(script).not.toContain("duration: 10");
});
});
// ─── Label ops ────────────────────────────────────────────────────────────────
describe("addLabel", () => {
it("inserts addLabel call into script", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('addLabel("intro"');
expect(newScript).toContain("0.5");
});
it("addLabel output is not blocked by GSAP validator", async () => {
const { validateCompositionGsap } = await import("@hyperframes/core/gsap-parser");
const parsed = fresh();
const result = applyOp(parsed, { type: "addLabel", name: "scene1", position: 1.0 });
const newScript = String(result.forward[0]?.value ?? "");
const { errors } = validateCompositionGsap(newScript);
const labelError = errors.find((e) => /addLabel/i.test(e));
expect(labelError).toBeUndefined();
});
it("inverse restores original script", () => {
const parsed = fresh();
const original = getScript(parsed);
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
applyPatchesToDocument(parsed, result.inverse);
expect(getScript(parsed)).toBe(original);
});
});
describe("removeLabel", () => {
it("removes addLabel call from script", () => {
const withLabel = GSAP_SCRIPT.replace(
'window.__timelines["t"] = tl;',
'tl.addLabel("intro", 0.5);\nwindow.__timelines["t"] = tl;',
);
const parsed = fresh(withLabel);
const result = applyOp(parsed, { type: "removeLabel", name: "intro" });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain("addLabel");
});
it("returns EMPTY when label not found", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "removeLabel", name: "nonexistent" });
expect(result.forward).toHaveLength(0);
});
});
// ─── removeElement GSAP cascade ──────────────────────────────────────────────
describe("removeElement — GSAP cascade", () => {
it("removes animations targeting the removed element from the script", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
// forward: [remove_element, replace_script]
expect(result.forward).toHaveLength(2);
expect(result.forward[0]).toEqual({ op: "remove", path: "/elements/hf-box" });
const newScript = String(result.forward[1]?.value ?? "");
expect(newScript).not.toContain("hf-box");
});
it("inverse restores element AND script", () => {
const parsed = fresh();
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
// inverse[0] = restore element, inverse[1] = restore script
expect(inverse).toHaveLength(2);
expect(inverse[0]?.op).toBe("add");
expect(inverse[0]?.path).toBe("/elements/hf-box");
expect(inverse[1]?.op).toBe("replace");
expect(inverse[1]?.path).toBe("/script/gsap");
const restoredScript = String(inverse[1]?.value ?? "");
expect(restoredScript).toContain("hf-box");
});
it("applying inverse restores element and GSAP script to original", () => {
const parsed = fresh();
const origScript = getScript(parsed);
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
applyPatchesToDocument(parsed, inverse);
expect(parsed.document.querySelector('[data-hf-id="hf-box"]')).not.toBeNull();
expect(getScript(parsed)).toBe(origScript);
});
it("emits only element patch when composition has no GSAP script", () => {
const noScriptHtml = ``.trim();
const parsed = parseMutable(noScriptHtml);
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.op).toBe("remove");
});
it("does not remove animations targeting other elements", () => {
const twoTweenScript = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
tl.to("[data-hf-id=\\"hf-stage\\"]", { scale: 1.05, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(twoTweenScript);
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
const newScript = String(result.forward[1]?.value ?? "");
expect(newScript).not.toContain("hf-box");
expect(newScript).toContain("hf-stage");
});
it("strips ALL tweens for the element, not just the first (positional-id renumber)", () => {
// Two tweens on the same element: removing the first renumbers the survivor's
// count-based id, so a single up-front parse left the second tween orphaned.
const twoOwnTweens = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 1);
window.__timelines["t"] = tl;`;
const parsed = fresh(twoOwnTweens);
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
const newScript = String(result.forward[1]?.value ?? "");
expect(newScript).not.toContain("hf-box");
expect(newScript).not.toContain("x: 100");
expect(newScript).not.toContain("x: 200");
});
});
// ─── GSAP ops on composition with no script block ────────────────────────────
const NO_SCRIPT_HTML = ``.trim();
describe("GSAP ops on composition with no GSAP script block", () => {
function freshNoScript() {
return parseMutable(NO_SCRIPT_HTML);
}
it("addGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
}),
).toThrow();
});
it("setGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "setGsapTween",
animationId: "anim-1",
properties: { ease: "power2.out" },
}),
).toThrow();
});
it("removeGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }),
).toThrow();
});
it("addGsapKeyframe throws when script element is null", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapKeyframe",
animationId: "a1",
percentage: 0,
value: { opacity: 0 },
}),
).toThrow("No GSAP script block found");
});
});
// ─── arc path ops ─────────────────────────────────────────────────────────────
const ARC_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to('[data-hf-id="hf-hero"]', { x: 100, y: 50, duration: 2 }, 0);
window.__timelines["t"] = tl;`;
const ARC_ANIM_ID = `[data-hf-id="hf-hero"]-to-0-position`;
const ARC_ENABLED_CONFIG = {
enabled: true as const,
autoRotate: false as const,
segments: [{ curviness: 1 }],
};
function freshArc() {
return parseMutable(makeHtml(ARC_SCRIPT));
}
function enableArc(parsed: ReturnType) {
applyOp(parsed, { type: "setArcPath", animationId: ARC_ANIM_ID, config: ARC_ENABLED_CONFIG });
}
describe("setArcPath", () => {
it("enabled: true adds motionPath to script", () => {
const parsed = freshArc();
enableArc(parsed);
expect(getScript(parsed)).toContain("motionPath");
});
it("enabled: false removes motionPath and restores x/y", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, {
type: "setArcPath",
animationId: ARC_ANIM_ID,
config: { enabled: false, autoRotate: false, segments: [] },
});
const s = getScript(parsed);
expect(s).not.toContain("motionPath");
});
it("no-op when animation not found", () => {
const parsed = freshArc();
const before = getScript(parsed);
applyOp(parsed, { type: "setArcPath", animationId: "nonexistent", config: ARC_ENABLED_CONFIG });
expect(getScript(parsed)).toBe(before);
});
});
describe("updateArcSegment", () => {
it("changes curviness of segment", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, {
type: "updateArcSegment",
animationId: ARC_ANIM_ID,
segmentIndex: 0,
update: { curviness: 2 },
});
expect(getScript(parsed)).toContain("motionPath");
});
});
describe("removeArcPath", () => {
it("removes motionPath from script", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, { type: "removeArcPath", animationId: ARC_ANIM_ID });
expect(getScript(parsed)).not.toContain("motionPath");
});
});
// ─── R3 #6 — validateOp rejects unappliable arc-segment edits ─────────────────
describe("validateOp updateArcSegment (R3 #6)", () => {
it("E_ARC_NOT_ENABLED when the tween has no enabled arc path", () => {
const r = validateOp(freshArc(), {
type: "updateArcSegment",
animationId: ARC_ANIM_ID,
segmentIndex: 0,
update: { curviness: 2 },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_ARC_NOT_ENABLED");
});
it("E_INVALID_ARGS when the segment index is out of range", () => {
const parsed = freshArc();
enableArc(parsed);
const r = validateOp(parsed, {
type: "updateArcSegment",
animationId: ARC_ANIM_ID,
segmentIndex: 9,
update: { curviness: 2 },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS");
});
});
// ─── R3 #13b — deleteAllForSelector matches across quote styles ────────────────
describe("deleteAllForSelector quote-insensitive match (R3 #13b)", () => {
it("removes a tween authored with double quotes when given a single-quoted selector", () => {
const html = ``;
const parsed = parseMutable(html);
const result = applyOp(parsed, {
type: "deleteAllForSelector",
selector: `[data-hf-id='hf-box']`,
});
expect(result.forward.length).toBeGreaterThan(0);
expect(getScript(parsed)).not.toContain("tl.to(");
});
});
// ─── CF2 #15/#16 — handleSetTiming syncs #domId tweens + resizes data-duration ─
describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => {
function timingDoc(attrs: string, tween: string) {
return parseMutable(
``,
);
}
it("#15: a #domId-targeted tween shifts when the clip moves", () => {
const parsed = timingDoc(
`data-start="2" data-end="5"`,
`tl.to("#box", { x: 100, duration: 1 }, 2);`,
);
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 });
// position remapped 2 → 5 (delta +3); the bug left it at 2.
expect(getScript(parsed)).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/);
});
it("#16: a data-duration clip updates data-duration and scales its tween", () => {
const parsed = timingDoc(
`data-start="2" data-duration="4"`,
`tl.to("#box", { x: 100, duration: 4 }, 2);`,
);
applyOp(parsed, { type: "setTiming", target: "hf-box", duration: 8 });
const el = parsed.document.querySelector('[data-hf-id="hf-box"]');
// data-duration updated (not a stale value beside a fresh data-end).
expect(el?.getAttribute("data-duration")).toBe("8");
expect(el?.getAttribute("data-end")).toBeNull();
// tween duration scaled 4 → 8 (ratio 2).
expect(getScript(parsed)).toContain("duration: 8");
});
it("R5 #3: a start-less clip (no data-start) still shifts its tween (implicit start 0)", () => {
const parsed = timingDoc(`data-duration="4"`, `tl.to("#box", { x: 100, duration: 1 }, 2);`);
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 3 });
// oldStart defaults to 0, so position remaps 2 → 3 + (2 − 0) = 5.
// The bug skipped the whole sync block when data-start was absent.
expect(getScript(parsed)).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/);
});
it("R5 #3: a malformed data-start never writes position: NaN", () => {
const parsed = timingDoc(
`data-start="" data-duration="4"`,
`tl.to("#box", { x: 100, duration: 1 }, 2);`,
);
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 3 });
const script = getScript(parsed);
expect(script).not.toContain("NaN");
expect(script).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/);
});
it("R5 #2: an implicit-position tween is not collapsed to an absolute position on move", () => {
const parsed = timingDoc(
`data-start="2" data-end="5"`,
`tl.to("#box", { x: 100, duration: 1 });`,
);
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 });
const script = getScript(parsed);
// The tween had no position arg (auto-sequenced); it must stay that way —
// appending an absolute position would collapse the stagger.
expect(script).toContain('tl.to("#box", { x: 100, duration: 1 })');
expect(script).not.toMatch(/tl\.to\("#box",[^)]*\}, \d/);
});
it("R5 #7: a clip with BOTH data-duration and data-end keeps data-end in sync on move", () => {
const parsed = timingDoc(
`data-start="1" data-duration="2" data-end="3"`,
`tl.to("#box", { x: 1, duration: 2 }, 1);`,
);
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 });
const el = parsed.document.querySelector('[data-hf-id="hf-box"]');
expect(el?.getAttribute("data-start")).toBe("5");
expect(el?.getAttribute("data-duration")).toBe("2");
// data-end recomputed (5 + 2); the bug left it stale at 3 → inverted clip.
expect(el?.getAttribute("data-end")).toBe("7");
});
});
// ─── WS-3.C dispatch-path guards (validateOp is advisory; handlers self-guard) ──
describe("addWithKeyframes / replaceWithKeyframes — handler self-guards", () => {
const KFS = [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
];
const SEL = '[data-hf-id="hf-box"]';
// Dispatch skips validateOp, so each handler must self-guard: no degenerate
// `keyframes: {}` tween (empty list), and no silent degrade-to-add when the
// replace target id resolves to nothing.
it.each([
{
name: "addWithKeyframes with empty keyframes",
op: {
type: "addWithKeyframes",
targetSelector: SEL,
position: 0,
duration: 1,
keyframes: [],
},
},
{
name: "replaceWithKeyframes with an unknown animationId",
op: {
type: "replaceWithKeyframes",
animationId: "does-not-exist",
targetSelector: SEL,
position: 0,
duration: 1,
keyframes: KFS,
},
},
] as const)("$name is a no-op (script unchanged)", ({ op }) => {
const parsed = fresh();
const before = getScript(parsed);
const result = applyOp(parsed, op);
expect(result.forward).toHaveLength(0);
expect(getScript(parsed)).toBe(before);
});
// #11: a stale positional id that re-points to a tween on a DIFFERENT selector
// must NOT be silently replaced; only an id still targeting the caller's
// selector applies.
it("replaceWithKeyframes: stale id whose tween targets another selector is a no-op", () => {
const parsed = fresh();
const sel = '[data-hf-id="hf-box"]';
const add = applyOp(parsed, {
type: "addWithKeyframes",
targetSelector: sel,
position: 0,
duration: 1,
keyframes: KFS,
});
const id = add.meta!.animationId!;
const before = getScript(parsed);
// Same id, but the caller now claims a different selector → bail.
const wrong = applyOp(parsed, {
type: "replaceWithKeyframes",
animationId: id,
targetSelector: '[data-hf-id="hf-other"]',
position: 0,
duration: 1,
keyframes: KFS,
});
expect(wrong.forward).toHaveLength(0);
expect(getScript(parsed)).toBe(before);
// Correct selector → the replace applies.
const right = applyOp(parsed, {
type: "replaceWithKeyframes",
animationId: id,
targetSelector: sel,
position: 0,
duration: 1,
keyframes: KFS,
});
expect(right.forward.length).toBeGreaterThan(0);
});
});