mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
666 lines
20 KiB
TypeScript
666 lines
20 KiB
TypeScript
|
|
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)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|