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.
This commit is contained in:
dayuan.jiang
2026-08-09 11:56:08 +09:00
parent 8765dfb96c
commit a2f892ca82
8 changed files with 2525 additions and 222 deletions

View File

@@ -0,0 +1,208 @@
/**
* The closed loop, in a real browser: engine → draw.io → user drags something → engine
* reads the structure back.
*
* The unit tests prove render and parse agree with each other. This proves they agree
* with the actual editor: that the XML the engine writes renders, that a frame really
* accepts a drop, and that the structure recovered afterwards matches what the user did
* on screen.
*
* The engine runs in the test process (Playwright transpiles the spec, so the TypeScript
* imports resolve); only XML strings cross into the page.
*/
import { expect, test } from "@playwright/test"
import { parseDiagram } from "../../lib/diagram-engine/parse"
import { renderDiagram } from "../../lib/diagram-engine/render"
import { type DiagramTree, findParent } from "../../lib/diagram-engine/types"
/** Two frames side by side; MOVER starts in the left one. */
function twoFrames(extraLeft: string[] = []): DiagramTree {
return {
roots: [
{
kind: "group",
id: "root",
gname: null,
label: "Root",
dir: "row",
gap: 60,
children: [
{
kind: "group",
id: "left",
gname: null,
label: "Left",
dir: "col",
gap: 20,
children: [
{ kind: "box", id: "mover", label: "MOVER" },
...extraLeft.map((id) => ({
kind: "box" as const,
id,
label: id.toUpperCase(),
})),
],
},
{
kind: "group",
id: "right",
gname: null,
label: "Right",
dir: "col",
gap: 20,
children: [
{ kind: "box", id: "anchor", label: "ANCHOR" },
],
},
],
},
],
links: [],
foreign: [],
}
}
async function loadAndWatch(
page: import("@playwright/test").Page,
xml: string,
) {
await page.evaluate(() => {
const w = window as unknown as { __xml?: string[] }
w.__xml = []
window.addEventListener("message", (e: MessageEvent) => {
if (typeof e.data !== "string") return
try {
const m = JSON.parse(e.data)
if ((m.event === "autosave" || m.event === "save") && m.xml)
w.__xml?.push(m.xml)
} catch {
/* not our message */
}
})
})
await page.evaluate((x) => {
const iframe = document.querySelector("iframe") as HTMLIFrameElement
iframe.contentWindow?.postMessage(
JSON.stringify({ action: "load", xml: x, autosave: 1 }),
"*",
)
}, xml)
await page.waitForTimeout(4000)
}
const lastXml = (page: import("@playwright/test").Page) =>
page.evaluate(() => {
const w = window as unknown as { __xml?: string[] }
const a = w.__xml ?? []
return a.length ? a[a.length - 1] : null
})
const parentAttr = (xml: string, id: string) =>
xml
.match(new RegExp(`<mxCell[^>]*\\bid="${id}"[^>]*>`))?.[0]
.match(/\bparent="([^"]*)"/)?.[1] ?? null
/** Drag the cell labelled `from` into the frame that holds the cell labelled `into`. */
async function dragInto(
page: import("@playwright/test").Page,
from: string,
into: string,
) {
const canvas = page.frameLocator("iframe")
const src = canvas.getByText(from, { exact: true }).first()
await src.waitFor({ state: "visible", timeout: 30000 })
const dst = canvas.getByText(into, { exact: true }).first()
await dst.waitFor({ state: "visible", timeout: 30000 })
const sb = await src.boundingBox()
const db = await dst.boundingBox()
if (!sb || !db) throw new Error("cells not rendered")
// Drop below the anchor: inside the target frame, but not on top of the anchor.
await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2)
await page.mouse.down()
await page.mouse.move(sb.x + sb.width / 2 + 20, sb.y + 10, { steps: 8 })
await page.mouse.move(db.x + db.width / 2, db.y + db.height + 30, {
steps: 30,
})
await page.waitForTimeout(800)
await page.mouse.up()
await page.waitForTimeout(3000)
}
test.describe("diagram engine, end to end", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 60000 })
await page.waitForTimeout(6000)
})
test("engine output renders, and a drop into a frame becomes structure", async ({
page,
}) => {
test.setTimeout(180000)
const { xml } = renderDiagram(twoFrames())
expect(xml).toContain("container=1")
await loadAndWatch(page, xml)
await dragInto(page, "MOVER", "ANCHOR")
const after = await lastXml(page)
expect(after, "editor emitted no autosave after the drag").toBeTruthy()
if (!after) return
// draw.io reparented it, because the frame carries container=1.
expect(parentAttr(after, "mover")).toBe("right")
// ...and the engine reads the user's change back as structure. There is no
// second copy of the state, so nothing to reconcile.
const { tree, needsAdoption } = parseDiagram(after)
expect(findParent(tree, "mover")?.id).toBe("right")
expect(findParent(tree, "anchor")?.id).toBe("right")
expect(needsAdoption).toBe(false) // markers survived the editor
})
test("a re-layout after the drag keeps the node in its new frame", async ({
page,
}) => {
test.setTimeout(180000)
const { xml } = renderDiagram(twoFrames(["stay"]))
await loadAndWatch(page, xml)
await dragInto(page, "MOVER", "ANCHOR")
const after = await lastXml(page)
expect(after).toBeTruthy()
if (!after) return
const afterDrag = parseDiagram(after).tree
expect(findParent(afterDrag, "mover")?.id).toBe("right")
// Re-lay-out from what the canvas says, then read it back: the user's move is
// preserved rather than undone, and the node they did not touch stays put.
const relaid = parseDiagram(renderDiagram(afterDrag).xml).tree
expect(findParent(relaid, "mover")?.id).toBe("right")
expect(findParent(relaid, "stay")?.id).toBe("left")
})
test("the re-laid-out diagram still renders in the editor", async ({
page,
}) => {
test.setTimeout(180000)
// Guards against the engine emitting XML that parses fine but the editor
// rejects — geometry the wrong side of a parent, a forward reference, and so on.
const first = renderDiagram(twoFrames(["stay"]))
const relaid = renderDiagram(parseDiagram(first.xml).tree)
await loadAndWatch(page, relaid.xml)
const canvas = page.frameLocator("iframe")
for (const label of ["MOVER", "STAY", "ANCHOR", "Left", "Right"]) {
await expect(
canvas.getByText(label, { exact: true }).first(),
).toBeVisible({ timeout: 20000 })
}
})
})

