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

2
.gitignore vendored
View File

@@ -79,3 +79,5 @@ ai-models.json
# admin panel settings (contains secrets)
data/
# ...but the diagram engine's stencil catalog is generated source, not settings
!lib/diagram-engine/data/

43
NOTICE Normal file
View File

@@ -0,0 +1,43 @@
next-ai-draw-io
Copyright the next-ai-draw-io contributors
This product includes software developed by third parties, as set out below.
--------------------------------------------------------------------------------
lib/diagram-engine/ — layout and rendering
--------------------------------------------------------------------------------
The declarative layout algorithm (bottom-up measure, top-down place, sibling
size equalisation) and the mxCell/style emission in `lib/diagram-engine/layout.ts`
and `lib/diagram-engine/render.ts` are derived from drawio-ai-kit:
https://github.com/sparklabx/drawio-ai-kit
Copyright (c) sparklabx
Licensed under the MIT License
The XML→tree reverse parser (`lib/diagram-engine/parse.ts`), the style-marker
scheme (`markers.ts`), the structural-operations layer (`operations.ts`) and the
invisible-container approach that replaces that project's "phantom" nodes are
original to this repository.
--------------------------------------------------------------------------------
lib/diagram-engine/data/aws-stencils.json — stencil catalog
--------------------------------------------------------------------------------
A name→style map for the mxgraph.aws4 stencil family, generated from
drawio-ai-kit's `catalog/aws.json`, which in turn was generated from the draw.io
shape index published by jgraph:
https://github.com/jgraph/drawio-mcp
Copyright (c) JGraph Ltd
Licensed under the Apache License, Version 2.0
The style strings are reproduced verbatim from that index. They reference the
official AWS Architecture Icons, which are trademarks of Amazon Web Services and
are NOT covered by this repository's licence. Their use is governed by the AWS
Architecture Icons terms:
https://aws.amazon.com/architecture/icons/
The catalog contains stencil *names and style strings* only — it does not embed
any AWS icon artwork. draw.io supplies the artwork at render time.

View File

