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