From 8765dfb96c746c0ec569d222e449e21337b5ddd9 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 9 Aug 2026 11:35:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(diagram-engine):=20style=20markers=20+=20X?= =?UTF-8?q?ML=E2=86=92tree=20reverse=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately. --- lib/diagram-engine/markers.ts | 163 ++++ lib/diagram-engine/parse.ts | 538 +++++++++++++ lib/diagram-engine/types.ts | 173 ++++ tests/unit/diagram-engine-markers.test.ts | 212 +++++ tests/unit/diagram-engine-parse.test.ts | 742 ++++++++++++++++++ tests/unit/fixtures/engine-vpc-multiaz.drawio | 1 + 6 files changed, 1829 insertions(+) create mode 100644 lib/diagram-engine/markers.ts create mode 100644 lib/diagram-engine/parse.ts create mode 100644 lib/diagram-engine/types.ts create mode 100644 tests/unit/diagram-engine-markers.test.ts create mode 100644 tests/unit/diagram-engine-parse.test.ts create mode 100644 tests/unit/fixtures/engine-vpc-multiaz.drawio diff --git a/lib/diagram-engine/markers.ts b/lib/diagram-engine/markers.ts new file mode 100644 index 0000000..1c0b3c1 --- /dev/null +++ b/lib/diagram-engine/markers.ts @@ -0,0 +1,163 @@ +/** + * Style markers — how layout structure survives a round-trip through draw.io. + * + * The layout engine's tree carries information plain draw.io XML does not: which + * direction a container stacks its children, the gap between them, and whether the + * user has pinned a node's position. We encode that as extra `key=value` tokens in + * the cell's style string. + * + * Two behaviours this relies on, both verified in a real browser (Playwright drag + * against the embedded editor, reading the editor's own autosave payload): + * + * 1. draw.io PRESERVES style keys it does not understand. After a user drags a + * shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came + * back byte-identical. + * 2. On a DUPLICATE key, the LAST value wins. A style ending in + * `container=0;pointerEvents=0;container=1;` behaved as a container: a shape + * dragged into it was reparented. So we can append a normalising token without + * first parsing out the old one. + * + * (2) matters because the AWS catalog is inconsistent: group_region, group_vpc, + * group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship + * WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2, + * group_security_group and group_corporate_data_center ship WITH it. Appending + * unconditionally normalises all of them. + */ + +/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */ +export const MARKER = { + /** Node kind, so the parser does not have to re-guess it from the shape. */ + kind: "dai_kind", + /** Child stacking direction of a container: "row" | "col" | "grid". */ + dir: "dai_dir", + /** Gap between children, in px. */ + gap: "dai_gap", + /** Column count, for grid containers. */ + cols: "dai_cols", + /** Set by the user to freeze a node's position across re-layouts. */ + pin: "dai_pin", +} as const + +export type NodeKind = "group" | "grid" | "icon" | "box" | "title" +export type Direction = "row" | "col" | "grid" + +/** + * Tokens that make a shape behave as a container in draw.io: it accepts a shape + * dragged into it and reparents that shape (setting `parent` and switching the + * child's geometry to parent-relative). + * + * `pointerEvents=0` keeps clicks falling through to the children — without it the + * frame swallows them and the user cannot select what is inside. `collapsible=0` + * hides the fold arrow. `recursiveResize=0` stops children from being scaled when + * the frame is resized, which would fight the layout engine. + */ +const CONTAINER_TOKENS = + "container=1;pointerEvents=0;collapsible=0;recursiveResize=0;" + +/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */ +export function readMarker(style: string, key: string): string | null { + // Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution. + const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g") + let last: string | null = null + let m = re.exec(style) + while (m !== null) { + last = m[1] + m = re.exec(style) + } + return last +} + +export function readKind(style: string): NodeKind | null { + const v = readMarker(style, MARKER.kind) + if ( + v === "group" || + v === "grid" || + v === "icon" || + v === "box" || + v === "title" + ) + return v + return null +} + +export function readDir(style: string): Direction | null { + const v = readMarker(style, MARKER.dir) + if (v === "row" || v === "col" || v === "grid") return v + return null +} + +/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */ +export function readIntMarker(style: string, key: string): number | null { + const v = readMarker(style, key) + if (v === null) return null + const n = Number(v) + return Number.isFinite(n) && n >= 0 ? Math.round(n) : null +} + +/** + * Has the user pinned this node? Any value other than "0"/""/"false" counts as + * pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's + * "Edit Style" dialog gets what they expect. + */ +export function isPinned(style: string): boolean { + const v = readMarker(style, MARKER.pin) + if (v === null) return false + const s = v.trim().toLowerCase() + return s !== "" && s !== "0" && s !== "false" +} + +/** Append `key=value;`, ensuring the style ends with a separator first. */ +function append(style: string, key: string, value: string | number): string { + const base = style.endsWith(";") || style === "" ? style : `${style};` + return `${base}${key}=${value};` +} + +/** + * Stamp a container's style: make it a real draw.io container and record its + * layout parameters. + * + * Appends rather than rewrites. Duplicate keys are legal and the last one wins, so + * a catalog style that already says `container=1` is unharmed, and one that says + * nothing (or `container=0`) is corrected. + */ +export function stampContainer( + style: string, + opts: { + kind: "group" | "grid" + dir: Direction + gap: number + cols?: number + }, +): string { + let s = style.endsWith(";") || style === "" ? style : `${style};` + s += CONTAINER_TOKENS + s = append(s, MARKER.kind, opts.kind) + s = append(s, MARKER.dir, opts.dir) + s = append(s, MARKER.gap, Math.round(opts.gap)) + if (opts.kind === "grid" && opts.cols != null) + s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols))) + return s +} + +/** Stamp a leaf (icon or box) with its kind, so the parser need not infer it. */ +export function stampLeaf( + style: string, + kind: "icon" | "box" | "title", +): string { + return append(style, MARKER.kind, kind) +} + +/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */ +export function stripMarkers(style: string): string { + return style + .split(";") + .filter((tok) => tok !== "" && !tok.startsWith("dai_")) + .join(";") + .concat(";") + .replace(/^;$/, "") +} + +/** Does this style carry any engine marker? Used to tell engine output from imported files. */ +export function hasMarkers(style: string): boolean { + return /(?:^|;)dai_[a-z]+=/.test(style) +} diff --git a/lib/diagram-engine/parse.ts b/lib/diagram-engine/parse.ts new file mode 100644 index 0000000..89b3e6a --- /dev/null +++ b/lib/diagram-engine/parse.ts @@ -0,0 +1,538 @@ +/** + * XML → tree. The reverse direction, which the reference project (drawio-ai-kit) does + * not have — it only goes tree → XML. + * + * This is what lets the canvas be the single source of truth. The model never holds a + * copy of the tree; whenever it wants to restructure a diagram we re-derive the tree + * from whatever is on the canvas right now, including everything the user changed by + * hand. There is no second copy of the state to drift out of sync. + * + * Two things make this viable, and both were verified against the real editor: + * + * - Engine-emitted containers carry `container=1`, so when a user drags a shape into + * a frame draw.io sets the shape's `parent` to that frame and rewrites its geometry + * to be parent-relative. The `parent` attribute therefore tracks what the user did. + * - draw.io preserves unknown style keys, so the `dai_*` markers written at emit time + * are still there on the way back. + * + * When a container lacks `container=1` (an imported file, or output from the old + * hand-written-XML path) the `parent` attribute and the visual nesting can disagree: a + * shape sits inside a frame on screen but its parent is still the root layer. We trust + * GEOMETRY over `parent` in exactly that case, and only that case — see resolveNesting. + */ + +import { extractDiagramXML } from "@/lib/utils" +import { + type Direction, + hasMarkers, + isPinned, + readDir, + readIntMarker, + readKind, +} from "./markers" +import type { + BoxNode, + DiagramNode, + DiagramTree, + ForeignCell, + GridNode, + GroupNode, + IconNode, + LinkSpec, + Rect, +} from "./types" + +/** draw.io's own layer/root cells, plus the boundaries layer the engine emits. */ +const LAYER_IDS = new Set(["0", "1", "boundaries"]) + +/** A flattened cell, before it becomes a node. */ +interface RawCell { + id: string + parent: string + style: string + value: string + isEdge: boolean + source: string | null + target: string | null + /** Geometry as written: relative to the parent for a nested cell. */ + geo: Rect | null + /** Geometry resolved to page coordinates through the parent chain. */ + abs: Rect | null + /** The cell's serialised XML, kept so unrecognised cells survive verbatim. */ + xml: string +} + +export interface ParseResult { + tree: DiagramTree + /** True when no cell carried a `dai_*` marker — an imported or legacy diagram. */ + needsAdoption: boolean + /** Non-fatal problems worth surfacing (cycles broken, cells dropped). */ + warnings: string[] +} + +/** + * Pull the mxGraphModel body of one page out of whatever the caller has: an mxfile + * document, a bare mxGraphModel, or the XML embedded in an exported SVG. + */ +export function extractPage(xml: string, pageIndex = 0): string | null { + let doc = xml.trim() + if (doc.startsWith("]*>([\s\S]*?)<\/diagram>/g)] + if (diagrams.length > 0) { + const body = diagrams[Math.min(pageIndex, diagrams.length - 1)][1] + // A compressed page is base64 with no markup — nothing to parse. + if (!/]*>/g)].length || 1 +} + +function attr(tag: string, name: string): string | null { + const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`)) + return m ? m[1] : null +} + +/** Undo the XML entity escaping the builder applies to labels. */ +function unescapeXml(s: string): string { + return s + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") +} + +/** + * Split the page into cells with a regex rather than a DOM parser. + * + * The DOM route needs a real parser (browser DOMParser or @xmldom/xmldom) and gives us + * nothing extra here: we want each cell's raw XML preserved byte-for-byte so foreign + * cells can be re-emitted untouched, and re-serialising a DOM node changes attribute + * order and whitespace. The reference project's validator parses the same way. + */ +function splitCells(page: string): RawCell[] { + const out: RawCell[] = [] + // Match a full or . + const re = /]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g + for (const m of page.matchAll(re)) { + const xml = m[0] + const head = xml.slice(0, xml.indexOf(">") + 1) + const id = attr(head, "id") + if (!id) continue + const geoTag = xml.match(/]*?(?:\/>|>)/)?.[0] ?? "" + const num = (n: string) => { + const v = attr(geoTag, n) + return v === null ? null : Number(v) + } + const x = num("x") + const y = num("y") + const w = num("width") + const h = num("height") + out.push({ + id, + parent: attr(head, "parent") ?? "", + style: attr(head, "style") ?? "", + value: unescapeXml(attr(head, "value") ?? ""), + isEdge: attr(head, "edge") === "1", + source: attr(head, "source"), + target: attr(head, "target"), + geo: + w !== null && h !== null + ? { x: x ?? 0, y: y ?? 0, w, h } + : null, + abs: null, + xml, + }) + } + return out +} + +/** + * Resolve every cell's geometry into page coordinates. + * + * A nested cell's geometry is relative to its parent, so absolute position is the sum + * down the parent chain. The hop limit breaks a cycle in a malformed file instead of + * hanging. + */ +function resolveAbsolute(cells: RawCell[], warnings: string[]): void { + const byId = new Map(cells.map((c) => [c.id, c])) + for (const c of cells) { + if (!c.geo) continue + let x = c.geo.x + let y = c.geo.y + let p = byId.get(c.parent) + let hops = 0 + while (p && hops < 50) { + if (p.geo) { + x += p.geo.x + y += p.geo.y + } + p = byId.get(p.parent) + hops++ + } + if (hops >= 50) + warnings.push( + `Parent chain of "${c.id}" exceeded 50 hops — possible cycle; geometry may be wrong.`, + ) + c.abs = { x, y, w: c.geo.w, h: c.geo.h } + } +} + +/** Does this style declare a draw.io container? Last duplicate wins, as draw.io does. */ +function declaresContainer(style: string): boolean { + const matches = [...style.matchAll(/(?:^|;)container=([^;]*)/g)] + if (matches.length === 0) return false + return matches[matches.length - 1][1] === "1" +} + +/** + * Classify a cell. + * + * The marker wins when present. Without one we fall back to the shape, which has to + * cover four different icon encodings: `resIcon=mxgraph.aws4.` (554 of the 983 + * AWS icons), a bare `shape=mxgraph.aws4.` (the other 429), `shape=image` with an + * embedded data URI (all 626 Azure and 216 GCP icons), and `grIcon=` for group frames. + * Keying only on `resIcon=` would misread well over a thousand icons as plain boxes. + */ +function classify( + c: RawCell, + hasChildren: boolean, +): "group" | "grid" | "icon" | "box" | "title" { + const marked = readKind(c.style) + if (marked) return marked + + if (/(?:^|;)text;/.test(c.style) || c.id === "__title") return "title" + // A group stencil, or anything draw.io treats as a container, or anything that + // actually holds children — all are containers regardless of how they were styled. + if (/grIcon=/.test(c.style) || declaresContainer(c.style) || hasChildren) + return "group" + if ( + /resIcon=/.test(c.style) || + /shape=mxgraph\.[a-z0-9_]+\./.test(c.style) || + /shape=image/.test(c.style) + ) + return "icon" + return "box" +} + +/** The catalog name of an icon, when it can be recovered from the style. */ +function iconName(style: string): string | null { + return ( + style.match(/resIcon=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? + style.match(/shape=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? + null + ) +} + +/** The group stencil name of a container, when it has one. */ +function groupName(style: string): string | null { + return ( + style.match(/grIcon=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? null + ) +} + +function styleValue(style: string, key: string): string | undefined { + const all = [...style.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g"))] + return all.length ? all[all.length - 1][1] : undefined +} + +/** + * Decide each cell's true parent. + * + * Normally `parent` is authoritative: with `container=1` in place draw.io maintains it + * as the user drags things around. The exception is a container WITHOUT `container=1` + * — draw.io will not reparent into it, so a shape the user dropped inside it visually + * still claims the root layer as its parent. There, and only there, we believe the + * geometry: if a cell sits geometrically inside such a frame and its declared parent is + * a layer, we re-home it into the smallest frame that contains it. + * + * Trusting geometry unconditionally would be wrong the other way round: a legitimately + * reparented cell whose geometry got stale for a frame, or a deliberately overlapping + * badge, would be silently moved. + */ +function resolveNesting(cells: RawCell[]): Map { + const byId = new Map(cells.map((c) => [c.id, c])) + const parentOf = new Map() + + const contains = (outer: Rect, inner: Rect) => + inner.x >= outer.x - 2 && + inner.y >= outer.y - 2 && + inner.x + inner.w <= outer.x + outer.w + 2 && + inner.y + inner.h <= outer.y + outer.h + 2 + + // Frames that draw.io will NOT reparent into, so their contents may be mis-parented. + const looseFrames = cells.filter( + (c) => + !c.isEdge && + c.abs !== null && + !declaresContainer(c.style) && + (/grIcon=/.test(c.style) || readKind(c.style) === "group"), + ) + + for (const c of cells) { + let p = c.parent + const declaredIsLayer = LAYER_IDS.has(p) || !byId.has(p) + if (declaredIsLayer && c.abs && !c.isEdge && looseFrames.length > 0) { + let best: RawCell | null = null + for (const f of looseFrames) { + if (f.id === c.id || !f.abs) continue + if (!contains(f.abs, c.abs)) continue + // smallest containing frame — the innermost one the user dropped into + if (!best?.abs || f.abs.w * f.abs.h < best.abs.w * best.abs.h) + best = f + } + if (best) p = best.id + } + parentOf.set(c.id, p) + } + return parentOf +} + +/** + * Recover a container's stacking direction and gap from its children's positions. + * + * Used only when the style marker is missing. Compares how much the children spread + * along each axis: a row varies in x and shares y, a column the reverse. Ties and + * genuinely two-dimensional arrangements fall back to a row, which is what an + * unlabelled cluster of icons most often is. + */ +function inferLayout(children: RawCell[]): { dir: Direction; gap: number } { + const boxes = children + .map((c) => c.abs) + .filter((r): r is Rect => r !== null) + if (boxes.length < 2) return { dir: "row", gap: 20 } + + const xs = boxes.map((b) => b.x) + const ys = boxes.map((b) => b.y) + const spreadX = Math.max(...xs) - Math.min(...xs) + const spreadY = Math.max(...ys) - Math.min(...ys) + + // Distinct row/column bands, to notice a real grid. + const bands = (vals: number[], tol: number) => { + const sorted = [...vals].sort((a, b) => a - b) + let n = 1 + for (let i = 1; i < sorted.length; i++) + if (sorted[i] - sorted[i - 1] > tol) n++ + return n + } + const rows = bands(ys, 20) + const cols = bands(xs, 20) + if (rows > 1 && cols > 1) return { dir: "grid", gap: 20 } + + const dir: Direction = spreadX >= spreadY ? "row" : "col" + + // Gap = median edge-to-edge distance between neighbours along the flow axis. + const sorted = [...boxes].sort((a, b) => + dir === "row" ? a.x - b.x : a.y - b.y, + ) + const gaps: number[] = [] + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1] + const cur = sorted[i] + gaps.push( + dir === "row" + ? cur.x - (prev.x + prev.w) + : cur.y - (prev.y + prev.h), + ) + } + gaps.sort((a, b) => a - b) + const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 20 + return { dir, gap: Math.max(0, Math.round(median)) } +} + +/** Turn an edge cell into a link spec. */ +function toLink(c: RawCell): LinkSpec | null { + if (!c.source || !c.target) return null + let label = c.value + let step: number | undefined + const m = label.match(/^(\d+)\.\s*(.*)$/) + if (m) { + step = Number(m[1]) + label = m[2] + } + return { + id: c.id, + source: c.source, + target: c.target, + label: label || undefined, + dashed: /(?:^|;)dashed=1/.test(c.style) || undefined, + step, + style: c.style, + } +} + +/** + * Re-derive the node tree from a page of canvas XML. + * + * Cells the classifier cannot place — a shape whose parent is a foreign cell, anything + * on the boundaries layer — are returned in `tree.foreign` and re-emitted verbatim, so + * a re-layout never deletes a user's annotations. + */ +export function parseDiagram(xml: string, pageIndex = 0): ParseResult { + const warnings: string[] = [] + const page = extractPage(xml, pageIndex) + if (!page) + return { + tree: { roots: [], links: [], foreign: [] }, + needsAdoption: true, + warnings: [ + "No cells found — the page is empty or the .drawio is compressed.", + ], + } + + const cells = splitCells(page) + resolveAbsolute(cells, warnings) + + const marked = cells.some((c) => hasMarkers(c.style)) + const parentOf = resolveNesting(cells) + + const vertices = cells.filter((c) => !c.isEdge && !LAYER_IDS.has(c.id)) + const edges = cells.filter((c) => c.isEdge) + + // children, in document order — which is layout order for engine output + const childrenOf = new Map() + for (const c of vertices) { + const p = parentOf.get(c.id) ?? "" + if (!childrenOf.has(p)) childrenOf.set(p, []) + childrenOf.get(p)?.push(c) + } + + const byId = new Map(cells.map((c) => [c.id, c])) + const kindOf = new Map>() + for (const c of vertices) + kindOf.set(c.id, classify(c, (childrenOf.get(c.id)?.length ?? 0) > 0)) + + const foreign: ForeignCell[] = [] + let title: string | undefined + + const build = (c: RawCell, depth: number): DiagramNode | null => { + if (depth > 50) { + warnings.push( + `Nesting deeper than 50 at "${c.id}" — subtree dropped.`, + ) + return null + } + const kind = kindOf.get(c.id) ?? "box" + const pinned = isPinned(c.style) || undefined + const rect = c.abs ?? undefined + + if (kind === "title") { + if (title === undefined) title = c.value + return null // laid out separately, not part of the flow + } + + if (kind === "icon") { + const node: IconNode = { + kind: "icon", + id: c.id, + name: iconName(c.style) ?? "", + label: c.value, + style: c.style, + size: c.geo ? Math.round(c.geo.w) : undefined, + pinned, + rect, + } + return node + } + + if (kind === "box") { + const node: BoxNode = { + kind: "box", + id: c.id, + label: c.value, + w: c.geo ? Math.round(c.geo.w) : undefined, + h: c.geo ? Math.round(c.geo.h) : undefined, + fill: styleValue(c.style, "fillColor"), + stroke: styleValue(c.style, "strokeColor"), + style: c.style, + pinned, + rect, + } + return node + } + + // container + const kids = childrenOf.get(c.id) ?? [] + const built = kids + .map((k) => build(k, depth + 1)) + .filter((n): n is DiagramNode => n !== null) + const markedDir = readDir(c.style) + const markedGap = readIntMarker(c.style, "dai_gap") + const inferred = markedDir === null ? inferLayout(kids) : null + const dir = markedDir ?? inferred?.dir ?? "row" + const gap = markedGap ?? inferred?.gap ?? 20 + + if (kind === "grid" || dir === "grid") { + const cols = + readIntMarker(c.style, "dai_cols") ?? + Math.max(1, Math.round(Math.sqrt(built.length))) + const node: GridNode = { + kind: "grid", + id: c.id, + gname: groupName(c.style), + label: c.value, + cols, + gap, + children: built, + fill: styleValue(c.style, "fillColor"), + stroke: styleValue(c.style, "strokeColor"), + style: c.style, + pinned, + rect, + } + return node + } + + const node: GroupNode = { + kind: "group", + id: c.id, + gname: groupName(c.style), + label: c.value, + dir: dir === "col" ? "col" : "row", + gap, + children: built, + fill: styleValue(c.style, "fillColor"), + stroke: styleValue(c.style, "strokeColor"), + style: c.style, + pinned, + rect, + } + return node + } + + // Roots: cells parented to a layer. The boundaries layer holds engine-drawn + // cluster frames, which are decoration over the real nesting — keep them verbatim. + const roots: DiagramNode[] = [] + for (const c of vertices) { + const p = parentOf.get(c.id) ?? "" + if (byId.has(p) && !LAYER_IDS.has(p)) continue // not a root + if (p === "boundaries") { + foreign.push({ id: c.id, xml: c.xml, parent: p }) + continue + } + const n = build(c, 0) + if (n) roots.push(n) + } + + const links = edges.map(toLink).filter((l): l is LinkSpec => l !== null) + + const pages = countPages(xml) + if (pages > 1) + warnings.push( + `Document has ${pages} pages; parsed page ${pageIndex + 1} only.`, + ) + + return { + tree: { roots, links, title, foreign }, + needsAdoption: !marked, + warnings, + } +} diff --git a/lib/diagram-engine/types.ts b/lib/diagram-engine/types.ts new file mode 100644 index 0000000..3fdd06e --- /dev/null +++ b/lib/diagram-engine/types.ts @@ -0,0 +1,173 @@ +/** + * The declarative node tree the layout engine works on. + * + * The model never writes coordinates. It declares nesting and direction; the engine + * computes every x/y/width/height. The tree is not persisted anywhere — it is + * re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas + * stays the single source of truth and a user's manual edits are an input, never + * something to be reconciled against a second copy of the state. + */ + +import type { Direction } from "./markers" + +export type { Direction } from "./markers" + +/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */ +export interface IconNode { + kind: "icon" + id: string + /** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */ + name: string + label: string + /** Glyph size in px. Defaults to the diagram's icon size. */ + size?: number + /** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */ + style?: string + /** User froze this node's position — the engine must not move it. */ + pinned?: boolean + /** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */ + rect?: Rect +} + +/** A plain labelled rectangle, for things the catalog has no icon for. */ +export interface BoxNode { + kind: "box" + id: string + label: string + w?: number + h?: number + fill?: string + stroke?: string + bold?: boolean + style?: string + pinned?: boolean + rect?: Rect +} + +/** A page title. At most one per diagram; laid out outside the tree flow. */ +export interface TitleNode { + kind: "title" + id: string + label: string +} + +/** + * A container that stacks its children in one direction. + * + * `gname` is the catalog group stencil (group_vpc, group_region, …). When null the + * container renders as a plain frame — a labelled rectangle with a border. + */ +export interface GroupNode { + kind: "group" + id: string + gname: string | null + label: string + dir: Extract + gap: number + children: DiagramNode[] + fill?: string + stroke?: string + style?: string + pinned?: boolean + rect?: Rect +} + +/** A container that packs its children into a fixed number of columns. */ +export interface GridNode { + kind: "grid" + id: string + gname: string | null + label: string + cols: number + gap: number + children: DiagramNode[] + fill?: string + stroke?: string + style?: string + pinned?: boolean + rect?: Rect +} + +export type ContainerNode = GroupNode | GridNode +export type LeafNode = IconNode | BoxNode | TitleNode +export type DiagramNode = ContainerNode | LeafNode + +export interface Rect { + x: number + y: number + w: number + h: number +} + +/** An arrow. Routing is the engine's business; the model only says what connects to what. */ +export interface LinkSpec { + /** Cell id, so an existing edge can be addressed by later operations. */ + id?: string + source: string + target: string + label?: string + /** Dashed line — replication, sync, policy, lineage. */ + dashed?: boolean + /** Step number, rendered as an "N. " prefix on the label. */ + step?: number + /** Verbatim style, when recovered from XML. */ + style?: string +} + +/** A whole diagram page: the node forest plus its arrows. */ +export interface DiagramTree { + /** Top-level nodes, in layout order. */ + roots: DiagramNode[] + links: LinkSpec[] + /** Page title, if the diagram has one. */ + title?: string + /** + * Cells the parser could not fit into the tree — a user's own annotation boxes, a + * legend, shapes from an imported file. Kept verbatim and re-emitted untouched so + * a re-layout never destroys work the engine does not understand. + */ + foreign: ForeignCell[] +} + +/** A cell carried through the round-trip without interpretation. */ +export interface ForeignCell { + id: string + /** The cell's own serialised XML, verbatim. */ + xml: string + /** Parent id at parse time, so it can be re-attached. */ + parent: string +} + +export function isContainer(n: DiagramNode): n is ContainerNode { + return n.kind === "group" || n.kind === "grid" +} + +export function isLeaf(n: DiagramNode): n is LeafNode { + return !isContainer(n) +} + +/** Depth-first walk over a node and its descendants. */ +export function* walk(n: DiagramNode): Generator { + yield n + if (isContainer(n)) for (const c of n.children) yield* walk(c) +} + +/** Every node in a tree, in document order. */ +export function* walkTree(t: DiagramTree): Generator { + for (const r of t.roots) yield* walk(r) +} + +/** Find a node by id, or null. */ +export function findNode(t: DiagramTree, id: string): DiagramNode | null { + for (const n of walkTree(t)) if (n.id === id) return n + return null +} + +/** The container holding `id`, or null when it is a root or absent. */ +export function findParent(t: DiagramTree, id: string): ContainerNode | null { + for (const n of walkTree(t)) { + if (!isContainer(n)) continue + if (n.children.some((c) => c.id === id)) return n + } + return null +} diff --git a/tests/unit/diagram-engine-markers.test.ts b/tests/unit/diagram-engine-markers.test.ts new file mode 100644 index 0000000..8376884 --- /dev/null +++ b/tests/unit/diagram-engine-markers.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest" +import { + hasMarkers, + isPinned, + readDir, + readIntMarker, + readKind, + readMarker, + stampContainer, + stampLeaf, + stripMarkers, +} from "@/lib/diagram-engine/markers" + +// Real catalog styles from drawio-ai-kit's catalog/aws.json, verbatim. group_vpc +// ships WITHOUT container=1; group_account ships WITH it. The engine has to handle both. +const VPC_STYLE = + "sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_vpc;strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#879196;dashed=0;" +const ACCOUNT_STYLE = + "points=[[0,0],[0.25,0],[0.5,0]];outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_account;strokeColor=#CD2264;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#CD2264;dashed=0;" + +describe("stampContainer", () => { + it("adds container tokens to a catalog style that lacks them", () => { + const s = stampContainer(VPC_STYLE, { + kind: "group", + dir: "col", + gap: 22, + }) + expect(s).toContain("container=1;") + expect(s).toContain("pointerEvents=0;") + expect(s).toContain("collapsible=0;") + expect(s).toContain("recursiveResize=0;") + }) + + it("is safe on a style that already declares container=1", () => { + const s = stampContainer(ACCOUNT_STYLE, { + kind: "group", + dir: "row", + gap: 30, + }) + // Duplicate keys are legal in draw.io and the LAST wins (verified in-browser), + // so appending a second container=1 keeps the shape a container. + expect(s.match(/container=1/g)?.length).toBe(2) + expect(readDir(s)).toBe("row") + }) + + it("records kind, dir and gap so the parser need not guess", () => { + const s = stampContainer(VPC_STYLE, { + kind: "group", + dir: "col", + gap: 22, + }) + expect(readKind(s)).toBe("group") + expect(readDir(s)).toBe("col") + expect(readIntMarker(s, "dai_gap")).toBe(22) + }) + + it("records cols only for a grid", () => { + const grid = stampContainer(VPC_STYLE, { + kind: "grid", + dir: "grid", + gap: 14, + cols: 3, + }) + expect(readIntMarker(grid, "dai_cols")).toBe(3) + + const group = stampContainer(VPC_STYLE, { + kind: "group", + dir: "row", + gap: 14, + cols: 3, + }) + expect(readIntMarker(group, "dai_cols")).toBeNull() + }) + + it("rounds a fractional gap", () => { + const s = stampContainer(VPC_STYLE, { + kind: "group", + dir: "col", + gap: 21.6, + }) + expect(readIntMarker(s, "dai_gap")).toBe(22) + }) + + it("adds the missing separator when a style does not end in ;", () => { + const s = stampContainer("rounded=0;fillColor=#FFF", { + kind: "group", + dir: "row", + gap: 10, + }) + expect(s).not.toContain("#FFFcontainer") + expect(s).toContain("#FFF;container=1;") + }) +}) + +describe("readMarker duplicate handling", () => { + it("returns the LAST value, mirroring how draw.io resolves duplicate keys", () => { + // Verified in-browser: container=0;...;container=1; behaves as a container. + expect(readMarker("a=1;dai_dir=row;b=2;dai_dir=col;", "dai_dir")).toBe( + "col", + ) + }) + + it("does not match a key that is only a suffix of another key", () => { + expect(readMarker("xdai_dir=row;", "dai_dir")).toBeNull() + }) + + it("returns null for an absent key", () => { + expect(readMarker(VPC_STYLE, "dai_dir")).toBeNull() + }) + + it("reads a marker at the very start of the style", () => { + expect(readMarker("dai_kind=box;rounded=0;", "dai_kind")).toBe("box") + }) +}) + +describe("readKind / readDir reject unknown values", () => { + it("rejects a kind that is not in the union", () => { + expect(readKind("dai_kind=wormhole;")).toBeNull() + }) + + it("rejects a direction that is not in the union", () => { + expect(readDir("dai_dir=diagonal;")).toBeNull() + }) +}) + +describe("readIntMarker", () => { + it("rejects a non-numeric value rather than returning NaN", () => { + expect(readIntMarker("dai_gap=wide;", "dai_gap")).toBeNull() + }) + + it("rejects a negative value", () => { + expect(readIntMarker("dai_gap=-5;", "dai_gap")).toBeNull() + }) + + it("accepts zero", () => { + expect(readIntMarker("dai_gap=0;", "dai_gap")).toBe(0) + }) +}) + +describe("isPinned", () => { + it("is false when the marker is absent", () => { + expect(isPinned(VPC_STYLE)).toBe(false) + }) + + it("is true for the value the engine writes", () => { + expect(isPinned("dai_pin=1;")).toBe(true) + }) + + it("accepts what a user might hand-type in draw.io's Edit Style dialog", () => { + expect(isPinned("dai_pin=yes;")).toBe(true) + expect(isPinned("dai_pin=true;")).toBe(true) + }) + + it("treats 0 / false / empty as not pinned, so a user can unpin by editing", () => { + expect(isPinned("dai_pin=0;")).toBe(false) + expect(isPinned("dai_pin=false;")).toBe(false) + expect(isPinned("dai_pin=;")).toBe(false) + }) +}) + +describe("stampLeaf", () => { + it("marks an icon", () => { + expect(readKind(stampLeaf("shape=mxgraph.aws4.ec2;", "icon"))).toBe( + "icon", + ) + }) + + it("marks a box", () => { + expect(readKind(stampLeaf("rounded=0;", "box"))).toBe("box") + }) +}) + +describe("stripMarkers", () => { + it("removes every dai_ token and keeps the rest intact", () => { + const stamped = stampContainer(VPC_STYLE, { + kind: "group", + dir: "col", + gap: 22, + }) + const stripped = stripMarkers(stamped) + expect(stripped).not.toContain("dai_") + // the real style tokens survive + expect(stripped).toContain("grIcon=mxgraph.aws4.group_vpc") + expect(stripped).toContain("container=1") + }) + + it("leaves a marker-free style semantically unchanged", () => { + expect(stripMarkers(VPC_STYLE)).toBe(VPC_STYLE) + }) +}) + +describe("hasMarkers", () => { + it("is false for a plain catalog style", () => { + expect(hasMarkers(VPC_STYLE)).toBe(false) + }) + + it("is true for engine output", () => { + expect( + hasMarkers( + stampContainer(VPC_STYLE, { + kind: "group", + dir: "row", + gap: 8, + }), + ), + ).toBe(true) + }) + + it("is not fooled by a key that merely contains dai_", () => { + expect(hasMarkers("mydai_dir=row;")).toBe(false) + }) +}) diff --git a/tests/unit/diagram-engine-parse.test.ts b/tests/unit/diagram-engine-parse.test.ts new file mode 100644 index 0000000..65a9ec1 --- /dev/null +++ b/tests/unit/diagram-engine-parse.test.ts @@ -0,0 +1,742 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { stampContainer } from "@/lib/diagram-engine/markers" +import { + countPages, + extractPage, + parseDiagram, +} from "@/lib/diagram-engine/parse" +import { + type ContainerNode, + type DiagramNode, + findNode, + findParent, + isContainer, + walkTree, +} from "@/lib/diagram-engine/types" + +/** + * Real output from drawio-ai-kit's examples/aws/build_vpc.mjs — the exact XML shape the + * parser has to handle. Structure declared by that script: + * + * root (phantom, row) + * ├── users (box) + * └── region (group_region, row) + * ├── vpc (group_vpc, col) + * │ ├── igw, alb (icons) + * │ └── azs (phantom, row) + * │ ├── az_a (group_availability_zone, col) → pub_a/app_a/db_a → nat_a/ec2_a/rds_a + * │ └── az_b (same, mirrored) + * └── reg_svc (plain frame, col) → waf, cw, s3 + * + * The two phantoms emit no cell, so their children are reparented to the nearest + * visible ancestor: users/region become roots, az_a/az_b become children of vpc. + */ +const ENGINE_XML = readFileSync( + join(__dirname, "fixtures/engine-vpc-multiaz.drawio"), + "utf8", +) + +/** Minimal page, built by hand so each test controls exactly one variable. */ +function page(cells: string): string { + return `${cells}` +} +function vtx( + id: string, + style: string, + geo: { x: number; y: number; w: number; h: number }, + opts: { parent?: string; value?: string } = {}, +): string { + return `` +} +const AWS_GROUP = (name: string) => + `sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;` +const RES_ICON = (name: string) => + `sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.${name};` +const OWN_SHAPE_ICON = (name: string) => + `sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.${name};` +const IMAGE_ICON = + "sketch=0;html=1;outlineConnect=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;fontColor=#232F3E;aspect=fixed;shape=image;image=data:image/png,iVBORw0KGgoAAAANSUhEUg==;" +const PLAIN_BOX = + "rounded=0;whiteSpace=wrap;html=1;fillColor=#DAE8FC;strokeColor=#6C8EBF;" + +describe("extractPage", () => { + it("pulls the model body out of an mxfile", () => { + const p = extractPage(ENGINE_XML) + expect(p).toContain(" { + const bare = `` + expect(extractPage(bare)).toContain(" { + const compressed = `7VvbcuI4EP0aHmfL8oXLY0gyu1O1t` + expect(extractPage(compressed)).toBeNull() + }) + + it("selects the requested page and clamps an out-of-range index", () => { + const two = `` + expect(extractPage(two, 0)).toContain("onlyA") + expect(extractPage(two, 1)).toContain("onlyB") + expect(extractPage(two, 99)).toContain("onlyB") + }) +}) + +describe("countPages", () => { + it("counts a multi-page deck", () => { + const two = `` + expect(countPages(two)).toBe(2) + }) + + it("reports 1 for a single page", () => { + expect(countPages(ENGINE_XML)).toBe(1) + }) +}) + +describe("parseDiagram on real engine output", () => { + const { tree, needsAdoption, warnings } = parseDiagram(ENGINE_XML) + + it("recovers the nesting the build script declared", () => { + // az_a is a child of vpc (the `azs` phantom emitted no cell) + expect(findParent(tree, "az_a")?.id).toBe("vpc") + expect(findParent(tree, "vpc")?.id).toBe("region") + expect(findParent(tree, "ec2_a")?.id).toBe("app_a") + expect(findParent(tree, "app_a")?.id).toBe("az_a") + expect(findParent(tree, "rds_b")?.id).toBe("db_b") + }) + + it("puts users and region at the top level", () => { + const rootIds = tree.roots.map((r) => r.id).sort() + expect(rootIds).toEqual(["region", "users"]) + }) + + it("classifies AWS group stencils as containers and keeps their stencil name", () => { + const vpc = findNode(tree, "vpc") + expect(isContainer(vpc as DiagramNode)).toBe(true) + expect((vpc as ContainerNode).gname).toBe("group_vpc") + expect((findNode(tree, "az_a") as ContainerNode).gname).toBe( + "group_availability_zone", + ) + expect((findNode(tree, "pub_a") as ContainerNode).gname).toBe( + "group_subnet", + ) + }) + + it("cannot recover a direction the phantom erased — the case that rules phantoms out", () => { + // The build script declared vpc as dir:"col" holding igw, alb, and a phantom + // wrapping the two AZ columns. A phantom emits NO cell, so its children were + // reparented onto vpc: igw(260,60) alb(260,164) az_a(24,268) az_b(309,268). + // That is a 2-D arrangement, so inference correctly reads "grid" — the original + // column structure is simply not in the XML any more. + // + // This is why our engine must not have phantoms: a wrapper that emits no cell + // makes the round-trip lossy by construction. See task #5. + expect(findNode(tree, "vpc")?.kind).toBe("grid") + expect(findParent(tree, "az_a")?.id).toBe("vpc") + expect(findNode(tree, "azs")).toBeNull() + }) + + it("classifies resourceIcon cells as icons and recovers their catalog name", () => { + const ec2 = findNode(tree, "ec2_a") + expect(ec2?.kind).toBe("icon") + expect(ec2 && "name" in ec2 ? ec2.name : null).toBe("ec2") + const nat = findNode(tree, "nat_a") + expect(nat && "name" in nat ? nat.name : null).toBe("nat_gateway") + }) + + it("classifies a plain rectangle as a box", () => { + expect(findNode(tree, "users")?.kind).toBe("box") + }) + + it("lifts the title out of the flow instead of leaving it as a node", () => { + expect(tree.title).toContain("VPC Multi-AZ 3-tier") + expect(findNode(tree, "__title")).toBeNull() + }) + + it("recovers every edge with its endpoints", () => { + expect(tree.links).toHaveLength(7) + const pairs = tree.links.map((l) => `${l.source}->${l.target}`) + expect(pairs).toContain("users->igw") + expect(pairs).toContain("alb->ec2_a") + expect(pairs).toContain("rds_a->rds_b") + }) + + it("splits a step number off the edge label", () => { + const first = tree.links.find( + (l) => l.source === "users" && l.target === "igw", + ) + expect(first?.step).toBe(1) + expect(first?.label).toBe("HTTPS") + }) + + it("marks a dashed edge", () => { + const repl = tree.links.find( + (l) => l.source === "rds_a" && l.target === "rds_b", + ) + expect(repl?.dashed).toBe(true) + expect(repl?.label).toBe("Multi-AZ replication") + }) + + it("flags the diagram as needing adoption — it carries no markers yet", () => { + // This fixture predates the marker scheme, so direction had to be inferred. + expect(needsAdoption).toBe(true) + }) + + it("parses without warnings on a well-formed single page", () => { + expect(warnings).toEqual([]) + }) + + it("assigns every cell exactly once — no duplicates, nothing lost", () => { + const ids = [...walkTree(tree)].map((n) => n.id) + expect(new Set(ids).size).toBe(ids.length) + // 24 vertex cells in the fixture, minus __title which is lifted out of the flow + expect(ids).toHaveLength(23) + }) +}) + +describe("layout inference (no markers present)", () => { + it("reads a row from children spread along x", () => { + const xml = page( + vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 20, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "b", + RES_ICON("ec2"), + { x: 120, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "c", + RES_ICON("rds"), + { x: 220, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode + expect(f.kind).toBe("group") + expect((f as { dir?: string }).dir).toBe("row") + }) + + it("reads a column from children spread along y", () => { + const xml = page( + vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 120, h: 400 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 30, y: 20, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "b", + RES_ICON("ec2"), + { x: 30, y: 120, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "c", + RES_ICON("rds"), + { x: 30, y: 220, w: 48, h: 48 }, + { parent: "f" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode + expect((f as { dir?: string }).dir).toBe("col") + }) + + it("recognises a 2-D arrangement as a grid rather than forcing row or column", () => { + const xml = page( + vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 300, h: 300 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 20, y: 20, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "b", + RES_ICON("ec2"), + { x: 140, y: 20, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "c", + RES_ICON("rds"), + { x: 20, y: 140, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "d", + RES_ICON("sqs"), + { x: 140, y: 140, w: 48, h: 48 }, + { parent: "f" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "f") + expect(f?.kind).toBe("grid") + }) + + it("measures the gap between neighbours, not between their origins", () => { + // icons 48 wide at x=20,120,220 → edge-to-edge gap is 52 + const xml = page( + vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 20, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "b", + RES_ICON("ec2"), + { x: 120, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "c", + RES_ICON("rds"), + { x: 220, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode + expect((f as { gap?: number }).gap).toBe(52) + }) +}) + +describe("markers take precedence over inference", () => { + it("believes dai_dir even when the geometry suggests otherwise", () => { + // children are laid out in a row, but the marker says col + const marked = stampContainer(AWS_GROUP("group_vpc"), { + kind: "group", + dir: "col", + gap: 33, + }) + const xml = page( + vtx("f", marked, { x: 0, y: 0, w: 400, h: 120 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 20, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ) + + vtx( + "b", + RES_ICON("ec2"), + { x: 120, y: 40, w: 48, h: 48 }, + { parent: "f" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode + expect((f as { dir?: string }).dir).toBe("col") + expect((f as { gap?: number }).gap).toBe(33) + }) + + it("clears needsAdoption once any cell carries a marker", () => { + const marked = stampContainer(AWS_GROUP("group_vpc"), { + kind: "group", + dir: "row", + gap: 20, + }) + const xml = page(vtx("f", marked, { x: 0, y: 0, w: 200, h: 100 })) + expect(parseDiagram(xml).needsAdoption).toBe(false) + }) + + it("recovers grid columns from dai_cols", () => { + const marked = stampContainer("rounded=0;html=1;", { + kind: "grid", + dir: "grid", + gap: 14, + cols: 3, + }) + const xml = page( + vtx("g", marked, { x: 0, y: 0, w: 300, h: 200 }) + + vtx( + "a", + RES_ICON("s3"), + { x: 10, y: 10, w: 48, h: 48 }, + { parent: "g" }, + ), + ) + const g = findNode(parseDiagram(xml).tree, "g") + expect(g?.kind).toBe("grid") + expect((g as { cols?: number }).cols).toBe(3) + }) + + it("carries a user pin through to the node", () => { + const xml = page( + vtx("b", `${PLAIN_BOX}dai_kind=box;dai_pin=1;`, { + x: 10, + y: 10, + w: 120, + h: 60, + }), + ) + const b = findNode(parseDiagram(xml).tree, "b") + expect((b as { pinned?: boolean }).pinned).toBe(true) + }) +}) + +describe("icon classification covers all four encodings", () => { + it("handles resIcon=, bare shape=, and shape=image", () => { + // 554 AWS icons use resIcon; 429 use their own shape=; Azure/GCP use shape=image. + const xml = page( + vtx("res", RES_ICON("ec2"), { x: 0, y: 0, w: 48, h: 48 }) + + vtx("own", OWN_SHAPE_ICON("a1_instance"), { + x: 100, + y: 0, + w: 48, + h: 48, + }) + + vtx("img", IMAGE_ICON, { x: 200, y: 0, w: 48, h: 48 }), + ) + const { tree } = parseDiagram(xml) + expect(findNode(tree, "res")?.kind).toBe("icon") + expect(findNode(tree, "own")?.kind).toBe("icon") + expect(findNode(tree, "img")?.kind).toBe("icon") + }) + + it("recovers the catalog name from a bare shape= icon", () => { + const xml = page( + vtx("own", OWN_SHAPE_ICON("a1_instance"), { + x: 0, + y: 0, + w: 48, + h: 48, + }), + ) + const n = findNode(parseDiagram(xml).tree, "own") + expect(n && "name" in n ? n.name : null).toBe("a1_instance") + }) + + it("leaves the name empty for an embedded-image icon instead of inventing one", () => { + const xml = page(vtx("img", IMAGE_ICON, { x: 0, y: 0, w: 48, h: 48 })) + const n = findNode(parseDiagram(xml).tree, "img") + expect(n && "name" in n ? n.name : null).toBe("") + // ...but the verbatim style is kept, so it can be re-emitted unchanged + expect(n && "style" in n ? n.style : "").toContain("data:image/png") + }) +}) + +describe("nesting when a frame lacks container=1", () => { + it("re-homes a cell that visually sits inside a loose frame", () => { + // draw.io will NOT reparent into a frame without container=1 (verified + // in-browser), so the user's drop leaves parent="1" while the shape is + // visually inside. Geometry is the truth here. + const xml = page( + vtx("loose", AWS_GROUP("group_vpc"), { + x: 100, + y: 100, + w: 400, + h: 300, + }) + + vtx("inside", RES_ICON("ec2"), { + x: 200, + y: 200, + w: 48, + h: 48, + }), + ) + const { tree } = parseDiagram(xml) + expect(findParent(tree, "inside")?.id).toBe("loose") + }) + + it("picks the innermost frame when loose frames nest", () => { + const xml = page( + vtx("outer", AWS_GROUP("group_region"), { + x: 0, + y: 0, + w: 800, + h: 600, + }) + + vtx("inner", AWS_GROUP("group_vpc"), { + x: 100, + y: 100, + w: 300, + h: 200, + }) + + vtx("leaf", RES_ICON("ec2"), { x: 150, y: 150, w: 48, h: 48 }), + ) + const { tree } = parseDiagram(xml) + expect(findParent(tree, "leaf")?.id).toBe("inner") + }) + + it("leaves a cell alone when it is outside every loose frame", () => { + const xml = page( + vtx("loose", AWS_GROUP("group_vpc"), { + x: 100, + y: 100, + w: 200, + h: 200, + }) + + vtx("outside", RES_ICON("ec2"), { + x: 600, + y: 600, + w: 48, + h: 48, + }), + ) + const { tree } = parseDiagram(xml) + expect(findParent(tree, "outside")).toBeNull() + expect(tree.roots.map((r) => r.id)).toContain("outside") + }) + + it("does NOT override an explicit parent that draw.io maintained", () => { + // With container=1 the parent attribute is authoritative — even if a stale + // geometry would place the child inside a different frame. + const withContainer = stampContainer(AWS_GROUP("group_vpc"), { + kind: "group", + dir: "row", + gap: 20, + }) + const xml = page( + vtx("real", withContainer, { x: 500, y: 0, w: 300, h: 200 }) + + vtx("loose", AWS_GROUP("group_region"), { + x: 0, + y: 0, + w: 400, + h: 400, + }) + + // parent says "real"; geometry (relative to real) lands it at 520,20 + vtx( + "kid", + RES_ICON("ec2"), + { x: 20, y: 20, w: 48, h: 48 }, + { parent: "real" }, + ), + ) + const { tree } = parseDiagram(xml) + expect(findParent(tree, "kid")?.id).toBe("real") + }) +}) + +describe("robustness", () => { + it("returns an empty tree with a warning for a compressed file", () => { + const compressed = `7VvbcuI4EP0aHmfLGHN5DCQzu1O1t` + const r = parseDiagram(compressed) + expect(r.tree.roots).toEqual([]) + expect(r.warnings[0]).toContain("compressed") + }) + + it("warns when it silently parsed only the first page of a deck", () => { + const two = `${vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 })}` + const r = parseDiagram(two) + expect(r.warnings.some((w) => w.includes("2 pages"))).toBe(true) + }) + + it("treats a cell whose parent does not exist as a root instead of dropping it", () => { + const xml = page( + vtx( + "orphan", + PLAIN_BOX, + { x: 0, y: 0, w: 100, h: 50 }, + { parent: "ghost" }, + ), + ) + const { tree } = parseDiagram(xml) + expect(tree.roots.map((r) => r.id)).toContain("orphan") + }) + + it("does not hang on a parent cycle", () => { + const cyclic = page( + vtx( + "a", + PLAIN_BOX, + { x: 0, y: 0, w: 100, h: 50 }, + { parent: "b" }, + ) + + vtx( + "b", + PLAIN_BOX, + { x: 0, y: 0, w: 100, h: 50 }, + { parent: "a" }, + ), + ) + const r = parseDiagram(cyclic) + expect(r.warnings.some((w) => w.includes("cycle"))).toBe(true) + }) + + it("unescapes entities in labels", () => { + const xml = page( + vtx( + "b", + PLAIN_BOX, + { x: 0, y: 0, w: 100, h: 50 }, + { + value: "A & B <tag> "q"", + }, + ), + ) + const b = findNode(parseDiagram(xml).tree, "b") + expect((b as { label?: string }).label).toBe('A & B "q"') + }) + + it("keeps cells on the boundaries layer verbatim instead of restructuring them", () => { + const xml = page( + `` + + vtx( + "cluster", + "rounded=0;dashed=1;fillColor=none;strokeColor=#ED7100;", + { + x: 10, + y: 10, + w: 200, + h: 100, + }, + { parent: "boundaries" }, + ), + ) + const { tree } = parseDiagram(xml) + expect(tree.foreign.map((f) => f.id)).toContain("cluster") + expect(findNode(tree, "cluster")).toBeNull() + expect(tree.foreign[0].xml).toContain("strokeColor=#ED7100") + }) + + it("ignores an edge that is missing an endpoint", () => { + const xml = page( + vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 }) + + ``, + ) + expect(parseDiagram(xml).tree.links).toHaveLength(0) + }) + + it("handles a self-closing mxCell", () => { + const xml = page( + ``, + ) + const { tree } = parseDiagram(xml) + expect(tree.roots.map((r) => r.id)).toContain("bare") + }) +}) + +describe("container classification edge cases", () => { + it("treats a plain box that has children as a container", () => { + const xml = page( + vtx("frame", PLAIN_BOX, { x: 0, y: 0, w: 300, h: 200 }) + + vtx( + "kid", + RES_ICON("s3"), + { x: 20, y: 20, w: 48, h: 48 }, + { parent: "frame" }, + ), + ) + const f = findNode(parseDiagram(xml).tree, "frame") + expect(f?.kind).toBe("group") + expect(isContainer(f as DiagramNode)).toBe(true) + }) + + it("treats a childless container=1 frame as a container, not a box", () => { + const xml = page( + vtx("empty", `${PLAIN_BOX}container=1;`, { + x: 0, + y: 0, + w: 200, + h: 100, + }), + ) + expect(findNode(parseDiagram(xml).tree, "empty")?.kind).toBe("group") + }) + + it("honours the last container= value when the key is duplicated", () => { + // Verified in-browser: draw.io resolves duplicate keys last-wins. + const xml = page( + vtx("c", `${PLAIN_BOX}container=1;container=0;`, { + x: 0, + y: 0, + w: 200, + h: 100, + }), + ) + // last value is 0 → not a container, and it has no children → a box + expect(findNode(parseDiagram(xml).tree, "c")?.kind).toBe("box") + }) + + it("recognises a text cell as the title even without the __title id", () => { + const xml = page( + ``, + ) + const { tree } = parseDiagram(xml) + expect(tree.title).toBe("My Diagram") + expect(findNode(tree, "t9")).toBeNull() + }) + + it("keeps the first title when a diagram somehow has two", () => { + const xml = page( + `` + + ``, + ) + expect(parseDiagram(xml).tree.title).toBe("First") + }) +}) + +describe("geometry resolution", () => { + it("resolves a nested cell's position to page coordinates", () => { + const withContainer = stampContainer(AWS_GROUP("group_vpc"), { + kind: "group", + dir: "row", + gap: 20, + }) + const xml = page( + vtx("outer", withContainer, { x: 100, y: 200, w: 400, h: 300 }) + + vtx( + "kid", + `${RES_ICON("ec2")}dai_kind=icon;dai_pin=1;`, + { + x: 30, + y: 40, + w: 48, + h: 48, + }, + { parent: "outer" }, + ), + ) + const kid = findNode(parseDiagram(xml).tree, "kid") + // 100+30, 200+40 + expect((kid as { rect?: { x: number; y: number } }).rect).toEqual({ + x: 130, + y: 240, + w: 48, + h: 48, + }) + }) + + it("resolves through two levels of nesting", () => { + const c = stampContainer(AWS_GROUP("group_vpc"), { + kind: "group", + dir: "col", + gap: 10, + }) + const xml = page( + vtx("l1", c, { x: 100, y: 100, w: 500, h: 400 }) + + vtx( + "l2", + c, + { x: 50, y: 60, w: 300, h: 200 }, + { parent: "l1" }, + ) + + vtx( + "leaf", + `${RES_ICON("s3")}dai_kind=icon;dai_pin=1;`, + { + x: 10, + y: 20, + w: 48, + h: 48, + }, + { parent: "l2" }, + ), + ) + const leaf = findNode(parseDiagram(xml).tree, "leaf") + // 100+50+10, 100+60+20 + expect((leaf as { rect?: { x: number; y: number } }).rect?.x).toBe(160) + expect((leaf as { rect?: { x: number; y: number } }).rect?.y).toBe(180) + }) +}) diff --git a/tests/unit/fixtures/engine-vpc-multiaz.drawio b/tests/unit/fixtures/engine-vpc-multiaz.drawio new file mode 100644 index 0000000..af255d1 --- /dev/null +++ b/tests/unit/fixtures/engine-vpc-multiaz.drawio @@ -0,0 +1 @@ + \ No newline at end of file