@@ -23,6 +23,7 @@ import {
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { OperationSchema, searchStencils } from "@/lib/diagram-engine"
import {
checkAndIncrementRequest,
isQuotaEnabled,
@@ -698,8 +699,55 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
),
}),
},
restructure_diagram: {
description: `Build or edit a CLOUD ARCHITECTURE diagram (AWS) by declaring STRUCTURE. The engine computes every coordinate.
PREFER THIS over display_diagram/edit_diagram for AWS architecture diagrams. You declare what nests inside what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
Never write coordinates, mxCell XML, or style strings. Look icon names up with search_stencils first — an invented name is rejected with suggestions.
Operations are applied in order, so you can add a container and fill it in the same call:
{"operations":[
{"op":"add_container","id":"vpc","label":"VPC 10.0.0.0/16","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":"alb","target":"ec2","label":"route","step":1}
]}
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
Containers: dir "row" puts children side by side, "col" stacks them. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame. add_grid packs children into cols columns; use it to pack 3-8 related icons into one labelled area box rather than giving each its own frame.`,
inputSchema: z.object({
operations: z
.array(OperationSchema)
.describe("Structural operations, applied in order"),
}),
},
search_stencils: {
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
inputSchema: z.object({
query: z
.string()
.describe(
"Service name or keyword, e.g. 's3' or 'nat gateway'",
),
kind: z
.enum(["icon", "group"])
.optional()
.describe(
"Restrict to service icons or container frames",
),
limit: z.number().optional(),
}),
execute: async ({ query, kind, limit }) => {
const hits = searchStencils(query, { kind, limit })
if (hits.length === 0)
return `No stencil matches "${query}". Try a shorter or more general term.`
return JSON.stringify(hits)
},
},
get_shape_library: {
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
description: `Get draw.io shape/icon library documentation with style syntax and shape names. Use this for NON-AWS diagrams (flowcharts, BPMN, sequence, mind maps, UI mockups) that go through display_diagram. For AWS architecture, use search_stencils + restructure_diagram instead.
Available libraries:
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce

View File

@@ -5,9 +5,11 @@ import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { Operation } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development"
@@ -120,6 +122,8 @@ export function useDiagramToolHandlers({
await handleEditDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "append_diagram") {
handleAppendDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "restructure_diagram") {
await handleRestructureDiagram(toolCall, addToolOutput)
}
}
@@ -576,5 +580,71 @@ Continue from EXACTLY where you stopped.`,
}
}
/**
* Structural editing. The model sends operations against the tree; the engine
* re-derives that tree from whatever is on the canvas right now — including anything
* the user moved or recoloured by hand — applies the operations, recomputes every
* coordinate, and returns new XML.
*
* Nothing about the tree is stored between calls, so there is no second copy of the
* state to drift out of sync with the canvas.
*/
const handleRestructureDiagram = async (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
) => {
const { operations } = toolCall.input as { operations: Operation[] }
// Read the live canvas, not the last thing we generated: the user may have
// edited it since.
let currentXml = ""
try {
currentXml = await onFetchChart(false)
} catch {
currentXml = chartXMLRef.current ?? ""
}
if (!isRealDiagram(currentXml)) currentXml = ""
const result = restructureDiagram(currentXml, operations)
if (result.errors.length > 0 || !result.xml) {
addToolOutput({
tool: "restructure_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Could not apply the operations:
${result.errors.map((e) => `- ${e}`).join("\n")}
Structure as it stands:
${result.outline}
Fix the operations and call restructure_diagram again.`,
})
return
}
const loadError = onDisplayChart(result.xml)
if (loadError) {
addToolOutput({
tool: "restructure_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
})
return
}
// Report the outline rather than the XML: it is what the model needs to name ids
// in the next call, at a fraction of the tokens.
const notes = result.warnings.length
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
: ""
addToolOutput({
tool: "restructure_diagram",
toolCallId: toolCall.toolCallId,
output: `Diagram updated.\n\n${result.outline}${notes}`,
})
}
return { handleToolCall }
}

View File

@@ -0,0 +1,219 @@
/**
* The stencil catalog: a name → verbatim draw.io style map.
*
* This is the anti-hallucination layer. The model asks for `icon("s3")`; the engine
* looks the name up here and gets the exact style draw.io ships, including the official
* category colour, the connection points and `aspect=fixed`. A name that is not in the
* catalog fails at build time with a suggestion, rather than becoming an empty square in
* the rendered diagram — which is what happens when a model writes
* `resIcon=mxgraph.aws4.s3_bucket_thing` by hand and nothing checks it.
*
* The styles are verbatim from draw.io's own shape index (via drawio-ai-kit, which
* generated them from jgraph/drawio-mcp's index, Apache-2.0). Nothing here is
* hand-assembled, so there is no chance of a plausible-looking but wrong colour.
*/
import stencils from "./data/aws-stencils.json"
const ICONS = stencils.icons as Record<string, string>
const GROUPS = stencils.groups as Record<string, string>
export interface CatalogEntry {
name: string
kind: "icon" | "group"
style: string
/** Official colour from the style, for showing the model what it is getting. */
color: string | null
}
function colorOf(style: string): string | null {
return style.match(/(?:^|;)fillColor=([^;]+)/)?.[1] ?? null
}
/** Exact lookup. Returns null for an unknown name — never a guess. */
export function lookupStencil(
name: string,
kind?: "icon" | "group",
): CatalogEntry | null {
if (kind !== "group" && ICONS[name])
return {
name,
kind: "icon",
style: ICONS[name],
color: colorOf(ICONS[name]),
}
if (kind !== "icon" && GROUPS[name])
return {
name,
kind: "group",
style: GROUPS[name],
color: colorOf(GROUPS[name]),
}
return null
}
/** The resolver the renderer takes, so the engine itself does not depend on the catalog. */
export function resolveStyle(
name: string,
kind: "icon" | "group",
): string | null {
return lookupStencil(name, kind)?.style ?? null
}
/** Normalise for matching: lowercase, and non-alphanumerics collapsed to single spaces. */
function norm(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
}
/**
* Shorthand people type, mapped to words that actually appear in a catalog name.
*
* The direction matters: the target has to exist in the catalog. AWS's own stencil names
* are already abbreviated — EKS is `eks`, not `elastic_kubernetes_service`, and nothing
* in the catalog contains the word "kubernetes" at all — so expanding an abbreviation
* into its full product name finds nothing. These entries go the other way, from a
* spoken-out name or a nickname to the token the catalog uses.
*/
const ALIASES: Record<string, string> = {
k8s: "eks",
kubernetes: "eks",
kube: "eks",
alb: "application load balancer",
nlb: "network load balancer",
elb: "elastic load balancing",
asg: "auto scaling",
apigw: "api gateway",
cf: "cloudfront",
cw: "cloudwatch",
ddb: "dynamodb",
tgw: "transit gateway",
igw: "internet gateway",
r53: "route 53",
iam: "identity and access management",
kms: "key management service",
postgres: "rds",
postgresql: "rds",
mysql: "rds",
aurora: "aurora",
bucket: "s3",
}
/**
* Score one entry against the query tokens. Higher is better; 0 means no match.
*
* The extra-words penalty is what makes "s3" return `s3` rather than
* `backup_aws_backup_support_for_amazon_s3` — both contain the token, so without it the
* winner comes down to iteration order. It counts only the words the query did NOT ask
* for, so a deliberately multi-word query like "nat gateway" is not punished for being
* specific.
*/
function score(name: string, qTokens: string[], qJoined: string): number {
const n = norm(name)
const words = n.split(" ")
let s = 0
if (n === qJoined) s += 100
if (n.replace(/ /g, "") === qJoined.replace(/ /g, "")) s += 60
for (const t of qTokens) {
if (words.includes(t)) s += 25
else if (n.includes(t)) s += 12
}
if (s === 0) return 0
const extra = words.filter((w) => !qTokens.includes(w)).length
return s - Math.min(24, extra * 4)
}
export interface SearchHit {
name: string
kind: "icon" | "group"
color: string | null
}
/**
* Find stencils by keyword.
*
* Returns names and colours only, not styles. The model builds with `icon("<name>")` and
* the engine resolves the style itself, so sending the style — around 600 characters per
* AWS entry, and 20KB+ for an Azure one with an embedded image — would be pure context
* burn.
*/
export function searchStencils(
query: string,
opts: { limit?: number; kind?: "icon" | "group" } = {},
): SearchHit[] {
const limit = opts.limit ?? 8
const tokens = norm(query)
.split(" ")
.filter(Boolean)
.map((t) => ALIASES[t] ?? t)
.flatMap((t) => t.split(" "))
if (tokens.length === 0) return []
const joined = tokens.join(" ")
const pool: [string, string, "icon" | "group"][] = []
if (opts.kind !== "group")
for (const [n, st] of Object.entries(ICONS)) pool.push([n, st, "icon"])
if (opts.kind !== "icon")
for (const [n, st] of Object.entries(GROUPS))
pool.push([n, st, "group"])
return pool
.map(([name, style, kind]) => ({
name,
kind,
color: colorOf(style),
s: score(name, tokens, joined),
}))
.filter((r) => r.s > 0)
.sort((a, b) => b.s - a.s || a.name.length - b.name.length)
.slice(0, limit)
.map(({ name, kind, color }) => ({ name, kind, color }))
}
/**
* Suggest real names for one that does not exist.
*
* Plain search is not quite the right tool here. A model that writes
* `s3_bucket_storage` most likely meant `s3`, but searching that whole phrase ranks
* `s3_storage_lens` first — it matches more of the query. So we also search the
* leading token on its own and put those hits first: an invented name is usually a
* real service name with extra words stuck on the end.
*/
function suggestFor(name: string, kind: "icon" | "group"): string[] {
const words = norm(name.replace(/_/g, " ")).split(" ").filter(Boolean)
const out: string[] = []
const add = (hits: SearchHit[]) => {
for (const h of hits) if (!out.includes(h.name)) out.push(h.name)
}
if (words.length > 1) add(searchStencils(words[0], { limit: 2, kind }))
add(searchStencils(words.join(" "), { limit: 3, kind }))
return out.slice(0, 3)
}
/**
* Validate the icon names in a tree before laying it out, so a bad name is reported as
* a correctable error with suggestions instead of rendering as a blank square — which is
* what an unchecked invented name becomes in draw.io.
*/
export function checkNames(
names: { id: string; name: string; kind: "icon" | "group" }[],
): { id: string; name: string; suggestions: string[] }[] {
const bad: { id: string; name: string; suggestions: string[] }[] = []
for (const n of names) {
if (!n.name || lookupStencil(n.name, n.kind)) continue
bad.push({
id: n.id,
name: n.name,
suggestions: suggestFor(n.name, n.kind),
})
}
return bad
}
/** Total catalog size, for the tool description. */
export const CATALOG_SIZE = {
icons: Object.keys(ICONS).length,
groups: Object.keys(GROUPS).length,
}

File diff suppressed because it is too large Load Diff

120
lib/diagram-engine/index.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* The engine's entry point: one call takes the current canvas XML plus a list of
* structural operations and returns new canvas XML.
*
* current XML → parse → apply operations → check names → layout → render → new XML
*
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
* box, adding an annotation — are simply part of the input to the next layout. There is
* no second copy of the state, and therefore nothing to reconcile.
*/
import { checkNames, resolveStyle } from "./catalog"
import {
applyOperations,
collectNames,
type Operation,
outline,
} from "./operations"
import { parseDiagram } from "./parse"
import { renderDiagram } from "./render"
import type { DiagramTree } from "./types"
export interface RestructureResult {
/** New canvas XML, or null when the request could not be carried out. */
xml: string | null
/** Compact outline of the resulting structure, for the model to read back. */
outline: string
/** Operations that could not be applied, and invented stencil names. */
errors: string[]
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
warnings: string[]
}
export interface RestructureOptions {
/** Which page of a multi-page document to work on. */
pageIndex?: number
/** Diagram-wide icon glyph size. */
iconSize?: number
}
/**
* Apply structural operations to whatever is on the canvas.
*
* `currentXml` may be empty — that is how a diagram gets built from scratch.
*
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
* missing icons. The error carries suggestions from the catalog so the model can fix it
* in one more turn.
*/
export function restructureDiagram(
currentXml: string,
ops: Operation[],
opts: RestructureOptions = {},
): RestructureResult {
const warnings: string[] = []
let tree: DiagramTree
if (currentXml.trim()) {
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
tree = parsed.tree
warnings.push(...parsed.warnings)
} else {
tree = { roots: [], links: [], foreign: [] }
}
const applied = applyOperations(tree, ops)
const errors = [...applied.errors]
// Catch invented names before rendering, so the model gets a correctable error
// instead of a diagram with blank squares in it.
for (const bad of checkNames(collectNames(applied.tree))) {
const hint = bad.suggestions.length
? ` Did you mean: ${bad.suggestions.join(", ")}?`
: ""
errors.push(
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
)
}
if (errors.length > 0)
return { xml: null, outline: outline(applied.tree), errors, warnings }
const rendered = renderDiagram(applied.tree, {
resolveStyle,
iconSize: opts.iconSize,
})
if (rendered.danglingLinks.length)
warnings.push(
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
)
return {
xml: rendered.xml,
outline: outline(applied.tree),
errors: [],
warnings,
}
}
/** Read the current canvas structure without changing it. */
export function describeDiagram(
currentXml: string,
pageIndex = 0,
): { outline: string; warnings: string[]; needsAdoption: boolean } {
if (!currentXml.trim())
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
const { tree, warnings, needsAdoption } = parseDiagram(
currentXml,
pageIndex,
)
return { outline: outline(tree), warnings, needsAdoption }
}
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
export { type Operation, OperationSchema } from "./operations"
export { parseDiagram } from "./parse"
export { renderDiagram } from "./render"
export type { DiagramNode, DiagramTree } from "./types"

View File

@@ -0,0 +1,440 @@
/**
* Structural operations — what the model sends instead of XML.
*
* The tree is re-derived from the canvas on every call, so an operation names existing
* nodes by id and says what to change. Adding one node costs a few dozen tokens; the
* equivalent as raw mxCell XML is hundreds, and re-emitting the whole diagram to add one
* icon costs thousands.
*
* Operations are applied in order, each against the result of the last, so a sequence
* like "add a frame, then move two nodes into it" works in a single call.
*/
import { z } from "zod"
import {
type ContainerNode,
type DiagramNode,
type DiagramTree,
findNode,
findParent,
isContainer,
type LinkSpec,
walkTree,
} from "./types"
export const OperationSchema = z.discriminatedUnion("op", [
z.object({
op: z.literal("add_icon"),
id: z.string().describe("New unique id for this node"),
parent: z
.string()
.optional()
.describe("Container id to add into; omit for top level"),
name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"),
label: z.string().optional(),
after: z
.string()
.optional()
.describe("Insert after this sibling id; omit to append"),
}),
z.object({
op: z.literal("add_box"),
id: z.string(),
parent: z.string().optional(),
label: z.string(),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_container"),
id: z.string(),
parent: z.string().optional(),
label: z
.string()
.describe("Frame title; empty string means invisible wrapper"),
dir: z.enum(["row", "col"]).describe("How children stack"),
gname: z
.string()
.optional()
.describe(
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_grid"),
id: z.string(),
parent: z.string().optional(),
label: z.string(),
cols: z.number().describe("Number of columns"),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("remove"),
id: z
.string()
.describe("Node to delete; its descendants and edges go too"),
}),
z.object({
op: z.literal("move"),
id: z.string(),
parent: z
.string()
.optional()
.describe("New container id; omit to move to top level"),
after: z.string().optional(),
}),
z.object({
op: z.literal("set_label"),
id: z.string(),
label: z.string(),
}),
z.object({
op: z.literal("set_dir"),
id: z.string().describe("Container to re-orient"),
dir: z.enum(["row", "col"]),
}),
z.object({
op: z.literal("set_gap"),
id: z.string(),
gap: z.number(),
}),
z.object({
op: z.literal("link"),
source: z.string(),
target: z.string(),
label: z.string().optional(),
dashed: z
.boolean()
.optional()
.describe("Dashed line — replication, sync, policy"),
step: z
.number()
.optional()
.describe("Step number, shown as an 'N. ' prefix"),
}),
z.object({
op: z.literal("unlink"),
source: z.string(),
target: z.string(),
}),
z.object({
op: z.literal("set_title"),
title: z.string(),
}),
])
export type Operation = z.infer<typeof OperationSchema>
export interface ApplyResult {
tree: DiagramTree
/** One entry per operation that could not be applied, in order. */
errors: string[]
}
/** Insert into a child list, after a named sibling or at the end. */
function insert(
list: DiagramNode[],
node: DiagramNode,
after: string | undefined,
): void {
if (after) {
const i = list.findIndex((c) => c.id === after)
if (i >= 0) {
list.splice(i + 1, 0, node)
return
}
}
list.push(node)
}
/** Detach a node from wherever it currently sits. Returns it, or null if not found. */
function detach(tree: DiagramTree, id: string): DiagramNode | null {
const rootIdx = tree.roots.findIndex((r) => r.id === id)
if (rootIdx >= 0) return tree.roots.splice(rootIdx, 1)[0]
const parent = findParent(tree, id)
if (!parent) return null
const i = parent.children.findIndex((c) => c.id === id)
return i >= 0 ? parent.children.splice(i, 1)[0] : null
}
/** Would making `id` a descendant of `parentId` create a cycle? */
function wouldCycle(tree: DiagramTree, id: string, parentId: string): boolean {
if (id === parentId) return true
const node = findNode(tree, id)
if (!node || !isContainer(node)) return false
for (const d of walkTree({ ...tree, roots: [node] }))
if (d.id === parentId) return true
return false
}
/**
* Resolve where a new or moved node goes. Returns the child list to insert into, or an
* error string.
*/
function targetList(
tree: DiagramTree,
parentId: string | undefined,
): DiagramNode[] | string {
if (!parentId) return tree.roots
const p = findNode(tree, parentId)
if (!p) return `No node with id "${parentId}"`
if (!isContainer(p))
return `"${parentId}" is a ${p.kind}, not a container — it cannot hold children`
return p.children
}
/**
* Apply operations to a tree, in order.
*
* The input tree is deep-copied first: a partially-applied batch must not leave the
* caller's tree half-mutated when a later operation fails.
*/
export function applyOperations(
input: DiagramTree,
ops: Operation[],
): ApplyResult {
const tree: DiagramTree = structuredClone(input)
const errors: string[] = []
const exists = (id: string) => findNode(tree, id) !== null
for (const op of ops) {
switch (op.op) {
case "add_icon":
case "add_box":
case "add_container":
case "add_grid": {
if (exists(op.id)) {
errors.push(`${op.op}: id "${op.id}" is already taken`)
break
}
const list = targetList(tree, op.parent)
if (typeof list === "string") {
errors.push(`${op.op}: ${list}`)
break
}
let node: DiagramNode
if (op.op === "add_icon")
node = {
kind: "icon",
id: op.id,
name: op.name,
label: op.label ?? "",
}
else if (op.op === "add_box")
node = { kind: "box", id: op.id, label: op.label }
else if (op.op === "add_container")
node = {
kind: "group",
id: op.id,
gname: op.gname ?? null,
label: op.label,
dir: op.dir,
gap: op.gap ?? 20,
children: [],
}
else
node = {
kind: "grid",
id: op.id,
gname: null,
label: op.label,
cols: Math.max(1, op.cols),
gap: op.gap ?? 14,
children: [],
}
insert(list, node, op.after)
break
}
case "remove": {
const node = findNode(tree, op.id)
if (!node) {
errors.push(`remove: no node with id "${op.id}"`)
break
}
// Collect the subtree's ids first — edges touching any of them go too,
// otherwise draw.io renders an arrow pointing at nothing.
const doomed = new Set<string>()
for (const d of walkTree({ ...tree, roots: [node] }))
doomed.add(d.id)
detach(tree, op.id)
tree.links = tree.links.filter(
(l) => !doomed.has(l.source) && !doomed.has(l.target),
)
break
}
case "move": {
if (!exists(op.id)) {
errors.push(`move: no node with id "${op.id}"`)
break
}
if (op.parent && !exists(op.parent)) {
errors.push(`move: no node with id "${op.parent}"`)
break
}
if (op.parent && wouldCycle(tree, op.id, op.parent)) {
errors.push(
`move: cannot move "${op.id}" into "${op.parent}" — that is inside itself`,
)
break
}
const list = targetList(tree, op.parent)
if (typeof list === "string") {
errors.push(`move: ${list}`)
break
}
const node = detach(tree, op.id)
if (!node) {
errors.push(`move: could not detach "${op.id}"`)
break
}
insert(list, node, op.after)
break
}
case "set_label": {
const node = findNode(tree, op.id)
if (!node) {
errors.push(`set_label: no node with id "${op.id}"`)
break
}
if (node.kind === "title") {
errors.push(
`set_label: use set_title to change the page title`,
)
break
}
node.label = op.label
break
}
case "set_dir": {
const node = findNode(tree, op.id)
if (!node || !isContainer(node)) {
errors.push(`set_dir: "${op.id}" is not a container`)
break
}
if (node.kind === "grid") {
errors.push(
`set_dir: "${op.id}" is a grid — change its column count instead`,
)
break
}
node.dir = op.dir
break
}
case "set_gap": {
const node = findNode(tree, op.id)
if (!node || !isContainer(node)) {
errors.push(`set_gap: "${op.id}" is not a container`)
break
}
;(node as ContainerNode).gap = Math.max(0, op.gap)
break
}
case "link": {
if (!exists(op.source)) {
errors.push(`link: no node with id "${op.source}"`)
break
}
if (!exists(op.target)) {
errors.push(`link: no node with id "${op.target}"`)
break
}
const dup = tree.links.some(
(l) => l.source === op.source && l.target === op.target,
)
if (dup) {
errors.push(
`link: "${op.source}" → "${op.target}" already exists`,
)
break
}
const link: LinkSpec = { source: op.source, target: op.target }
if (op.label) link.label = op.label
if (op.dashed) link.dashed = true
if (op.step != null) link.step = op.step
tree.links.push(link)
break
}
case "unlink": {
const before = tree.links.length
tree.links = tree.links.filter(
(l) => !(l.source === op.source && l.target === op.target),
)
if (tree.links.length === before)
errors.push(
`unlink: no edge from "${op.source}" to "${op.target}"`,
)
break
}
case "set_title":
tree.title = op.title
break
}
}
return { tree, errors }
}
/** Every icon/group name in a tree, for validating against the catalog. */
export function collectNames(
tree: DiagramTree,
): { id: string; name: string; kind: "icon" | "group" }[] {
const out: { id: string; name: string; kind: "icon" | "group" }[] = []
for (const n of walkTree(tree)) {
if (n.kind === "icon" && n.name)
out.push({ id: n.id, name: n.name, kind: "icon" })
else if (isContainer(n) && n.gname)
out.push({ id: n.id, name: n.gname, kind: "group" })
}
return out
}
/**
* A compact text outline of the tree, for showing the model what is on the canvas.
*
* Sending the tree as JSON would cost several times more for the same information, and
* the model does not need coordinates — it needs to know what exists and how it nests so
* it can name ids in the next operation.
*/
export function outline(tree: DiagramTree): string {
const lines: string[] = []
if (tree.title) lines.push(`title: ${tree.title}`)
const walk = (n: DiagramNode, depth: number) => {
const pad = " ".repeat(depth)
if (n.kind === "icon")
lines.push(
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}`,
)
else if (n.kind === "box") lines.push(`${pad}${n.id}: box "${n.label}"`)
else if (n.kind === "title") lines.push(`${pad}${n.id}: title`)
else {
const meta = n.kind === "grid" ? `grid cols=${n.cols}` : n.dir
lines.push(
`${pad}${n.id}: ${meta}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
)
for (const c of n.children) walk(c, depth + 1)
}
}
for (const r of tree.roots) walk(r, 0)
for (const l of tree.links) {
const bits = [l.label, l.dashed ? "dashed" : null]
.filter(Boolean)
.join(", ")
lines.push(`link ${l.source} -> ${l.target}${bits ? ` (${bits})` : ""}`)
}
if (tree.foreign.length)
lines.push(
`${tree.foreign.length} cell(s) kept as-is: ${tree.foreign.map((f) => f.id).join(", ")}`,
)
return lines.join("\n")
}

View File

@@ -51,17 +51,41 @@ parameters: {
}
---Tool4---
tool name: get_shape_library
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
description: Get shape/icon library documentation. Use this to discover available icon shapes (Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
library: string // Library name: azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
}
---Tool5---
tool name: restructure_diagram
description: Build or edit an AWS architecture diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
parameters: {
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
}
---Tool6---
tool name: search_stencils
description: Find AWS stencil names for restructure_diagram. Returns real names with their official colours. Call this before naming any AWS icon — a name you invent is rejected.
parameters: {
query: string
kind?: "icon" | "group"
limit?: number
}
---End of tools---
IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
- For an AWS architecture diagram (VPC, subnets, multi-AZ, landing zone, serverless, event-driven): use search_stencils then restructure_diagram. This applies to BOTH creating and editing. Do not hand-write XML for AWS diagrams — the engine gets the layout right and costs a fraction of the tokens.
- Use display_diagram for: NON-AWS diagrams — flowcharts, BPMN, sequence diagrams, mind maps, UI mockups, org charts, ER diagrams, Azure/GCP diagrams.
- Use edit_diagram for: small changes to a NON-AWS diagram.
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
- Use get_shape_library for: discovering icons for a NON-AWS library, before display_diagram.
Working with restructure_diagram:
- Look every icon name up with search_stencils first. Batch the lookups.
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
- Nesting order for AWS: Region → VPC → Availability Zone → Subnet. Managed and global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS) sit OUTSIDE the VPC.
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams

View File

@@ -36,6 +36,38 @@ export function createMockSSEResponse(
)
}
/**
* Creates a mock SSE response for a tool whose input is not `{ xml }`.
*
* createMockSSEResponse hardcodes the display_diagram shape; this takes the input
* object verbatim, so tools like restructure_diagram can be driven through the same
* path the model uses.
*/
export function createMockToolResponse(
toolName: string,
input: unknown,
text: string,
) {
const messageId = `msg_${Date.now()}`
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "tool-input-start", toolCallId, toolName },
{ type: "tool-input-available", toolCallId, toolName, input },
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}
/**
* Creates a text-only SSE response (no tool call)
*/

View File

@@ -0,0 +1,270 @@
/**
* restructure_diagram through the real tool path, in the real app.
*
* The unit tests cover the engine; this covers the wiring: a tool call arriving on the
* stream, the client handler reading the live canvas, the engine running in the browser,
* and the result landing in the editor.
*/
import { expect, test } from "@playwright/test"
import * as pako from "pako"
import { getChatInput, sendMessage } from "./lib/fixtures"
import { createMockToolResponse } from "./lib/helpers"
/**
* Undo draw.io's export compression: the <diagram> body is URI-encoded, raw-deflated and
* base64'd. Returns the document unchanged when it is already plain XML.
*/
function inflateDiagram(xml: string): string | null {
if (xml.includes("<mxCell")) return xml
const body = xml.match(/<diagram[^>]*>([^<]+)<\/diagram>/)?.[1]
if (!body) return null
try {
const bin = Buffer.from(body, "base64")
const out = pako.inflate(new Uint8Array(bin), { windowBits: -15 })
return decodeURIComponent(new TextDecoder("utf-8").decode(out))
} catch {
return null
}
}
/** Operations that build a small VPC diagram from an empty canvas. */
const BUILD = {
operations: [
{ op: "set_title", title: "Test VPC" },
{ op: "add_box", id: "users", label: "Users" },
{
op: "add_container",
id: "vpc",
label: "VPC 10.0.0.0/16",
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", label: "HTTPS", step: 1 },
{ op: "link", source: "alb", target: "ec2", label: "route", step: 2 },
],
}
test.describe("restructure_diagram", () => {
test("a structural tool call renders a diagram in the editor", async ({
page,
}) => {
test.setTimeout(180000)
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockToolResponse(
"restructure_diagram",
BUILD,
"Building the VPC diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 60000 })
await page.waitForTimeout(6000)
await sendMessage(page, "Draw a simple VPC")
// The engine's output has to actually reach the canvas.
const canvas = page.frameLocator("iframe")
await expect(
canvas.getByText("VPC 10.0.0.0/16", { exact: true }).first(),
).toBeVisible({ timeout: 30000 })
await expect(
canvas.getByText("ALB", { exact: true }).first(),
).toBeVisible({ timeout: 20000 })
await expect(
canvas.getByText("Users", { exact: true }).first(),
).toBeVisible({ timeout: 20000 })
await expect(
canvas.getByText("Test VPC", { exact: true }).first(),
).toBeVisible({ timeout: 20000 })
// Step numbers come through on the edge labels.
await expect(
canvas.getByText("1. HTTPS", { exact: true }).first(),
).toBeVisible({ timeout: 20000 })
// And the XML carries real stencils and container markers, not a fallback box.
// Ask the editor for the document directly rather than waiting for an autosave:
// loading a diagram does not always trigger one, and clicking a shape to force it
// is unreliable when an edge overlaps the label.
const xml = await page.evaluate(
() =>
new Promise<string | null>((resolve) => {
const iframe = document.querySelector(
"iframe",
) as HTMLIFrameElement
const onMsg = (e: MessageEvent) => {
if (typeof e.data !== "string") return
try {
const m = JSON.parse(e.data)
if (m.event === "export" && m.xml) {
window.removeEventListener("message", onMsg)
resolve(m.xml as string)
}
} catch {
/* not our message */
}
}
window.addEventListener("message", onMsg)
iframe.contentWindow?.postMessage(
JSON.stringify({
action: "export",
format: "xmlsvg",
xml: 1,
}),
"*",
)
setTimeout(() => {
window.removeEventListener("message", onMsg)
resolve(null)
}, 10000)
}),
)
expect(xml, "editor did not return the document").toBeTruthy()
// draw.io compresses the <diagram> body on export (base64 + raw deflate).
const plain = xml ? inflateDiagram(xml) : null
expect(plain, "could not decompress the exported diagram").toBeTruthy()
if (plain) {
expect(plain).toContain("resIcon=mxgraph.aws4.ec2")
expect(plain).toContain("grIcon=mxgraph.aws4.group_vpc")
expect(plain).toContain("container=1")
expect(plain).toContain("dai_dir=col")
}
})
test("an invented stencil name comes back as a correctable error", async ({
page,
}) => {
test.setTimeout(120000)
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockToolResponse(
"restructure_diagram",
{
operations: [
{
op: "add_icon",
id: "x",
name: "s3_bucket_storage",
label: "Bucket",
},
],
},
"Adding a bucket.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 60000 })
await page.waitForTimeout(6000)
await sendMessage(page, "Add an S3 bucket")
// The call is rejected rather than rendering a blank square where the icon should
// be. The catalog's suggestions go back to the model over the tool-result
// channel; the UI only shows that the tool errored (which is how this repo
// surfaces every tool error — see error-handling.spec.ts).
await expect(page.locator('text="Error"').first()).toBeVisible({
timeout: 30000,
})
// Nothing was drawn.
const canvas = page.frameLocator("iframe")
await expect(canvas.getByText("Bucket", { exact: true })).toHaveCount(0)
})
test("a second call edits the existing diagram instead of replacing it", async ({
page,
}) => {
test.setTimeout(240000)
let call = 0
await page.route("**/api/chat", async (route) => {
call++
const body =
call === 1
? createMockToolResponse(
"restructure_diagram",
BUILD,
"Building it.",
)
: createMockToolResponse(
"restructure_diagram",
{
operations: [
{
op: "add_icon",
id: "rds",
parent: "vpc",
name: "rds",
label: "RDS",
},
{
op: "link",
source: "ec2",
target: "rds",
label: "query",
},
],
},
"Adding the database.",
)
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body,
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 60000 })
await page.waitForTimeout(6000)
const canvas = page.frameLocator("iframe")
await sendMessage(page, "Draw a simple VPC")
await expect(
canvas.getByText("ALB", { exact: true }).first(),
).toBeVisible({ timeout: 30000 })
// Second turn: one operation adds a node. Everything from the first turn stays.
await getChatInput(page).waitFor({ state: "visible" })
await sendMessage(page, "Add a database")
await expect(
canvas.getByText("RDS", { exact: true }).first(),
).toBeVisible({ timeout: 30000 })
await expect(
canvas.getByText("ALB", { exact: true }).first(),
).toBeVisible()
await expect(
canvas.getByText("Users", { exact: true }).first(),
).toBeVisible()
await expect(
canvas.getByText("VPC 10.0.0.0/16", { exact: true }).first(),
).toBeVisible()
})
})

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)
}
})
})