Files
next-ai-draw-io/tests/unit/diagram-engine-graph.test.ts
dayuan.jiang 6b5fd613f2 feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:

1. Edge labels sat on boxes and on each other (4 collisions on the
   reported diagram; 280 across 250 generated flowcharts). The router
   keeps LINES off the boxes but a label renders at its edge's midpoint,
   which on a long edge is beside exactly the things the line was routed
   around. placeLabels slides each label along its own edge to a clear
   spot — longest edges first, midpoint-outward tries — written as the
   geometry's relative x, which draw.io natively supports. Corpus: 280
   label collisions -> 8.

2. A->B and B->A were routed independently, so "git add" ran straight
   while "git reset" wandered through a different corridor with a kink.
   Opposite edges that agree on axis now get two absolute parallel tracks
   in the strip where the two boxes overlap, a constant 24px apart,
   converted back to port fractions. Zero crossing regressions.

3. All boxes rendered the same white, because the render layer's
   fill/stroke support was never reachable: neither add_box's schema nor
   draw_graph's nodes exposed it. Rather than exposing raw hex (the model
   picks mismatched saturations, differently every time), nodes take a
   semantic group name and the engine maps groups to a fixed palette of
   six paired fill/strokes in order of first appearance. The model names
   the zones - remote vs local vs temp - and never touches a colour.

532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00

