feat(sdk): variable CRUD (declare/remove/get/list) + export gaps

Closes the remaining Tier 2/3 gaps from the SDK surface audit that motivated
#2092 — real, contained fixes short of the two genuinely architectural items
(a live-DOM apply adapter, structural editing ops) that need their own design
pass, not a quick patch.

Variable CRUD was write-only and creation-blocked: setVariableValue existed,
but there was no getVariableValue, listVariables, declareVariable, or
removeVariable — and writeVariableDefault intentionally refuses to create an
undeclared variable ("keep the schema authoritative"), so a variables panel
(list what exists, read current values, let someone add one) could not be
built against the SDK at all.

- getVariableValue(id) / listVariables(): thin reads over the existing
  readVariableDefault / a new listVariableDecls.
- declareVariable(decl) / removeVariable(id): new EditOps with full
  undo/redo support via a new patch path (/variable-decls/{id}, distinct
  from /variables/{id} which is default-only) — removeVariable's inverse
  bundles the original array index so undo reinserts at the same position
  instead of appending, mirroring handleRemoveElement's siblingIndex.

Export gaps (same shape as #2092's fixes — the logic already existed,
just wasn't reachable): resolveScoped, findById, escapeHfId from
engine/model.ts; readVariableDefault from engine/variableModel.ts.

17 new tests across mutate.test.ts (declareVariable/removeVariable engine
semantics + undo), session.test.ts (Composition-level API), and smoke.test.ts
(export-surface import check). 439/439 sdk tests passing. Full workspace
build (incl. studio) verified clean.
This commit is contained in:
Vance Ingalls
2026-07-09 11:52:12 -07:00
parent d32fb19b9c
commit 19756faa5d
10 changed files with 402 additions and 5 deletions
+70
View File
@@ -556,3 +556,73 @@ describe("getAllAnimationIds", () => {
expect(comp.getAllAnimationIds().has(realId)).toBe(true);
});
});
// ─── getVariableValue / listVariables / declareVariable / removeVariable ──────
const VARIABLES_HTML = `<!DOCTYPE html>
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
[{ id: "brand-color", type: "color", label: "Brand color", default: "#0066cc" }],
)}'>
<body>${BASE_HTML}</body>
</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 () => {
const comp = await openComposition(BASE_HTML); // no data-composition-variables at all
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" },
]);
});
});