feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
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,
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
type GroupNode,
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
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 `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="p1"><mxGraphModel dx="1400" dy="900" grid="0" pageWidth="1200" pageHeight="800"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
}
|
|
|
|
|
function vtx(
|
|
|
|
|
id: string,
|
|
|
|
|
style: string,
|
|
|
|
|
geo: { x: number; y: number; w: number; h: number },
|
|
|
|
|
opts: { parent?: string; value?: string } = {},
|
|
|
|
|
): string {
|
|
|
|
|
return `<mxCell id="${id}" value="${opts.value ?? ""}" style="${style}" vertex="1" parent="${opts.parent ?? "1"}"><mxGeometry x="${geo.x}" y="${geo.y}" width="${geo.w}" height="${geo.h}" as="geometry"/></mxCell>`
|
|
|
|
|
}
|
|
|
|
|
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("<mxCell")
|
|
|
|
|
expect(p).not.toContain("<mxfile")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("accepts a bare mxGraphModel", () => {
|
|
|
|
|
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
|
|
|
|
expect(extractPage(bare)).toContain("<mxCell")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("returns null for a compressed page rather than pretending it is empty", () => {
|
|
|
|
|
const compressed = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfL8oXLY0gyu1O1t</diagram></mxfile>`
|
|
|
|
|
expect(extractPage(compressed)).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("selects the requested page and clamps an out-of-range index", () => {
|
|
|
|
|
const two = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyA"/></root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyB"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
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 = `<mxfile><diagram name="A" id="a"></diagram><diagram name="B" id="b"></diagram></mxfile>`
|
|
|
|
|
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)
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
expect((vpc as GroupNode).gname).toBe("group_vpc")
|
|
|
|
|
expect((findNode(tree, "az_a") as GroupNode).gname).toBe(
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
"group_availability_zone",
|
|
|
|
|
)
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
expect((findNode(tree, "pub_a") as GroupNode).gname).toBe(
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
"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.
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
//
|
|
|
|
|
// 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.
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
expect(findParent(tree, "az_a")?.id).toBe("vpc")
|
|
|
|
|
expect(findNode(tree, "azs")).toBeNull()
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
expect(
|
|
|
|
|
warnings.some(
|
|
|
|
|
(w) => w.includes("vpc") && w.includes("two dimensions"),
|
|
|
|
|
),
|
|
|
|
|
).toBe(true)
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
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")
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfLGHN5DCQzu1O1t</diagram></mxfile>`
|
|
|
|
|
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 = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 })}</root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
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 <tag> "q"')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps cells on the boundaries layer verbatim instead of restructuring them", () => {
|
|
|
|
|
const xml = page(
|
|
|
|
|
`<mxCell id="boundaries" value="Stack boundaries (locked)" parent="0" style="locked=1;"/>` +
|
|
|
|
|
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 }) +
|
|
|
|
|
`<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a"><mxGeometry relative="1" as="geometry"/></mxCell>`,
|
|
|
|
|
)
|
|
|
|
|
expect(parseDiagram(xml).tree.links).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("handles a self-closing mxCell", () => {
|
|
|
|
|
const xml = page(
|
|
|
|
|
`<mxCell id="bare" value="x" style="${PLAIN_BOX}" vertex="1" parent="1"/>`,
|
|
|
|
|
)
|
|
|
|
|
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(
|
|
|
|
|
`<mxCell id="t9" value="My Diagram" style="text;html=1;align=center;fontSize=14;" vertex="1" parent="1"><mxGeometry x="0" y="24" width="800" height="30" as="geometry"/></mxCell>`,
|
|
|
|
|
)
|
|
|
|
|
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(
|
|
|
|
|
`<mxCell id="t1" value="First" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="30" as="geometry"/></mxCell>` +
|
|
|
|
|
`<mxCell id="t2" value="Second" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="40" width="100" height="30" as="geometry"/></mxCell>`,
|
|
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|