Files
next-ai-draw-io/tests/unit/diagram-engine-pool.test.ts
dayuan.jiang a3814f702d feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.

Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.

graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.

Three new container kinds, each because one layout rule cannot serve them all:

  pool     — swimlanes. Lanes are real cells and each step is parented to its
             band, so dragging a step to another role records the change.
  sequence — participants across the top, one lifeline cell per participant so
             head and line stay together on a drag. Messages bypass the router:
             a message's height IS its order.
  radial   — mind maps and org charts. Children are a flat list and the
             hierarchy comes from the links, because a branch is a box and a box
             cannot hold children.

Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.

Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.

523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:49:11 +09:00

329 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { PoolNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
parentOf,
rectOf,
} from "./fixtures/geometry"
/** An expense-approval swimlane: three roles, five steps, three milestone labels. */
const EXPENSE: Operation[] = [
{ op: "set_title", title: "Expense Approval" },
{
op: "add_pool",
id: "p",
label: "Expense claim",
lanes: ["Employee", "Manager", "Finance"],
phases: ["Submit", "Review", "Pay"],
},
{
op: "add_box",
id: "fill",
parent: "p",
label: "Fill form",
lane: 0,
col: 0,
shape: "terminator",
},
{
op: "add_box",
id: "send",
parent: "p",
label: "Submit claim",
lane: 0,
col: 1,
},
{ op: "add_box", id: "rev", parent: "p", label: "Review", lane: 1, col: 2 },
{
op: "add_box",
id: "ok",
parent: "p",
label: "Approved?",
lane: 1,
col: 3,
shape: "decision",
},
{
op: "add_box",
id: "pay",
parent: "p",
label: "Pay out",
lane: 2,
col: 4,
},
{ op: "link", source: "fill", target: "send" },
{ op: "link", source: "send", target: "rev" },
{ op: "link", source: "rev", target: "ok" },
{ op: "link", source: "ok", target: "pay", label: "yes" },
]
const STEPS = ["fill", "send", "rev", "ok", "pay"]
describe("swimlane pool: layout", () => {
const result = restructureDiagram("", EXPENSE)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("stacks the lanes in the order they were declared", () => {
expect(rectOf(rects, "fill").y).toBeLessThan(rectOf(rects, "rev").y)
expect(rectOf(rects, "rev").y).toBeLessThan(rectOf(rects, "pay").y)
})
it("advances the columns left to right", () => {
expect(rectOf(rects, "fill").x).toBeLessThan(rectOf(rects, "send").x)
expect(rectOf(rects, "send").x).toBeLessThan(rectOf(rects, "rev").x)
expect(rectOf(rects, "rev").x).toBeLessThan(rectOf(rects, "ok").x)
})
it("puts two steps with the same column at the same x", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A", "B"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 1,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 1,
},
])
const rr = absoluteRects(r.xml as string)
expect(rectOf(rr, "x").x).toBe(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).not.toBe(rectOf(rr, "y").y)
})
it("draws one band per lane, with the role names beside them", () => {
expect([...xml.matchAll(/id="p__band(\d)"/g)].map((m) => m[1])).toEqual(
["0", "1", "2"],
)
expect(
[...xml.matchAll(/id="p__lane\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Employee", "Manager", "Finance"])
})
it("draws the milestone labels", () => {
expect(
[...xml.matchAll(/id="p__phase\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Submit", "Review", "Pay"])
})
it("omits the milestone band when no phases were given", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(r.xml).not.toContain("p__phase")
})
it("parents each step to its lane band, not to the pool", () => {
// This is what records a role change when the user drags a step to another lane:
// draw.io rewrites `parent` to the band it was dropped on.
expect(parentOf(xml, "fill")).toBe("p__band0")
expect(parentOf(xml, "rev")).toBe("p__band1")
expect(parentOf(xml, "pay")).toBe("p__band2")
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
expect(overlaps(rects, STEPS)).toEqual([])
})
it("clamps a lane index past the last lane instead of drawing off the pool", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["only"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 9,
col: 0,
},
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("lays a vertical pool out with the lanes as columns", () => {
const r = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 0,
},
{
op: "add_box",
id: "z",
parent: "p",
label: "Z",
lane: 0,
col: 1,
},
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
// Lanes side by side, the flow running downwards.
expect(rectOf(rr, "x").x).toBeLessThan(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).toBeLessThan(rectOf(rr, "z").y)
expect(escapesParent(r.xml as string)).toEqual([])
})
it("refuses a pool with no lanes", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "x", lanes: [] },
])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("at least one lane")
})
})
describe("swimlane pool: round-trip", () => {
it("comes back with the same lanes, phases and cells", () => {
const first = restructureDiagram("", EXPENSE)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const pool = findNode(tree, "p") as PoolNode
expect(pool.kind).toBe("pool")
expect(pool.lanes).toEqual(["Employee", "Manager", "Finance"])
expect(pool.phases).toEqual(["Submit", "Review", "Pay"])
expect(pool.orientation).toBe("horizontal")
expect(pool.children.map((c) => c.id)).toEqual(STEPS)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of STEPS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not accumulate lane bands across round-trips", () => {
// The bands are chrome the renderer rebuilds. Preserving them verbatim would leave a
// stale set behind the new ones on every pass.
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const count = (xml: string) => (xml.match(/__band\d/g) ?? []).length
expect(count(third.xml as string)).toBe(count(first.xml as string))
})
it("reads a step's new lane from the band the user dropped it on", () => {
const first = restructureDiagram("", EXPENSE)
// Simulate the drag: draw.io rewrites the cell's parent to the new band.
const moved = (first.xml as string).replace(
/(<mxCell id="pay"[^>]*parent=")p__band2(")/,
"$1p__band0$2",
)
expect(moved).not.toBe(first.xml)
const pool = findNode(parseDiagram(moved).tree, "p") as PoolNode
const pay = pool.children.find((c) => c.id === "pay")
expect(pay).toBeDefined()
// The band wins over the stale marker still on the cell.
expect((pay as { cell?: { lane: number } }).cell?.lane).toBe(0)
})
it("keeps a vertical pool vertical", () => {
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "V",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 1,
col: 0,
},
])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.orientation).toBe("vertical")
expect(pool.lanes).toEqual(["A", "B"])
})
it("survives a lane name containing a semicolon or an equals sign", () => {
// Those two characters delimit a draw.io style string, so a naive marker would break
// the whole cell.
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["a;b", "c=d", "plain"],
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(first.errors).toEqual([])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.lanes).toEqual(["a;b", "c=d", "plain"])
})
})