mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by declaring structure, and never writes an mxCell again. catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles are verbatim, so the official category colours, connection points and aspect=fixed come along for free and nothing is hand-assembled. An invented name is rejected with suggestions instead of rendering as a blank square, which is what draw.io does with an unknown resIcon today. operations.ts — what the model actually sends: add_icon / add_container / move / link / set_dir and so on, applied in order against the tree. Guards the things that break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges left pointing at a removed node, and moving a container inside itself. index.ts — the entry point. current XML → parse → apply ops → check names → layout → render → new XML. The tree is not stored between calls; it is re-derived from the canvas every time, so a user's manual edits are input to the next layout rather than state to reconcile. Token cost, measured with Claude's tokenizer rather than estimated: - build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x) - add one icon: 27 tok as an operation vs 3823 re-emitting (142x) - read current state: 216 tok as an outline vs 3180 as XML (14.7x) The 142x is the one that matters day to day: "add a Redis" is one operation, not a rewrite of the whole diagram. Routing in the system prompt sends AWS architecture through this path and leaves flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram — the layout engine's primitives (nested rows, columns, grids) do not model a sequence diagram's lifelines or a mind map's radial spread, and pretending otherwise would make those worse rather than better. Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms, and a narrow .gitignore exception so the generated catalog is tracked while the root data/ directory (admin settings, contains secrets) stays ignored. 403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders with real stencils and container markers; a second call adds one node and keeps everything from the first; an invented name is refused and nothing is drawn. The 13 existing diagram e2e tests still pass.
This commit is contained in:
202
tests/unit/diagram-engine-catalog.test.ts
Normal file
202
tests/unit/diagram-engine-catalog.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
CATALOG_SIZE,
|
||||
checkNames,
|
||||
lookupStencil,
|
||||
resolveStyle,
|
||||
searchStencils,
|
||||
} from "@/lib/diagram-engine/catalog"
|
||||
|
||||
describe("catalog contents", () => {
|
||||
it("carries the full AWS stencil set", () => {
|
||||
expect(CATALOG_SIZE.icons).toBe(983)
|
||||
expect(CATALOG_SIZE.groups).toBe(19)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lookupStencil", () => {
|
||||
it("returns the verbatim draw.io style, not a reconstruction", () => {
|
||||
const s3 = lookupStencil("s3")
|
||||
expect(s3?.style).toContain("shape=mxgraph.aws4.resourceIcon")
|
||||
expect(s3?.style).toContain("resIcon=mxgraph.aws4.s3")
|
||||
expect(s3?.style).toContain("aspect=fixed")
|
||||
})
|
||||
|
||||
it("carries the official category colour", () => {
|
||||
// Storage is #7AA116, Compute #ED7100 — from AWS's own palette.
|
||||
expect(lookupStencil("s3")?.color).toBe("#7AA116")
|
||||
expect(lookupStencil("ec2")?.color).toBe("#ED7100")
|
||||
})
|
||||
|
||||
it("finds an icon whose stencil has no resIcon token", () => {
|
||||
// 429 of the 983 AWS icons use a bare shape= instead of resourceIcon+resIcon.
|
||||
const a1 = lookupStencil("a1_instance")
|
||||
expect(a1).not.toBeNull()
|
||||
expect(a1?.style).toContain("shape=mxgraph.aws4.a1_instance")
|
||||
expect(a1?.style).not.toContain("resIcon=")
|
||||
})
|
||||
|
||||
it("finds group stencils", () => {
|
||||
for (const g of [
|
||||
"group_vpc",
|
||||
"group_region",
|
||||
"group_subnet",
|
||||
"group_availability_zone",
|
||||
"group_account",
|
||||
])
|
||||
expect(lookupStencil(g, "group")?.kind).toBe("group")
|
||||
})
|
||||
|
||||
it("returns null for a name the model invented, rather than guessing", () => {
|
||||
expect(lookupStencil("s3_bucket_thing")).toBeNull()
|
||||
expect(lookupStencil("totally_made_up_service")).toBeNull()
|
||||
})
|
||||
|
||||
it("respects the kind filter", () => {
|
||||
expect(lookupStencil("group_vpc", "icon")).toBeNull()
|
||||
expect(lookupStencil("s3", "group")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveStyle", () => {
|
||||
it("hands the renderer a style for a known name", () => {
|
||||
expect(resolveStyle("ec2", "icon")).toContain("mxgraph.aws4")
|
||||
})
|
||||
|
||||
it("returns null for an unknown name so the caller can decide", () => {
|
||||
expect(resolveStyle("nope", "icon")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchStencils", () => {
|
||||
it("ranks the plain service above its longer variants", () => {
|
||||
// Without a length penalty, "backup_aws_backup_support_for_amazon_s3"
|
||||
// scores the same as "s3" and can win by iteration order.
|
||||
expect(searchStencils("s3")[0].name).toBe("s3")
|
||||
expect(searchStencils("ec2")[0].name).toBe("ec2")
|
||||
expect(searchStencils("lambda")[0].name).toBe("lambda")
|
||||
})
|
||||
|
||||
it("finds a multi-word service", () => {
|
||||
const hits = searchStencils("nat gateway").map((h) => h.name)
|
||||
expect(hits).toContain("nat_gateway")
|
||||
})
|
||||
|
||||
it("maps shorthand onto tokens the catalog actually uses", () => {
|
||||
// AWS's stencil names are already abbreviated: EKS is "eks", and no name in the
|
||||
// catalog contains the word "kubernetes". So the alias has to resolve TO the
|
||||
// catalog's token, not to the spelled-out product name.
|
||||
expect(searchStencils("k8s")[0].name).toBe("eks")
|
||||
expect(searchStencils("kubernetes")[0].name).toBe("eks")
|
||||
expect(searchStencils("alb").map((h) => h.name)).toContain(
|
||||
"application_load_balancer",
|
||||
)
|
||||
expect(searchStencils("ddb")[0].name).toBe("dynamodb")
|
||||
expect(searchStencils("bucket")[0].name).toBe("s3")
|
||||
})
|
||||
|
||||
it("returns colours so the model can see what it is getting", () => {
|
||||
expect(searchStencils("s3")[0].color).toBe("#7AA116")
|
||||
})
|
||||
|
||||
it("does NOT return styles — they would be pure context burn", () => {
|
||||
const hit = searchStencils("s3")[0] as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(hit.style).toBeUndefined()
|
||||
})
|
||||
|
||||
it("honours the limit", () => {
|
||||
expect(searchStencils("aws", { limit: 3 })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("can search groups only", () => {
|
||||
const hits = searchStencils("vpc", { kind: "group" })
|
||||
expect(hits.length).toBeGreaterThan(0)
|
||||
expect(hits.every((h) => h.kind === "group")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns nothing for an empty query rather than the whole catalog", () => {
|
||||
expect(searchStencils("")).toEqual([])
|
||||
expect(searchStencils(" ")).toEqual([])
|
||||
})
|
||||
|
||||
it("returns nothing for a query that matches no stencil", () => {
|
||||
expect(searchStencils("zzzznotathing")).toEqual([])
|
||||
})
|
||||
|
||||
it("is case- and separator-insensitive", () => {
|
||||
const a = searchStencils("NAT_GATEWAY")[0].name
|
||||
const b = searchStencils("nat gateway")[0].name
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkNames", () => {
|
||||
it("passes a tree whose names are all real", () => {
|
||||
expect(
|
||||
checkNames([
|
||||
{ id: "a", name: "s3", kind: "icon" },
|
||||
{ id: "b", name: "ec2", kind: "icon" },
|
||||
{ id: "c", name: "group_vpc", kind: "group" },
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("catches an invented name and suggests real ones", () => {
|
||||
const bad = checkNames([
|
||||
{ id: "x", name: "s3_bucket_storage", kind: "icon" },
|
||||
])
|
||||
expect(bad).toHaveLength(1)
|
||||
expect(bad[0].id).toBe("x")
|
||||
expect(bad[0].suggestions.length).toBeGreaterThan(0)
|
||||
expect(bad[0].suggestions).toContain("s3")
|
||||
})
|
||||
|
||||
it("ignores a node with no name — a box, not an icon", () => {
|
||||
expect(checkNames([{ id: "b", name: "", kind: "icon" }])).toEqual([])
|
||||
})
|
||||
|
||||
it("reports every bad name, not just the first", () => {
|
||||
const bad = checkNames([
|
||||
{ id: "x", name: "fake_one", kind: "icon" },
|
||||
{ id: "y", name: "s3", kind: "icon" },
|
||||
{ id: "z", name: "fake_two", kind: "icon" },
|
||||
])
|
||||
expect(bad.map((b) => b.id)).toEqual(["x", "z"])
|
||||
})
|
||||
|
||||
it("catches a group name used where a group is expected", () => {
|
||||
const bad = checkNames([
|
||||
{ id: "g", name: "group_nonexistent", kind: "group" },
|
||||
])
|
||||
expect(bad).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("catalog styles are usable as-is", () => {
|
||||
it("every icon style names a shape draw.io can render", () => {
|
||||
// Spot-check a spread of names rather than all 983 — a systematic problem would
|
||||
// show up in any of them.
|
||||
for (const n of [
|
||||
"s3",
|
||||
"ec2",
|
||||
"lambda",
|
||||
"rds",
|
||||
"dynamodb",
|
||||
"a1_instance",
|
||||
"nat_gateway",
|
||||
]) {
|
||||
const st = lookupStencil(n)?.style ?? ""
|
||||
expect(st).toMatch(/shape=mxgraph\.aws4\./)
|
||||
}
|
||||
})
|
||||
|
||||
it("every group style carries grIcon and a container declaration", () => {
|
||||
for (const g of ["group_vpc", "group_region", "group_account"]) {
|
||||
const st = lookupStencil(g, "group")?.style ?? ""
|
||||
expect(st).toContain(`grIcon=mxgraph.aws4.${g}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
325
tests/unit/diagram-engine-index.test.ts
Normal file
325
tests/unit/diagram-engine-index.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { countTokens } from "@anthropic-ai/tokenizer"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
describeDiagram,
|
||||
type Operation,
|
||||
restructureDiagram,
|
||||
} from "@/lib/diagram-engine"
|
||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
||||
import { findNode, findParent } from "@/lib/diagram-engine/types"
|
||||
|
||||
/** The operations that build a 3-tier VPC diagram from nothing. */
|
||||
const BUILD_3TIER: Operation[] = [
|
||||
{ op: "set_title", title: "VPC Multi-AZ 3-tier" },
|
||||
{ op: "add_box", id: "users", label: "Users / Internet" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "region",
|
||||
label: "Region (ap-southeast-1)",
|
||||
dir: "row",
|
||||
gname: "group_region",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "vpc",
|
||||
parent: "region",
|
||||
label: "VPC 10.0.0.0/16",
|
||||
dir: "col",
|
||||
gname: "group_vpc",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "igw",
|
||||
parent: "vpc",
|
||||
name: "internet_gateway",
|
||||
label: "Internet Gateway",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "alb",
|
||||
parent: "vpc",
|
||||
name: "application_load_balancer",
|
||||
label: "ALB",
|
||||
},
|
||||
{ op: "add_container", id: "azs", parent: "vpc", label: "", dir: "row" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "az_a",
|
||||
parent: "azs",
|
||||
label: "AZ-a",
|
||||
dir: "col",
|
||||
gname: "group_availability_zone",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "pub_a",
|
||||
parent: "az_a",
|
||||
label: "Public Subnet",
|
||||
dir: "col",
|
||||
gname: "group_subnet",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "nat_a",
|
||||
parent: "pub_a",
|
||||
name: "nat_gateway",
|
||||
label: "NAT",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "app_a",
|
||||
parent: "az_a",
|
||||
label: "Private Subnet (App)",
|
||||
dir: "col",
|
||||
gname: "group_subnet",
|
||||
},
|
||||
{ op: "add_icon", id: "ec2_a", parent: "app_a", name: "ec2", label: "EC2" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "db_a",
|
||||
parent: "az_a",
|
||||
label: "Private Subnet (Data)",
|
||||
dir: "col",
|
||||
gname: "group_subnet",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "rds_a",
|
||||
parent: "db_a",
|
||||
name: "rds",
|
||||
label: "RDS (Primary)",
|
||||
},
|
||||
{ op: "link", source: "users", target: "igw", label: "HTTPS", step: 1 },
|
||||
{ op: "link", source: "igw", target: "alb", label: "forward", step: 2 },
|
||||
{ op: "link", source: "alb", target: "ec2_a", label: "route", step: 3 },
|
||||
{ op: "link", source: "ec2_a", target: "rds_a", label: "query", step: 4 },
|
||||
]
|
||||
|
||||
describe("restructureDiagram builds from an empty canvas", () => {
|
||||
const r = restructureDiagram("", BUILD_3TIER)
|
||||
|
||||
it("succeeds", () => {
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.xml).not.toBeNull()
|
||||
})
|
||||
|
||||
it("produces XML draw.io can load", () => {
|
||||
expect(r.xml).toContain("<mxfile")
|
||||
expect(r.xml).toContain('<mxCell id="0"/>')
|
||||
expect(r.xml).toContain('<mxCell id="1" parent="0"/>')
|
||||
})
|
||||
|
||||
it("resolves catalog names to verbatim stencil styles, with official colours", () => {
|
||||
// #ED7100 is AWS Compute orange; nothing here is hand-assembled.
|
||||
expect(r.xml).toContain("resIcon=mxgraph.aws4.ec2")
|
||||
expect(r.xml).toContain("#ED7100")
|
||||
expect(r.xml).toContain("grIcon=mxgraph.aws4.group_vpc")
|
||||
})
|
||||
|
||||
it("stamps container=1 so a user can drag shapes between frames", () => {
|
||||
const vpcStyle =
|
||||
r.xml?.match(/<mxCell id="vpc"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
||||
expect(vpcStyle).toContain("container=1")
|
||||
})
|
||||
|
||||
it("reads back the structure it was asked to build", () => {
|
||||
const { tree } = parseDiagram(r.xml as string)
|
||||
expect(findParent(tree, "vpc")?.id).toBe("region")
|
||||
expect(findParent(tree, "az_a")?.id).toBe("azs")
|
||||
expect(findParent(tree, "nat_a")?.id).toBe("pub_a")
|
||||
expect(tree.links).toHaveLength(4)
|
||||
expect(tree.title).toBe("VPC Multi-AZ 3-tier")
|
||||
})
|
||||
})
|
||||
|
||||
describe("restructureDiagram edits an existing canvas", () => {
|
||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
||||
|
||||
it("adds one node with one small operation", () => {
|
||||
const r = restructureDiagram(built, [
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "cache_a",
|
||||
parent: "app_a",
|
||||
name: "elasticache",
|
||||
label: "Redis",
|
||||
},
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
const { tree } = parseDiagram(r.xml as string)
|
||||
expect(findParent(tree, "cache_a")?.id).toBe("app_a")
|
||||
// everything else is still there
|
||||
expect(findNode(tree, "rds_a")).not.toBeNull()
|
||||
expect(tree.links).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("re-orients a container without touching anything else", () => {
|
||||
const r = restructureDiagram(built, [
|
||||
{ op: "set_dir", id: "vpc", dir: "row" },
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
const { tree } = parseDiagram(r.xml as string)
|
||||
expect((findNode(tree, "vpc") as { dir: string }).dir).toBe("row")
|
||||
})
|
||||
|
||||
it("removes a subtree and the edges that pointed into it", () => {
|
||||
const r = restructureDiagram(built, [{ op: "remove", id: "db_a" }])
|
||||
const { tree } = parseDiagram(r.xml as string)
|
||||
expect(findNode(tree, "db_a")).toBeNull()
|
||||
expect(findNode(tree, "rds_a")).toBeNull()
|
||||
// the ec2 → rds edge went with it
|
||||
expect(tree.links).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("keeps a colour the user changed by hand", () => {
|
||||
const edited = built.replace(
|
||||
/(<mxCell id="users"[^>]*style="[^"]*)"/,
|
||||
'$1fillColor=#FF0000;"',
|
||||
)
|
||||
const r = restructureDiagram(edited, [
|
||||
{ op: "set_label", id: "users", label: "Clients" },
|
||||
])
|
||||
expect(r.xml).toContain("fillColor=#FF0000")
|
||||
})
|
||||
|
||||
it("keeps a shape the user added by hand", () => {
|
||||
const edited = built.replace(
|
||||
"</root>",
|
||||
'<mxCell id="mynote" value="Note" style="shape=note;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="1200" y="60" width="140" height="80" as="geometry"/></mxCell></root>',
|
||||
)
|
||||
const r = restructureDiagram(edited, [
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "s3",
|
||||
parent: "vpc",
|
||||
name: "s3",
|
||||
label: "S3",
|
||||
},
|
||||
])
|
||||
expect(r.xml).toContain('id="mynote"')
|
||||
})
|
||||
|
||||
it("respects where the user dragged a node to", () => {
|
||||
// The user moved the EC2 icon from the app subnet into the public subnet.
|
||||
const moved = built.replace(
|
||||
/(<mxCell id="ec2_a"[^>]*)parent="app_a"/,
|
||||
'$1parent="pub_a"',
|
||||
)
|
||||
const r = restructureDiagram(moved, [
|
||||
{ op: "set_label", id: "ec2_a", label: "EC2 (moved)" },
|
||||
])
|
||||
const { tree } = parseDiagram(r.xml as string)
|
||||
expect(findParent(tree, "ec2_a")?.id).toBe("pub_a")
|
||||
})
|
||||
})
|
||||
|
||||
describe("invented stencil names are rejected, not rendered blank", () => {
|
||||
it("fails the whole call and suggests real names", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "x",
|
||||
name: "s3_bucket_storage",
|
||||
label: "Bucket",
|
||||
},
|
||||
])
|
||||
expect(r.xml).toBeNull()
|
||||
expect(r.errors[0]).toContain("not in the stencil catalog")
|
||||
expect(r.errors[0]).toContain("s3")
|
||||
})
|
||||
|
||||
it("rejects an invented group stencil too", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{
|
||||
op: "add_container",
|
||||
id: "g",
|
||||
label: "X",
|
||||
dir: "row",
|
||||
gname: "group_made_up",
|
||||
},
|
||||
])
|
||||
expect(r.xml).toBeNull()
|
||||
expect(r.errors[0]).toContain("not in the stencil catalog")
|
||||
})
|
||||
|
||||
it("still returns the outline so the model can see what it built", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_icon", id: "x", name: "nope_not_real" },
|
||||
])
|
||||
expect(r.outline).toContain("x: icon nope_not_real")
|
||||
})
|
||||
|
||||
it("reports a failed operation without rendering a half-built diagram", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_icon", id: "a", name: "s3" },
|
||||
{ op: "move", id: "ghost", parent: "a" },
|
||||
])
|
||||
expect(r.xml).toBeNull()
|
||||
expect(r.errors.some((e) => e.includes("ghost"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeDiagram", () => {
|
||||
it("reports an empty canvas plainly", () => {
|
||||
expect(describeDiagram("").outline).toBe("(empty canvas)")
|
||||
})
|
||||
|
||||
it("outlines what is on the canvas without changing it", () => {
|
||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
||||
const d = describeDiagram(built)
|
||||
expect(d.outline).toContain("vpc: col")
|
||||
expect(d.outline).toContain("ec2_a: icon ec2")
|
||||
expect(d.outline).toContain("link users -> igw")
|
||||
expect(d.needsAdoption).toBe(false)
|
||||
})
|
||||
|
||||
it("flags a diagram that did not come from the engine", () => {
|
||||
const foreign = `<mxfile><diagram name="Page-1" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="X" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
expect(describeDiagram(foreign).needsAdoption).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("token cost: operations versus raw XML", () => {
|
||||
// The reason for the whole exercise. Measured with Claude's own tokenizer, which the
|
||||
// repo already depends on, rather than a characters/4 estimate.
|
||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
||||
|
||||
it("building a diagram costs far fewer tokens as operations than as XML", () => {
|
||||
const asOps = countTokens(JSON.stringify(BUILD_3TIER))
|
||||
const asXml = countTokens(built)
|
||||
console.log(
|
||||
`build: ${asOps} tok as operations vs ${asXml} tok as XML (${(asXml / asOps).toFixed(1)}x)`,
|
||||
)
|
||||
expect(asOps).toBeLessThan(asXml / 2)
|
||||
})
|
||||
|
||||
it("adding one icon costs a fraction of re-emitting the diagram", () => {
|
||||
const oneOp: Operation[] = [
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "cache",
|
||||
parent: "app_a",
|
||||
name: "elasticache",
|
||||
label: "Redis",
|
||||
},
|
||||
]
|
||||
const opTokens = countTokens(JSON.stringify(oneOp))
|
||||
const xmlTokens = countTokens(
|
||||
restructureDiagram(built, oneOp).xml as string,
|
||||
)
|
||||
console.log(
|
||||
`add one icon: ${opTokens} tok as an operation vs ${xmlTokens} tok re-emitting the XML (${Math.round(xmlTokens / opTokens)}x)`,
|
||||
)
|
||||
expect(opTokens).toBeLessThan(60)
|
||||
expect(opTokens).toBeLessThan(xmlTokens / 20)
|
||||
})
|
||||
|
||||
it("the outline the model reads back is much cheaper than the XML", () => {
|
||||
const outlineTokens = countTokens(describeDiagram(built).outline)
|
||||
const xmlTokens = countTokens(built)
|
||||
console.log(
|
||||
`read current state: ${outlineTokens} tok as an outline vs ${xmlTokens} tok as XML (${(xmlTokens / outlineTokens).toFixed(1)}x)`,
|
||||
)
|
||||
expect(outlineTokens).toBeLessThan(xmlTokens / 3)
|
||||
})
|
||||
})
|
||||
665
tests/unit/diagram-engine-operations.test.ts
Normal file
665
tests/unit/diagram-engine-operations.test.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
applyOperations,
|
||||
collectNames,
|
||||
type Operation,
|
||||
OperationSchema,
|
||||
outline,
|
||||
} from "@/lib/diagram-engine/operations"
|
||||
import {
|
||||
type ContainerNode,
|
||||
type DiagramNode,
|
||||
type DiagramTree,
|
||||
findNode,
|
||||
findParent,
|
||||
type GroupNode,
|
||||
walkTree,
|
||||
} from "@/lib/diagram-engine/types"
|
||||
|
||||
const empty = (): DiagramTree => ({ roots: [], links: [], foreign: [] })
|
||||
|
||||
/** A small starting diagram: one frame holding two icons. */
|
||||
function base(): DiagramTree {
|
||||
return {
|
||||
roots: [
|
||||
{
|
||||
kind: "group",
|
||||
id: "vpc",
|
||||
gname: "group_vpc",
|
||||
label: "VPC",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [
|
||||
{
|
||||
kind: "icon",
|
||||
id: "alb",
|
||||
name: "application_load_balancer",
|
||||
label: "ALB",
|
||||
},
|
||||
{ kind: "icon", id: "ec2", name: "ec2", label: "EC2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
links: [{ source: "alb", target: "ec2" }],
|
||||
foreign: [],
|
||||
}
|
||||
}
|
||||
|
||||
const apply = (t: DiagramTree, ...ops: Operation[]) => applyOperations(t, ops)
|
||||
|
||||
describe("add operations", () => {
|
||||
it("adds an icon into a container", () => {
|
||||
const { tree, errors } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "rds",
|
||||
parent: "vpc",
|
||||
name: "rds",
|
||||
label: "RDS",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
expect(findParent(tree, "rds")?.id).toBe("vpc")
|
||||
expect((findNode(tree, "rds") as { name: string }).name).toBe("rds")
|
||||
})
|
||||
|
||||
it("adds at the top level when no parent is given", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "add_box",
|
||||
id: "users",
|
||||
label: "Users",
|
||||
})
|
||||
expect(tree.roots.map((r) => r.id)).toContain("users")
|
||||
})
|
||||
|
||||
it("inserts after a named sibling", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "mid",
|
||||
parent: "vpc",
|
||||
name: "s3",
|
||||
after: "alb",
|
||||
})
|
||||
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
|
||||
(c) => c.id,
|
||||
)
|
||||
expect(kids).toEqual(["alb", "mid", "ec2"])
|
||||
})
|
||||
|
||||
it("appends when the named sibling does not exist", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "last",
|
||||
parent: "vpc",
|
||||
name: "s3",
|
||||
after: "ghost",
|
||||
})
|
||||
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
|
||||
(c) => c.id,
|
||||
)
|
||||
expect(kids[kids.length - 1]).toBe("last")
|
||||
})
|
||||
|
||||
it("adds a container and lets a later op fill it, in one batch", () => {
|
||||
const { tree, errors } = apply(
|
||||
base(),
|
||||
{
|
||||
op: "add_container",
|
||||
id: "subnet",
|
||||
parent: "vpc",
|
||||
label: "Private Subnet",
|
||||
dir: "col",
|
||||
gname: "group_subnet",
|
||||
},
|
||||
{ op: "move", id: "ec2", parent: "subnet" },
|
||||
)
|
||||
expect(errors).toEqual([])
|
||||
expect(findParent(tree, "ec2")?.id).toBe("subnet")
|
||||
expect(findParent(tree, "subnet")?.id).toBe("vpc")
|
||||
})
|
||||
|
||||
it("adds a grid with its column count", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "add_grid",
|
||||
id: "area",
|
||||
parent: "vpc",
|
||||
label: "Services",
|
||||
cols: 3,
|
||||
})
|
||||
const g = findNode(tree, "area")
|
||||
expect(g?.kind).toBe("grid")
|
||||
expect((g as { cols: number }).cols).toBe(3)
|
||||
})
|
||||
|
||||
it("clamps a nonsensical column count instead of producing a broken grid", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "add_grid",
|
||||
id: "area",
|
||||
label: "X",
|
||||
cols: 0,
|
||||
})
|
||||
expect((findNode(tree, "area") as { cols: number }).cols).toBe(1)
|
||||
})
|
||||
|
||||
it("rejects a duplicate id — draw.io silently drops one of two cells sharing an id", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "ec2",
|
||||
parent: "vpc",
|
||||
name: "s3",
|
||||
})
|
||||
expect(errors[0]).toContain("already taken")
|
||||
})
|
||||
|
||||
it("rejects adding into something that is not a container", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "x",
|
||||
parent: "ec2",
|
||||
name: "s3",
|
||||
})
|
||||
expect(errors[0]).toContain("not a container")
|
||||
})
|
||||
|
||||
it("rejects adding into a nonexistent parent", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "add_icon",
|
||||
id: "x",
|
||||
parent: "ghost",
|
||||
name: "s3",
|
||||
})
|
||||
expect(errors[0]).toContain("ghost")
|
||||
})
|
||||
})
|
||||
|
||||
describe("remove", () => {
|
||||
it("removes a leaf", () => {
|
||||
const { tree, errors } = apply(base(), { op: "remove", id: "ec2" })
|
||||
expect(errors).toEqual([])
|
||||
expect(findNode(tree, "ec2")).toBeNull()
|
||||
})
|
||||
|
||||
it("removes a container together with its descendants", () => {
|
||||
const { tree } = apply(base(), { op: "remove", id: "vpc" })
|
||||
expect(findNode(tree, "vpc")).toBeNull()
|
||||
expect(findNode(tree, "alb")).toBeNull()
|
||||
expect(findNode(tree, "ec2")).toBeNull()
|
||||
})
|
||||
|
||||
it("removes edges that touched the deleted subtree", () => {
|
||||
// Leaving them would render as an arrow pointing at nothing.
|
||||
const { tree } = apply(base(), { op: "remove", id: "ec2" })
|
||||
expect(tree.links).toEqual([])
|
||||
})
|
||||
|
||||
it("removes edges anchored deep inside a removed container", () => {
|
||||
const t = base()
|
||||
t.links.push({ source: "alb", target: "alb" })
|
||||
const { tree } = apply(t, { op: "remove", id: "vpc" })
|
||||
expect(tree.links).toEqual([])
|
||||
})
|
||||
|
||||
it("reports a removal of something that is not there", () => {
|
||||
const { errors } = apply(base(), { op: "remove", id: "ghost" })
|
||||
expect(errors[0]).toContain("ghost")
|
||||
})
|
||||
})
|
||||
|
||||
describe("move", () => {
|
||||
it("moves a node between containers", () => {
|
||||
const t = base()
|
||||
;(t.roots[0] as GroupNode).children.push({
|
||||
kind: "group",
|
||||
id: "other",
|
||||
gname: null,
|
||||
label: "Other",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [],
|
||||
})
|
||||
const { tree, errors } = apply(t, {
|
||||
op: "move",
|
||||
id: "ec2",
|
||||
parent: "other",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
expect(findParent(tree, "ec2")?.id).toBe("other")
|
||||
})
|
||||
|
||||
it("moves a node to the top level", () => {
|
||||
const { tree } = apply(base(), { op: "move", id: "ec2" })
|
||||
expect(tree.roots.map((r) => r.id)).toContain("ec2")
|
||||
expect(findParent(tree, "ec2")).toBeNull()
|
||||
})
|
||||
|
||||
it("reorders within the same container", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "move",
|
||||
id: "alb",
|
||||
parent: "vpc",
|
||||
after: "ec2",
|
||||
})
|
||||
expect(
|
||||
(findNode(tree, "vpc") as ContainerNode).children.map((c) => c.id),
|
||||
).toEqual(["ec2", "alb"])
|
||||
})
|
||||
|
||||
it("keeps the moved node's own children with it", () => {
|
||||
const t: DiagramTree = {
|
||||
roots: [
|
||||
{
|
||||
kind: "group",
|
||||
id: "a",
|
||||
gname: null,
|
||||
label: "A",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [
|
||||
{
|
||||
kind: "group",
|
||||
id: "sub",
|
||||
gname: null,
|
||||
label: "Sub",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [
|
||||
{
|
||||
kind: "icon",
|
||||
id: "leaf",
|
||||
name: "s3",
|
||||
label: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
id: "b",
|
||||
gname: null,
|
||||
label: "B",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
foreign: [],
|
||||
}
|
||||
const { tree } = apply(t, { op: "move", id: "sub", parent: "b" })
|
||||
expect(findParent(tree, "sub")?.id).toBe("b")
|
||||
expect(findParent(tree, "leaf")?.id).toBe("sub")
|
||||
})
|
||||
|
||||
it("refuses to move a container into itself", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "move",
|
||||
id: "vpc",
|
||||
parent: "vpc",
|
||||
})
|
||||
expect(errors[0]).toContain("inside itself")
|
||||
})
|
||||
|
||||
it("refuses to move a container into its own descendant", () => {
|
||||
const { errors, tree } = apply(base(), {
|
||||
op: "move",
|
||||
id: "vpc",
|
||||
parent: "ec2",
|
||||
})
|
||||
// ec2 is a leaf, so this is caught as "not a container" — either way the tree
|
||||
// must survive intact rather than losing the subtree into a cycle.
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(findNode(tree, "vpc")).not.toBeNull()
|
||||
expect(findNode(tree, "ec2")).not.toBeNull()
|
||||
})
|
||||
|
||||
it("refuses to move a container into a nested descendant container", () => {
|
||||
const t: DiagramTree = {
|
||||
roots: [
|
||||
{
|
||||
kind: "group",
|
||||
id: "outer",
|
||||
gname: null,
|
||||
label: "O",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [
|
||||
{
|
||||
kind: "group",
|
||||
id: "inner",
|
||||
gname: null,
|
||||
label: "I",
|
||||
dir: "col",
|
||||
gap: 20,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
foreign: [],
|
||||
}
|
||||
const { errors, tree } = apply(t, {
|
||||
op: "move",
|
||||
id: "outer",
|
||||
parent: "inner",
|
||||
})
|
||||
expect(errors[0]).toContain("inside itself")
|
||||
expect(findNode(tree, "outer")).not.toBeNull()
|
||||
expect(findParent(tree, "inner")?.id).toBe("outer")
|
||||
})
|
||||
|
||||
it("reports a move of something that is not there", () => {
|
||||
const { errors } = apply(base(), { op: "move", id: "ghost" })
|
||||
expect(errors[0]).toContain("ghost")
|
||||
})
|
||||
})
|
||||
|
||||
describe("property setters", () => {
|
||||
it("renames a node", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "set_label",
|
||||
id: "vpc",
|
||||
label: "Production VPC",
|
||||
})
|
||||
expect((findNode(tree, "vpc") as ContainerNode).label).toBe(
|
||||
"Production VPC",
|
||||
)
|
||||
})
|
||||
|
||||
it("re-orients a container", () => {
|
||||
const { tree } = apply(base(), { op: "set_dir", id: "vpc", dir: "row" })
|
||||
expect((findNode(tree, "vpc") as GroupNode).dir).toBe("row")
|
||||
})
|
||||
|
||||
it("refuses set_dir on a grid, whose layout is driven by its column count", () => {
|
||||
const t = apply(base(), {
|
||||
op: "add_grid",
|
||||
id: "g",
|
||||
label: "G",
|
||||
cols: 2,
|
||||
}).tree
|
||||
const { errors } = apply(t, { op: "set_dir", id: "g", dir: "row" })
|
||||
expect(errors[0]).toContain("grid")
|
||||
})
|
||||
|
||||
it("refuses set_dir on a leaf", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "set_dir",
|
||||
id: "ec2",
|
||||
dir: "row",
|
||||
})
|
||||
expect(errors[0]).toContain("not a container")
|
||||
})
|
||||
|
||||
it("changes a gap and refuses a negative one", () => {
|
||||
expect(
|
||||
(
|
||||
apply(base(), { op: "set_gap", id: "vpc", gap: 40 }).tree
|
||||
.roots[0] as GroupNode
|
||||
).gap,
|
||||
).toBe(40)
|
||||
expect(
|
||||
(
|
||||
apply(base(), { op: "set_gap", id: "vpc", gap: -10 }).tree
|
||||
.roots[0] as GroupNode
|
||||
).gap,
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it("sets the page title", () => {
|
||||
const { tree } = apply(base(), { op: "set_title", title: "My Diagram" })
|
||||
expect(tree.title).toBe("My Diagram")
|
||||
})
|
||||
})
|
||||
|
||||
describe("links", () => {
|
||||
it("adds an edge", () => {
|
||||
const { tree, errors } = apply(base(), {
|
||||
op: "link",
|
||||
source: "ec2",
|
||||
target: "alb",
|
||||
label: "response",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
expect(tree.links).toHaveLength(2)
|
||||
expect(tree.links[1].label).toBe("response")
|
||||
})
|
||||
|
||||
it("carries dashed and step through", () => {
|
||||
const { tree } = apply(base(), {
|
||||
op: "link",
|
||||
source: "ec2",
|
||||
target: "alb",
|
||||
dashed: true,
|
||||
step: 3,
|
||||
})
|
||||
const l = tree.links[1]
|
||||
expect(l.dashed).toBe(true)
|
||||
expect(l.step).toBe(3)
|
||||
})
|
||||
|
||||
it("refuses an edge to a node that does not exist", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "link",
|
||||
source: "ec2",
|
||||
target: "ghost",
|
||||
})
|
||||
expect(errors[0]).toContain("ghost")
|
||||
})
|
||||
|
||||
it("refuses a duplicate edge instead of drawing two arrows on top of each other", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "link",
|
||||
source: "alb",
|
||||
target: "ec2",
|
||||
})
|
||||
expect(errors[0]).toContain("already exists")
|
||||
})
|
||||
|
||||
it("removes an edge", () => {
|
||||
const { tree, errors } = apply(base(), {
|
||||
op: "unlink",
|
||||
source: "alb",
|
||||
target: "ec2",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
expect(tree.links).toEqual([])
|
||||
})
|
||||
|
||||
it("reports unlinking an edge that is not there", () => {
|
||||
const { errors } = apply(base(), {
|
||||
op: "unlink",
|
||||
source: "ec2",
|
||||
target: "alb",
|
||||
})
|
||||
expect(errors[0]).toContain("no edge")
|
||||
})
|
||||
})
|
||||
|
||||
describe("batch semantics", () => {
|
||||
it("does not mutate the input tree", () => {
|
||||
const original = base()
|
||||
const snapshot = JSON.stringify(original)
|
||||
apply(original, { op: "remove", id: "ec2" })
|
||||
expect(JSON.stringify(original)).toBe(snapshot)
|
||||
})
|
||||
|
||||
it("applies later ops against the result of earlier ones", () => {
|
||||
const { tree, errors } = apply(
|
||||
base(),
|
||||
{
|
||||
op: "add_container",
|
||||
id: "az",
|
||||
parent: "vpc",
|
||||
label: "AZ",
|
||||
dir: "col",
|
||||
},
|
||||
{ op: "add_icon", id: "nat", parent: "az", name: "nat_gateway" },
|
||||
{ op: "link", source: "nat", target: "ec2" },
|
||||
)
|
||||
expect(errors).toEqual([])
|
||||
expect(findParent(tree, "nat")?.id).toBe("az")
|
||||
expect(tree.links).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("keeps going after a failed op and reports each failure", () => {
|
||||
const { tree, errors } = apply(
|
||||
base(),
|
||||
{ op: "remove", id: "ghost" },
|
||||
{ op: "add_icon", id: "s3", parent: "vpc", name: "s3" },
|
||||
{ op: "set_dir", id: "ec2", dir: "row" },
|
||||
)
|
||||
expect(errors).toHaveLength(2)
|
||||
// the valid op in the middle still took effect
|
||||
expect(findNode(tree, "s3")).not.toBeNull()
|
||||
})
|
||||
|
||||
it("builds a whole diagram from an empty canvas", () => {
|
||||
const { tree, errors } = apply(
|
||||
empty(),
|
||||
{ op: "set_title", title: "Three Tier" },
|
||||
{ op: "add_box", id: "users", label: "Users" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "region",
|
||||
label: "Region",
|
||||
dir: "row",
|
||||
gname: "group_region",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "vpc",
|
||||
parent: "region",
|
||||
label: "VPC",
|
||||
dir: "col",
|
||||
gname: "group_vpc",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "alb",
|
||||
parent: "vpc",
|
||||
name: "application_load_balancer",
|
||||
label: "ALB",
|
||||
},
|
||||
{
|
||||
op: "add_icon",
|
||||
id: "ec2",
|
||||
parent: "vpc",
|
||||
name: "ec2",
|
||||
label: "EC2",
|
||||
},
|
||||
{ op: "link", source: "users", target: "alb", step: 1 },
|
||||
{ op: "link", source: "alb", target: "ec2", step: 2 },
|
||||
)
|
||||
expect(errors).toEqual([])
|
||||
expect(tree.title).toBe("Three Tier")
|
||||
expect(tree.roots.map((r) => r.id)).toEqual(["users", "region"])
|
||||
expect(findParent(tree, "vpc")?.id).toBe("region")
|
||||
expect(tree.links).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("OperationSchema", () => {
|
||||
it("accepts a well-formed operation", () => {
|
||||
expect(
|
||||
OperationSchema.safeParse({
|
||||
op: "add_icon",
|
||||
id: "a",
|
||||
name: "s3",
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects an unknown op name", () => {
|
||||
expect(
|
||||
OperationSchema.safeParse({ op: "teleport", id: "a" }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects a missing required field", () => {
|
||||
expect(
|
||||
OperationSchema.safeParse({ op: "add_icon", id: "a" }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects a direction outside the union", () => {
|
||||
expect(
|
||||
OperationSchema.safeParse({
|
||||
op: "set_dir",
|
||||
id: "a",
|
||||
dir: "diagonal",
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("collectNames", () => {
|
||||
it("returns every icon and group name for catalog checking", () => {
|
||||
const names = collectNames(base())
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "vpc", name: "group_vpc", kind: "group" },
|
||||
{ id: "alb", name: "application_load_balancer", kind: "icon" },
|
||||
{ id: "ec2", name: "ec2", kind: "icon" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("skips a plain frame, which has no stencil to check", () => {
|
||||
const t = apply(base(), {
|
||||
op: "add_container",
|
||||
id: "plain",
|
||||
label: "Plain",
|
||||
dir: "row",
|
||||
}).tree
|
||||
expect(collectNames(t).map((n) => n.id)).not.toContain("plain")
|
||||
})
|
||||
|
||||
it("skips a box", () => {
|
||||
const t = apply(base(), { op: "add_box", id: "b", label: "B" }).tree
|
||||
expect(collectNames(t).map((n) => n.id)).not.toContain("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("outline", () => {
|
||||
it("shows nesting, kinds and links compactly", () => {
|
||||
const text = outline(base())
|
||||
expect(text).toContain("vpc: col")
|
||||
expect(text).toContain("alb: icon application_load_balancer")
|
||||
expect(text).toContain("link alb -> ec2")
|
||||
})
|
||||
|
||||
it("includes the title", () => {
|
||||
const t = apply(base(), { op: "set_title", title: "T" }).tree
|
||||
expect(outline(t)).toContain("title: T")
|
||||
})
|
||||
|
||||
it("marks an unlabelled container as a wrapper so its purpose is clear", () => {
|
||||
const t = apply(base(), {
|
||||
op: "add_container",
|
||||
id: "w",
|
||||
label: "",
|
||||
dir: "row",
|
||||
}).tree
|
||||
expect(outline(t)).toContain("w: row (wrapper)")
|
||||
})
|
||||
|
||||
it("notes cells kept verbatim, so the model knows they exist but are not its to edit", () => {
|
||||
const t = base()
|
||||
t.foreign.push({ id: "note", xml: '<mxCell id="note"/>', parent: "1" })
|
||||
expect(outline(t)).toContain("1 cell(s) kept as-is: note")
|
||||
})
|
||||
|
||||
it("is far more compact than the JSON tree", () => {
|
||||
const t = base()
|
||||
expect(outline(t).length).toBeLessThan(JSON.stringify(t).length / 2)
|
||||
})
|
||||
|
||||
it("shows every node exactly once", () => {
|
||||
const t = base()
|
||||
const text = outline(t)
|
||||
for (const n of walkTree(t)) {
|
||||
const hits = text.split("\n").filter((l) => l.includes(`${n.id}:`))
|
||||
expect(hits).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user