/** * Variable declaration edit ops: declareVariable / updateVariableDeclaration / * removeVariableDeclaration. Covers dispatch semantics, can() validation, * CSS compat sync, patch grammar, and undo round-trips. */ import { describe, it, expect } from "vitest"; import { openComposition } from "./session.js"; import { variableDeclPath, pathToKey, keyToPath } from "./engine/patches.js"; import type { CompositionVariable } from "@hyperframes/core/variables"; const TITLE_DECL: CompositionVariable = { id: "title", type: "string", label: "Title", default: "Hello", }; const COUNT_DECL: CompositionVariable = { id: "count", type: "number", label: "Count", default: 3, min: 0, max: 10, }; const BARE_HTML = `

Hello

`.trim(); const DECLARED_HTML = ` ${BARE_HTML} `; const UNDECLARED_HTML = ` ${BARE_HTML} `; describe("declareVariable", () => { it("creates the attribute on a composition with no declarations", async () => { const comp = await openComposition(UNDECLARED_HTML); expect(comp.getVariableDeclarations()).toEqual([]); comp.declareVariable(TITLE_DECL); expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]); expect(comp.serialize()).toContain("data-composition-variables"); }); it("appends to existing declarations and survives serialize round-trip", async () => { const comp = await openComposition(DECLARED_HTML); comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false }); expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]); const reopened = await openComposition(comp.serialize()); expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]); }); it("no-ops on duplicate ids and can() reports E_DUPLICATE_VARIABLE", async () => { const comp = await openComposition(DECLARED_HTML); const dup = comp.can({ type: "declareVariable", declaration: TITLE_DECL }); expect(dup).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" }); comp.declareVariable({ ...TITLE_DECL, default: "clobbered" }); expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Hello"); }); it("rejects structurally invalid declarations", async () => { const comp = await openComposition(UNDECLARED_HTML); const invalid = { id: "broken", type: "number", label: "Broken", default: "not-a-number", } as unknown as CompositionVariable; expect(comp.can({ type: "declareVariable", declaration: invalid })).toMatchObject({ ok: false, code: "E_INVALID_ARGS", }); comp.declareVariable(invalid); expect(comp.getVariableDeclarations()).toEqual([]); }); it("rejects ids that are not valid CSS/attribute identifiers", async () => { const comp = await openComposition(UNDECLARED_HTML); for (const id of ["", " ", "has space", "1leading", "dot.id", "sla/sh", 'quo"te']) { expect( comp.can({ type: "declareVariable", declaration: { ...TITLE_DECL, id } }), ).toMatchObject({ ok: false, code: "E_INVALID_VARIABLE_ID" }); comp.declareVariable({ ...TITLE_DECL, id }); } expect(comp.getVariableDeclarations()).toEqual([]); // Sanity: a valid id with the allowed charset still declares. comp.declareVariable({ ...TITLE_DECL, id: "brand_color-2" }); expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["brand_color-2"]); }); it("undo removes the declaration again", async () => { const comp = await openComposition(UNDECLARED_HTML); comp.declareVariable(TITLE_DECL); comp.undo(); expect(comp.getVariableDeclarations()).toEqual([]); expect(comp.serialize()).not.toContain("data-composition-variables"); comp.redo(); expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]); }); // fallow-ignore-next-line code-duplication it("supports fragment compositions with a root element (schema on the root div)", async () => { // A fragment (no ) still has a composition root; declarations live on // that root div, which survives serialize — so template/sub-comp files are // first-class editable, not refused. const comp = await openComposition(BARE_HTML); expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({ ok: true, }); comp.declareVariable(TITLE_DECL); expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]); expect(comp.serialize()).toContain("data-composition-variables"); }); it("refuses a fragment with no root element (nowhere durable to write)", async () => { const comp = await openComposition("just text, no element"); expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({ ok: false, code: "E_FRAGMENT_COMPOSITION", }); comp.declareVariable(TITLE_DECL); expect(comp.getVariableDeclarations()).toEqual([]); expect(comp.serialize()).not.toContain("data-composition-variables"); }); }); describe("updateVariableDeclaration", () => { it("replaces the declaration wholesale", async () => { const comp = await openComposition(DECLARED_HTML); comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Item count", max: 20 }); const decl = comp.getVariableDeclarations().find((d) => d.id === "count"); expect(decl).toMatchObject({ label: "Item count", max: 20, default: 3 }); }); it("syncs the CSS compat prop when a scalar default changes", async () => { const comp = await openComposition(DECLARED_HTML); comp.updateVariableDeclaration("count", { ...COUNT_DECL, default: 7 }); const root = comp.getElements().find((e) => e.id === "hf-stage"); expect(root?.inlineStyles["--count"]).toBe("7"); }); it("keeps CSS untouched when the default is unchanged", async () => { const comp = await openComposition(DECLARED_HTML); comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Renamed only" }); const root = comp.getElements().find((e) => e.id === "hf-stage"); expect(root?.inlineStyles["--count"]).toBeUndefined(); }); it("validates id immutability and existence via can()", async () => { const comp = await openComposition(DECLARED_HTML); expect( comp.can({ type: "updateVariableDeclaration", id: "count", declaration: TITLE_DECL }), ).toMatchObject({ ok: false, code: "E_INVALID_ARGS" }); expect( comp.can({ type: "updateVariableDeclaration", id: "ghost", declaration: { ...TITLE_DECL, id: "ghost" }, }), ).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND" }); }); it("undo restores the previous declaration", async () => { const comp = await openComposition(DECLARED_HTML); comp.updateVariableDeclaration("title", { ...TITLE_DECL, label: "Headline" }); comp.undo(); expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.label).toBe("Title"); }); }); describe("removeVariableDeclaration", () => { it("removes the entry and drops the attribute with the last one", async () => { const comp = await openComposition(DECLARED_HTML); comp.removeVariableDeclaration("count"); expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]); comp.removeVariableDeclaration("title"); expect(comp.getVariableDeclarations()).toEqual([]); expect(comp.serialize()).not.toContain("data-composition-variables"); }); it("clears the CSS compat prop and undo restores declaration + CSS", async () => { const comp = await openComposition(DECLARED_HTML); comp.setVariableValue("count", 5); const rootBefore = comp.getElements().find((e) => e.id === "hf-stage"); expect(rootBefore?.inlineStyles["--count"]).toBe("5"); comp.removeVariableDeclaration("count"); const rootAfter = comp.getElements().find((e) => e.id === "hf-stage"); expect(rootAfter?.inlineStyles["--count"]).toBeUndefined(); expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]); comp.undo(); const rootRestored = comp.getElements().find((e) => e.id === "hf-stage"); expect(rootRestored?.inlineStyles["--count"]).toBe("5"); expect(comp.getVariableDeclarations().find((d) => d.id === "count")?.default).toBe(5); }); it("no-ops on unknown ids and can() reports E_VARIABLE_NOT_FOUND", async () => { const comp = await openComposition(DECLARED_HTML); expect(comp.can({ type: "removeVariableDeclaration", id: "ghost" })).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND", }); comp.removeVariableDeclaration("ghost"); expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count"]); }); }); describe("patch grammar", () => { it("maps /variableDeclarations/{id} ↔ varDecl.{id} without colliding with /variables/", () => { const path = variableDeclPath("brand-color"); expect(path).toBe("/variableDeclarations/brand-color"); expect(pathToKey(path)).toBe("varDecl.brand-color"); expect(keyToPath("varDecl.brand-color")).toBe(path); // The value path family must stay untouched. expect(pathToKey("/variables/brand-color")).toBe("var.brand-color"); expect(keyToPath("var.brand-color")).toBe("/variables/brand-color"); }); it("emits declaration patches on dispatch", async () => { const comp = await openComposition(UNDECLARED_HTML); const events: string[] = []; comp.on("patch", (e) => { for (const p of e.patches) events.push(`${p.op} ${p.path}`); }); comp.declareVariable(TITLE_DECL); comp.removeVariableDeclaration("title"); // Declaration ops also maintain the --{id} CSS compat prop (scalar defaults). expect(events).toEqual([ "add /variableDeclarations/title", "add /elements/hf-stage/inlineStyles/--title", "remove /variableDeclarations/title", "remove /elements/hf-stage/inlineStyles/--title", ]); }); });