mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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.
677 lines
25 KiB
TypeScript
677 lines
25 KiB
TypeScript
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"),
|
||
)
|
||
})
|
||
})
|