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.
This commit is contained in:
dayuan.jiang
2026-08-09 12:13:54 +09:00
parent a2f892ca82
commit cd1df1eb6a
14 changed files with 3475 additions and 7 deletions

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest"
import {
CATALOG_SIZE,
checkNames,
lookupStencil,
resolveStyle,
searchStencils,
} from "@/lib/diagram-engine/catalog"
describe("catalog contents", () => {
it("carries the full AWS stencil set", () => {
expect(CATALOG_SIZE.icons).toBe(983)
expect(CATALOG_SIZE.groups).toBe(19)
})
})
describe("lookupStencil", () => {
it("returns the verbatim draw.io style, not a reconstruction", () => {
const s3 = lookupStencil("s3")
expect(s3?.style).toContain("shape=mxgraph.aws4.resourceIcon")
expect(s3?.style).toContain("resIcon=mxgraph.aws4.s3")
expect(s3?.style).toContain("aspect=fixed")
})
it("carries the official category colour", () => {
// Storage is #7AA116, Compute #ED7100 — from AWS's own palette.
expect(lookupStencil("s3")?.color).toBe("#7AA116")
expect(lookupStencil("ec2")?.color).toBe("#ED7100")
})
it("finds an icon whose stencil has no resIcon token", () => {
// 429 of the 983 AWS icons use a bare shape= instead of resourceIcon+resIcon.
const a1 = lookupStencil("a1_instance")
expect(a1).not.toBeNull()
expect(a1?.style).toContain("shape=mxgraph.aws4.a1_instance")
expect(a1?.style).not.toContain("resIcon=")
})
it("finds group stencils", () => {
for (const g of [
"group_vpc",
"group_region",
"group_subnet",
"group_availability_zone",
"group_account",
])
expect(lookupStencil(g, "group")?.kind).toBe("group")
})
it("returns null for a name the model invented, rather than guessing", () => {
expect(lookupStencil("s3_bucket_thing")).toBeNull()
expect(lookupStencil("totally_made_up_service")).toBeNull()
})
it("respects the kind filter", () => {
expect(lookupStencil("group_vpc", "icon")).toBeNull()
expect(lookupStencil("s3", "group")).toBeNull()
})
})
describe("resolveStyle", () => {
it("hands the renderer a style for a known name", () => {
expect(resolveStyle("ec2", "icon")).toContain("mxgraph.aws4")
})
it("returns null for an unknown name so the caller can decide", () => {
expect(resolveStyle("nope", "icon")).toBeNull()
})
})
describe("searchStencils", () => {
it("ranks the plain service above its longer variants", () => {
// Without a length penalty, "backup_aws_backup_support_for_amazon_s3"
// scores the same as "s3" and can win by iteration order.
expect(searchStencils("s3")[0].name).toBe("s3")
expect(searchStencils("ec2")[0].name).toBe("ec2")
expect(searchStencils("lambda")[0].name).toBe("lambda")
})
it("finds a multi-word service", () => {
const hits = searchStencils("nat gateway").map((h) => h.name)
expect(hits).toContain("nat_gateway")
})
it("maps shorthand onto tokens the catalog actually uses", () => {
// AWS's stencil names are already abbreviated: EKS is "eks", and no name in the
// catalog contains the word "kubernetes". So the alias has to resolve TO the
// catalog's token, not to the spelled-out product name.
expect(searchStencils("k8s")[0].name).toBe("eks")
expect(searchStencils("kubernetes")[0].name).toBe("eks")
expect(searchStencils("alb").map((h) => h.name)).toContain(
"application_load_balancer",
)
expect(searchStencils("ddb")[0].name).toBe("dynamodb")
expect(searchStencils("bucket")[0].name).toBe("s3")
})
it("returns colours so the model can see what it is getting", () => {
expect(searchStencils("s3")[0].color).toBe("#7AA116")
})
it("does NOT return styles — they would be pure context burn", () => {
const hit = searchStencils("s3")[0] as unknown as Record<
string,
unknown
>
expect(hit.style).toBeUndefined()
})
it("honours the limit", () => {
expect(searchStencils("aws", { limit: 3 })).toHaveLength(3)
})
it("can search groups only", () => {
const hits = searchStencils("vpc", { kind: "group" })
expect(hits.length).toBeGreaterThan(0)
expect(hits.every((h) => h.kind === "group")).toBe(true)
})
it("returns nothing for an empty query rather than the whole catalog", () => {
expect(searchStencils("")).toEqual([])
expect(searchStencils(" ")).toEqual([])
})
it("returns nothing for a query that matches no stencil", () => {
expect(searchStencils("zzzznotathing")).toEqual([])
})
it("is case- and separator-insensitive", () => {
const a = searchStencils("NAT_GATEWAY")[0].name
const b = searchStencils("nat gateway")[0].name
expect(a).toBe(b)
})
})
describe("checkNames", () => {
it("passes a tree whose names are all real", () => {
expect(
checkNames([
{ id: "a", name: "s3", kind: "icon" },
{ id: "b", name: "ec2", kind: "icon" },
{ id: "c", name: "group_vpc", kind: "group" },
]),
).toEqual([])
})
it("catches an invented name and suggests real ones", () => {
const bad = checkNames([
{ id: "x", name: "s3_bucket_storage", kind: "icon" },
])
expect(bad).toHaveLength(1)
expect(bad[0].id).toBe("x")
expect(bad[0].suggestions.length).toBeGreaterThan(0)
expect(bad[0].suggestions).toContain("s3")
})
it("ignores a node with no name — a box, not an icon", () => {
expect(checkNames([{ id: "b", name: "", kind: "icon" }])).toEqual([])
})
it("reports every bad name, not just the first", () => {
const bad = checkNames([
{ id: "x", name: "fake_one", kind: "icon" },
{ id: "y", name: "s3", kind: "icon" },
{ id: "z", name: "fake_two", kind: "icon" },
])
expect(bad.map((b) => b.id)).toEqual(["x", "z"])
})
it("catches a group name used where a group is expected", () => {
const bad = checkNames([
{ id: "g", name: "group_nonexistent", kind: "group" },
])
expect(bad).toHaveLength(1)
})
})
describe("catalog styles are usable as-is", () => {
it("every icon style names a shape draw.io can render", () => {
// Spot-check a spread of names rather than all 983 — a systematic problem would
// show up in any of them.
for (const n of [
"s3",
"ec2",
"lambda",
"rds",
"dynamodb",
"a1_instance",
"nat_gateway",
]) {
const st = lookupStencil(n)?.style ?? ""
expect(st).toMatch(/shape=mxgraph\.aws4\./)
}
})
it("every group style carries grIcon and a container declaration", () => {
for (const g of ["group_vpc", "group_region", "group_account"]) {
const st = lookupStencil(g, "group")?.style ?? ""
expect(st).toContain(`grIcon=mxgraph.aws4.${g}`)
}
})
})

