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. // // The parser does not guess a direction here. It keeps the container and warns // that the arrangement is two-dimensional, so the caller knows a re-layout will // move these children rather than discovering it afterwards. expect(findParent(tree, "az_a")?.id).toBe("vpc") expect(findNode(tree, "azs")).toBeNull() expect( warnings.some( (w) => w.includes("vpc") && w.includes("two dimensions"), ), ).toBe(true) }) 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("warns only about the phantom-flattened container, nothing else", () => { // The single warning is the 2-D arrangement the phantom left behind; a // well-formed page produces no other complaint. expect(warnings).toHaveLength(1) expect(warnings[0]).toContain("two dimensions") }) 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) }) })