/**
* 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 = `
Hello World
subtitle
`.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 = ``;
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 = `
`.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 = `
`.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 = `
`.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 */ `
Hello
`;
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 */ `
Hello
`;
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 */ `
Hello
`;
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 = `
${BASE_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 is stripped on serialize. No declarations yet.
const comp = await openComposition(`${BASE_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" },
]);
});
});