Files
next-ai-draw-io/tests/unit/diagram-engine-parse.test.ts
dayuan.jiang a2f892ca82 feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.

layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.

render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.

Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.

Two bugs the round-trip test caught, both real:

  - An icon's cell was being emitted at its measured slot size, which includes room
    for the label underneath. Parsing read that width back as the glyph size, so the
    icon grew on every round-trip. The cell is now the glyph square and the label
    renders outside it via verticalLabelPosition, as the reference does.
  - An Azure or GCP icon is an embedded base64 image whose style contains no name
    anywhere, so the catalog name was unrecoverable. Added a dai_name marker.

Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.

That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.

304 unit tests + 3 e2e.
2026-08-09 13:49:11 +09:00

754 lines
28 KiB
TypeScript

import { readFileSync } from "node:fs"
import { join } from "node:path"
import { describe, expect, it } from "vitest"
import { stampContainer } from "@/lib/diagram-engine/markers"
import {
countPages,
extractPage,
parseDiagram,
} from "@/lib/diagram-engine/parse"
import {
type ContainerNode,
type DiagramNode,
findNode,
findParent,
isContainer,
walkTree,
} from "@/lib/diagram-engine/types"
/**
* Real output from drawio-ai-kit's examples/aws/build_vpc.mjs — the exact XML shape the
* parser has to handle. Structure declared by that script:
*
* root (phantom, row)
* ├── users (box)
* └── region (group_region, row)
* ├── vpc (group_vpc, col)
* │ ├── igw, alb (icons)
* │ └── azs (phantom, row)
* │ ├── az_a (group_availability_zone, col) → pub_a/app_a/db_a → nat_a/ec2_a/rds_a
* │ └── az_b (same, mirrored)
* └── reg_svc (plain frame, col) → waf, cw, s3
*
* The two phantoms emit no cell, so their children are reparented to the nearest
* visible ancestor: users/region become roots, az_a/az_b become children of vpc.
*/
const ENGINE_XML = readFileSync(
join(__dirname, "fixtures/engine-vpc-multiaz.drawio"),
"utf8",
)
/** Minimal page, built by hand so each test controls exactly one variable. */
function page(cells: string): string {
return `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="p1"><mxGraphModel dx="1400" dy="900" grid="0" pageWidth="1200" pageHeight="800"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
}
function vtx(
id: string,
style: string,
geo: { x: number; y: number; w: number; h: number },
opts: { parent?: string; value?: string } = {},
): string {
return `<mxCell id="${id}" value="${opts.value ?? ""}" style="${style}" vertex="1" parent="${opts.parent ?? "1"}"><mxGeometry x="${geo.x}" y="${geo.y}" width="${geo.w}" height="${geo.h}" as="geometry"/></mxCell>`
}
const AWS_GROUP = (name: string) =>
`sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;`
const RES_ICON = (name: string) =>
`sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.${name};`
const OWN_SHAPE_ICON = (name: string) =>
`sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.${name};`
const IMAGE_ICON =
"sketch=0;html=1;outlineConnect=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;fontColor=#232F3E;aspect=fixed;shape=image;image=data:image/png,iVBORw0KGgoAAAANSUhEUg==;"
const PLAIN_BOX =
"rounded=0;whiteSpace=wrap;html=1;fillColor=#DAE8FC;strokeColor=#6C8EBF;"
describe("extractPage", () => {
it("pulls the model body out of an mxfile", () => {
const p = extractPage(ENGINE_XML)
expect(p).toContain("<mxCell")
expect(p).not.toContain("<mxfile")
})
it("accepts a bare mxGraphModel", () => {
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
expect(extractPage(bare)).toContain("<mxCell")
})
it("returns null for a compressed page rather than pretending it is empty", () => {
const compressed = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfL8oXLY0gyu1O1t</diagram></mxfile>`
expect(extractPage(compressed)).toBeNull()
})
it("selects the requested page and clamps an out-of-range index", () => {
const two = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyA"/></root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyB"/></root></mxGraphModel></diagram></mxfile>`
expect(extractPage(two, 0)).toContain("onlyA")
expect(extractPage(two, 1)).toContain("onlyB")
expect(extractPage(two, 99)).toContain("onlyB")
})
})
describe("countPages", () => {
it("counts a multi-page deck", () => {
const two = `<mxfile><diagram name="A" id="a"></diagram><diagram name="B" id="b"></diagram></mxfile>`
expect(countPages(two)).toBe(2)
})
it("reports 1 for a single page", () => {
expect(countPages(ENGINE_XML)).toBe(1)
})
})
describe("parseDiagram on real engine output", () => {
const { tree, needsAdoption, warnings } = parseDiagram(ENGINE_XML)
it("recovers the nesting the build script declared", () => {
// az_a is a child of vpc (the `azs` phantom emitted no cell)
expect(findParent(tree, "az_a")?.id).toBe("vpc")
expect(findParent(tree, "vpc")?.id).toBe("region")
expect(findParent(tree, "ec2_a")?.id).toBe("app_a")
expect(findParent(tree, "app_a")?.id).toBe("az_a")
expect(findParent(tree, "rds_b")?.id).toBe("db_b")
})
it("puts users and region at the top level", () => {
const rootIds = tree.roots.map((r) => r.id).sort()
expect(rootIds).toEqual(["region", "users"])
})
it("classifies AWS group stencils as containers and keeps their stencil name", () => {
const vpc = findNode(tree, "vpc")
expect(isContainer(vpc as DiagramNode)).toBe(true)
expect((vpc as ContainerNode).gname).toBe("group_vpc")
expect((findNode(tree, "az_a") as ContainerNode).gname).toBe(
"group_availability_zone",
)
expect((findNode(tree, "pub_a") as ContainerNode).gname).toBe(
"group_subnet",
)
})
it("cannot recover a direction the phantom erased — the case that rules phantoms out", () => {
// The build script declared vpc as dir:"col" holding igw, alb, and a phantom
// wrapping the two AZ columns. A phantom emits NO cell, so its children were
// reparented onto vpc: igw(260,60) alb(260,164) az_a(24,268) az_b(309,268).
// That is a 2-D arrangement, so inference correctly reads "grid" — the original
// column structure is simply not in the XML any more.
//
// This is why our engine must not have phantoms: a wrapper that emits no cell
// makes the round-trip lossy by construction. See task #5.
//
// The parser does not guess a direction here. It keeps the container and warns
// that the arrangement is two-dimensional, so the caller knows a re-layout will
// move these children rather than discovering it afterwards.
expect(findParent(tree, "az_a")?.id).toBe("vpc")
expect(findNode(tree, "azs")).toBeNull()
expect(
warnings.some(
(w) => w.includes("vpc") && w.includes("two dimensions"),
),
).toBe(true)
})
it("classifies resourceIcon cells as icons and recovers their catalog name", () => {
const ec2 = findNode(tree, "ec2_a")
expect(ec2?.kind).toBe("icon")
expect(ec2 && "name" in ec2 ? ec2.name : null).toBe("ec2")
const nat = findNode(tree, "nat_a")
expect(nat && "name" in nat ? nat.name : null).toBe("nat_gateway")
})
it("classifies a plain rectangle as a box", () => {
expect(findNode(tree, "users")?.kind).toBe("box")
})
it("lifts the title out of the flow instead of leaving it as a node", () => {
expect(tree.title).toContain("VPC Multi-AZ 3-tier")
expect(findNode(tree, "__title")).toBeNull()
})
it("recovers every edge with its endpoints", () => {
expect(tree.links).toHaveLength(7)
const pairs = tree.links.map((l) => `${l.source}->${l.target}`)
expect(pairs).toContain("users->igw")
expect(pairs).toContain("alb->ec2_a")
expect(pairs).toContain("rds_a->rds_b")
})
it("splits a step number off the edge label", () => {
const first = tree.links.find(
(l) => l.source === "users" && l.target === "igw",
)
expect(first?.step).toBe(1)
expect(first?.label).toBe("HTTPS")
})
it("marks a dashed edge", () => {
const repl = tree.links.find(
(l) => l.source === "rds_a" && l.target === "rds_b",
)
expect(repl?.dashed).toBe(true)
expect(repl?.label).toBe("Multi-AZ replication")
})
it("flags the diagram as needing adoption — it carries no markers yet", () => {
// This fixture predates the marker scheme, so direction had to be inferred.
expect(needsAdoption).toBe(true)
})
it("warns only about the phantom-flattened container, nothing else", () => {
// The single warning is the 2-D arrangement the phantom left behind; a
// well-formed page produces no other complaint.
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain("two dimensions")
})
it("assigns every cell exactly once — no duplicates, nothing lost", () => {
const ids = [...walkTree(tree)].map((n) => n.id)
expect(new Set(ids).size).toBe(ids.length)
// 24 vertex cells in the fixture, minus __title which is lifted out of the flow
expect(ids).toHaveLength(23)
})
})
describe("layout inference (no markers present)", () => {
it("reads a row from children spread along x", () => {
const xml = page(
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 20, y: 40, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"b",
RES_ICON("ec2"),
{ x: 120, y: 40, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"c",
RES_ICON("rds"),
{ x: 220, y: 40, w: 48, h: 48 },
{ parent: "f" },
),
)
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
expect(f.kind).toBe("group")
expect((f as { dir?: string }).dir).toBe("row")
})
it("reads a column from children spread along y", () => {
const xml = page(
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 120, h: 400 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 30, y: 20, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"b",
RES_ICON("ec2"),
{ x: 30, y: 120, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"c",
RES_ICON("rds"),
{ x: 30, y: 220, w: 48, h: 48 },
{ parent: "f" },
),
)
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
expect((f as { dir?: string }).dir).toBe("col")
})
it("recognises a 2-D arrangement as a grid rather than forcing row or column", () => {
const xml = page(
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 300, h: 300 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 20, y: 20, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"b",
RES_ICON("ec2"),
{ x: 140, y: 20, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"c",
RES_ICON("rds"),
{ x: 20, y: 140, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"d",
RES_ICON("sqs"),
{ x: 140, y: 140, w: 48, h: 48 },
{ parent: "f" },
),
)
const f = findNode(parseDiagram(xml).tree, "f")
expect(f?.kind).toBe("grid")
})
it("measures the gap between neighbours, not between their origins", () => {
// icons 48 wide at x=20,120,220 → edge-to-edge gap is 52
const xml = page(
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 20, y: 40, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"b",
RES_ICON("ec2"),
{ x: 120, y: 40, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"c",
RES_ICON("rds"),
{ x: 220, y: 40, w: 48, h: 48 },
{ parent: "f" },
),
)
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
expect((f as { gap?: number }).gap).toBe(52)
})
})
describe("markers take precedence over inference", () => {
it("believes dai_dir even when the geometry suggests otherwise", () => {
// children are laid out in a row, but the marker says col
const marked = stampContainer(AWS_GROUP("group_vpc"), {
kind: "group",
dir: "col",
gap: 33,
})
const xml = page(
vtx("f", marked, { x: 0, y: 0, w: 400, h: 120 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 20, y: 40, w: 48, h: 48 },
{ parent: "f" },
) +
vtx(
"b",
RES_ICON("ec2"),
{ x: 120, y: 40, w: 48, h: 48 },
{ parent: "f" },
),
)
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
expect((f as { dir?: string }).dir).toBe("col")
expect((f as { gap?: number }).gap).toBe(33)
})
it("clears needsAdoption once any cell carries a marker", () => {
const marked = stampContainer(AWS_GROUP("group_vpc"), {
kind: "group",
dir: "row",
gap: 20,
})
const xml = page(vtx("f", marked, { x: 0, y: 0, w: 200, h: 100 }))
expect(parseDiagram(xml).needsAdoption).toBe(false)
})
it("recovers grid columns from dai_cols", () => {
const marked = stampContainer("rounded=0;html=1;", {
kind: "grid",
dir: "grid",
gap: 14,
cols: 3,
})
const xml = page(
vtx("g", marked, { x: 0, y: 0, w: 300, h: 200 }) +
vtx(
"a",
RES_ICON("s3"),
{ x: 10, y: 10, w: 48, h: 48 },
{ parent: "g" },
),
)
const g = findNode(parseDiagram(xml).tree, "g")
expect(g?.kind).toBe("grid")
expect((g as { cols?: number }).cols).toBe(3)
})
it("carries a user pin through to the node", () => {
const xml = page(
vtx("b", `${PLAIN_BOX}dai_kind=box;dai_pin=1;`, {
x: 10,
y: 10,
w: 120,
h: 60,
}),
)
const b = findNode(parseDiagram(xml).tree, "b")
expect((b as { pinned?: boolean }).pinned).toBe(true)
})
})
describe("icon classification covers all four encodings", () => {
it("handles resIcon=, bare shape=, and shape=image", () => {
// 554 AWS icons use resIcon; 429 use their own shape=; Azure/GCP use shape=image.
const xml = page(
vtx("res", RES_ICON("ec2"), { x: 0, y: 0, w: 48, h: 48 }) +
vtx("own", OWN_SHAPE_ICON("a1_instance"), {
x: 100,
y: 0,
w: 48,
h: 48,
}) +
vtx("img", IMAGE_ICON, { x: 200, y: 0, w: 48, h: 48 }),
)
const { tree } = parseDiagram(xml)
expect(findNode(tree, "res")?.kind).toBe("icon")
expect(findNode(tree, "own")?.kind).toBe("icon")
expect(findNode(tree, "img")?.kind).toBe("icon")
})
it("recovers the catalog name from a bare shape= icon", () => {
const xml = page(
vtx("own", OWN_SHAPE_ICON("a1_instance"), {
x: 0,
y: 0,
w: 48,
h: 48,
}),
)
const n = findNode(parseDiagram(xml).tree, "own")
expect(n && "name" in n ? n.name : null).toBe("a1_instance")
})
it("leaves the name empty for an embedded-image icon instead of inventing one", () => {
const xml = page(vtx("img", IMAGE_ICON, { x: 0, y: 0, w: 48, h: 48 }))
const n = findNode(parseDiagram(xml).tree, "img")
expect(n && "name" in n ? n.name : null).toBe("")
// ...but the verbatim style is kept, so it can be re-emitted unchanged
expect(n && "style" in n ? n.style : "").toContain("data:image/png")
})
})
describe("nesting when a frame lacks container=1", () => {
it("re-homes a cell that visually sits inside a loose frame", () => {
// draw.io will NOT reparent into a frame without container=1 (verified
// in-browser), so the user's drop leaves parent="1" while the shape is
// visually inside. Geometry is the truth here.
const xml = page(
vtx("loose", AWS_GROUP("group_vpc"), {
x: 100,
y: 100,
w: 400,
h: 300,
}) +
vtx("inside", RES_ICON("ec2"), {
x: 200,
y: 200,
w: 48,
h: 48,
}),
)
const { tree } = parseDiagram(xml)
expect(findParent(tree, "inside")?.id).toBe("loose")
})
it("picks the innermost frame when loose frames nest", () => {
const xml = page(
vtx("outer", AWS_GROUP("group_region"), {
x: 0,
y: 0,
w: 800,
h: 600,
}) +
vtx("inner", AWS_GROUP("group_vpc"), {
x: 100,
y: 100,
w: 300,
h: 200,
}) +
vtx("leaf", RES_ICON("ec2"), { x: 150, y: 150, w: 48, h: 48 }),
)
const { tree } = parseDiagram(xml)
expect(findParent(tree, "leaf")?.id).toBe("inner")
})
it("leaves a cell alone when it is outside every loose frame", () => {
const xml = page(
vtx("loose", AWS_GROUP("group_vpc"), {
x: 100,
y: 100,
w: 200,
h: 200,
}) +
vtx("outside", RES_ICON("ec2"), {
x: 600,
y: 600,
w: 48,
h: 48,
}),
)
const { tree } = parseDiagram(xml)
expect(findParent(tree, "outside")).toBeNull()
expect(tree.roots.map((r) => r.id)).toContain("outside")
})
it("does NOT override an explicit parent that draw.io maintained", () => {
// With container=1 the parent attribute is authoritative — even if a stale
// geometry would place the child inside a different frame.
const withContainer = stampContainer(AWS_GROUP("group_vpc"), {
kind: "group",
dir: "row",
gap: 20,
})
const xml = page(
vtx("real", withContainer, { x: 500, y: 0, w: 300, h: 200 }) +
vtx("loose", AWS_GROUP("group_region"), {
x: 0,
y: 0,
w: 400,
h: 400,
}) +
// parent says "real"; geometry (relative to real) lands it at 520,20
vtx(
"kid",
RES_ICON("ec2"),
{ x: 20, y: 20, w: 48, h: 48 },
{ parent: "real" },
),
)
const { tree } = parseDiagram(xml)
expect(findParent(tree, "kid")?.id).toBe("real")
})
})
describe("robustness", () => {
it("returns an empty tree with a warning for a compressed file", () => {
const compressed = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfLGHN5DCQzu1O1t</diagram></mxfile>`
const r = parseDiagram(compressed)
expect(r.tree.roots).toEqual([])
expect(r.warnings[0]).toContain("compressed")
})
it("warns when it silently parsed only the first page of a deck", () => {
const two = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 })}</root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const r = parseDiagram(two)
expect(r.warnings.some((w) => w.includes("2 pages"))).toBe(true)
})
it("treats a cell whose parent does not exist as a root instead of dropping it", () => {
const xml = page(
vtx(
"orphan",
PLAIN_BOX,
{ x: 0, y: 0, w: 100, h: 50 },
{ parent: "ghost" },
),
)
const { tree } = parseDiagram(xml)
expect(tree.roots.map((r) => r.id)).toContain("orphan")
})
it("does not hang on a parent cycle", () => {
const cyclic = page(
vtx(
"a",
PLAIN_BOX,
{ x: 0, y: 0, w: 100, h: 50 },
{ parent: "b" },
) +
vtx(
"b",
PLAIN_BOX,
{ x: 0, y: 0, w: 100, h: 50 },
{ parent: "a" },
),
)
const r = parseDiagram(cyclic)
expect(r.warnings.some((w) => w.includes("cycle"))).toBe(true)
})
it("unescapes entities in labels", () => {
const xml = page(
vtx(
"b",
PLAIN_BOX,
{ x: 0, y: 0, w: 100, h: 50 },
{
value: "A &amp; B &lt;tag&gt; &quot;q&quot;",
},
),
)
const b = findNode(parseDiagram(xml).tree, "b")
expect((b as { label?: string }).label).toBe('A & B <tag> "q"')
})
it("keeps cells on the boundaries layer verbatim instead of restructuring them", () => {
const xml = page(
`<mxCell id="boundaries" value="Stack boundaries (locked)" parent="0" style="locked=1;"/>` +
vtx(
"cluster",
"rounded=0;dashed=1;fillColor=none;strokeColor=#ED7100;",
{
x: 10,
y: 10,
w: 200,
h: 100,
},
{ parent: "boundaries" },
),
)
const { tree } = parseDiagram(xml)
expect(tree.foreign.map((f) => f.id)).toContain("cluster")
expect(findNode(tree, "cluster")).toBeNull()
expect(tree.foreign[0].xml).toContain("strokeColor=#ED7100")
})
it("ignores an edge that is missing an endpoint", () => {
const xml = page(
vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 }) +
`<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a"><mxGeometry relative="1" as="geometry"/></mxCell>`,
)
expect(parseDiagram(xml).tree.links).toHaveLength(0)
})
it("handles a self-closing mxCell", () => {
const xml = page(
`<mxCell id="bare" value="x" style="${PLAIN_BOX}" vertex="1" parent="1"/>`,
)
const { tree } = parseDiagram(xml)
expect(tree.roots.map((r) => r.id)).toContain("bare")
})
})
describe("container classification edge cases", () => {
it("treats a plain box that has children as a container", () => {
const xml = page(
vtx("frame", PLAIN_BOX, { x: 0, y: 0, w: 300, h: 200 }) +
vtx(
"kid",
RES_ICON("s3"),
{ x: 20, y: 20, w: 48, h: 48 },
{ parent: "frame" },
),
)
const f = findNode(parseDiagram(xml).tree, "frame")
expect(f?.kind).toBe("group")
expect(isContainer(f as DiagramNode)).toBe(true)
})
it("treats a childless container=1 frame as a container, not a box", () => {
const xml = page(
vtx("empty", `${PLAIN_BOX}container=1;`, {
x: 0,
y: 0,
w: 200,
h: 100,
}),
)
expect(findNode(parseDiagram(xml).tree, "empty")?.kind).toBe("group")
})
it("honours the last container= value when the key is duplicated", () => {
// Verified in-browser: draw.io resolves duplicate keys last-wins.
const xml = page(
vtx("c", `${PLAIN_BOX}container=1;container=0;`, {
x: 0,
y: 0,
w: 200,
h: 100,
}),
)
// last value is 0 → not a container, and it has no children → a box
expect(findNode(parseDiagram(xml).tree, "c")?.kind).toBe("box")
})
it("recognises a text cell as the title even without the __title id", () => {
const xml = page(
`<mxCell id="t9" value="My Diagram" style="text;html=1;align=center;fontSize=14;" vertex="1" parent="1"><mxGeometry x="0" y="24" width="800" height="30" as="geometry"/></mxCell>`,
)
const { tree } = parseDiagram(xml)
expect(tree.title).toBe("My Diagram")
expect(findNode(tree, "t9")).toBeNull()
})
it("keeps the first title when a diagram somehow has two", () => {
const xml = page(
`<mxCell id="t1" value="First" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="30" as="geometry"/></mxCell>` +
`<mxCell id="t2" value="Second" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="40" width="100" height="30" as="geometry"/></mxCell>`,
)
expect(parseDiagram(xml).tree.title).toBe("First")
})
})
describe("geometry resolution", () => {
it("resolves a nested cell's position to page coordinates", () => {
const withContainer = stampContainer(AWS_GROUP("group_vpc"), {
kind: "group",
dir: "row",
gap: 20,
})
const xml = page(
vtx("outer", withContainer, { x: 100, y: 200, w: 400, h: 300 }) +
vtx(
"kid",
`${RES_ICON("ec2")}dai_kind=icon;dai_pin=1;`,
{
x: 30,
y: 40,
w: 48,
h: 48,
},
{ parent: "outer" },
),
)
const kid = findNode(parseDiagram(xml).tree, "kid")
// 100+30, 200+40
expect((kid as { rect?: { x: number; y: number } }).rect).toEqual({
x: 130,
y: 240,
w: 48,
h: 48,
})
})
it("resolves through two levels of nesting", () => {
const c = stampContainer(AWS_GROUP("group_vpc"), {
kind: "group",
dir: "col",
gap: 10,
})
const xml = page(
vtx("l1", c, { x: 100, y: 100, w: 500, h: 400 }) +
vtx(
"l2",
c,
{ x: 50, y: 60, w: 300, h: 200 },
{ parent: "l1" },
) +
vtx(
"leaf",
`${RES_ICON("s3")}dai_kind=icon;dai_pin=1;`,
{
x: 10,
y: 20,
w: 48,
h: 48,
},
{ parent: "l2" },
),
)
const leaf = findNode(parseDiagram(xml).tree, "leaf")
// 100+50+10, 100+60+20
expect((leaf as { rect?: { x: number; y: number } }).rect?.x).toBe(160)
expect((leaf as { rect?: { x: number; y: number } }).rect?.y).toBe(180)
})
})