/** * 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

Logo
`.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 = `
x
`; 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 = `
box
plain
`.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 = `
box
`.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 = `
a
b
`.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" }, ]); }); });