mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
650 lines
26 KiB
TypeScript
650 lines
26 KiB
TypeScript
/**
|
|
* Session-level behavior: history coalescing invariants and T3 override replay.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { openComposition } from "./session.js";
|
|
import type { DraftProps, ElementAtPointResult, PreviewAdapter } from "./adapters/types.js";
|
|
|
|
const BASE_HTML = `
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
|
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
|
|
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
|
|
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
|
</div>
|
|
`.trim();
|
|
|
|
class TestPreviewAdapter implements PreviewAdapter {
|
|
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
|
return null;
|
|
}
|
|
|
|
applyDraft(_id: string, _props: DraftProps): void {
|
|
// Test adapter tracks selection only.
|
|
}
|
|
|
|
commitPreview(): void {
|
|
// Test adapter tracks selection only.
|
|
}
|
|
|
|
cancelPreview(): void {
|
|
// Test adapter tracks selection only.
|
|
}
|
|
|
|
select(ids: string[], _opts?: { additive?: boolean }): void {
|
|
this.emitSelection(ids);
|
|
}
|
|
|
|
on(_event: "selection", handler: (ids: string[]) => void): () => void {
|
|
this.selectionHandlers.push(handler);
|
|
return () => {
|
|
this.selectionHandlers = this.selectionHandlers.filter((h) => h !== handler);
|
|
};
|
|
}
|
|
|
|
emitSelection(ids: readonly string[]): void {
|
|
const snapshot = [...ids];
|
|
for (const handler of this.selectionHandlers) {
|
|
handler([...snapshot]);
|
|
}
|
|
}
|
|
|
|
listenerCount(): number {
|
|
return this.selectionHandlers.length;
|
|
}
|
|
}
|
|
|
|
// ─── Preview selection bridge ────────────────────────────────────────────────
|
|
|
|
describe("preview selection bridge", () => {
|
|
it("mirrors preview selection into session state and notifies subscribers", async () => {
|
|
const preview = new TestPreviewAdapter();
|
|
const comp = await openComposition(BASE_HTML, { preview });
|
|
const events: string[][] = [];
|
|
|
|
comp.on("selectionchange", (ids) => events.push([...ids]));
|
|
preview.select(["hf-title"]);
|
|
|
|
expect(comp.getSelection()).toEqual(["hf-title"]);
|
|
expect(comp.selection().ids).toEqual(["hf-title"]);
|
|
expect(events).toEqual([["hf-title"]]);
|
|
});
|
|
|
|
it("selection proxy applies edits to ids selected by the preview", async () => {
|
|
const preview = new TestPreviewAdapter();
|
|
const comp = await openComposition(BASE_HTML, { preview });
|
|
|
|
preview.select(["hf-title", "hf-sub"]);
|
|
comp.selection().setStyle({ color: "#123456" });
|
|
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#123456");
|
|
expect(comp.getElement("hf-sub")?.inlineStyles["color"]).toBe("#123456");
|
|
});
|
|
|
|
it("dispose unsubscribes from preview selection events", async () => {
|
|
const preview = new TestPreviewAdapter();
|
|
const comp = await openComposition(BASE_HTML, { preview });
|
|
|
|
expect(preview.listenerCount()).toBe(1);
|
|
comp.dispose();
|
|
expect(preview.listenerCount()).toBe(0);
|
|
|
|
preview.select(["hf-title"]);
|
|
expect(comp.getSelection()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── History coalescing ───────────────────────────────────────────────────────
|
|
|
|
describe("history coalescing", () => {
|
|
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#111" });
|
|
comp.setStyle("hf-title", { color: "#222" });
|
|
comp.setStyle("hf-title", { color: "#333" });
|
|
|
|
comp.undo();
|
|
const el = comp.getElement("hf-title");
|
|
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
|
|
});
|
|
|
|
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#111" });
|
|
comp.setStyle("hf-sub", { opacity: "1" });
|
|
|
|
comp.undo();
|
|
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
|
|
|
|
comp.undo();
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
|
});
|
|
|
|
it("rapid edits to different properties of the same element do not coalesce", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#111" });
|
|
comp.setStyle("hf-title", { fontSize: "96px" });
|
|
|
|
comp.undo();
|
|
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
|
|
});
|
|
});
|
|
|
|
// ─── T3 override replay ───────────────────────────────────────────────────────
|
|
|
|
describe("override-set replay on open", () => {
|
|
it("applies style, text, and attribute overrides to the base document", async () => {
|
|
const comp = await openComposition(BASE_HTML, {
|
|
overrides: {
|
|
"hf-title.style.color": "#e63946",
|
|
"hf-title.text": "Edited headline",
|
|
"hf-logo.attr.src": "/new-logo.png",
|
|
},
|
|
});
|
|
|
|
const title = comp.getElement("hf-title");
|
|
expect(title?.inlineStyles["color"]).toBe("#e63946");
|
|
expect(title?.text).toBe("Edited headline");
|
|
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
|
|
|
|
const html = comp.serialize();
|
|
expect(html).toContain("Edited headline");
|
|
expect(html).toContain("/new-logo.png");
|
|
expect(html).toContain("#e63946");
|
|
});
|
|
|
|
it("applies timing overrides (computed absolute end)", async () => {
|
|
const comp = await openComposition(BASE_HTML, {
|
|
overrides: { "hf-title.timing.end": 4.5 },
|
|
});
|
|
expect(comp.serialize()).toContain('data-end="4.5"');
|
|
});
|
|
|
|
it("removes elements marked with the null removal marker", async () => {
|
|
const comp = await openComposition(BASE_HTML, {
|
|
overrides: { "hf-sub": null },
|
|
});
|
|
expect(comp.getElement("hf-sub")).toBeNull();
|
|
expect(comp.serialize()).not.toContain("subtitle");
|
|
});
|
|
|
|
it("treats property-level null as a deletion marker — removes the property from the base", async () => {
|
|
// Null in the override-set is emitted only from patchRemove (explicit deletion).
|
|
// On replay against a base that has the property set, it must be removed.
|
|
const comp = await openComposition(BASE_HTML, {
|
|
overrides: { "hf-title.style.color": null },
|
|
});
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBeUndefined();
|
|
});
|
|
|
|
it("null removal override on non-existent property is a safe no-op", async () => {
|
|
// backgroundColor doesn't exist on hf-title in the base; removing it must not throw.
|
|
const comp = await openComposition(BASE_HTML, {
|
|
overrides: { "hf-title.style.backgroundColor": null },
|
|
});
|
|
expect(comp.getElement("hf-title")).not.toBeNull();
|
|
expect(comp.getElement("hf-title")?.inlineStyles["backgroundColor"]).toBeUndefined();
|
|
});
|
|
|
|
it("getOverrides returns the set the session was opened with", async () => {
|
|
const overrides = { "hf-title.style.color": "#e63946" };
|
|
const comp = await openComposition(BASE_HTML, { overrides });
|
|
expect(comp.getOverrides()).toEqual(overrides);
|
|
});
|
|
});
|
|
|
|
// ─── batch() transactional rollback ───────────────────────────────────────────
|
|
|
|
describe("batch rollback on throw", () => {
|
|
it("reverts DOM mutations and override-set when the callback throws", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const htmlBefore = comp.serialize();
|
|
|
|
expect(() =>
|
|
comp.batch(() => {
|
|
comp.setStyle("hf-title", { color: "#e63946" });
|
|
comp.setText("hf-sub", "changed");
|
|
throw new Error("user cancelled");
|
|
}),
|
|
).toThrowError("user cancelled");
|
|
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
|
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
|
|
expect(comp.serialize()).toBe(htmlBefore);
|
|
expect(comp.getOverrides()).toEqual({});
|
|
});
|
|
|
|
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
try {
|
|
comp.batch(() => {
|
|
comp.setStyle("hf-title", { color: "#e63946" });
|
|
throw new Error("boom");
|
|
});
|
|
} catch {
|
|
// expected
|
|
}
|
|
comp.undo();
|
|
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
|
});
|
|
});
|
|
|
|
// ─── canUndo / canRedo ────────────────────────────────────────────────────────
|
|
|
|
describe("canUndo / canRedo", () => {
|
|
it("returns false before any mutation", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
expect(comp.canUndo()).toBe(false);
|
|
expect(comp.canRedo()).toBe(false);
|
|
});
|
|
|
|
it("canUndo true after a mutation, false after undoing back to start", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#ff0000" });
|
|
expect(comp.canUndo()).toBe(true);
|
|
expect(comp.canRedo()).toBe(false);
|
|
|
|
comp.undo();
|
|
expect(comp.canUndo()).toBe(false);
|
|
expect(comp.canRedo()).toBe(true);
|
|
});
|
|
|
|
it("canRedo cleared after a new mutation", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#ff0000" });
|
|
comp.undo();
|
|
expect(comp.canRedo()).toBe(true);
|
|
|
|
comp.setStyle("hf-title", { color: "#00ff00" });
|
|
expect(comp.canRedo()).toBe(false);
|
|
});
|
|
|
|
it("returns false in embedded (T3) mode — no history", async () => {
|
|
const comp = await openComposition(BASE_HTML, { overrides: {} });
|
|
comp.setStyle("hf-title", { color: "#ff0000" });
|
|
expect(comp.canUndo()).toBe(false);
|
|
expect(comp.canRedo()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── override-set orphan cleanup ──────────────────────────────────────────────
|
|
|
|
describe("override-set orphan cleanup on removeElement", () => {
|
|
it("purges property keys for removed element from the override-set", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
|
|
expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");
|
|
|
|
comp.removeElement("hf-title");
|
|
const overrides = comp.getOverrides();
|
|
// removal marker present
|
|
expect(overrides["hf-title"]).toBeNull();
|
|
// orphan property keys gone
|
|
expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
|
|
expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
|
|
});
|
|
|
|
it("property keys for other elements are unaffected", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#ff0000" });
|
|
comp.setStyle("hf-sub", { opacity: "1" });
|
|
comp.removeElement("hf-title");
|
|
const overrides = comp.getOverrides();
|
|
expect(overrides["hf-sub.style.opacity"]).toBe("1");
|
|
});
|
|
});
|
|
|
|
describe("single-dispatch undo reverses the inverse patch list", () => {
|
|
// A single dispatch that emits order-dependent inverse patches (here a nested
|
|
// parent+child removeElement) must undo in reverse application order. Without
|
|
// the reverse, undo replays 'add child' before 'add parent' → the child has no
|
|
// parent to attach to and is dropped.
|
|
it("removeElement([child, parent]) undo restores both, child included", async () => {
|
|
const NESTED = `<div data-hf-id="hf-root" data-hf-root data-duration="5">
|
|
<div data-hf-id="hf-parent"><span data-hf-id="hf-child">x</span></div>
|
|
</div>`;
|
|
const comp = await openComposition(NESTED);
|
|
comp.dispatch({ type: "removeElement", target: ["hf-child", "hf-parent"] });
|
|
expect(comp.getElement("hf-parent")).toBeNull();
|
|
expect(comp.getElement("hf-child")).toBeNull();
|
|
|
|
comp.undo();
|
|
expect(comp.getElement("hf-parent")).not.toBeNull();
|
|
expect(comp.getElement("hf-child")).not.toBeNull();
|
|
});
|
|
|
|
// Defense-in-depth: an aliased multi-target (the same element twice) makes the
|
|
// 2nd id capture the value the 1st already wrote; undo must replay the inverse
|
|
// in reverse to land on the ORIGINAL, not the intermediate.
|
|
it("setStyle with a duplicate target undoes to the original, not the intermediate", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.dispatch({
|
|
type: "setStyle",
|
|
target: ["hf-title", "hf-title"],
|
|
styles: { fontSize: "96px" },
|
|
});
|
|
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("96px");
|
|
comp.undo();
|
|
expect(comp.getElement("hf-title")?.inlineStyles.fontSize).toBe("64px");
|
|
});
|
|
});
|
|
|
|
// ─── setSelection / getSelection / selectionchange ───────────────────────────
|
|
|
|
describe("setSelection", () => {
|
|
it("getSelection returns empty array before any setSelection call", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
expect(comp.getSelection()).toEqual([]);
|
|
});
|
|
|
|
it("setSelection updates getSelection", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title"]);
|
|
expect(comp.getSelection()).toEqual(["hf-title"]);
|
|
});
|
|
|
|
it("setSelection with multiple ids", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title", "hf-sub"]);
|
|
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
|
|
});
|
|
|
|
it("setSelection([]) clears selection", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title"]);
|
|
comp.setSelection([]);
|
|
expect(comp.getSelection()).toEqual([]);
|
|
});
|
|
|
|
it("setSelection fires selectionchange with new ids", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const calls: string[][] = [];
|
|
comp.on("selectionchange", (ids) => calls.push(ids));
|
|
comp.setSelection(["hf-title"]);
|
|
expect(calls).toEqual([["hf-title"]]);
|
|
});
|
|
|
|
it("setSelection fires selectionchange with empty array when clearing", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title"]);
|
|
const calls: string[][] = [];
|
|
comp.on("selectionchange", (ids) => calls.push(ids));
|
|
comp.setSelection([]);
|
|
expect(calls).toEqual([[]]);
|
|
});
|
|
|
|
it("selectionchange listener receives a fresh copy each call", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const snapshots: string[][] = [];
|
|
comp.on("selectionchange", (ids) => snapshots.push(ids));
|
|
comp.setSelection(["hf-title"]);
|
|
comp.setSelection(["hf-sub"]);
|
|
expect(snapshots[0]).toEqual(["hf-title"]);
|
|
expect(snapshots[1]).toEqual(["hf-sub"]);
|
|
});
|
|
|
|
it("unsubscribed listener does not fire", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const calls: string[][] = [];
|
|
const off = comp.on("selectionchange", (ids) => calls.push(ids));
|
|
off();
|
|
comp.setSelection(["hf-title"]);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("selection() proxy operates on ids at call time", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title"]);
|
|
const proxy = comp.selection();
|
|
expect(proxy.ids).toEqual(["hf-title"]);
|
|
});
|
|
|
|
it("setSelection does not affect undo stack", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setStyle("hf-title", { color: "#ff0000" });
|
|
comp.setSelection(["hf-sub"]);
|
|
expect(comp.canUndo()).toBe(true);
|
|
comp.undo();
|
|
// selection must not have been pushed to history
|
|
expect(comp.canUndo()).toBe(false);
|
|
});
|
|
|
|
it("setSelection does not emit a patch event", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const patches: unknown[] = [];
|
|
comp.on("patch", (e) => patches.push(e));
|
|
comp.setSelection(["hf-title"]);
|
|
expect(patches).toHaveLength(0);
|
|
});
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
it("setSelection with same ids does not fire selectionchange again", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const calls: string[][] = [];
|
|
comp.on("selectionchange", (ids) => calls.push(ids));
|
|
comp.setSelection(["hf-title"]);
|
|
comp.setSelection(["hf-title"]); // same ids — must be a no-op
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it("setSelection with same ids in different order fires selectionchange", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const calls: string[][] = [];
|
|
comp.on("selectionchange", (ids) => calls.push(ids));
|
|
comp.setSelection(["hf-title", "hf-sub"]);
|
|
comp.setSelection(["hf-sub", "hf-title"]); // order differs — must fire
|
|
expect(calls).toHaveLength(2);
|
|
});
|
|
|
|
it("setSelection de-duplicates repeated ids", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
comp.setSelection(["hf-title", "hf-title", "hf-sub", "hf-title"]);
|
|
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
|
|
});
|
|
|
|
it("setSelection with duplicates matching stored selection does not fire selectionchange", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
const calls: string[][] = [];
|
|
comp.on("selectionchange", (ids) => calls.push(ids));
|
|
comp.setSelection(["hf-title"]);
|
|
comp.setSelection(["hf-title", "hf-title"]); // de-duped = ["hf-title"] — no change
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("animationIds population", () => {
|
|
const GSAP_HTML = `
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
|
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
|
<div data-hf-id="hf-plain">plain</div>
|
|
<script>var tl = gsap.timeline({ paused: true });
|
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;</script>
|
|
</div>`.trim();
|
|
|
|
it("attaches the parser's stable tween id to the targeted element", async () => {
|
|
const comp = await openComposition(GSAP_HTML);
|
|
const box = comp.getElement("hf-box");
|
|
expect(box?.animationIds.length).toBe(1);
|
|
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
|
|
expect(box?.animationIds[0]).toContain("hf-box");
|
|
expect(box?.animationIds[0]).toContain("-to-");
|
|
});
|
|
|
|
it("leaves untargeted elements with an empty animationIds", async () => {
|
|
const comp = await openComposition(GSAP_HTML);
|
|
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
|
|
});
|
|
|
|
it("the populated id is dispatchable as a removeGsapTween target", async () => {
|
|
const comp = await openComposition(GSAP_HTML);
|
|
const id = comp.getElement("hf-box")?.animationIds[0];
|
|
expect(id).toBeDefined();
|
|
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
|
|
});
|
|
|
|
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
|
|
const html = `
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
|
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
|
<script>var tl = gsap.timeline({ paused: true });
|
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
|
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
|
|
window.__timelines["t"] = tl;</script>
|
|
</div>`.trim();
|
|
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
|
|
expect(ids.length).toBe(2);
|
|
expect(new Set(ids).size).toBe(2); // distinct
|
|
});
|
|
|
|
it("fans a shared-selector tween out to every matched element", async () => {
|
|
const html = `
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
|
<div data-hf-id="hf-a" class="fade">a</div>
|
|
<div data-hf-id="hf-b" class="fade">b</div>
|
|
<script>var tl = gsap.timeline({ paused: true });
|
|
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
|
|
window.__timelines["t"] = tl;</script>
|
|
</div>`.trim();
|
|
const comp = await openComposition(html);
|
|
const a = comp.getElement("hf-a")?.animationIds ?? [];
|
|
const b = comp.getElement("hf-b")?.animationIds ?? [];
|
|
expect(a.length).toBe(1);
|
|
expect(b).toEqual(a); // same tween id on both matched elements
|
|
});
|
|
});
|
|
|
|
describe("getAllAnimationIds", () => {
|
|
it("includes a tween id even when its selector matches no live DOM element", async () => {
|
|
const html = /* html */ `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
|
<script>var tl = gsap.timeline({ paused: true }); tl.to("#does-not-exist", { x: 100, duration: 1 }, 3);</script>
|
|
</body></html>`;
|
|
const comp = await openComposition(html);
|
|
const flatIds = comp.getAllAnimationIds();
|
|
expect(flatIds.size).toBeGreaterThan(0);
|
|
const [unmatchedId] = [...flatIds];
|
|
// Confirms the bug this fixes: no element's animationIds contains this id,
|
|
// because "#does-not-exist" never CSS-matches anything in the document.
|
|
expect(comp.getElements().some((el) => el.animationIds.includes(unmatchedId ?? ""))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("returns an empty set when the composition has no GSAP script", async () => {
|
|
const html = /* html */ `<!DOCTYPE html>
|
|
<html><body><div data-hf-id="hf-box">Hello</div></body></html>`;
|
|
const comp = await openComposition(html);
|
|
expect(comp.getAllAnimationIds().size).toBe(0);
|
|
});
|
|
|
|
it("still includes ids for tweens that DO match a live DOM element", async () => {
|
|
const html = /* html */ `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
|
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
|
|
</body></html>`;
|
|
const comp = await openComposition(html);
|
|
const realId = comp.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
|
expect(realId).not.toBe("");
|
|
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── getVariableValue / listVariables / declareVariable / removeVariable ──────
|
|
|
|
const VARIABLES_HTML = `<!DOCTYPE html>
|
|
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
|
[{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" }],
|
|
)}'>
|
|
<body>${BASE_HTML}</body>
|
|
</html>`;
|
|
|
|
describe("variable declarations (Composition API)", () => {
|
|
it("getVariableValue reads a declared variable's current default", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
expect(comp.getVariableValue("brand-color")).toBe("#0066cc");
|
|
});
|
|
|
|
it("getVariableValue returns undefined for an undeclared id", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
|
});
|
|
|
|
it("listVariables returns every declared variable's full schema", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
expect(comp.listVariables()).toEqual([
|
|
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
|
]);
|
|
});
|
|
|
|
it("listVariables returns [] when the composition declares none", async () => {
|
|
const comp = await openComposition(BASE_HTML);
|
|
expect(comp.listVariables()).toEqual([]);
|
|
});
|
|
|
|
it("declareVariable creates a new declaration a variables panel can list immediately", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
|
expect(comp.getVariableValue("brand-title")).toBe("Hi");
|
|
expect(comp.listVariables()).toHaveLength(2);
|
|
});
|
|
|
|
it("declareVariable can create where setVariableValue's model write silently no-ops", async () => {
|
|
// Full document (not a fragment): declareVariable refuses fragment sources,
|
|
// whose synthetic <html> is stripped on serialize. No declarations yet.
|
|
const comp = await openComposition(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
|
|
comp.setVariableValue("never-declared", "x");
|
|
expect(comp.getVariableValue("never-declared")).toBeUndefined();
|
|
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
|
|
expect(comp.getVariableValue("never-declared")).toBe("x");
|
|
});
|
|
|
|
it("removeVariable removes the declaration; listVariables reflects it immediately", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
comp.removeVariable("brand-color");
|
|
expect(comp.listVariables()).toEqual([]);
|
|
expect(comp.getVariableValue("brand-color")).toBeUndefined();
|
|
});
|
|
|
|
it("declareVariable / removeVariable both support undo", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
|
expect(comp.listVariables()).toHaveLength(2);
|
|
comp.undo();
|
|
expect(comp.listVariables()).toHaveLength(1);
|
|
|
|
comp.removeVariable("brand-color");
|
|
expect(comp.listVariables()).toEqual([]);
|
|
comp.undo();
|
|
expect(comp.listVariables()).toEqual([
|
|
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
|
]);
|
|
});
|
|
|
|
it("declareVariable / removeVariable both support redo after undo", async () => {
|
|
const comp = await openComposition(VARIABLES_HTML);
|
|
|
|
comp.declareVariable({ id: "brand-title", type: "string", label: "Title", default: "Hi" });
|
|
comp.undo();
|
|
comp.redo();
|
|
expect(comp.listVariables()).toEqual([
|
|
{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" },
|
|
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
|
]);
|
|
|
|
comp.removeVariable("brand-color");
|
|
comp.undo();
|
|
comp.redo();
|
|
expect(comp.listVariables()).toEqual([
|
|
{ id: "brand-title", type: "string", label: "Title", default: "Hi" },
|
|
]);
|
|
});
|
|
});
|