/**
* 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
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
// 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: '
',
});
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: '',
});
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);
});
});