mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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([])
|
||
|
|
})
|
||
|
|
})
|