From dc0c3e7a27e9315ed753f728e079f34007e98f0f Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 04:10:03 +0000 Subject: [PATCH] test(canvas): add pure-function tests for resolveRuntime and canvas-topology utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preflight-resolveRuntime.test.ts: resolveRuntime from deploy-preflight.ts covering explicit runtime-map entries, identity fallback, -default suffix stripping, edge cases (empty string, multiple suffixes). - canvas-topology-pure.test.ts: sortParentsBeforeChildren (topological sort, orphan handling, no-op, non-mutating), defaultChildSlot (2-col grid), childSlotInGrid (variable-size siblings, uniform-grid fallback), parentMinSize (0–5 children, grid dimensions), parentMinSizeFromChildren (variable sizes, empty array, width/height correctness). Co-Authored-By: Claude Opus 4.7 --- .../preflight-resolveRuntime.test.ts | 78 ++++++ .../__tests__/canvas-topology-pure.test.ts | 251 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts create mode 100644 canvas/src/store/__tests__/canvas-topology-pure.test.ts diff --git a/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts new file mode 100644 index 00000000..609e756d --- /dev/null +++ b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +/** + * Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts. + * + * Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the + * two share the same describe block convention and the fixture types + * are close at hand. Separate file keeps the deploy-preflight fixture + * count bounded. + */ +import { describe, it, expect } from "vitest"; +import { resolveRuntime } from "../deploy-preflight"; + +describe("resolveRuntime", () => { + describe("explicit runtime-map entries", () => { + it('maps "langgraph" to "langgraph"', () => { + expect(resolveRuntime("langgraph")).toBe("langgraph"); + }); + + it('maps "claude-code-default" to "claude-code"', () => { + expect(resolveRuntime("claude-code-default")).toBe("claude-code"); + }); + + it('maps "openclaw" to "openclaw"', () => { + expect(resolveRuntime("openclaw")).toBe("openclaw"); + }); + + it('maps "deepagents" to "deepagents"', () => { + expect(resolveRuntime("deepagents")).toBe("deepagents"); + }); + + it('maps "crewai" to "crewai"', () => { + expect(resolveRuntime("crewai")).toBe("crewai"); + }); + + it('maps "autogen" to "autogen"', () => { + expect(resolveRuntime("autogen")).toBe("autogen"); + }); + }); + + describe("identity fallback for modern template ids", () => { + it("returns the id unchanged when not in the map", () => { + expect(resolveRuntime("hermes")).toBe("hermes"); + }); + + it("strips trailing -default suffix as fallback", () => { + expect(resolveRuntime("hermes-default")).toBe("hermes"); + }); + + it("strips -default only when it is the suffix", () => { + // "default-something" should NOT strip + expect(resolveRuntime("default-langgraph")).toBe("default-langgraph"); + }); + + it("returns the id unchanged when id has no -default suffix", () => { + expect(resolveRuntime("gemini-cli")).toBe("gemini-cli"); + }); + + it("handles custom template ids from community templates", () => { + expect(resolveRuntime("my-custom-template")).toBe("my-custom-template"); + }); + }); + + describe("edge cases", () => { + it("handles empty string", () => { + // Falls through to the replace branch + expect(resolveRuntime("")).toBe(""); + }); + + it("handles id that is just '-default'", () => { + expect(resolveRuntime("-default")).toBe(""); + }); + + it("multiple -default suffixes only strips the last one", () => { + // The JS replace only replaces the first match by default + expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default"); + }); + }); +}); diff --git a/canvas/src/store/__tests__/canvas-topology-pure.test.ts b/canvas/src/store/__tests__/canvas-topology-pure.test.ts new file mode 100644 index 00000000..2f3c02f1 --- /dev/null +++ b/canvas/src/store/__tests__/canvas-topology-pure.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment jsdom +/** + * Tests for pure utility functions in canvas-topology.ts: + * sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid, + * parentMinSize, parentMinSizeFromChildren. + */ +import { describe, it, expect } from "vitest"; +import { + sortParentsBeforeChildren, + defaultChildSlot, + childSlotInGrid, + parentMinSize, + parentMinSizeFromChildren, +} from "../canvas-topology"; + +// ─── sortParentsBeforeChildren ───────────────────────────────────────────────── + +describe("sortParentsBeforeChildren", () => { + it("returns [] for empty input", () => { + expect(sortParentsBeforeChildren([])).toEqual([]); + }); + + it("returns single node unchanged", () => { + const nodes = [{ id: "a", parentId: undefined }]; + expect(sortParentsBeforeChildren(nodes)).toEqual(nodes); + }); + + it("places parent before child", () => { + // Deliberately reversed so naive iteration would place child first + const nodes = [ + { id: "child", parentId: "parent" }, + { id: "parent", parentId: undefined }, + ]; + const result = sortParentsBeforeChildren(nodes); + expect(result[0].id).toBe("parent"); + expect(result[1].id).toBe("child"); + }); + + it("places grandparent before parent before child (deep chain)", () => { + const nodes = [ + { id: "child", parentId: "parent" }, + { id: "grandchild", parentId: "child" }, + { id: "parent", parentId: "grandparent" }, + { id: "grandparent", parentId: undefined }, + ]; + const result = sortParentsBeforeChildren(nodes); + const ids = result.map((n) => n.id); + expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]); + }); + + it("siblings share the same parent", () => { + const nodes = [ + { id: "b", parentId: "a" }, + { id: "a", parentId: undefined }, + { id: "c", parentId: "a" }, + ]; + const result = sortParentsBeforeChildren(nodes); + expect(result[0].id).toBe("a"); + expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"])); + }); + + it("no-ops when children already precede parents", () => { + // Already sorted — output should be in the same order + const nodes = [ + { id: "root", parentId: undefined }, + { id: "child", parentId: "root" }, + ]; + expect(sortParentsBeforeChildren(nodes)).toEqual(nodes); + }); + + it("handles orphan nodes (no parentId)", () => { + const nodes = [{ id: "a" }, { id: "b" }]; + expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]); + }); + + it("returns a new array (does not mutate input)", () => { + const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }]; + const result = sortParentsBeforeChildren(nodes); + expect(result).not.toBe(nodes); + }); + + it("deduplicates already-visited nodes", () => { + // Child's parent is also in the list — visited guard prevents loops + const nodes = [ + { id: "child", parentId: "parent" }, + { id: "parent", parentId: undefined }, + ]; + const result = sortParentsBeforeChildren(nodes); + expect(result.map((n) => n.id)).toEqual(["parent", "child"]); + }); + + it("does not crash when parentId references a missing node", () => { + const nodes = [ + { id: "orphan", parentId: "ghost" }, + { id: "root", parentId: undefined }, + ]; + // Missing parent is skipped; orphan placed after root + const result = sortParentsBeforeChildren(nodes); + expect(result.map((n) => n.id)).toEqual(["root", "orphan"]); + }); +}); + +// ─── defaultChildSlot ───────────────────────────────────────────────────────── + +describe("defaultChildSlot — 2-column grid (240×130 cards)", () => { + it("slot 0 → column 0, row 0", () => { + const s = defaultChildSlot(0); + expect(s).toEqual({ x: 16, y: 130 }); + }); + + it("slot 1 → column 1, row 0", () => { + const s = defaultChildSlot(1); + expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER + expect(s.y).toBe(130); + }); + + it("slot 2 → column 0, row 1", () => { + const s = defaultChildSlot(2); + expect(s.x).toBe(16); + expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter + }); + + it("slot 3 → column 1, row 1", () => { + const s = defaultChildSlot(3); + expect(s.x).toBe(16 + 240 + 14); + expect(s.y).toBe(130 + 130 + 14); + }); + + it("slot 4 → column 0, row 2", () => { + const s = defaultChildSlot(4); + expect(s.x).toBe(16); + expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter + }); +}); + +// ─── childSlotInGrid ────────────────────────────────────────────────────────── + +describe("childSlotInGrid — variable-size siblings", () => { + it("empty siblingSizes returns side-padded position", () => { + const s = childSlotInGrid(0, []); + expect(s).toEqual({ x: 16, y: 130 }); + }); + + it("slot 0 in uniform-size siblings matches defaultChildSlot", () => { + const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }]; + const s = childSlotInGrid(0, sizes); + expect(s.x).toBe(16); + expect(s.y).toBe(130); + }); + + it("taller sibling bumps next row down", () => { + // Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300 + const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }]; + const slot1 = childSlotInGrid(1, sizes); + // Slot 1 is in column 1, row 0; x = 16 + 1*(240+14) + expect(slot1.x).toBe(16 + 240 + 14); + expect(slot1.y).toBe(130); + // Slot 2 (col 0, row 1) — y must include row 0 height + gutter + const slot2 = childSlotInGrid(2, sizes); + expect(slot2.x).toBe(16); + expect(slot2.y).toBe(130 + 300 + 14); + }); + + it("colW is the maximum sibling width, not the column of the target slot", () => { + // Column width is always the max — slot at col 0 uses colW of wider col 1 sibling + const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }]; + const slot0 = childSlotInGrid(0, sizes); + expect(slot0.x).toBe(16); // col 0 + // x for col 1 would be 16 + 300 + 14 = 330 + const slot1 = childSlotInGrid(1, sizes); + expect(slot1.x).toBe(16 + 300 + 14); + }); +}); + +// ─── parentMinSize ───────────────────────────────────────────────────────────── + +describe("parentMinSize — uniform-size children", () => { + it("0 children → compact default (210×120)", () => { + expect(parentMinSize(0)).toEqual({ width: 210, height: 120 }); + }); + + it("1 child → 1 col, 1 row", () => { + const s = parentMinSize(1); + // width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276 + expect(s.width).toBe(16 * 2 + 240); + expect(s.height).toBe(130 + 130 + 16); + }); + + it("2 children → 2 cols, 1 row", () => { + const s = parentMinSize(2); + // width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276 + expect(s.width).toBe(16 * 2 + 2 * 240 + 14); + expect(s.height).toBe(130 + 130 + 16); + }); + + it("3 children → 2 cols, 2 rows", () => { + const s = parentMinSize(3); + // width = 16*2 + 2*240 + 1*14 = 526 + expect(s.width).toBe(16 * 2 + 2 * 240 + 14); + // height = 130 + 2*130 + 1*14 + 16 = 416 + expect(s.height).toBe(130 + 2 * 130 + 14 + 16); + }); + + it("4 children → 2 cols, 2 rows (full grid)", () => { + const s = parentMinSize(4); + expect(s.width).toBe(16 * 2 + 2 * 240 + 14); + expect(s.height).toBe(130 + 2 * 130 + 14 + 16); + }); + + it("5 children → 2 cols, 3 rows", () => { + const s = parentMinSize(5); + expect(s.width).toBe(16 * 2 + 2 * 240 + 14); + expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16); + }); +}); + +// ─── parentMinSizeFromChildren ──────────────────────────────────────────────── + +describe("parentMinSizeFromChildren — variable-size children", () => { + it("empty array → compact default (210×120)", () => { + expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 }); + }); + + it("single child matches defaultChildSlot bounding box", () => { + const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]); + // cols=1, rows=1, colW=240 + expect(s.width).toBe(16 * 2 + 240); // 272 + expect(s.height).toBe(130 + 130 + 16); // 276 + }); + + it("two equal-width children → same as parentMinSize(2)", () => { + const fromChildren = parentMinSizeFromChildren([ + { width: 240, height: 130 }, + { width: 240, height: 130 }, + ]); + expect(fromChildren.width).toBe(parentMinSize(2).width); + expect(fromChildren.height).toBe(parentMinSize(2).height); + }); + + it("taller child increases height", () => { + const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]); + const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]); + expect(tall.height).toBeGreaterThan(short.height); + }); + + it("wider child increases width", () => { + const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]); + const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]); + expect(wide.width).toBeGreaterThan(narrow.width); + }); +}); -- 2.45.2