View File

@@ -0,0 +1,466 @@
import { describe, expect, it } from "vitest"
import {
autoBoxSize,
flatten,
layoutForest,
type Placed,
} from "@/lib/diagram-engine/layout"
import type {
BoxNode,
DiagramNode,
GridNode,
GroupNode,
IconNode,
Rect,
} from "@/lib/diagram-engine/types"
const icon = (id: string, label = "", size?: number): IconNode => ({
kind: "icon",
id,
name: "ec2",
label,
size,
})
const box = (id: string, label = "", w?: number, h?: number): BoxNode => ({
kind: "box",
id,
label,
w,
h,
})
const group = (
id: string,
dir: "row" | "col",
children: DiagramNode[],
label = "",
gap = 20,
): GroupNode => ({ kind: "group", id, gname: null, label, dir, gap, children })
const grid = (
id: string,
cols: number,
children: DiagramNode[],
label = "",
gap = 20,
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
/** Look a rect up by node id in a placed forest. */
function rects(roots: Placed[]): Map<string, Rect> {
const m = new Map<string, Rect>()
for (const f of flatten(roots)) m.set(f.node.id, f.rect)
return m
}
const contains = (outer: Rect, inner: Rect) =>
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.w <= outer.x + outer.w &&
inner.y + inner.h <= outer.y + outer.h
const overlaps = (a: Rect, b: Rect) =>
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0 &&
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0
describe("autoBoxSize", () => {
it("widens with the label but stops at a maximum", () => {
expect(autoBoxSize("hi").w).toBe(120) // floor
expect(autoBoxSize("x".repeat(200)).w).toBe(260) // ceiling
expect(autoBoxSize("a medium length label").w).toBeGreaterThan(120)
})
it("grows taller with each line", () => {
expect(autoBoxSize("one\ntwo\nthree").h).toBeGreaterThan(
autoBoxSize("one").h,
)
})
it("never returns a degenerate size for an empty label", () => {
const s = autoBoxSize("")
expect(s.w).toBeGreaterThan(0)
expect(s.h).toBeGreaterThan(0)
})
})
describe("containers always fit their children", () => {
it("holds a row of icons", () => {
const tree = group(
"f",
"row",
[icon("a"), icon("b"), icon("c")],
"Frame",
)
const r = rects(layoutForest([tree]).roots)
const frame = r.get("f") as Rect
for (const id of ["a", "b", "c"])
expect(contains(frame, r.get(id) as Rect)).toBe(true)
})
it("holds a column of icons", () => {
const tree = group("f", "col", [icon("a"), icon("b")], "Frame")
const r = rects(layoutForest([tree]).roots)
for (const id of ["a", "b"])
expect(contains(r.get("f") as Rect, r.get(id) as Rect)).toBe(true)
})
it("holds a grid", () => {
const tree = grid(
"g",
3,
[icon("a"), icon("b"), icon("c"), icon("d")],
"G",
)
const r = rects(layoutForest([tree]).roots)
for (const id of ["a", "b", "c", "d"])
expect(contains(r.get("g") as Rect, r.get(id) as Rect)).toBe(true)
})
it("holds a deeply nested structure at every level", () => {
// Region → VPC → AZ → Subnet → icon, the shape of a real cloud diagram
const tree = group(
"region",
"row",
[
group(
"vpc",
"col",
[
group(
"az",
"col",
[group("subnet", "col", [icon("ec2")], "Subnet")],
"AZ",
),
],
"VPC",
),
],
"Region",
)
const r = rects(layoutForest([tree]).roots)
const chain = ["region", "vpc", "az", "subnet", "ec2"]
for (let i = 1; i < chain.length; i++)
expect(
contains(r.get(chain[i - 1]) as Rect, r.get(chain[i]) as Rect),
).toBe(true)
})
it("holds a child whose label is far wider than the frame's own", () => {
const tree = group(
"f",
"col",
[box("wide", "a considerably longer label than the frame title")],
"F",
)
const r = rects(layoutForest([tree]).roots)
expect(contains(r.get("f") as Rect, r.get("wide") as Rect)).toBe(true)
})
})
describe("siblings never overlap", () => {
it("keeps a row of icons apart", () => {
const tree = group("f", "row", [icon("a"), icon("b"), icon("c")])
const r = rects(layoutForest([tree]).roots)
expect(overlaps(r.get("a") as Rect, r.get("b") as Rect)).toBe(false)
expect(overlaps(r.get("b") as Rect, r.get("c") as Rect)).toBe(false)
})
it("keeps a column of frames apart", () => {
const tree = group("f", "col", [
group("s1", "row", [icon("a")], "Public"),
group("s2", "row", [icon("b")], "Private"),
group("s3", "row", [icon("c")], "Data"),
])
const r = rects(layoutForest([tree]).roots)
expect(overlaps(r.get("s1") as Rect, r.get("s2") as Rect)).toBe(false)
expect(overlaps(r.get("s2") as Rect, r.get("s3") as Rect)).toBe(false)
})
it("keeps grid cells apart", () => {
const tree = grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")])
const r = rects(layoutForest([tree]).roots)
const ids = ["a", "b", "c", "d"]
for (let i = 0; i < ids.length; i++)
for (let j = i + 1; j < ids.length; j++)
expect(
overlaps(r.get(ids[i]) as Rect, r.get(ids[j]) as Rect),
).toBe(false)
})
it("keeps separate roots apart", () => {
const r = rects(
layoutForest([
box("users", "Users"),
group("region", "row", [icon("a")]),
]).roots,
)
expect(overlaps(r.get("users") as Rect, r.get("region") as Rect)).toBe(
false,
)
})
it("keeps 30 siblings apart — no accumulation error", () => {
const kids = Array.from({ length: 30 }, (_, i) =>
icon(`i${i}`, `n${i}`),
)
const r = rects(layoutForest([group("f", "row", kids)]).roots)
for (let i = 1; i < 30; i++) {
const prev = r.get(`i${i - 1}`) as Rect
const cur = r.get(`i${i}`) as Rect
expect(cur.x).toBeGreaterThanOrEqual(prev.x + prev.w)
}
})
})
describe("direction", () => {
it("advances along x in a row and keeps y aligned", () => {
const r = rects(
layoutForest([group("f", "row", [icon("a"), icon("b")])]).roots,
)
const a = r.get("a") as Rect
const b = r.get("b") as Rect
expect(b.x).toBeGreaterThan(a.x)
expect(b.y).toBe(a.y)
})
it("advances along y in a column and keeps x aligned", () => {
const r = rects(
layoutForest([group("f", "col", [icon("a"), icon("b")])]).roots,
)
const a = r.get("a") as Rect
const b = r.get("b") as Rect
expect(b.y).toBeGreaterThan(a.y)
expect(b.x).toBe(a.x)
})
it("wraps a grid at the column count", () => {
const r = rects(
layoutForest([
grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")]),
]).roots,
)
const a = r.get("a") as Rect
const b = r.get("b") as Rect
const c = r.get("c") as Rect
expect(b.y).toBe(a.y) // same row
expect(c.y).toBeGreaterThan(a.y) // wrapped
expect(c.x).toBe(a.x) // back to the first column
})
})
describe("sibling equalisation", () => {
it("gives frames in a row a shared height", () => {
const tree = group("f", "row", [
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
group("short", "col", [icon("d")], "Short"),
])
const r = rects(layoutForest([tree]).roots)
expect((r.get("short") as Rect).h).toBe((r.get("tall") as Rect).h)
})
it("gives frames in a column a shared width", () => {
const tree = group("f", "col", [
group("wide", "row", [icon("a"), icon("b"), icon("c")], "Wide"),
group("narrow", "row", [icon("d")], "Narrow"),
])
const r = rects(layoutForest([tree]).roots)
expect((r.get("narrow") as Rect).w).toBe((r.get("wide") as Rect).w)
})
it("does not stretch a leaf icon — that would distort the glyph", () => {
const tree = group("f", "row", [
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
icon("lone", "Lone"),
])
const r = rects(layoutForest([tree]).roots)
expect((r.get("lone") as Rect).h).toBeLessThan(
(r.get("tall") as Rect).h,
)
})
})
describe("title strip", () => {
it("reserves space above the children when a container is labelled", () => {
const withLabel = rects(
layoutForest([group("f", "row", [icon("a")], "Titled")]).roots,
)
const withoutLabel = rects(
layoutForest([group("f", "row", [icon("a")], "")]).roots,
)
expect((withLabel.get("f") as Rect).h).toBeGreaterThan(
(withoutLabel.get("f") as Rect).h,
)
})
it("pushes children below the strip so the label is not covered", () => {
const r = rects(
layoutForest([group("f", "col", [icon("a")], "Titled")]).roots,
)
const f = r.get("f") as Rect
const a = r.get("a") as Rect
expect(a.y).toBeGreaterThanOrEqual(f.y + 36)
})
it("widens a frame whose title is longer than its contents", () => {
const longTitle =
"A Very Long Container Title That Exceeds Its Single Child"
const r = rects(
layoutForest([group("f", "row", [icon("a")], longTitle)]).roots,
)
expect((r.get("f") as Rect).w).toBeGreaterThan(longTitle.length * 5)
})
})
describe("page size", () => {
it("covers every node plus a margin", () => {
const { roots, page } = layoutForest([
group("f", "row", [icon("a"), icon("b")], "F"),
])
const all = flatten(roots)
const maxX = Math.max(...all.map((n) => n.rect.x + n.rect.w))
const maxY = Math.max(...all.map((n) => n.rect.y + n.rect.h))
expect(page.w).toBeGreaterThan(maxX)
expect(page.h).toBeGreaterThan(maxY)
})
it("grows with the content", () => {
const small = layoutForest([group("f", "row", [icon("a")])]).page
const big = layoutForest([
group(
"f",
"row",
Array.from({ length: 10 }, (_, i) => icon(`i${i}`)),
),
]).page
expect(big.w).toBeGreaterThan(small.w)
})
})
describe("pinned nodes", () => {
it("keeps a pinned root where the user left it", () => {
const pinned: GroupNode = {
...group("f", "row", [icon("a")], "F"),
pinned: true,
rect: { x: 777, y: 555, w: 100, h: 100 },
}
const r = rects(layoutForest([pinned]).roots)
expect((r.get("f") as Rect).x).toBe(777)
expect((r.get("f") as Rect).y).toBe(555)
})
it("still lays the pinned node's children out inside it", () => {
const pinned: GroupNode = {
...group("f", "row", [icon("a")], "F"),
pinned: true,
rect: { x: 300, y: 300, w: 100, h: 100 },
}
const r = rects(layoutForest([pinned]).roots)
expect(contains(r.get("f") as Rect, r.get("a") as Rect)).toBe(true)
})
it("does not let a pinned root consume flow space from the others", () => {
const pinned: BoxNode = {
...box("pin", "Pinned"),
pinned: true,
rect: { x: 900, y: 900, w: 120, h: 60 },
}
const r = rects(layoutForest([pinned, box("flow", "Flow")]).roots)
// the un-pinned root starts at the normal origin, not offset past the pinned one
expect((r.get("flow") as Rect).x).toBe(40)
})
})
describe("icon sizing", () => {
it("applies the diagram-wide glyph size", () => {
const small = rects(layoutForest([icon("a")], { iconSize: 48 }).roots)
const large = rects(layoutForest([icon("a")], { iconSize: 96 }).roots)
expect((large.get("a") as Rect).h).toBeGreaterThan(
(small.get("a") as Rect).h,
)
})
it("lets a per-icon size override the diagram default", () => {
const r = rects(
layoutForest([group("f", "row", [icon("a"), icon("b", "", 96)])], {
iconSize: 48,
}).roots,
)
expect((r.get("b") as Rect).h).toBeGreaterThan((r.get("a") as Rect).h)
})
it("widens the cell for a long label so it does not overflow", () => {
const r = rects(
layoutForest([
group("f", "row", [
icon("a", "x"),
icon("b", "a much longer label"),
]),
]).roots,
)
expect((r.get("b") as Rect).w).toBeGreaterThan((r.get("a") as Rect).w)
})
})
describe("degenerate input", () => {
it("handles an empty forest", () => {
const { roots, page } = layoutForest([])
expect(roots).toEqual([])
expect(page.w).toBeGreaterThan(0)
})
it("handles an empty container without producing a negative size", () => {
const r = rects(layoutForest([group("f", "row", [], "Empty")]).roots)
const f = r.get("f") as Rect
expect(f.w).toBeGreaterThan(0)
expect(f.h).toBeGreaterThan(0)
})
it("handles a grid with fewer children than columns", () => {
const r = rects(layoutForest([grid("g", 5, [icon("a")], "G")]).roots)
expect(contains(r.get("g") as Rect, r.get("a") as Rect)).toBe(true)
})
it("rounds every coordinate to an integer — draw.io renders half-pixels blurry", () => {
const tree = group("f", "row", [
group("a", "col", [icon("x"), icon("y"), icon("z")], "A"),
icon("b"),
])
for (const f of flatten(layoutForest([tree]).roots)) {
expect(Number.isInteger(f.rect.x)).toBe(true)
expect(Number.isInteger(f.rect.y)).toBe(true)
}
})
})
describe("determinism", () => {
it("produces identical geometry for the same tree twice", () => {
const build = () =>
group("f", "row", [
group("a", "col", [icon("x"), icon("y")], "A"),
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
])
const first = flatten(layoutForest([build()]).roots).map((n) => [
n.node.id,
n.rect,
])
const second = flatten(layoutForest([build()]).roots).map((n) => [
n.node.id,
n.rect,
])
expect(second).toEqual(first)
})
})
describe("flatten", () => {
it("reports the real parent id for a nested node and the layer for a root", () => {
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
const flat = flatten(layoutForest([tree]).roots)
const by = new Map(flat.map((n) => [n.node.id, n.parent]))
expect(by.get("f")).toBe("1")
expect(by.get("inner")).toBe("f")
expect(by.get("leaf")).toBe("inner")
})
it("emits a parent before its children, so XML order is valid", () => {
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
const ids = flatten(layoutForest([tree]).roots).map((n) => n.node.id)
expect(ids.indexOf("f")).toBeLessThan(ids.indexOf("inner"))
expect(ids.indexOf("inner")).toBeLessThan(ids.indexOf("leaf"))
})
})

View File

@@ -135,9 +135,17 @@ describe("parseDiagram on real engine output", () => {
//
// 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.
expect(findNode(tree, "vpc")?.kind).toBe("grid")
//
// 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", () => {
@@ -186,8 +194,11 @@ describe("parseDiagram on real engine output", () => {
expect(needsAdoption).toBe(true)
})
it("parses without warnings on a well-formed single page", () => {
expect(warnings).toEqual([])
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", () => {

View File

@@ -0,0 +1,541 @@
/**
* Round-trip: tree → XML → tree.
*
* This is the test the whole design rests on. If structure does not survive a trip
* through draw.io XML, then the canvas cannot be the single source of truth and we are
* back to keeping a second copy of the state in sync.
*/
import { describe, expect, it } from "vitest"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import { renderDiagram } from "@/lib/diagram-engine/render"
import {
type BoxNode,
type ContainerNode,
type DiagramNode,
type DiagramTree,
findNode,
findParent,
type GridNode,
type GroupNode,
type IconNode,
isContainer,
walkTree,
} from "@/lib/diagram-engine/types"
const icon = (id: string, name = "ec2", label = ""): IconNode => ({
kind: "icon",
id,
name,
label,
})
const box = (id: string, label = ""): BoxNode => ({ kind: "box", id, label })
const group = (
id: string,
dir: "row" | "col",
children: DiagramNode[],
label = "",
gname: string | null = null,
gap = 20,
): GroupNode => ({ kind: "group", id, gname, label, dir, gap, children })
const grid = (
id: string,
cols: number,
children: DiagramNode[],
label = "",
gap = 14,
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
const tree = (
roots: DiagramNode[],
extra: Partial<DiagramTree> = {},
): DiagramTree => ({
roots,
links: [],
foreign: [],
...extra,
})
/** Structural signature: nesting, kinds, directions and order — everything but coordinates. */
function signature(t: DiagramTree): string {
const line = (n: DiagramNode, depth: number): string[] => {
const pad = " ".repeat(depth)
if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`]
const meta = n.kind === "grid" ? `cols=${n.cols}` : `dir=${n.dir}`
return [
`${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`,
...n.children.flatMap((c) => line(c, depth + 1)),
]
}
return t.roots.flatMap((r) => line(r, 0)).join("\n")
}
/** Render then parse, returning the recovered tree. */
function roundTrip(t: DiagramTree) {
const { xml } = renderDiagram(t)
return { xml, ...parseDiagram(xml) }
}
describe("structure survives a round-trip", () => {
it("recovers a flat row", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "Frame")])
expect(signature(roundTrip(t).tree)).toBe(signature(t))
})
it("recovers a column", () => {
const t = tree([group("f", "col", [icon("a"), icon("b")], "Frame")])
expect(signature(roundTrip(t).tree)).toBe(signature(t))
})
it("recovers a grid with its column count", () => {
const t = tree([
grid("g", 3, [icon("a"), icon("b"), icon("c"), icon("d")], "G"),
])
expect(signature(roundTrip(t).tree)).toBe(signature(t))
})
it("recovers a deep cloud-architecture nesting", () => {
const t = tree([
group(
"region",
"row",
[
group(
"vpc",
"col",
[
icon("igw", "internet_gateway", "IGW"),
group(
"az_a",
"col",
[
group(
"pub_a",
"col",
[icon("nat_a", "nat_gateway", "NAT")],
"Public Subnet",
"group_subnet",
),
group(
"app_a",
"col",
[icon("ec2_a", "ec2", "EC2")],
"Private Subnet",
"group_subnet",
),
],
"AZ-a",
"group_availability_zone",
),
],
"VPC",
"group_vpc",
),
],
"Region",
"group_region",
),
])
expect(signature(roundTrip(t).tree)).toBe(signature(t))
})
it("recovers several roots in order", () => {
const t = tree([
box("users", "Users"),
group("cloud", "row", [icon("a")], "Cloud"),
box("consumers", "Consumers"),
])
const back = roundTrip(t).tree
expect(back.roots.map((r) => r.id)).toEqual([
"users",
"cloud",
"consumers",
])
})
it("recovers the direction of an UNLABELLED wrapper — the phantom problem, fixed", () => {
// The reference project would use a phantom here, which emits no cell: its two
// children would be reparented onto vpc, and the wrapper's "row" direction would
// be gone from the XML for good. We emit a real but invisible cell instead.
const t = tree([
group(
"vpc",
"col",
[
icon("igw", "internet_gateway", "IGW"),
group(
"azs",
"row",
[
group("az_a", "col", [icon("ec2_a")], "AZ-a"),
group("az_b", "col", [icon("ec2_b")], "AZ-b"),
],
"", // no label — a layout-only wrapper
),
],
"VPC",
"group_vpc",
),
])
const back = roundTrip(t).tree
// the wrapper is still there, still a row, still holding both AZs
const azs = findNode(back, "azs")
expect(azs).not.toBeNull()
expect((azs as GroupNode).dir).toBe("row")
expect(findParent(back, "az_a")?.id).toBe("azs")
expect(findParent(back, "azs")?.id).toBe("vpc")
// and vpc kept its own direction instead of collapsing into a grid
expect((findNode(back, "vpc") as GroupNode).dir).toBe("col")
expect(signature(back)).toBe(signature(t))
})
it("keeps the wrapper invisible", () => {
const t = tree([
group("w", "row", [icon("a"), icon("b")], ""), // unlabelled → invisible
])
const { xml } = renderDiagram(t)
const cell = xml.match(/<mxCell id="w"[^>]*style="([^"]*)"/)?.[1] ?? ""
expect(cell).toContain("fillColor=none")
expect(cell).toContain("strokeColor=none")
// ...but it is still a real container, so draw.io reparents into it
expect(cell).toContain("container=1")
})
it("keeps a labelled frame visible", () => {
const t = tree([group("f", "row", [icon("a")], "Visible")])
const { xml } = renderDiagram(t)
const style = xml.match(/<mxCell id="f"[^>]*style="([^"]*)"/)?.[1] ?? ""
expect(style).not.toContain("strokeColor=none")
})
it("survives repeated round-trips without drift", () => {
const t = tree([
group(
"outer",
"row",
[
group("a", "col", [icon("x"), icon("y")], "A"),
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
],
"Outer",
),
])
const once = roundTrip(t).tree
const twice = roundTrip(once).tree
const thrice = roundTrip(twice).tree
expect(signature(twice)).toBe(signature(once))
expect(signature(thrice)).toBe(signature(once))
})
it("keeps geometry stable across repeated round-trips", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")])
const first = renderDiagram(t)
const second = renderDiagram(parseDiagram(first.xml).tree)
expect(second.page).toEqual(first.page)
})
})
describe("labels and content survive", () => {
it("recovers labels on containers and leaves", () => {
const t = tree([
group(
"f",
"row",
[icon("a", "s3", "My Bucket"), box("b", "A Box")],
"My Frame",
),
])
const back = roundTrip(t).tree
expect((findNode(back, "f") as ContainerNode).label).toBe("My Frame")
expect((findNode(back, "a") as IconNode).label).toBe("My Bucket")
expect((findNode(back, "b") as BoxNode).label).toBe("A Box")
})
it("recovers a label containing XML metacharacters", () => {
const nasty = "A & B <tag> \"quoted\" 'apostrophe'"
const t = tree([group("f", "row", [box("b", nasty)], nasty)])
const back = roundTrip(t).tree
expect((findNode(back, "b") as BoxNode).label).toBe(nasty)
expect((findNode(back, "f") as ContainerNode).label).toBe(nasty)
})
it("recovers the page title", () => {
const t = tree([group("f", "row", [icon("a")], "F")], {
title: "My Architecture",
})
expect(roundTrip(t).tree.title).toBe("My Architecture")
})
it("recovers a gap that is not the default", () => {
const t = tree([
group("f", "row", [icon("a"), icon("b")], "F", null, 47),
])
expect((findNode(roundTrip(t).tree, "f") as GroupNode).gap).toBe(47)
})
it("recovers an icon's catalog name", () => {
const t = tree([
group(
"f",
"row",
[icon("a", "nat_gateway"), icon("b", "rds")],
"F",
),
])
const back = roundTrip(t).tree
expect((findNode(back, "a") as IconNode).name).toBe("nat_gateway")
expect((findNode(back, "b") as IconNode).name).toBe("rds")
})
it("recovers a group stencil name", () => {
const t = tree([group("v", "col", [icon("a")], "VPC", "group_vpc")])
const { xml } = renderDiagram(t, {
resolveStyle: (name, kind) =>
kind === "group"
? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;`
: null,
})
expect(
(findNode(parseDiagram(xml).tree, "v") as ContainerNode).gname,
).toBe("group_vpc")
})
})
describe("edges survive", () => {
it("recovers source, target and label", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
links: [{ source: "a", target: "b", label: "flows to" }],
})
const back = roundTrip(t).tree
expect(back.links).toHaveLength(1)
expect(back.links[0].source).toBe("a")
expect(back.links[0].target).toBe("b")
expect(back.links[0].label).toBe("flows to")
})
it("recovers a step number and keeps it out of the label", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
links: [{ source: "a", target: "b", label: "HTTPS", step: 1 }],
})
const back = roundTrip(t).tree
expect(back.links[0].step).toBe(1)
expect(back.links[0].label).toBe("HTTPS")
})
it("recovers a dashed edge", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
links: [{ source: "a", target: "b", dashed: true }],
})
expect(roundTrip(t).tree.links[0].dashed).toBe(true)
})
it("drops an edge with a missing endpoint and reports it", () => {
const t = tree([group("f", "row", [icon("a")], "F")], {
links: [{ source: "a", target: "ghost" }],
})
const r = renderDiagram(t)
expect(r.danglingLinks).toEqual(["ghost"])
expect(r.xml).not.toContain('target="ghost"')
})
it("emits no waypoints, so draw.io re-routes when the user moves a node", () => {
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
links: [{ source: "a", target: "b", label: "x" }],
})
expect(renderDiagram(t).xml).not.toContain('as="points"')
})
})
describe("foreign cells survive", () => {
it("re-emits an unrecognised cell verbatim", () => {
const custom =
'<mxCell id="note" value="hand-written note" style="shape=note;whiteSpace=wrap;html=1;fillColor=#FFF2CC;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="160" height="80" as="geometry"/></mxCell>'
const t = tree([group("f", "row", [icon("a")], "F")], {
foreign: [{ id: "note", xml: custom, parent: "1" }],
})
expect(renderDiagram(t).xml).toContain(custom)
})
it("re-creates the boundaries layer when a foreign cell needs it", () => {
const t = tree([group("f", "row", [icon("a")], "F")], {
foreign: [
{
id: "cluster",
xml: '<mxCell id="cluster" value="EKS" style="dashed=1;fillColor=none;" vertex="1" parent="boundaries"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell>',
parent: "boundaries",
},
],
})
const { xml } = renderDiagram(t)
expect(xml).toContain('<mxCell id="boundaries"')
expect(xml.indexOf('id="boundaries"')).toBeLessThan(
xml.indexOf('parent="boundaries"'),
)
})
it("lets an edge anchor to a foreign cell", () => {
const t = tree([group("f", "row", [icon("a")], "F")], {
foreign: [
{
id: "legend",
xml: '<mxCell id="legend" value="L" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="50" height="50" as="geometry"/></mxCell>',
parent: "1",
},
],
links: [{ source: "a", target: "legend" }],
})
const r = renderDiagram(t)
expect(r.danglingLinks).toEqual([])
expect(r.xml).toContain('target="legend"')
})
})
describe("the emitted XML is well formed for draw.io", () => {
const t = tree(
[
group(
"region",
"row",
[
group(
"vpc",
"col",
[icon("a"), icon("b")],
"VPC",
"group_vpc",
),
],
"Region",
"group_region",
),
],
{ title: "T", links: [{ source: "a", target: "b" }] },
)
const { xml } = renderDiagram(t)
it("wraps the model in mxfile/diagram", () => {
expect(xml.startsWith("<mxfile")).toBe(true)
expect(xml).toContain("<diagram")
expect(xml).toContain("<mxGraphModel")
})
it("includes the two root cells draw.io requires", () => {
expect(xml).toContain('<mxCell id="0"/>')
expect(xml).toContain('<mxCell id="1" parent="0"/>')
})
it("declares a parent before any cell that references it", () => {
expect(xml.indexOf('id="region"')).toBeLessThan(
xml.indexOf('parent="region"'),
)
expect(xml.indexOf('id="vpc"')).toBeLessThan(
xml.indexOf('parent="vpc"'),
)
})
it("gives every cell a unique id", () => {
const ids = [...xml.matchAll(/<mxCell id="([^"]+)"/g)].map((m) => m[1])
expect(new Set(ids).size).toBe(ids.length)
})
it("sets the page size from the content", () => {
const w = Number(xml.match(/pageWidth="(\d+)"/)?.[1])
const h = Number(xml.match(/pageHeight="(\d+)"/)?.[1])
expect(w).toBeGreaterThan(0)
expect(h).toBeGreaterThan(0)
})
it("writes nested geometry relative to the parent, as draw.io expects", () => {
// vpc sits inside region, so its x must be small — an absolute x would push it
// outside the frame when draw.io adds the parent offset.
const vpcGeo = xml.match(
/<mxCell id="vpc"[^>]*>\s*<mxGeometry x="(-?\d+)"/,
)?.[1]
expect(Number(vpcGeo)).toBeLessThan(100)
})
it("stamps container=1 on containers so drag-and-drop reparents correctly", () => {
// Verified in-browser: without this, a shape dragged into the frame keeps
// parent="1" and the nesting is lost.
const regionStyle =
xml.match(/<mxCell id="region"[^>]*style="([^"]*)"/)?.[1] ?? ""
expect(regionStyle).toContain("container=1")
})
it("does not stamp container=1 on a leaf", () => {
const iconStyle =
xml.match(/<mxCell id="a"[^>]*style="([^"]*)"/)?.[1] ?? ""
expect(iconStyle).not.toContain("container=1")
})
})
describe("user edits are inputs, not conflicts", () => {
it("keeps a hand-changed fill through a re-layout", () => {
// The user recoloured a box in draw.io. Re-deriving the tree picks up the style
// verbatim, so re-rendering preserves the colour rather than resetting it.
const t = tree([group("f", "row", [icon("a"), box("b", "Box")], "F")])
const first = renderDiagram(t).xml
const edited = first.replace(
/(<mxCell id="b"[^>]*style=")([^"]*)"/,
'$1$2fillColor=#FF0000;"',
)
const back = parseDiagram(edited).tree
expect(renderDiagram(back).xml).toContain("fillColor=#FF0000")
})
it("re-lays-out around a node the user dragged into a different frame", () => {
// Two frames; the user moves icon "b" from f1 into f2. draw.io rewrites the
// parent attribute (container=1 is in place), so the next layout puts it inside
// f2 — no reconciliation step, the canvas simply says where things are.
const t = tree([
group(
"root",
"row",
[
group("f1", "col", [icon("a"), icon("b")], "F1"),
group("f2", "col", [icon("c")], "F2"),
],
"Root",
),
])
const first = renderDiagram(t).xml
const moved = first.replace(
/(<mxCell id="b"[^>]*)parent="f1"/,
'$1parent="f2"',
)
const back = parseDiagram(moved).tree
expect(findParent(back, "b")?.id).toBe("f2")
// and the re-render keeps it there, sized to fit
const again = parseDiagram(renderDiagram(back).xml).tree
expect(findParent(again, "b")?.id).toBe("f2")
})
it("honours a pin the user added by hand in Edit Style", () => {
const t = tree([box("pin", "Pinned"), box("flow", "Flow")])
const first = renderDiagram(t).xml
const pinned = first.replace(
/(<mxCell id="pin"[^>]*style="[^"]*)"/,
'$1dai_pin=1;"',
)
const back = parseDiagram(pinned).tree
const node = findNode(back, "pin") as BoxNode
expect(node.pinned).toBe(true)
const pos = node.rect
// re-rendering leaves the pinned node exactly where it was
const again = parseDiagram(renderDiagram(back).xml).tree
expect((findNode(again, "pin") as BoxNode).rect).toEqual(pos)
})
it("does not lose a cell the user added by hand", () => {
const t = tree([group("f", "row", [icon("a")], "F")])
const first = renderDiagram(t).xml
// user drops a new shape on the canvas
const withNew = first.replace(
"</root>",
'<mxCell id="userbox" value="Mine" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="800" y="400" width="120" height="60" as="geometry"/></mxCell></root>',
)
const back = parseDiagram(withNew).tree
const ids = [...walkTree(back)].map((n) => n.id)
expect(ids).toContain("userbox")
expect(renderDiagram(back).xml).toContain('id="userbox"')
})
})