677 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest"
import {
drawGraph,
type GraphEdge,
type GraphNode,
graphToOperations,
restructureDiagram,
} from "@/lib/diagram-engine"
import {
absoluteRects,
edgePaths,
nodeCollisions,
rectOf,
} from "./fixtures/geometry"
const n = (id: string, label = id): GraphNode => ({ id, label })
const e = (source: string, target: string, label?: string): GraphEdge => ({
source,
target,
...(label ? { label } : {}),
})
describe("graphToOperations: layering", () => {
it("puts a chain in one node per layer", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c")],
)
expect(layers).toEqual([["a"], ["b"], ["c"]])
})
it("puts the branches of a decision in the same layer", () => {
const { layers } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
expect(layers[0]).toEqual(["q"])
expect(layers[1].sort()).toEqual(["no", "yes"])
})
it("uses the LONGEST path, so no arrow points sideways", () => {
// a→b, a→c, c→b. The shortest path would put b in layer 1 beside c, leaving c→b
// pointing sideways. b has to come after c.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("a", "c"), e("c", "b")],
)
expect(layers).toEqual([["a"], ["c"], ["b"]])
})
it("keeps a node with no arrows at all", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("island")],
[e("a", "b")],
)
expect(layers.flat().sort()).toEqual(["a", "b", "island"])
})
it("draws a loop but does not let it set the layering", () => {
const r = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c"), e("c", "a")],
)
expect(r.layers).toEqual([["a"], ["b"], ["c"]])
expect(r.backEdges).toEqual([{ source: "c", target: "a" }])
// The loop is still drawn.
expect(r.operations.filter((o) => o.op === "link").length).toBe(3)
})
it("draws a self-loop and keeps it out of the layering", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("a", "a")],
)
expect(r.layers).toEqual([["a"], ["b"]])
expect(r.operations.filter((o) => o.op === "link").length).toBe(2)
})
it("reports an edge naming a node that does not exist", () => {
const r = graphToOperations([n("a")], [e("a", "ghost")])
expect(r.unknownEndpoints).toEqual(["ghost"])
expect(r.operations.filter((o) => o.op === "link")).toEqual([])
})
it("survives a graph that is nothing but a cycle", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("b", "a")],
)
expect(r.layers.flat().sort()).toEqual(["a", "b"])
})
})
describe("graphToOperations: within-layer ordering", () => {
it("reverses a layer when that removes the crossings", () => {
// a→z, b→y, c→x. Declared order would make all three cross.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c"), n("x"), n("y"), n("z")],
[e("a", "z"), e("b", "y"), e("c", "x")],
)
expect(layers[0]).toEqual(["a", "b", "c"])
expect(layers[1]).toEqual(["z", "y", "x"])
})
it("leaves an already-good order alone", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("x"), n("y")],
[e("a", "x"), e("b", "y")],
)
expect(layers[1]).toEqual(["x", "y"])
})
})
describe("graphToOperations: emitted operations", () => {
it("does not wrap a layer holding one node", () => {
const { operations } = graphToOperations(
[n("a"), n("b")],
[e("a", "b")],
)
const containers = operations.filter((o) => o.op === "add_container")
// Only the outer flow container: neither single-node layer needs a wrapper.
expect(containers.length).toBe(1)
})
it("wraps a layer holding several nodes", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.length).toBe(2)
// The layer band runs ACROSS the flow.
const band = containers.find((c) => c.id !== "__layers")
expect(band?.dir).toBe("row")
})
it("flips both axes when the flow runs left to right", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
{ flow: "row" },
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.find((c) => c.id === "__layers")?.dir).toBe("row")
expect(containers.find((c) => c.id !== "__layers")?.dir).toBe("col")
})
it("carries shapes and labels through", () => {
const { operations } = graphToOperations(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
],
[e("s", "q", "go")],
)
const boxes = operations.filter((o) => o.op === "add_box")
expect(boxes.map((b) => b.shape)).toEqual(["terminator", "decision"])
expect(operations.find((o) => o.op === "link")?.label).toBe("go")
})
it("emits an icon node as an icon", () => {
const { operations } = graphToOperations(
[{ id: "s3", label: "Bucket", icon: "s3" }],
[],
)
const icon = operations.find((o) => o.op === "add_icon")
expect(icon).toMatchObject({ id: "s3", name: "s3", label: "Bucket" })
})
it("does not emit a plain box shape as an explicit shape", () => {
const { operations } = graphToOperations(
[{ id: "a", label: "A", shape: "box" }],
[],
)
expect(operations.find((o) => o.op === "add_box")).not.toHaveProperty(
"shape",
)
})
})
describe("drawGraph: the whole pipeline", () => {
it("draws a decision flow with no arrow hitting an unrelated box", () => {
const ids = ["start", "check", "mgr", "auto", "ship", "reject"]
const r = drawGraph(
[
{ id: "start", label: "Order received", shape: "terminator" },
{ id: "check", label: "Amount > $1000?", shape: "decision" },
n("mgr", "Manager approval"),
n("auto", "Auto-approve"),
n("ship", "Ship order"),
{ id: "reject", label: "Reject", shape: "terminator" },
],
[
e("start", "check"),
e("check", "mgr", "yes"),
e("check", "auto", "no"),
e("mgr", "ship", "approved"),
e("mgr", "reject", "denied"),
e("auto", "ship"),
],
{ title: "Order Approval" },
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
expect(nodeCollisions(edgePaths(xml, rects), rects, ids)).toEqual([])
// Layers descend in flow order.
expect(rectOf(rects, "start").y).toBeLessThan(rectOf(rects, "check").y)
expect(rectOf(rects, "check").y).toBeLessThan(rectOf(rects, "mgr").y)
expect(rectOf(rects, "mgr").y).toBe(rectOf(rects, "auto").y)
expect(xml).toContain("Order Approval")
})
it("renders a decision as a diamond and a terminator as a stadium", () => {
const r = drawGraph(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
{ id: "d", label: "Report", shape: "document" },
{ id: "i", label: "Input", shape: "data" },
],
[e("s", "q"), e("q", "d"), e("q", "i")],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
expect(xml).toMatch(/id="q"[^>]*rhombus/)
expect(xml).toMatch(/id="s"[^>]*arcSize=50/)
expect(xml).toMatch(/id="d"[^>]*shape=document/)
expect(xml).toMatch(/id="i"[^>]*shape=parallelogram/)
})
it("keeps a 14-node pipeline free of arrows through boxes", () => {
const ids = [
"commit",
"lint",
"unit",
"build",
"itest",
"sec",
"stage",
"smoke",
"approve",
"prod",
"canary",
"monitor",
"alert",
"rollback",
]
const r = drawGraph(
ids.map((id) => n(id)),
[
e("commit", "lint"),
e("commit", "unit"),
e("lint", "build"),
e("unit", "build"),
e("build", "itest"),
e("build", "sec"),
e("itest", "stage"),
e("sec", "stage"),
e("stage", "smoke"),
e("smoke", "approve"),
e("approve", "prod"),
e("prod", "canary"),
e("canary", "monitor"),
e("monitor", "alert"),
e("alert", "rollback"),
e("rollback", "stage"),
],
)
expect(r.errors).toEqual([])
const rects = absoluteRects(r.xml as string)
expect(
nodeCollisions(edgePaths(r.xml as string, rects), rects, ids),
).toEqual([])
})
it("rejects an empty node list rather than drawing nothing", () => {
const r = drawGraph([], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("no nodes")
})
it("rejects a duplicate id instead of silently dropping one", () => {
const r = drawGraph([n("a"), n("a")], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("duplicate")
})
it("warns about an edge naming a node that is not there", () => {
const r = drawGraph([n("a")], [e("a", "ghost")])
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("ghost")
})
it("warns which arrows were treated as loops", () => {
const r = drawGraph(
[n("a"), n("b")],
[e("a", "b"), e("b", "a", "retry")],
)
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("b→a")
})
})
/**
* Two defects that both showed up as an arrow drawn over a box, on a flowchart with a
* "send it back" loop in it. Neither was visible on the smaller flowcharts above: one
* needs a back edge whose return path is lined with other steps, the other needs an edge
* that skips over a layer holding more than one node.
*/
describe("graphToOperations: arrows do not run through boxes", () => {
/** Ids of the real steps, excluding the engine's own layout cells. */
const steps = (xml: string): string[] =>
[...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g)]
.map((m) => m[1])
.filter((id) => !id.startsWith("__"))
it("routes a back edge around the steps it returns past", () => {
// The reported case: "return for correction" goes back to "submit", and the two
// steps between them are directly in the way. The engine wraps each layer in an
// INVISIBLE container, and those used to be handed to the router as frames — so
// every clean return path was rejected for "trespassing" on a frame that is not
// drawn, and the fallback cut straight through both steps.
const r = drawGraph(
[
{ id: "start", label: "Start", shape: "terminator" },
{ id: "submit", label: "Submit Request", shape: "data" },
n("validate", "Validate Input"),
{ id: "valid", label: "Valid?", shape: "decision" },
n("fix", "Return for Correction"),
n("review", "Manager Review"),
{ id: "end", label: "End", shape: "terminator" },
],
[
e("start", "submit"),
e("submit", "validate"),
e("validate", "valid"),
e("valid", "fix", "No"),
e("fix", "submit"),
e("valid", "review", "Yes"),
e("review", "end"),
],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
expect(
nodeCollisions(edgePaths(xml, rects), rects, steps(xml)),
).toEqual([])
})
it("keeps the placeholders out of the reported layers", () => {
// They are an internal device for reserving space; a caller asked about its own nodes.
const { layers } = graphToOperations(
[n("a"), n("b1"), n("b2"), n("z")],
[
e("a", "b1"),
e("a", "b2"),
e("b1", "z"),
e("b2", "z"),
e("a", "z"),
],
)
expect(layers.flat().filter((id) => id.startsWith("__"))).toEqual([])
})
})
/**
* An arrow that leaves its own shape and immediately turns back across it.
*
* Reported on a request-handling flowchart: a short hook came out of the right side of
* "Manager approval" and bent straight back over the box's own edge. The ordinary obstacle
* test cannot see this — an edge is exempt from its own two endpoints, and it has to be,
* since the line must touch them — so it is checked separately here.
*/
describe("graphToOperations: arrows leave their own shape cleanly", () => {
/** Waypoints that land inside any box, which is what draws the hook. */
const waypointsInsideBoxes = (xml: string): string[] => {
const rects = absoluteRects(xml)
const boxes = [
...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g),
]
.map((m) => m[1])
.filter((id) => !id.startsWith("__"))
const bad: string[] = []
for (const p of edgePaths(xml, rects))
for (const w of p.points.slice(1, -1))
for (const id of boxes) {
const r = rects.get(id)
if (!r) continue
if (
w.x > r.x + 1 &&
w.x < r.x + r.w - 1 &&
w.y > r.y + 1 &&
w.y < r.y + r.h - 1
)
bad.push(`${p.source}${p.target} bends inside ${id}`)
}
return [...new Set(bad)]
}
it("does not bend an arrow back over the box it just left", () => {
const r = drawGraph(
[
{
id: "start",
label: "Start: Request received",
shape: "terminator",
},
n("intake", "Log request in system"),
n("review", "Review details"),
{ id: "complete", label: "Info complete?", shape: "decision" },
{ id: "askinfo", label: "Request missing info", shape: "data" },
{ id: "approve", label: "Approval needed?", shape: "decision" },
n("manager", "Manager approval"),
n("process", "Process request"),
{ id: "report", label: "Generate report", shape: "document" },
n("notify", "Notify requester"),
{ id: "end", label: "End", shape: "terminator" },
{ id: "reject", label: "Reject & close", shape: "round" },
],
[
e("start", "intake"),
e("intake", "review"),
e("review", "complete"),
e("complete", "askinfo", "no"),
e("askinfo", "review", "resubmit"),
e("complete", "approve", "yes"),
e("approve", "manager", "yes"),
e("approve", "process", "no"),
e("manager", "process", "approved"),
e("manager", "reject", "declined"),
e("process", "report"),
e("report", "notify"),
e("reject", "notify"),
e("notify", "end"),
],
{ title: "Sample Request Handling Workflow" },
)
expect(r.errors).toEqual([])
expect(waypointsInsideBoxes(r.xml as string)).toEqual([])
})
})
/**
* Edge labels must not sit on boxes or on each other.
*
* The router keeps LINES off the boxes, but a label renders at its edge's midpoint — and on
* a long edge that midpoint is beside exactly the things the line was routed around. On the
* reported git-workflow diagram, four labels sat on unrelated boxes or on other labels.
* `placeLabels` slides each label along its own edge to a clear spot, written as the
* geometry's relative x.
*/
describe("edge labels avoid boxes and each other", () => {
it("places every git-workflow label on empty space", () => {
const r = drawGraph(
[
{
id: "remote",
label: "Remote Repository (origin / GitHub)",
shape: "data",
},
n("work", "Working Directory (edited files)"),
n("stage", "Staging Area (index)"),
{
id: "stash",
label: "Stash (temporary shelf)",
shape: "round",
},
n("local", "Local Repository (.git commits)"),
{
id: "branch",
label: "Branch / Merge (feature -> main)",
shape: "decision",
},
],
[
e("remote", "work", "git clone"),
e("work", "stage", "git add"),
{
source: "stage",
target: "work",
label: "git reset",
dashed: true,
},
e("work", "stash", "git stash"),
e("stash", "work", "git stash pop"),
e("stage", "local", "git commit"),
{
source: "local",
target: "stage",
label: "git checkout",
dashed: true,
},
e("local", "work", "git pull / merge"),
{
source: "remote",
target: "local",
label: "git fetch",
dashed: true,
},
e("local", "branch", "git branch / checkout -b"),
e("branch", "remote", "git push"),
],
{ title: "Git Operations Overview" },
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
const ids = ["remote", "work", "stage", "stash", "local", "branch"]
// Recompute each label's rectangle the way draw.io places it: at the geometry's
// relative x along the path (1 source, 0 middle, +1 target).
const labels: {
id: string
x: number
y: number
w: number
h: number
}[] = []
for (const p of edgePaths(xml, rects)) {
if (!p.label) continue
const m = xml.match(
new RegExp(
`<mxCell id="${p.id}"[^>]*>\\s*<mxGeometry x="(-?[\\d.]+)" relative="1"`,
),
)
const t = ((m ? Number(m[1]) : 0) + 1) / 2
let total = 0
const segs = p.points.slice(0, -1).map((pt, i) => {
const len =
Math.abs(p.points[i + 1].x - pt.x) +
Math.abs(p.points[i + 1].y - pt.y)
total += len
return { a: pt, b: p.points[i + 1], len }
})
let at = total * t
let pos = p.points[0]
for (const s of segs) {
if (at <= s.len || s === segs[segs.length - 1]) {
const f = s.len ? Math.min(1, at / s.len) : 0
pos = {
x: s.a.x + (s.b.x - s.a.x) * f,
y: s.a.y + (s.b.y - s.a.y) * f,
}
break
}
at -= s.len
}
const w = Math.min(160, p.label.length * 7 + 8)
labels.push({ id: p.id, x: pos.x - w / 2, y: pos.y - 8, w, h: 16 })
}
const bad: string[] = []
for (const l of labels) {
for (const id of ids) {
const b = rectOf(rects, id)
if (
l.x < b.x + b.w &&
l.x + l.w > b.x &&
l.y < b.y + b.h &&
l.y + l.h > b.y
)
bad.push(`${l.id} label sits on ${id}`)
}
for (const m of labels)
if (
m !== l &&
l.id < m.id &&
l.x < m.x + m.w &&
l.x + l.w > m.x &&
l.y < m.y + m.h &&
l.y + l.h > m.y
)
bad.push(`${l.id} label sits on ${m.id} label`)
}
expect(bad).toEqual([])
})
})
/**
* A→B and B→A are one relationship drawn as two arrows, and a reader expects a matched
* pair: two parallel lines a constant gap apart. Routed independently they land on port
* positions chosen for unrelated reasons, so one line runs straight while its partner
* wanders through a different corridor with a kink in it.
*/
describe("opposite edges are drawn as a parallel pair", () => {
it("gives git add / git reset two straight tracks a constant gap apart", () => {
const r = drawGraph(
[n("work", "Working Directory"), n("stage", "Staging Area")],
[
e("work", "stage", "git add"),
{
source: "stage",
target: "work",
label: "git reset",
dashed: true,
},
],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
const paths = edgePaths(xml, rects)
const fwd = paths.find((p) => p.source === "work")
const rev = paths.find((p) => p.source === "stage")
if (!fwd || !rev) throw new Error("both edges must render")
// Both straight: two points, no waypoints.
expect(fwd.points).toHaveLength(2)
expect(rev.points).toHaveLength(2)
// Parallel vertical tracks a constant gap apart.
expect(Math.abs(fwd.points[0].x - fwd.points[1].x)).toBeLessThan(1)
expect(Math.abs(rev.points[0].x - rev.points[1].x)).toBeLessThan(1)
const gap = Math.abs(fwd.points[0].x - rev.points[0].x)
expect(gap).toBeGreaterThan(12)
expect(gap).toBeLessThan(40)
})
})
/**
* Semantic groups: the caller names zones, the engine colours them.
*
* The model is good at judging which nodes belong together and bad at picking hex colours
* that match; letting it choose produced mismatched saturations and a different look per
* diagram. So `group` maps to a fixed engine palette in order of first appearance, and the
* same grouping always produces the same colours.
*/
describe("draw_graph: semantic groups", () => {
it("colours nodes by group, consistently, without the caller naming a colour", () => {
const r = drawGraph(
[
{ id: "a", label: "A", group: "remote" },
{ id: "b", label: "B", group: "local" },
{ id: "c", label: "C", group: "local" },
n("d", "D"),
],
[e("a", "b"), e("b", "c"), e("c", "d")],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const fillOf = (id: string) => {
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
}
// First group named gets the first palette slot; same group, same colour.
expect(fillOf("a")).toBe("#DAE8FC")
expect(fillOf("b")).toBe("#D5E8D4")
expect(fillOf("c")).toBe("#D5E8D4")
// No group: the plain white fallback.
expect(fillOf("d")).toBe("#FFFFFF")
})
it("group colours survive a round trip through the canvas", () => {
const r = drawGraph(
[
{ id: "a", label: "A", group: "g1" },
{ id: "b", label: "B", group: "g2" },
],
[e("a", "b")],
)
const again = restructureDiagram(r.xml as string, [])
const fillOf = (xml: string, id: string) => {
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
}
expect(fillOf(again.xml as string, "a")).toBe(
fillOf(r.xml as string, "a"),
)
expect(fillOf(again.xml as string, "b")).toBe(
fillOf(r.xml as string, "b"),
)
})
})