View File

@@ -0,0 +1,325 @@
import { countTokens } from "@anthropic-ai/tokenizer"
import { describe, expect, it } from "vitest"
import {
describeDiagram,
type Operation,
restructureDiagram,
} from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import { findNode, findParent } from "@/lib/diagram-engine/types"
/** The operations that build a 3-tier VPC diagram from nothing. */
const BUILD_3TIER: Operation[] = [
{ op: "set_title", title: "VPC Multi-AZ 3-tier" },
{ op: "add_box", id: "users", label: "Users / Internet" },
{
op: "add_container",
id: "region",
label: "Region (ap-southeast-1)",
dir: "row",
gname: "group_region",
},
{
op: "add_container",
id: "vpc",
parent: "region",
label: "VPC 10.0.0.0/16",
dir: "col",
gname: "group_vpc",
},
{
op: "add_icon",
id: "igw",
parent: "vpc",
name: "internet_gateway",
label: "Internet Gateway",
},
{
op: "add_icon",
id: "alb",
parent: "vpc",
name: "application_load_balancer",
label: "ALB",
},
{ op: "add_container", id: "azs", parent: "vpc", label: "", dir: "row" },
{
op: "add_container",
id: "az_a",
parent: "azs",
label: "AZ-a",
dir: "col",
gname: "group_availability_zone",
},
{
op: "add_container",
id: "pub_a",
parent: "az_a",
label: "Public Subnet",
dir: "col",
gname: "group_subnet",
},
{
op: "add_icon",
id: "nat_a",
parent: "pub_a",
name: "nat_gateway",
label: "NAT",
},
{
op: "add_container",
id: "app_a",
parent: "az_a",
label: "Private Subnet (App)",
dir: "col",
gname: "group_subnet",
},
{ op: "add_icon", id: "ec2_a", parent: "app_a", name: "ec2", label: "EC2" },
{
op: "add_container",
id: "db_a",
parent: "az_a",
label: "Private Subnet (Data)",
dir: "col",
gname: "group_subnet",
},
{
op: "add_icon",
id: "rds_a",
parent: "db_a",
name: "rds",
label: "RDS (Primary)",
},
{ op: "link", source: "users", target: "igw", label: "HTTPS", step: 1 },
{ op: "link", source: "igw", target: "alb", label: "forward", step: 2 },
{ op: "link", source: "alb", target: "ec2_a", label: "route", step: 3 },
{ op: "link", source: "ec2_a", target: "rds_a", label: "query", step: 4 },
]
describe("restructureDiagram builds from an empty canvas", () => {
const r = restructureDiagram("", BUILD_3TIER)
it("succeeds", () => {
expect(r.errors).toEqual([])
expect(r.xml).not.toBeNull()
})
it("produces XML draw.io can load", () => {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain('<mxCell id="0"/>')
expect(r.xml).toContain('<mxCell id="1" parent="0"/>')
})
it("resolves catalog names to verbatim stencil styles, with official colours", () => {
// #ED7100 is AWS Compute orange; nothing here is hand-assembled.
expect(r.xml).toContain("resIcon=mxgraph.aws4.ec2")
expect(r.xml).toContain("#ED7100")
expect(r.xml).toContain("grIcon=mxgraph.aws4.group_vpc")
})
it("stamps container=1 so a user can drag shapes between frames", () => {
const vpcStyle =
r.xml?.match(/<mxCell id="vpc"[^>]*style="([^"]*)"/)?.[1] ?? ""
expect(vpcStyle).toContain("container=1")
})
it("reads back the structure it was asked to build", () => {
const { tree } = parseDiagram(r.xml as string)
expect(findParent(tree, "vpc")?.id).toBe("region")
expect(findParent(tree, "az_a")?.id).toBe("azs")
expect(findParent(tree, "nat_a")?.id).toBe("pub_a")
expect(tree.links).toHaveLength(4)
expect(tree.title).toBe("VPC Multi-AZ 3-tier")
})
})
describe("restructureDiagram edits an existing canvas", () => {
const built = restructureDiagram("", BUILD_3TIER).xml as string
it("adds one node with one small operation", () => {
const r = restructureDiagram(built, [
{
op: "add_icon",
id: "cache_a",
parent: "app_a",
name: "elasticache",
label: "Redis",
},
])
expect(r.errors).toEqual([])
const { tree } = parseDiagram(r.xml as string)
expect(findParent(tree, "cache_a")?.id).toBe("app_a")
// everything else is still there
expect(findNode(tree, "rds_a")).not.toBeNull()
expect(tree.links).toHaveLength(4)
})
it("re-orients a container without touching anything else", () => {
const r = restructureDiagram(built, [
{ op: "set_dir", id: "vpc", dir: "row" },
])
expect(r.errors).toEqual([])
const { tree } = parseDiagram(r.xml as string)
expect((findNode(tree, "vpc") as { dir: string }).dir).toBe("row")
})
it("removes a subtree and the edges that pointed into it", () => {
const r = restructureDiagram(built, [{ op: "remove", id: "db_a" }])
const { tree } = parseDiagram(r.xml as string)
expect(findNode(tree, "db_a")).toBeNull()
expect(findNode(tree, "rds_a")).toBeNull()
// the ec2 → rds edge went with it
expect(tree.links).toHaveLength(3)
})
it("keeps a colour the user changed by hand", () => {
const edited = built.replace(
/(<mxCell id="users"[^>]*style="[^"]*)"/,
'$1fillColor=#FF0000;"',
)
const r = restructureDiagram(edited, [
{ op: "set_label", id: "users", label: "Clients" },
])
expect(r.xml).toContain("fillColor=#FF0000")
})
it("keeps a shape the user added by hand", () => {
const edited = built.replace(
"</root>",
'<mxCell id="mynote" value="Note" style="shape=note;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="1200" y="60" width="140" height="80" as="geometry"/></mxCell></root>',
)
const r = restructureDiagram(edited, [
{
op: "add_icon",
id: "s3",
parent: "vpc",
name: "s3",
label: "S3",
},
])
expect(r.xml).toContain('id="mynote"')
})
it("respects where the user dragged a node to", () => {
// The user moved the EC2 icon from the app subnet into the public subnet.
const moved = built.replace(
/(<mxCell id="ec2_a"[^>]*)parent="app_a"/,
'$1parent="pub_a"',
)
const r = restructureDiagram(moved, [
{ op: "set_label", id: "ec2_a", label: "EC2 (moved)" },
])
const { tree } = parseDiagram(r.xml as string)
expect(findParent(tree, "ec2_a")?.id).toBe("pub_a")
})
})
describe("invented stencil names are rejected, not rendered blank", () => {
it("fails the whole call and suggests real names", () => {
const r = restructureDiagram("", [
{
op: "add_icon",
id: "x",
name: "s3_bucket_storage",
label: "Bucket",
},
])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("not in the stencil catalog")
expect(r.errors[0]).toContain("s3")
})
it("rejects an invented group stencil too", () => {
const r = restructureDiagram("", [
{
op: "add_container",
id: "g",
label: "X",
dir: "row",
gname: "group_made_up",
},
])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("not in the stencil catalog")
})
it("still returns the outline so the model can see what it built", () => {
const r = restructureDiagram("", [
{ op: "add_icon", id: "x", name: "nope_not_real" },
])
expect(r.outline).toContain("x: icon nope_not_real")
})
it("reports a failed operation without rendering a half-built diagram", () => {
const r = restructureDiagram("", [
{ op: "add_icon", id: "a", name: "s3" },
{ op: "move", id: "ghost", parent: "a" },
])
expect(r.xml).toBeNull()
expect(r.errors.some((e) => e.includes("ghost"))).toBe(true)
})
})
describe("describeDiagram", () => {
it("reports an empty canvas plainly", () => {
expect(describeDiagram("").outline).toBe("(empty canvas)")
})
it("outlines what is on the canvas without changing it", () => {
const built = restructureDiagram("", BUILD_3TIER).xml as string
const d = describeDiagram(built)
expect(d.outline).toContain("vpc: col")
expect(d.outline).toContain("ec2_a: icon ec2")
expect(d.outline).toContain("link users -> igw")
expect(d.needsAdoption).toBe(false)
})
it("flags a diagram that did not come from the engine", () => {
const foreign = `<mxfile><diagram name="Page-1" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="X" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
expect(describeDiagram(foreign).needsAdoption).toBe(true)
})
})
describe("token cost: operations versus raw XML", () => {
// The reason for the whole exercise. Measured with Claude's own tokenizer, which the
// repo already depends on, rather than a characters/4 estimate.
const built = restructureDiagram("", BUILD_3TIER).xml as string
it("building a diagram costs far fewer tokens as operations than as XML", () => {
const asOps = countTokens(JSON.stringify(BUILD_3TIER))
const asXml = countTokens(built)
console.log(
`build: ${asOps} tok as operations vs ${asXml} tok as XML (${(asXml / asOps).toFixed(1)}x)`,
)
expect(asOps).toBeLessThan(asXml / 2)
})
it("adding one icon costs a fraction of re-emitting the diagram", () => {
const oneOp: Operation[] = [
{
op: "add_icon",
id: "cache",
parent: "app_a",
name: "elasticache",
label: "Redis",
},
]
const opTokens = countTokens(JSON.stringify(oneOp))
const xmlTokens = countTokens(
restructureDiagram(built, oneOp).xml as string,
)
console.log(
`add one icon: ${opTokens} tok as an operation vs ${xmlTokens} tok re-emitting the XML (${Math.round(xmlTokens / opTokens)}x)`,
)
expect(opTokens).toBeLessThan(60)
expect(opTokens).toBeLessThan(xmlTokens / 20)
})
it("the outline the model reads back is much cheaper than the XML", () => {
const outlineTokens = countTokens(describeDiagram(built).outline)
const xmlTokens = countTokens(built)
console.log(
`read current state: ${outlineTokens} tok as an outline vs ${xmlTokens} tok as XML (${(xmlTokens / outlineTokens).toFixed(1)}x)`,
)
expect(outlineTokens).toBeLessThan(xmlTokens / 3)
})
})

View File

@@ -0,0 +1,665 @@
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)
}
})
})