/** * T4 — Op contract tests for the Phase 3a dispatch boundary. * * Tests verify: correct DOM mutation, correct RFC 6902 forward patches, * correct inverse patches (applying them restores the original state), * and override-set key mapping. */ import { describe, it, expect } from "vitest"; import { parseMutable, getElementStyles, setElementStyles } from "./model.js"; import { applyOp, validateOp } from "./mutate.js"; import { applyPatchesToDocument, applyOverrideSet } from "./apply-patches.js"; import { pathToKey } from "./patches.js"; import { serializeDocument } from "./serialize.js"; // ─── Fixtures ──────────────────────────────────────────────────────────────── // No trailing semicolons in style attrs — serializeStyleAttr never adds them. const BASE_HTML = `

Hello World

Logo
sub text
`.trim(); function fresh() { return parseMutable(BASE_HTML); } /** Full HTML fixture with data-composition-variables for B1/B2 tests. */ const VARIABLES_HTML = `
`; function freshWithVars() { return parseMutable(VARIABLES_HTML); } /** Read the default value for a variable id from the parsed document. */ function readVarDefault(parsed: ReturnType, id: string): unknown { const raw = parsed.document.documentElement?.getAttribute("data-composition-variables"); if (!raw) return undefined; const arr = JSON.parse(raw) as Array<{ id: string; default: unknown }>; return arr.find((v) => v.id === id)?.default; } // ─── setStyle ──────────────────────────────────────────────────────────────── describe("setStyle", () => { it("mutates existing style prop and emits replace patches", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: "hf-title", styles: { fontSize: "96px" }, }); expect(result.forward).toHaveLength(1); expect(result.forward[0]).toEqual({ op: "replace", path: "/elements/hf-title/inlineStyles/fontSize", value: "96px", }); expect(result.inverse[0]).toEqual({ op: "replace", path: "/elements/hf-title/inlineStyles/fontSize", value: "64px", }); // DOM mutated const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); expect(el?.getAttribute("style")).toContain("font-size: 96px"); }); it("adds new style prop and emits add patch", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: "hf-logo", styles: { opacity: "0.8" }, }); expect(result.forward[0]?.op).toBe("add"); expect(result.inverse[0]?.op).toBe("remove"); }); it("removes style prop when value is null", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: "hf-title", styles: { color: null }, }); expect(result.forward[0]?.op).toBe("remove"); expect(result.inverse[0]?.op).toBe("add"); expect(result.inverse[0]?.value).toBe("#fff"); }); it("inverse patches restore original state", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setStyle", target: "hf-title", styles: { fontSize: "96px", color: "#f00" }, }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); it("applies to multiple targets", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: ["hf-title", "hf-span"], styles: { opacity: "1" }, }); expect(result.forward).toHaveLength(2); }); it("override-set key maps correctly", () => { const key = pathToKey("/elements/hf-title/inlineStyles/fontSize"); expect(key).toBe("hf-title.style.fontSize"); }); // Regression: a HYPHENATED (kebab) style key must derive its inverse against // the camelCase-keyed store, or oldValue is null → undo deletes the prior // value instead of restoring it, and a removal skips the inverse entirely. it("derives correct inverse for a kebab style key (change)", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: "hf-title", styles: { "font-size": "96px" }, }); // inverse restores the prior 64px (replace), not a remove expect(result.inverse[0]).toEqual({ op: "replace", path: "/elements/hf-title/inlineStyles/fontSize", value: "64px", }); }); it("emits the inverse for a kebab style removal (no DOM/patch desync)", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setStyle", target: "hf-title", styles: { "font-size": null }, }); // removal must be recorded (forward remove + inverse add restoring 64px) expect(result.forward[0]?.op).toBe("remove"); expect(result.inverse[0]).toEqual({ op: "add", path: "/elements/hf-title/inlineStyles/fontSize", value: "64px", }); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); expect(el?.getAttribute("style") ?? "").not.toContain("font-size"); }); }); // ─── setText ───────────────────────────────────────────────────────────────── describe("setText", () => { it("updates text content and emits replace patch", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setText", target: "hf-title", value: "Goodbye World", }); expect(result.forward[0]).toEqual({ op: "replace", path: "/elements/hf-title/text", value: "Goodbye World", }); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); // text node should contain new value expect(el?.textContent).toContain("Goodbye World"); }); it("inverse patches restore original text", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setText", target: "hf-title", value: "Changed", }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); it("creates text node when element has no existing text node", () => { const parsed = parseMutable( '
', ); const result = applyOp(parsed, { type: "setText", target: "hf-empty", value: "Added" }); const el = parsed.document.querySelector('[data-hf-id="hf-empty"]'); expect(el?.textContent).toBe("Added"); expect(result.forward[0]?.op).toBe("replace"); expect(result.forward[0]?.value).toBe("Added"); }); it("override-set key maps correctly", () => { expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text"); }); }); // ─── setAttribute ───────────────────────────────────────────────────────────── describe("setAttribute", () => { it("sets a new attribute and emits add patch", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setAttribute", target: "hf-logo", name: "src", value: "/new-logo.png", }); expect(result.forward[0]).toEqual({ op: "replace", path: "/elements/hf-logo/attributes/src", value: "/new-logo.png", }); }); it("removes attribute when value is null", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setAttribute", target: "hf-logo", name: "alt", value: null, }); expect(result.forward[0]?.op).toBe("remove"); expect(result.inverse[0]?.value).toBe("Logo"); }); it("inverse patches restore original attribute", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setAttribute", target: "hf-logo", name: "src", value: "/changed.png", }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); }); // ─── setTiming ──────────────────────────────────────────────────────────────── describe("setTiming", () => { it("updates start and recalculates end", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setTiming", target: "hf-title", start: 1, }); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); expect(el?.getAttribute("data-start")).toBe("1"); // duration was 3 (0→3), so end = 1+3 = 4 expect(el?.getAttribute("data-end")).toBe("4"); const startPatch = result.forward.find((p) => p.path.endsWith("/start")); expect(startPatch?.value).toBe(1); }); it("updates duration and recalculates end", () => { const parsed = fresh(); applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 }); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2 }); it("inverse patches restore original timing", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setTiming", target: "hf-title", start: 1, duration: 2, trackIndex: 1, }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); }); // ─── removeElement ─────────────────────────────────────────────────────────── describe("removeElement", () => { it("removes element from DOM and emits remove patch", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "removeElement", target: "hf-span", }); expect(result.forward[0]?.op).toBe("remove"); expect(result.forward[0]?.path).toBe("/elements/hf-span"); expect(parsed.document.querySelector('[data-hf-id="hf-span"]')).toBeNull(); }); it("inverse patch carries html and restore position", () => { const parsed = fresh(); const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-span", }); expect(inverse[0]?.op).toBe("add"); const val = inverse[0]?.value as { html: string; parentId: string | null; siblingIndex: number; }; expect(val.html).toContain("hf-span"); expect(val.parentId).toBe("hf-sub"); expect(val.siblingIndex).toBe(0); }); it("applying inverse patch restores the element in correct parent", () => { const parsed = fresh(); const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-span", }); applyPatchesToDocument(parsed, inverse); const restored = parsed.document.querySelector('[data-hf-id="hf-span"]'); expect(restored).not.toBeNull(); expect(restored?.parentElement?.getAttribute("data-hf-id")).toBe("hf-sub"); expect(restored?.getAttribute("style")).toBe("opacity: 0.5"); expect(restored?.textContent).toBe("sub text"); }); }); // ─── addElement ─────────────────────────────────────────────────────────────── describe("addElement", () => { it("inserts element at specified parent+index and resolves via getElement-style lookup", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: '

inserted

', }); expect(result.meta?.newId).toBeTruthy(); const newId = result.meta!.newId!; const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`); expect(el).not.toBeNull(); expect(el?.tagName.toLowerCase()).toBe("p"); // Inserted at index 0 → first child of hf-stage const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]'); expect(stage?.firstElementChild?.getAttribute("data-hf-id")).toBe(newId); }); it("insert at index >= childCount appends to parent", () => { const parsed = fresh(); const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]'); const countBefore = stage ? Array.from(stage.children).length : 0; const result = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 9999, html: 'tail', }); const newId = result.meta!.newId!; const stageAfter = parsed.document.querySelector('[data-hf-id="hf-stage"]'); expect(stageAfter?.lastElementChild?.getAttribute("data-hf-id")).toBe(newId); expect(Array.from(stageAfter?.children ?? []).length).toBe(countBefore + 1); }); it("minted id is unique vs all existing doc ids", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: '
content
', }); const newId = result.meta!.newId!; // Must not collide with any pre-existing id const existingIds = ["hf-stage", "hf-title", "hf-logo", "hf-sub", "hf-span"]; expect(existingIds).not.toContain(newId); // Must appear exactly once in the document const all = Array.from(parsed.document.querySelectorAll(`[data-hf-id="${newId}"]`)); expect(all).toHaveLength(1); }); it("content-collision with existing element yields a distinct rehashed id", () => { // Insert a fragment with identical content to an existing element → dup-rehash must yield a distinct id const parsed = fresh(); // hf-logo is Logo // Insert the same HTML without the data-hf-id so mintHfId runs fresh const result = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: 'Logo', }); const newId = result.meta!.newId!; expect(newId).not.toBe("hf-logo"); expect(newId.startsWith("hf-")).toBe(true); const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`); expect(el).not.toBeNull(); }); it("nested fragment: all new nodes get unique ids; root id returned", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: '
ab
', }); const rootId = result.meta!.newId!; const root = parsed.document.querySelector(`[data-hf-id="${rootId}"]`); expect(root).not.toBeNull(); // All children must have data-hf-id const children = root ? Array.from(root.querySelectorAll("*")) : []; for (const child of children) { expect(child.getAttribute("data-hf-id")).toBeTruthy(); } // All ids must be distinct const allIds = [rootId, ...children.map((c) => c.getAttribute("data-hf-id") as string)]; expect(new Set(allIds).size).toBe(allIds.length); }); it("forward patch is patchAdd; inverse patch is patchRemove — symmetry with removeElement", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "addElement", parent: "hf-sub", index: 0, html: 'em text', }); expect(result.forward).toHaveLength(1); expect(result.inverse).toHaveLength(1); expect(result.forward[0]?.op).toBe("add"); expect(result.inverse[0]?.op).toBe("remove"); const newId = result.meta!.newId!; expect(result.forward[0]?.path).toBe(`/elements/${newId}`); expect(result.inverse[0]?.path).toBe(`/elements/${newId}`); }); it("applying inverse patch removes the added element (undo)", () => { const parsed = fresh(); const { inverse, meta } = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: '
undo me
', }); const newId = meta!.newId!; expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).not.toBeNull(); applyPatchesToDocument(parsed, inverse); expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull(); }); it("add → undo → redo: element returns with the same id (id stability)", () => { const parsed = fresh(); // add const { forward, inverse, meta } = applyOp(parsed, { type: "addElement", parent: "hf-stage", index: 1, html: '
redo
', }); const newId = meta!.newId!; // undo applyPatchesToDocument(parsed, inverse); expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull(); // redo (replay forward patches) applyPatchesToDocument(parsed, forward); const restored = parsed.document.querySelector(`[data-hf-id="${newId}"]`); expect(restored).not.toBeNull(); expect(restored?.getAttribute("data-hf-id")).toBe(newId); }); it("parent: null inserts at document body root level", () => { // Use a simple fragment doc const parsed = parseMutable( '
', ); const result = applyOp(parsed, { type: "addElement", parent: null, index: 1, html: '', }); const newId = result.meta!.newId!; const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`); expect(el).not.toBeNull(); expect(el?.parentElement?.tagName.toLowerCase()).toBe("body"); }); it("serialize round-trip: addElement survives serialize()", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "addElement", parent: "hf-sub", index: 0, html: 'bold', }); const newId = result.meta!.newId!; const serialized = serializeDocument(parsed); expect(serialized).toContain(`data-hf-id="${newId}"`); expect(serialized).toContain("bold"); }); // ─── validateOp ───────────────────────────────────────────────────────────── it("validateOp: missing parent → E_TARGET_NOT_FOUND", () => { const parsed = fresh(); const r = validateOp(parsed, { type: "addElement", parent: "hf-nonexistent", index: 0, html: "
x
", }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND"); }); it("validateOp: negative index → E_INVALID_ARGS", () => { const parsed = fresh(); const r = validateOp(parsed, { type: "addElement", parent: "hf-stage", index: -1, html: "
x
", }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); }); it("validateOp: empty html → E_INVALID_HTML", () => { const parsed = fresh(); const r = validateOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: "", }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_INVALID_HTML"); }); it("validateOp: html with only text / zero element nodes → E_INVALID_HTML", () => { const parsed = fresh(); const r = validateOp(parsed, { type: "addElement", parent: "hf-stage", index: 0, html: "just text no element", }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_INVALID_HTML"); }); it("validateOp: html containing `, ); const r = validateOp(parsed, { type: "unrollDynamicAnimations", animationId: "#x-to-0-position", elements: [], }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); }); it("materializeKeyframes rejects an empty keyframe list (would empty the animation)", () => { const parsed = parseMutable( `
` + ``, ); const r = validateOp(parsed, { type: "materializeKeyframes", animationId: "#x-to-0-position", keyframes: [], }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS"); }); it("setClassStyle no longer throws — implemented in Phase 3b", () => { expect(() => applyOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { color: "red" }, }), ).not.toThrow(); }); }); // ─── setCompositionMetadata — data-width/data-height forced override ───────── describe("setCompositionMetadata data-* channel", () => { const ATTR_HTML = `

Hi

`.trim(); it("updates data-width/data-height when the composition carries them", () => { const parsed = parseMutable(ATTR_HTML); applyOp(parsed, { type: "setCompositionMetadata", width: 1920, height: 1080 }); const root = parsed.document.querySelector("[data-hf-root]"); expect(root?.getAttribute("data-width")).toBe("1920"); expect(root?.getAttribute("data-height")).toBe("1080"); expect(root?.getAttribute("style")).toContain("width: 1920px"); }); it("inverse restores both channels", () => { const parsed = parseMutable(ATTR_HTML); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setCompositionMetadata", width: 1920 }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); it("does not mint data-* attributes on compositions without them", () => { const parsed = fresh(); applyOp(parsed, { type: "setCompositionMetadata", width: 1920 }); const root = parsed.document.querySelector("[data-hf-root]"); expect(root?.hasAttribute("data-width")).toBe(false); expect(root?.getAttribute("style")).toContain("width: 1920px"); }); }); // ─── reorderElements ───────────────────────────────────────────────────────── describe("reorderElements", () => { it("sets zIndex on each entry", () => { const parsed = fresh(); applyOp(parsed, { type: "reorderElements", entries: [ { target: "hf-title", zIndex: 2 }, { target: "hf-logo", zIndex: 1 }, ], }); const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null; const logo = parsed.document.querySelector("[data-hf-id='hf-logo']") as HTMLElement | null; expect(title?.style.zIndex).toBe("2"); expect(logo?.style.zIndex).toBe("1"); }); it("inverse restores original zIndex values", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "reorderElements", entries: [{ target: "hf-title", zIndex: 5 }], }); applyPatchesToDocument(parsed, inverse); expect(serializeDocument(parsed)).toBe(before); }); it("validateOp returns ok:true for existing targets", () => { const r = validateOp(fresh(), { type: "reorderElements", entries: [{ target: "hf-title", zIndex: 1 }], }); expect(r.ok).toBe(true); }); it("validateOp returns E_TARGET_NOT_FOUND for unknown target", () => { const r = validateOp(fresh(), { type: "reorderElements", entries: [{ target: "hf-unknown", zIndex: 1 }], }); expect(r.ok).toBe(false); if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND"); }); it("duplicate target collapses to last-wins and inverse restores cleanly", () => { const parsed = fresh(); const before = serializeDocument(parsed); const { forward, inverse } = applyOp(parsed, { type: "reorderElements", entries: [ { target: "hf-title", zIndex: 2 }, { target: "hf-title", zIndex: 9 }, ], }); const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null; expect(title?.style.zIndex).toBe("9"); // last write wins expect(forward.length).toBe(1); // one patch, not two on the same path // Inverse must be applied in reverse order (session reverses single-dispatch // inverse) to land back on the original, not the intermediate "2". applyPatchesToDocument(parsed, [...inverse].reverse()); expect(serializeDocument(parsed)).toBe(before); }); });