Files
next-ai-draw-io/tests/unit/diagram-engine-operations.test.ts

666 lines
20 KiB
TypeScript
Raw Normal View History

feat(diagram-engine): wire up restructure_diagram + stencil catalog Closes the loop: the model can now build and edit AWS architecture diagrams by declaring structure, and never writes an mxCell again. catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles are verbatim, so the official category colours, connection points and aspect=fixed come along for free and nothing is hand-assembled. An invented name is rejected with suggestions instead of rendering as a blank square, which is what draw.io does with an unknown resIcon today. operations.ts — what the model actually sends: add_icon / add_container / move / link / set_dir and so on, applied in order against the tree. Guards the things that break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges left pointing at a removed node, and moving a container inside itself. index.ts — the entry point. current XML → parse → apply ops → check names → layout → render → new XML. The tree is not stored between calls; it is re-derived from the canvas every time, so a user's manual edits are input to the next layout rather than state to reconcile. Token cost, measured with Claude's tokenizer rather than estimated: - build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x) - add one icon: 27 tok as an operation vs 3823 re-emitting (142x) - read current state: 216 tok as an outline vs 3180 as XML (14.7x) The 142x is the one that matters day to day: "add a Redis" is one operation, not a rewrite of the whole diagram. Routing in the system prompt sends AWS architecture through this path and leaves flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram — the layout engine's primitives (nested rows, columns, grids) do not model a sequence diagram's lifelines or a mind map's radial spread, and pretending otherwise would make those worse rather than better. Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms, and a narrow .gitignore exception so the generated catalog is tracked while the root data/ directory (admin settings, contains secrets) stays ignored. 403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders with real stencils and container markers; a second call adds one node and keeps everything from the first; an invented name is refused and nothing is drawn. The 13 existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
import { describe, expect, it } from "vitest"
import {
applyOperations,
collectNames,
type Operation,
OperationSchema,
outline,
} from "@/lib/diagram-engine/operations"
import {
type ContainerNode,
type DiagramNode,
type DiagramTree,
findNode,
findParent,
type GroupNode,
walkTree,
} from "@/lib/diagram-engine/types"
const empty = (): DiagramTree => ({ roots: [], links: [], foreign: [] })
/** A small starting diagram: one frame holding two icons. */
function base(): DiagramTree {
return {
roots: [
{
kind: "group",
id: "vpc",
gname: "group_vpc",
label: "VPC",
dir: "col",
gap: 20,
children: [
{
kind: "icon",
id: "alb",
name: "application_load_balancer",
label: "ALB",
},
{ kind: "icon", id: "ec2", name: "ec2", label: "EC2" },
],
},
],
links: [{ source: "alb", target: "ec2" }],
foreign: [],
}
}
const apply = (t: DiagramTree, ...ops: Operation[]) => applyOperations(t, ops)
describe("add operations", () => {
it("adds an icon into a container", () => {
const { tree, errors } = apply(base(), {
op: "add_icon",
id: "rds",
parent: "vpc",
name: "rds",
label: "RDS",
})
expect(errors).toEqual([])
expect(findParent(tree, "rds")?.id).toBe("vpc")
expect((findNode(tree, "rds") as { name: string }).name).toBe("rds")
})
it("adds at the top level when no parent is given", () => {
const { tree } = apply(base(), {
op: "add_box",
id: "users",
label: "Users",
})
expect(tree.roots.map((r) => r.id)).toContain("users")
})
it("inserts after a named sibling", () => {
const { tree } = apply(base(), {
op: "add_icon",
id: "mid",
parent: "vpc",
name: "s3",
after: "alb",
})
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
(c) => c.id,
)
expect(kids).toEqual(["alb", "mid", "ec2"])
})
it("appends when the named sibling does not exist", () => {
const { tree } = apply(base(), {
op: "add_icon",
id: "last",
parent: "vpc",
name: "s3",
after: "ghost",
})
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
(c) => c.id,
)
expect(kids[kids.length - 1]).toBe("last")
})
it("adds a container and lets a later op fill it, in one batch", () => {
const { tree, errors } = apply(
base(),
{
op: "add_container",
id: "subnet",
parent: "vpc",
label: "Private Subnet",
dir: "col",
gname: "group_subnet",
},
{ op: "move", id: "ec2", parent: "subnet" },
)
expect(errors).toEqual([])
expect(findParent(tree, "ec2")?.id).toBe("subnet")
expect(findParent(tree, "subnet")?.id).toBe("vpc")
})
it("adds a grid with its column count", () => {
const { tree } = apply(base(), {
op: "add_grid",
id: "area",
parent: "vpc",
label: "Services",
cols: 3,
})
const g = findNode(tree, "area")
expect(g?.kind).toBe("grid")
expect((g as { cols: number }).cols).toBe(3)
})
it("clamps a nonsensical column count instead of producing a broken grid", () => {
const { tree } = apply(base(), {
op: "add_grid",
id: "area",
label: "X",
cols: 0,
})
expect((findNode(tree, "area") as { cols: number }).cols).toBe(1)
})
it("rejects a duplicate id — draw.io silently drops one of two cells sharing an id", () => {
const { errors } = apply(base(), {
op: "add_icon",
id: "ec2",
parent: "vpc",
name: "s3",
})
expect(errors[0]).toContain("already taken")
})
it("rejects adding into something that is not a container", () => {
const { errors } = apply(base(), {
op: "add_icon",
id: "x",
parent: "ec2",
name: "s3",
})
expect(errors[0]).toContain("not a container")
})
it("rejects adding into a nonexistent parent", () => {
const { errors } = apply(base(), {
op: "add_icon",
id: "x",
parent: "ghost",
name: "s3",
})
expect(errors[0]).toContain("ghost")
})
})
describe("remove", () => {
it("removes a leaf", () => {
const { tree, errors } = apply(base(), { op: "remove", id: "ec2" })
expect(errors).toEqual([])
expect(findNode(tree, "ec2")).toBeNull()
})
it("removes a container together with its descendants", () => {
const { tree } = apply(base(), { op: "remove", id: "vpc" })
expect(findNode(tree, "vpc")).toBeNull()
expect(findNode(tree, "alb")).toBeNull()
expect(findNode(tree, "ec2")).toBeNull()
})
it("removes edges that touched the deleted subtree", () => {
// Leaving them would render as an arrow pointing at nothing.
const { tree } = apply(base(), { op: "remove", id: "ec2" })
expect(tree.links).toEqual([])
})
it("removes edges anchored deep inside a removed container", () => {
const t = base()
t.links.push({ source: "alb", target: "alb" })
const { tree } = apply(t, { op: "remove", id: "vpc" })
expect(tree.links).toEqual([])
})
it("reports a removal of something that is not there", () => {
const { errors } = apply(base(), { op: "remove", id: "ghost" })
expect(errors[0]).toContain("ghost")
})
})
describe("move", () => {
it("moves a node between containers", () => {
const t = base()
;(t.roots[0] as GroupNode).children.push({
kind: "group",
id: "other",
gname: null,
label: "Other",
dir: "col",
gap: 20,
children: [],
})
const { tree, errors } = apply(t, {
op: "move",
id: "ec2",
parent: "other",
})
expect(errors).toEqual([])
expect(findParent(tree, "ec2")?.id).toBe("other")
})
it("moves a node to the top level", () => {
const { tree } = apply(base(), { op: "move", id: "ec2" })
expect(tree.roots.map((r) => r.id)).toContain("ec2")
expect(findParent(tree, "ec2")).toBeNull()
})
it("reorders within the same container", () => {
const { tree } = apply(base(), {
op: "move",
id: "alb",
parent: "vpc",
after: "ec2",
})
expect(
(findNode(tree, "vpc") as ContainerNode).children.map((c) => c.id),
).toEqual(["ec2", "alb"])
})
it("keeps the moved node's own children with it", () => {
const t: DiagramTree = {
roots: [
{
kind: "group",
id: "a",
gname: null,
label: "A",
dir: "col",
gap: 20,
children: [
{
kind: "group",
id: "sub",
gname: null,
label: "Sub",
dir: "col",
gap: 20,
children: [
{
kind: "icon",
id: "leaf",
name: "s3",
label: "",
},
],
},
],
},
{
kind: "group",
id: "b",
gname: null,
label: "B",
dir: "col",
gap: 20,
children: [],
},
],
links: [],
foreign: [],
}
const { tree } = apply(t, { op: "move", id: "sub", parent: "b" })
expect(findParent(tree, "sub")?.id).toBe("b")
expect(findParent(tree, "leaf")?.id).toBe("sub")
})
it("refuses to move a container into itself", () => {
const { errors } = apply(base(), {
op: "move",
id: "vpc",
parent: "vpc",
})
expect(errors[0]).toContain("inside itself")
})
it("refuses to move a container into its own descendant", () => {
const { errors, tree } = apply(base(), {
op: "move",
id: "vpc",
parent: "ec2",
})
// ec2 is a leaf, so this is caught as "not a container" — either way the tree
// must survive intact rather than losing the subtree into a cycle.
expect(errors).toHaveLength(1)
expect(findNode(tree, "vpc")).not.toBeNull()
expect(findNode(tree, "ec2")).not.toBeNull()
})
it("refuses to move a container into a nested descendant container", () => {
const t: DiagramTree = {
roots: [
{
kind: "group",
id: "outer",
gname: null,
label: "O",
dir: "col",
gap: 20,
children: [
{
kind: "group",
id: "inner",
gname: null,
label: "I",
dir: "col",
gap: 20,
children: [],
},
],
},
],
links: [],
foreign: [],
}
const { errors, tree } = apply(t, {
op: "move",
id: "outer",
parent: "inner",
})
expect(errors[0]).toContain("inside itself")
expect(findNode(tree, "outer")).not.toBeNull()
expect(findParent(tree, "inner")?.id).toBe("outer")
})
it("reports a move of something that is not there", () => {
const { errors } = apply(base(), { op: "move", id: "ghost" })
expect(errors[0]).toContain("ghost")
})
})
describe("property setters", () => {
it("renames a node", () => {
const { tree } = apply(base(), {
op: "set_label",
id: "vpc",
label: "Production VPC",
})
expect((findNode(tree, "vpc") as ContainerNode).label).toBe(
"Production VPC",
)
})
it("re-orients a container", () => {
const { tree } = apply(base(), { op: "set_dir", id: "vpc", dir: "row" })
expect((findNode(tree, "vpc") as GroupNode).dir).toBe("row")
})
it("refuses set_dir on a grid, whose layout is driven by its column count", () => {
const t = apply(base(), {
op: "add_grid",
id: "g",
label: "G",
cols: 2,
}).tree
const { errors } = apply(t, { op: "set_dir", id: "g", dir: "row" })
expect(errors[0]).toContain("grid")
})
it("refuses set_dir on a leaf", () => {
const { errors } = apply(base(), {
op: "set_dir",
id: "ec2",
dir: "row",
})
expect(errors[0]).toContain("not a container")
})
it("changes a gap and refuses a negative one", () => {
expect(
(
apply(base(), { op: "set_gap", id: "vpc", gap: 40 }).tree
.roots[0] as GroupNode
).gap,
).toBe(40)
expect(
(
apply(base(), { op: "set_gap", id: "vpc", gap: -10 }).tree
.roots[0] as GroupNode
).gap,
).toBe(0)
})
it("sets the page title", () => {
const { tree } = apply(base(), { op: "set_title", title: "My Diagram" })
expect(tree.title).toBe("My Diagram")
})
})
describe("links", () => {
it("adds an edge", () => {
const { tree, errors } = apply(base(), {
op: "link",
source: "ec2",
target: "alb",
label: "response",
})
expect(errors).toEqual([])
expect(tree.links).toHaveLength(2)
expect(tree.links[1].label).toBe("response")
})
it("carries dashed and step through", () => {
const { tree } = apply(base(), {
op: "link",
source: "ec2",
target: "alb",
dashed: true,
step: 3,
})
const l = tree.links[1]
expect(l.dashed).toBe(true)
expect(l.step).toBe(3)
})
it("refuses an edge to a node that does not exist", () => {
const { errors } = apply(base(), {
op: "link",
source: "ec2",
target: "ghost",
})
expect(errors[0]).toContain("ghost")
})
it("refuses a duplicate edge instead of drawing two arrows on top of each other", () => {
const { errors } = apply(base(), {
op: "link",
source: "alb",
target: "ec2",
})
expect(errors[0]).toContain("already exists")
})
it("removes an edge", () => {
const { tree, errors } = apply(base(), {
op: "unlink",
source: "alb",
target: "ec2",
})
expect(errors).toEqual([])
expect(tree.links).toEqual([])
})
it("reports unlinking an edge that is not there", () => {
const { errors } = apply(base(), {
op: "unlink",
source: "ec2",
target: "alb",
})
expect(errors[0]).toContain("no edge")
})
})
describe("batch semantics", () => {
it("does not mutate the input tree", () => {
const original = base()
const snapshot = JSON.stringify(original)
apply(original, { op: "remove", id: "ec2" })
expect(JSON.stringify(original)).toBe(snapshot)
})
it("applies later ops against the result of earlier ones", () => {
const { tree, errors } = apply(
base(),
{
op: "add_container",
id: "az",
parent: "vpc",
label: "AZ",
dir: "col",
},
{ op: "add_icon", id: "nat", parent: "az", name: "nat_gateway" },
{ op: "link", source: "nat", target: "ec2" },
)
expect(errors).toEqual([])
expect(findParent(tree, "nat")?.id).toBe("az")
expect(tree.links).toHaveLength(2)
})
it("keeps going after a failed op and reports each failure", () => {
const { tree, errors } = apply(
base(),
{ op: "remove", id: "ghost" },
{ op: "add_icon", id: "s3", parent: "vpc", name: "s3" },
{ op: "set_dir", id: "ec2", dir: "row" },
)
expect(errors).toHaveLength(2)
// the valid op in the middle still took effect
expect(findNode(tree, "s3")).not.toBeNull()
})
it("builds a whole diagram from an empty canvas", () => {
const { tree, errors } = apply(
empty(),
{ op: "set_title", title: "Three Tier" },
{ op: "add_box", id: "users", label: "Users" },
{
op: "add_container",
id: "region",
label: "Region",
dir: "row",
gname: "group_region",
},
{
op: "add_container",
id: "vpc",
parent: "region",
label: "VPC",
dir: "col",
gname: "group_vpc",
},
{
op: "add_icon",
id: "alb",
parent: "vpc",
name: "application_load_balancer",
label: "ALB",
},
{
op: "add_icon",
id: "ec2",
parent: "vpc",
name: "ec2",
label: "EC2",
},
{ op: "link", source: "users", target: "alb", step: 1 },
{ op: "link", source: "alb", target: "ec2", step: 2 },
)
expect(errors).toEqual([])
expect(tree.title).toBe("Three Tier")
expect(tree.roots.map((r) => r.id)).toEqual(["users", "region"])
expect(findParent(tree, "vpc")?.id).toBe("region")
expect(tree.links).toHaveLength(2)
})
})
describe("OperationSchema", () => {
it("accepts a well-formed operation", () => {
expect(
OperationSchema.safeParse({
op: "add_icon",
id: "a",
name: "s3",
}).success,
).toBe(true)
})
it("rejects an unknown op name", () => {
expect(
OperationSchema.safeParse({ op: "teleport", id: "a" }).success,
).toBe(false)
})
it("rejects a missing required field", () => {
expect(
OperationSchema.safeParse({ op: "add_icon", id: "a" }).success,
).toBe(false)
})
it("rejects a direction outside the union", () => {
expect(
OperationSchema.safeParse({
op: "set_dir",
id: "a",
dir: "diagonal",
}).success,
).toBe(false)
})
})
describe("collectNames", () => {
it("returns every icon and group name for catalog checking", () => {
const names = collectNames(base())
expect(names).toEqual(
expect.arrayContaining([
{ id: "vpc", name: "group_vpc", kind: "group" },
{ id: "alb", name: "application_load_balancer", kind: "icon" },
{ id: "ec2", name: "ec2", kind: "icon" },
]),
)
})
it("skips a plain frame, which has no stencil to check", () => {
const t = apply(base(), {
op: "add_container",
id: "plain",
label: "Plain",
dir: "row",
}).tree
expect(collectNames(t).map((n) => n.id)).not.toContain("plain")
})
it("skips a box", () => {
const t = apply(base(), { op: "add_box", id: "b", label: "B" }).tree
expect(collectNames(t).map((n) => n.id)).not.toContain("b")
})
})
describe("outline", () => {
it("shows nesting, kinds and links compactly", () => {
const text = outline(base())
expect(text).toContain("vpc: col")
expect(text).toContain("alb: icon application_load_balancer")
expect(text).toContain("link alb -> ec2")
})
it("includes the title", () => {
const t = apply(base(), { op: "set_title", title: "T" }).tree
expect(outline(t)).toContain("title: T")
})
it("marks an unlabelled container as a wrapper so its purpose is clear", () => {
const t = apply(base(), {
op: "add_container",
id: "w",
label: "",
dir: "row",
}).tree
expect(outline(t)).toContain("w: row (wrapper)")
})
it("notes cells kept verbatim, so the model knows they exist but are not its to edit", () => {
const t = base()
t.foreign.push({ id: "note", xml: '<mxCell id="note"/>', parent: "1" })
expect(outline(t)).toContain("1 cell(s) kept as-is: note")
})
it("is far more compact than the JSON tree", () => {
const t = base()
expect(outline(t).length).toBeLessThan(JSON.stringify(t).length / 2)
})
it("shows every node exactly once", () => {
const t = base()
const text = outline(t)
for (const n of walkTree(t)) {
const hits = text.split("\n").filter((l) => l.includes(`${n.id}:`))
expect(hits).toHaveLength(1)
}
})
})