mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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.
287 lines
12 KiB
TypeScript
287 lines
12 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 { RadialNode } from "@/lib/diagram-engine/types"
|
|
import { findNode } from "@/lib/diagram-engine/types"
|
|
import {
|
|
absoluteRects,
|
|
escapesParent,
|
|
outsidePage,
|
|
overlaps,
|
|
rectOf,
|
|
} from "./fixtures/geometry"
|
|
|
|
/** A mind map. Children are a FLAT list; the links carry the hierarchy. */
|
|
const MINDMAP: Operation[] = [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
{ op: "add_box", id: "root", parent: "m", label: "Product Launch" },
|
|
{ op: "add_box", id: "eng", parent: "m", label: "Engineering" },
|
|
{ op: "add_box", id: "api", parent: "m", label: "API" },
|
|
{ op: "add_box", id: "ui", parent: "m", label: "UI" },
|
|
{ op: "add_box", id: "mkt", parent: "m", label: "Marketing" },
|
|
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
|
|
{ op: "add_box", id: "ops", parent: "m", label: "Ops" },
|
|
{ op: "add_box", id: "sre", parent: "m", label: "SRE" },
|
|
{ op: "link", source: "root", target: "eng" },
|
|
{ op: "link", source: "root", target: "mkt" },
|
|
{ op: "link", source: "root", target: "legal" },
|
|
{ op: "link", source: "root", target: "ops" },
|
|
{ op: "link", source: "eng", target: "api" },
|
|
{ op: "link", source: "eng", target: "ui" },
|
|
{ op: "link", source: "ops", target: "sre" },
|
|
]
|
|
|
|
const MIND_IDS = ["root", "eng", "api", "ui", "mkt", "legal", "ops", "sre"]
|
|
|
|
const ORGCHART: Operation[] = [
|
|
{ op: "add_radial", id: "o", label: "", spread: "down" },
|
|
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
|
|
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
|
|
{ op: "add_box", id: "eng1", parent: "o", label: "Platform Lead" },
|
|
{ op: "add_box", id: "eng2", parent: "o", label: "Mobile Lead" },
|
|
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
|
|
{ op: "add_box", id: "acct", parent: "o", label: "Accounting" },
|
|
{ op: "link", source: "ceo", target: "cto" },
|
|
{ op: "link", source: "ceo", target: "cfo" },
|
|
{ op: "link", source: "cto", target: "eng1" },
|
|
{ op: "link", source: "cto", target: "eng2" },
|
|
{ op: "link", source: "cfo", target: "acct" },
|
|
]
|
|
|
|
const ORG_IDS = ["ceo", "cto", "eng1", "eng2", "cfo", "acct"]
|
|
|
|
describe("mind map: radial spread", () => {
|
|
const result = restructureDiagram("", MINDMAP)
|
|
const xml = result.xml as string
|
|
const rects = absoluteRects(xml)
|
|
|
|
it("builds without errors", () => {
|
|
expect(result.errors).toEqual([])
|
|
})
|
|
|
|
it("makes the node nothing points at the centre", () => {
|
|
const centre = rectOf(rects, "root")
|
|
const mid = centre.x + centre.w / 2
|
|
const sides = ["eng", "mkt", "legal", "ops"].map((id) =>
|
|
rectOf(rects, id).x > mid ? "right" : "left",
|
|
)
|
|
// Branches on both sides, which is what keeps a mind map compact.
|
|
expect(sides).toContain("right")
|
|
expect(sides).toContain("left")
|
|
})
|
|
|
|
it("puts a sub-branch further from the centre than its parent", () => {
|
|
const centre = rectOf(rects, "root")
|
|
const mid = centre.x + centre.w / 2
|
|
const far = (id: string) =>
|
|
Math.abs(rectOf(rects, id).x + rectOf(rects, id).w / 2 - mid)
|
|
expect(far("api")).toBeGreaterThan(far("eng"))
|
|
expect(far("ui")).toBeGreaterThan(far("eng"))
|
|
expect(far("sre")).toBeGreaterThan(far("ops"))
|
|
})
|
|
|
|
it("puts siblings of the same generation at the same distance out", () => {
|
|
expect(rectOf(rects, "api").x).toBe(rectOf(rects, "ui").x)
|
|
})
|
|
|
|
it("draws nothing on top of anything else", () => {
|
|
expect(overlaps(rects, MIND_IDS)).toEqual([])
|
|
})
|
|
|
|
it("keeps everything inside the page and inside the frame", () => {
|
|
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
expect(escapesParent(xml)).toEqual([])
|
|
})
|
|
|
|
it("fits a map whose two sides are different depths", () => {
|
|
// Reserving the same room on both sides would push the deeper side off the page.
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
{ op: "add_box", id: "root", parent: "m", label: "Root" },
|
|
{ op: "add_box", id: "shallow", parent: "m", label: "A" },
|
|
{ op: "add_box", id: "b", parent: "m", label: "B" },
|
|
{ op: "add_box", id: "b1", parent: "m", label: "B1" },
|
|
{ op: "add_box", id: "b2", parent: "m", label: "B2" },
|
|
{ op: "add_box", id: "b3", parent: "m", label: "B3" },
|
|
{ op: "link", source: "root", target: "shallow" },
|
|
{ op: "link", source: "root", target: "b" },
|
|
{ op: "link", source: "b", target: "b1" },
|
|
{ op: "link", source: "b1", target: "b2" },
|
|
{ op: "link", source: "b2", target: "b3" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
expect(escapesParent(r.xml as string)).toEqual([])
|
|
})
|
|
|
|
it("still draws a node no arrow reaches", () => {
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
{ op: "add_box", id: "root", parent: "m", label: "Root" },
|
|
{ op: "add_box", id: "a", parent: "m", label: "A" },
|
|
{ op: "add_box", id: "orphan", parent: "m", label: "Orphan" },
|
|
{ op: "link", source: "root", target: "a" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
const rr = absoluteRects(r.xml as string)
|
|
expect(rr.has("orphan")).toBe(true)
|
|
expect(overlaps(rr, ["root", "a", "orphan"])).toEqual([])
|
|
expect(escapesParent(r.xml as string)).toEqual([])
|
|
})
|
|
|
|
it("survives arrows that form a cycle", () => {
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
{ op: "add_box", id: "x", parent: "m", label: "X" },
|
|
{ op: "add_box", id: "y", parent: "m", label: "Y" },
|
|
{ op: "add_box", id: "z", parent: "m", label: "Z" },
|
|
{ op: "link", source: "x", target: "y" },
|
|
{ op: "link", source: "y", target: "z" },
|
|
{ op: "link", source: "z", target: "x" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
expect(
|
|
overlaps(absoluteRects(r.xml as string), ["x", "y", "z"]),
|
|
).toEqual([])
|
|
expect(escapesParent(r.xml as string)).toEqual([])
|
|
})
|
|
|
|
it("handles a radial container holding one node", () => {
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
{ op: "add_box", id: "only", parent: "m", label: "Only" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
expect(escapesParent(r.xml as string)).toEqual([])
|
|
})
|
|
|
|
it("handles an empty radial container", () => {
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
expect(r.xml).toContain('id="m"')
|
|
})
|
|
})
|
|
|
|
describe("org chart: downward spread", () => {
|
|
const result = restructureDiagram("", ORGCHART)
|
|
const xml = result.xml as string
|
|
const rects = absoluteRects(xml)
|
|
|
|
it("builds without errors", () => {
|
|
expect(result.errors).toEqual([])
|
|
})
|
|
|
|
it("hangs every level strictly below the one above", () => {
|
|
// A reporting line only reads correctly downwards, which is the whole reason this
|
|
// spread exists separately from the radial one.
|
|
expect(rectOf(rects, "ceo").y).toBeLessThan(rectOf(rects, "cto").y)
|
|
expect(rectOf(rects, "cto").y).toBeLessThan(rectOf(rects, "eng1").y)
|
|
expect(rectOf(rects, "cfo").y).toBeLessThan(rectOf(rects, "acct").y)
|
|
})
|
|
|
|
it("puts peers on the same line", () => {
|
|
expect(rectOf(rects, "cto").y).toBe(rectOf(rects, "cfo").y)
|
|
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "eng2").y)
|
|
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "acct").y)
|
|
})
|
|
|
|
it("keeps one manager's reports clear of another's", () => {
|
|
// Sizing each slice by its whole subtree, not by the number of direct reports, is what
|
|
// stops a manager with two reports from overrunning the next manager's column.
|
|
expect(overlaps(rects, ORG_IDS)).toEqual([])
|
|
const eng2 = rectOf(rects, "eng2")
|
|
expect(eng2.x + eng2.w).toBeLessThanOrEqual(rectOf(rects, "acct").x)
|
|
})
|
|
|
|
it("keeps everything inside the page and inside the frame", () => {
|
|
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
expect(escapesParent(xml)).toEqual([])
|
|
})
|
|
|
|
it("fits a chain five levels deep", () => {
|
|
const r = restructureDiagram("", [
|
|
{ op: "add_radial", id: "o", label: "", spread: "down" },
|
|
...["a", "b", "c", "d", "e"].map(
|
|
(id) =>
|
|
({
|
|
op: "add_box",
|
|
id,
|
|
parent: "o",
|
|
label: id.toUpperCase(),
|
|
}) as Operation,
|
|
),
|
|
{ op: "link", source: "a", target: "b" },
|
|
{ op: "link", source: "b", target: "c" },
|
|
{ op: "link", source: "c", target: "d" },
|
|
{ op: "link", source: "d", target: "e" },
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
const rr = absoluteRects(r.xml as string)
|
|
const ys = ["a", "b", "c", "d", "e"].map((id) => rectOf(rr, id).y)
|
|
expect(ys).toEqual([...ys].sort((x, y) => x - y))
|
|
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("radial: round-trip", () => {
|
|
it("comes back as a radial container with the same spread", () => {
|
|
const first = restructureDiagram("", MINDMAP)
|
|
const { tree, warnings } = parseDiagram(first.xml as string)
|
|
expect(warnings).toEqual([])
|
|
const radial = findNode(tree, "m") as RadialNode
|
|
expect(radial.kind).toBe("radial")
|
|
expect(radial.spread).toBe("radial")
|
|
expect(radial.children.map((c) => c.id).sort()).toEqual(
|
|
[...MIND_IDS].sort(),
|
|
)
|
|
})
|
|
|
|
it("keeps an org chart pointing downwards", () => {
|
|
const first = restructureDiagram("", ORGCHART)
|
|
const radial = findNode(
|
|
parseDiagram(first.xml as string).tree,
|
|
"o",
|
|
) as RadialNode
|
|
expect(radial.spread).toBe("down")
|
|
})
|
|
|
|
it("reaches a fixed point: a mind map does not drift on re-layout", () => {
|
|
const first = restructureDiagram("", MINDMAP)
|
|
const second = restructureDiagram(first.xml as string, [])
|
|
const third = restructureDiagram(second.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)
|
|
const c = absoluteRects(third.xml as string)
|
|
for (const id of MIND_IDS) {
|
|
expect(b.get(id)).toEqual(a.get(id))
|
|
expect(c.get(id)).toEqual(a.get(id))
|
|
}
|
|
})
|
|
|
|
it("reaches a fixed point: an org chart does not drift on re-layout", () => {
|
|
const first = restructureDiagram("", ORGCHART)
|
|
const second = restructureDiagram(first.xml as string, [])
|
|
const third = restructureDiagram(second.xml as string, [])
|
|
const a = absoluteRects(first.xml as string)
|
|
const c = absoluteRects(third.xml as string)
|
|
for (const id of ORG_IDS) expect(c.get(id)).toEqual(a.get(id))
|
|
})
|
|
|
|
it("adds a branch to an existing map without redrawing it", () => {
|
|
const first = restructureDiagram("", MINDMAP)
|
|
const second = restructureDiagram(first.xml as string, [
|
|
{ op: "add_box", id: "docs", parent: "m", label: "Docs" },
|
|
{ op: "link", source: "root", target: "docs" },
|
|
])
|
|
expect(second.errors).toEqual([])
|
|
const rects = absoluteRects(second.xml as string)
|
|
expect(rects.has("docs")).toBe(true)
|
|
expect(overlaps(rects, [...MIND_IDS, "docs"])).toEqual([])
|
|
expect(escapesParent(second.xml as string)).toEqual([])
|
|
})
|
|
})
|