mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest"
|
|
import {
|
|
autoBoxSize,
|
|
flatten,
|
|
layoutForest,
|
|
type Placed,
|
|
} from "@/lib/diagram-engine/layout"
|
|
import type {
|
|
BoxNode,
|
|
DiagramNode,
|
|
GridNode,
|
|
GroupNode,
|
|
IconNode,
|
|
Rect,
|
|
} from "@/lib/diagram-engine/types"
|
|
|
|
const icon = (id: string, label = "", size?: number): IconNode => ({
|
|
kind: "icon",
|
|
id,
|
|
name: "ec2",
|
|
label,
|
|
size,
|
|
})
|
|
const box = (id: string, label = "", w?: number, h?: number): BoxNode => ({
|
|
kind: "box",
|
|
id,
|
|
label,
|
|
w,
|
|
h,
|
|
})
|
|
const group = (
|
|
id: string,
|
|
dir: "row" | "col",
|
|
children: DiagramNode[],
|
|
label = "",
|
|
gap = 20,
|
|
): GroupNode => ({ kind: "group", id, gname: null, label, dir, gap, children })
|
|
const grid = (
|
|
id: string,
|
|
cols: number,
|
|
children: DiagramNode[],
|
|
label = "",
|
|
gap = 20,
|
|
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
|
|
|
|
/** Look a rect up by node id in a placed forest. */
|
|
function rects(roots: Placed[]): Map<string, Rect> {
|
|
const m = new Map<string, Rect>()
|
|
for (const f of flatten(roots)) m.set(f.node.id, f.rect)
|
|
return m
|
|
}
|
|
const contains = (outer: Rect, inner: Rect) =>
|
|
inner.x >= outer.x &&
|
|
inner.y >= outer.y &&
|
|
inner.x + inner.w <= outer.x + outer.w &&
|
|
inner.y + inner.h <= outer.y + outer.h
|
|
const overlaps = (a: Rect, b: Rect) =>
|
|
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0 &&
|
|
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0
|
|
|
|
describe("autoBoxSize", () => {
|
|
it("widens with the label but stops at a maximum", () => {
|
|
expect(autoBoxSize("hi").w).toBe(120) // floor
|
|
expect(autoBoxSize("x".repeat(200)).w).toBe(260) // ceiling
|
|
expect(autoBoxSize("a medium length label").w).toBeGreaterThan(120)
|
|
})
|
|
|
|
it("grows taller with each line", () => {
|
|
expect(autoBoxSize("one\ntwo\nthree").h).toBeGreaterThan(
|
|
autoBoxSize("one").h,
|
|
)
|
|
})
|
|
|
|
it("never returns a degenerate size for an empty label", () => {
|
|
const s = autoBoxSize("")
|
|
expect(s.w).toBeGreaterThan(0)
|
|
expect(s.h).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe("containers always fit their children", () => {
|
|
it("holds a row of icons", () => {
|
|
const tree = group(
|
|
"f",
|
|
"row",
|
|
[icon("a"), icon("b"), icon("c")],
|
|
"Frame",
|
|
)
|
|
const r = rects(layoutForest([tree]).roots)
|
|
const frame = r.get("f") as Rect
|
|
for (const id of ["a", "b", "c"])
|
|
expect(contains(frame, r.get(id) as Rect)).toBe(true)
|
|
})
|
|
|
|
it("holds a column of icons", () => {
|
|
const tree = group("f", "col", [icon("a"), icon("b")], "Frame")
|
|
const r = rects(layoutForest([tree]).roots)
|
|
for (const id of ["a", "b"])
|
|
expect(contains(r.get("f") as Rect, r.get(id) as Rect)).toBe(true)
|
|
})
|
|
|
|
it("holds a grid", () => {
|
|
const tree = grid(
|
|
"g",
|
|
3,
|
|
[icon("a"), icon("b"), icon("c"), icon("d")],
|
|
"G",
|
|
)
|
|
const r = rects(layoutForest([tree]).roots)
|
|
for (const id of ["a", "b", "c", "d"])
|
|
expect(contains(r.get("g") as Rect, r.get(id) as Rect)).toBe(true)
|
|
})
|
|
|
|
it("holds a deeply nested structure at every level", () => {
|
|
// Region → VPC → AZ → Subnet → icon, the shape of a real cloud diagram
|
|
const tree = group(
|
|
"region",
|
|
"row",
|
|
[
|
|
group(
|
|
"vpc",
|
|
"col",
|
|
[
|
|
group(
|
|
"az",
|
|
"col",
|
|
[group("subnet", "col", [icon("ec2")], "Subnet")],
|
|
"AZ",
|
|
),
|
|
],
|
|
"VPC",
|
|
),
|
|
],
|
|
"Region",
|
|
)
|
|
const r = rects(layoutForest([tree]).roots)
|
|
const chain = ["region", "vpc", "az", "subnet", "ec2"]
|
|
for (let i = 1; i < chain.length; i++)
|
|
expect(
|
|
contains(r.get(chain[i - 1]) as Rect, r.get(chain[i]) as Rect),
|
|
).toBe(true)
|
|
})
|
|
|
|
it("holds a child whose label is far wider than the frame's own", () => {
|
|
const tree = group(
|
|
"f",
|
|
"col",
|
|
[box("wide", "a considerably longer label than the frame title")],
|
|
"F",
|
|
)
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect(contains(r.get("f") as Rect, r.get("wide") as Rect)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("siblings never overlap", () => {
|
|
it("keeps a row of icons apart", () => {
|
|
const tree = group("f", "row", [icon("a"), icon("b"), icon("c")])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect(overlaps(r.get("a") as Rect, r.get("b") as Rect)).toBe(false)
|
|
expect(overlaps(r.get("b") as Rect, r.get("c") as Rect)).toBe(false)
|
|
})
|
|
|
|
it("keeps a column of frames apart", () => {
|
|
const tree = group("f", "col", [
|
|
group("s1", "row", [icon("a")], "Public"),
|
|
group("s2", "row", [icon("b")], "Private"),
|
|
group("s3", "row", [icon("c")], "Data"),
|
|
])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect(overlaps(r.get("s1") as Rect, r.get("s2") as Rect)).toBe(false)
|
|
expect(overlaps(r.get("s2") as Rect, r.get("s3") as Rect)).toBe(false)
|
|
})
|
|
|
|
it("keeps grid cells apart", () => {
|
|
const tree = grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
const ids = ["a", "b", "c", "d"]
|
|
for (let i = 0; i < ids.length; i++)
|
|
for (let j = i + 1; j < ids.length; j++)
|
|
expect(
|
|
overlaps(r.get(ids[i]) as Rect, r.get(ids[j]) as Rect),
|
|
).toBe(false)
|
|
})
|
|
|
|
it("keeps separate roots apart", () => {
|
|
const r = rects(
|
|
layoutForest([
|
|
box("users", "Users"),
|
|
group("region", "row", [icon("a")]),
|
|
]).roots,
|
|
)
|
|
expect(overlaps(r.get("users") as Rect, r.get("region") as Rect)).toBe(
|
|
false,
|
|
)
|
|
})
|
|
|
|
it("keeps 30 siblings apart — no accumulation error", () => {
|
|
const kids = Array.from({ length: 30 }, (_, i) =>
|
|
icon(`i${i}`, `n${i}`),
|
|
)
|
|
const r = rects(layoutForest([group("f", "row", kids)]).roots)
|
|
for (let i = 1; i < 30; i++) {
|
|
const prev = r.get(`i${i - 1}`) as Rect
|
|
const cur = r.get(`i${i}`) as Rect
|
|
expect(cur.x).toBeGreaterThanOrEqual(prev.x + prev.w)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("direction", () => {
|
|
it("advances along x in a row and keeps y aligned", () => {
|
|
const r = rects(
|
|
layoutForest([group("f", "row", [icon("a"), icon("b")])]).roots,
|
|
)
|
|
const a = r.get("a") as Rect
|
|
const b = r.get("b") as Rect
|
|
expect(b.x).toBeGreaterThan(a.x)
|
|
expect(b.y).toBe(a.y)
|
|
})
|
|
|
|
it("advances along y in a column and keeps x aligned", () => {
|
|
const r = rects(
|
|
layoutForest([group("f", "col", [icon("a"), icon("b")])]).roots,
|
|
)
|
|
const a = r.get("a") as Rect
|
|
const b = r.get("b") as Rect
|
|
expect(b.y).toBeGreaterThan(a.y)
|
|
expect(b.x).toBe(a.x)
|
|
})
|
|
|
|
it("wraps a grid at the column count", () => {
|
|
const r = rects(
|
|
layoutForest([
|
|
grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")]),
|
|
]).roots,
|
|
)
|
|
const a = r.get("a") as Rect
|
|
const b = r.get("b") as Rect
|
|
const c = r.get("c") as Rect
|
|
expect(b.y).toBe(a.y) // same row
|
|
expect(c.y).toBeGreaterThan(a.y) // wrapped
|
|
expect(c.x).toBe(a.x) // back to the first column
|
|
})
|
|
})
|
|
|
|
describe("sibling equalisation", () => {
|
|
it("gives frames in a row a shared height", () => {
|
|
const tree = group("f", "row", [
|
|
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
|
|
group("short", "col", [icon("d")], "Short"),
|
|
])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect((r.get("short") as Rect).h).toBe((r.get("tall") as Rect).h)
|
|
})
|
|
|
|
it("gives frames in a column a shared width", () => {
|
|
const tree = group("f", "col", [
|
|
group("wide", "row", [icon("a"), icon("b"), icon("c")], "Wide"),
|
|
group("narrow", "row", [icon("d")], "Narrow"),
|
|
])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect((r.get("narrow") as Rect).w).toBe((r.get("wide") as Rect).w)
|
|
})
|
|
|
|
it("does not stretch a leaf icon — that would distort the glyph", () => {
|
|
const tree = group("f", "row", [
|
|
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
|
|
icon("lone", "Lone"),
|
|
])
|
|
const r = rects(layoutForest([tree]).roots)
|
|
expect((r.get("lone") as Rect).h).toBeLessThan(
|
|
(r.get("tall") as Rect).h,
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("title strip", () => {
|
|
it("reserves space above the children when a container is labelled", () => {
|
|
const withLabel = rects(
|
|
layoutForest([group("f", "row", [icon("a")], "Titled")]).roots,
|
|
)
|
|
const withoutLabel = rects(
|
|
layoutForest([group("f", "row", [icon("a")], "")]).roots,
|
|
)
|
|
expect((withLabel.get("f") as Rect).h).toBeGreaterThan(
|
|
(withoutLabel.get("f") as Rect).h,
|
|
)
|
|
})
|
|
|
|
it("pushes children below the strip so the label is not covered", () => {
|
|
const r = rects(
|
|
layoutForest([group("f", "col", [icon("a")], "Titled")]).roots,
|
|
)
|
|
const f = r.get("f") as Rect
|
|
const a = r.get("a") as Rect
|
|
expect(a.y).toBeGreaterThanOrEqual(f.y + 36)
|
|
})
|
|
|
|
it("widens a frame whose title is longer than its contents", () => {
|
|
const longTitle =
|
|
"A Very Long Container Title That Exceeds Its Single Child"
|
|
const r = rects(
|
|
layoutForest([group("f", "row", [icon("a")], longTitle)]).roots,
|
|
)
|
|
expect((r.get("f") as Rect).w).toBeGreaterThan(longTitle.length * 5)
|
|
})
|
|
})
|
|
|
|
describe("page size", () => {
|
|
it("covers every node plus a margin", () => {
|
|
const { roots, page } = layoutForest([
|
|
group("f", "row", [icon("a"), icon("b")], "F"),
|
|
])
|
|
const all = flatten(roots)
|
|
const maxX = Math.max(...all.map((n) => n.rect.x + n.rect.w))
|
|
const maxY = Math.max(...all.map((n) => n.rect.y + n.rect.h))
|
|
expect(page.w).toBeGreaterThan(maxX)
|
|
expect(page.h).toBeGreaterThan(maxY)
|
|
})
|
|
|
|
it("grows with the content", () => {
|
|
const small = layoutForest([group("f", "row", [icon("a")])]).page
|
|
const big = layoutForest([
|
|
group(
|
|
"f",
|
|
"row",
|
|
Array.from({ length: 10 }, (_, i) => icon(`i${i}`)),
|
|
),
|
|
]).page
|
|
expect(big.w).toBeGreaterThan(small.w)
|
|
})
|
|
})
|
|
|
|
describe("pinned nodes", () => {
|
|
it("keeps a pinned root where the user left it", () => {
|
|
const pinned: GroupNode = {
|
|
...group("f", "row", [icon("a")], "F"),
|
|
pinned: true,
|
|
rect: { x: 777, y: 555, w: 100, h: 100 },
|
|
}
|
|
const r = rects(layoutForest([pinned]).roots)
|
|
expect((r.get("f") as Rect).x).toBe(777)
|
|
expect((r.get("f") as Rect).y).toBe(555)
|
|
})
|
|
|
|
it("still lays the pinned node's children out inside it", () => {
|
|
const pinned: GroupNode = {
|
|
...group("f", "row", [icon("a")], "F"),
|
|
pinned: true,
|
|
rect: { x: 300, y: 300, w: 100, h: 100 },
|
|
}
|
|
const r = rects(layoutForest([pinned]).roots)
|
|
expect(contains(r.get("f") as Rect, r.get("a") as Rect)).toBe(true)
|
|
})
|
|
|
|
it("does not let a pinned root consume flow space from the others", () => {
|
|
const pinned: BoxNode = {
|
|
...box("pin", "Pinned"),
|
|
pinned: true,
|
|
rect: { x: 900, y: 900, w: 120, h: 60 },
|
|
}
|
|
const r = rects(layoutForest([pinned, box("flow", "Flow")]).roots)
|
|
// the un-pinned root starts at the normal origin, not offset past the pinned one
|
|
expect((r.get("flow") as Rect).x).toBe(40)
|
|
})
|
|
})
|
|
|
|
describe("icon sizing", () => {
|
|
it("applies the diagram-wide glyph size", () => {
|
|
const small = rects(layoutForest([icon("a")], { iconSize: 48 }).roots)
|
|
const large = rects(layoutForest([icon("a")], { iconSize: 96 }).roots)
|
|
expect((large.get("a") as Rect).h).toBeGreaterThan(
|
|
(small.get("a") as Rect).h,
|
|
)
|
|
})
|
|
|
|
it("lets a per-icon size override the diagram default", () => {
|
|
const r = rects(
|
|
layoutForest([group("f", "row", [icon("a"), icon("b", "", 96)])], {
|
|
iconSize: 48,
|
|
}).roots,
|
|
)
|
|
expect((r.get("b") as Rect).h).toBeGreaterThan((r.get("a") as Rect).h)
|
|
})
|
|
|
|
it("widens the cell for a long label so it does not overflow", () => {
|
|
const r = rects(
|
|
layoutForest([
|
|
group("f", "row", [
|
|
icon("a", "x"),
|
|
icon("b", "a much longer label"),
|
|
]),
|
|
]).roots,
|
|
)
|
|
expect((r.get("b") as Rect).w).toBeGreaterThan((r.get("a") as Rect).w)
|
|
})
|
|
})
|
|
|
|
describe("degenerate input", () => {
|
|
it("handles an empty forest", () => {
|
|
const { roots, page } = layoutForest([])
|
|
expect(roots).toEqual([])
|
|
expect(page.w).toBeGreaterThan(0)
|
|
})
|
|
|
|
it("handles an empty container without producing a negative size", () => {
|
|
const r = rects(layoutForest([group("f", "row", [], "Empty")]).roots)
|
|
const f = r.get("f") as Rect
|
|
expect(f.w).toBeGreaterThan(0)
|
|
expect(f.h).toBeGreaterThan(0)
|
|
})
|
|
|
|
it("handles a grid with fewer children than columns", () => {
|
|
const r = rects(layoutForest([grid("g", 5, [icon("a")], "G")]).roots)
|
|
expect(contains(r.get("g") as Rect, r.get("a") as Rect)).toBe(true)
|
|
})
|
|
|
|
it("rounds every coordinate to an integer — draw.io renders half-pixels blurry", () => {
|
|
const tree = group("f", "row", [
|
|
group("a", "col", [icon("x"), icon("y"), icon("z")], "A"),
|
|
icon("b"),
|
|
])
|
|
for (const f of flatten(layoutForest([tree]).roots)) {
|
|
expect(Number.isInteger(f.rect.x)).toBe(true)
|
|
expect(Number.isInteger(f.rect.y)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("determinism", () => {
|
|
it("produces identical geometry for the same tree twice", () => {
|
|
const build = () =>
|
|
group("f", "row", [
|
|
group("a", "col", [icon("x"), icon("y")], "A"),
|
|
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
|
|
])
|
|
const first = flatten(layoutForest([build()]).roots).map((n) => [
|
|
n.node.id,
|
|
n.rect,
|
|
])
|
|
const second = flatten(layoutForest([build()]).roots).map((n) => [
|
|
n.node.id,
|
|
n.rect,
|
|
])
|
|
expect(second).toEqual(first)
|
|
})
|
|
})
|
|
|
|
describe("flatten", () => {
|
|
it("reports the real parent id for a nested node and the layer for a root", () => {
|
|
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
|
|
const flat = flatten(layoutForest([tree]).roots)
|
|
const by = new Map(flat.map((n) => [n.node.id, n.parent]))
|
|
expect(by.get("f")).toBe("1")
|
|
expect(by.get("inner")).toBe("f")
|
|
expect(by.get("leaf")).toBe("inner")
|
|
})
|
|
|
|
it("emits a parent before its children, so XML order is valid", () => {
|
|
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
|
|
const ids = flatten(layoutForest([tree]).roots).map((n) => n.node.id)
|
|
expect(ids.indexOf("f")).toBeLessThan(ids.indexOf("inner"))
|
|
expect(ids.indexOf("inner")).toBeLessThan(ids.indexOf("leaf"))
|
|
})
|
|
})
|