feat(diagram-engine): corners, borderless fills, shadows and strikethrough

Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:

  rounded-*      mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
                 absolute pixels and halves it, so the same class is the same
                 corner on every box. Previously excluded as 'percentage only'
  shadow-sm..xl  mxShape.js:505-535 — getShadowStyle reads five independent
                 params, not one flag, so Tailwind's offset+blur rungs map one
                 to one. Previously excluded as 'six sizes collapse to one'
  line-through   mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
                 mxText.js:723 and :1040. The bitmask has four bits, not three
  border-none    the only one of the four that adds something previously
                 inexpressible: a fill with no outline

Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.

Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.

Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).

615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
This commit is contained in:
dayuan.jiang
2026-08-11 09:09:45 +09:00
parent 8687e8f04b
commit 0b03e15336
25 changed files with 3669 additions and 1234 deletions

View File

@@ -95,7 +95,7 @@ describe("add_graph", () => {
expect(right.w).toBeGreaterThan(right.h)
})
it("reports unknown edge endpoints as an error", () => {
it("draws the rest of the graph and warns about an unknown edge endpoint", () => {
const r = restructureDiagram("", [
{
op: "add_graph",
@@ -104,6 +104,10 @@ describe("add_graph", () => {
edges: [{ source: "a", target: "ghost" }],
},
])
expect(r.errors.join(" ")).toContain("ghost")
// A warning, not an error: node "a" is perfectly drawable, and rejecting the whole
// call would cost a turn to arrive back at the same diagram.
expect(r.errors).toEqual([])
expect(r.xml).toBeTruthy()
expect(r.warnings.join(" ")).toContain("ghost")
})
})

View File

@@ -90,11 +90,62 @@ describe("grow", () => {
const flat = make(false)
const grown = make(true)
const extraMain = rectOf(grown, "main").w - rectOf(flat, "main").w
const extraSide = rectOf(grown, "side").w - rectOf(flat, "side").w
// The slack lands on the columns instead of the gaps, split 2:1.
expect(extraMain).toBeGreaterThan(0)
expect(extraMain / extraSide).toBeCloseTo(2, 0)
// The weights make main wider than it would be on its own content...
expect(rectOf(grown, "main").w).toBeGreaterThan(rectOf(flat, "main").w)
// ...but NOT the full 2:1, and that is correct rather than a shortfall. `side`
// will not shrink below the width of its own text, so the ratio settles wherever
// that floor allows. A browser does exactly the same: `min-width` defaults to
// `auto`, so a `flex: 2` column stops shrinking at its content too.
const ratio = rectOf(grown, "main").w / rectOf(grown, "side").w
expect(ratio).toBeGreaterThan(1.3)
expect(ratio).toBeLessThan(2.1)
})
it("min-w-0 lets the weights win over the content width", () => {
// The CSS escape hatch, same spelling: with min-w-0 the narrow column may be
// squeezed under its own text, so a declared 2:1 really comes out 2:1.
const r = restructureDiagram("", [
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
{
op: "add_box",
id: "mast",
parent: "page",
label: "A very wide masthead banner that stretches the page out",
role: "banner",
},
{
op: "add_container",
id: "cols",
parent: "page",
label: "",
dir: "row",
gap: 16,
},
{
op: "add_container",
id: "main",
parent: "cols",
label: "",
dir: "col",
class: "grow-2 min-w-0",
},
{
op: "add_container",
id: "side",
parent: "cols",
label: "",
dir: "col",
class: "grow-1 min-w-0",
},
{ op: "add_box", id: "a", parent: "main", label: "main content" },
{ op: "add_box", id: "b", parent: "side", label: "aside" },
])
expect(r.errors).toEqual([])
const xml = r.xml as string
const ratio = rectOf(xml, "main").w / rectOf(xml, "side").w
expect(ratio).toBeCloseTo(2, 0)
})
})

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest"
import {
drawGraph,
type GraphEdge,
type GraphNode,
graphToOperations,
type Operation,
restructureDiagram,
} from "@/lib/diagram-engine"
import {
@@ -20,6 +20,31 @@ const e = (source: string, target: string, label?: string): GraphEdge => ({
...(label ? { label } : {}),
})
/**
* Draw a whole-page graph the way the model does: one `add_graph` with no parent.
*
* There used to be a `drawGraph` entry point that did only this. It was removed once
* `add_graph` covered the same ground with a `parent` argument — one code path instead of
* two overlapping ones — and these tests kept their assertions by going through this.
*/
function drawGraph(
nodes: GraphNode[],
edges: GraphEdge[],
opts: { title?: string; flow?: "col" | "row" } = {},
) {
const ops: Operation[] = [
{
op: "add_graph",
id: "g",
nodes,
edges,
...(opts.flow ? { dir: opts.flow } : {}),
} as Operation,
]
if (opts.title) ops.unshift({ op: "set_title", title: opts.title })
return restructureDiagram("", ops)
}
describe("graphToOperations: layering", () => {
it("puts a chain in one node per layer", () => {
const { layers } = graphToOperations(

View File

@@ -47,12 +47,38 @@ describe("stampContainer", () => {
dir: "row",
gap: 30,
})
// Duplicate keys are legal in draw.io and the LAST wins (verified in-browser),
// so appending a second container=1 keeps the shape a container.
expect(s.match(/container=1/g)?.length).toBe(2)
// Stated exactly once. Duplicate keys are legal in draw.io and the last one wins
// (verified in-browser), so a second copy was harmless to render — but a container
// is re-stamped on EVERY layout, so appending unconditionally grew the style by
// another copy per round-trip and the XML never settled.
expect(s.match(/container=1/g)?.length).toBe(1)
expect(readDir(s)).toBe("row")
})
it("re-stamping is idempotent, so a round-trip settles", () => {
const once = stampContainer(ACCOUNT_STYLE, {
kind: "group",
dir: "row",
gap: 30,
})
const twice = stampContainer(once, {
kind: "group",
dir: "row",
gap: 30,
})
expect(twice).toBe(once)
})
it("corrects a catalog stencil that declares container=0", () => {
const s = stampContainer("rounded=0;container=0;fillColor=none;", {
kind: "group",
dir: "col",
gap: 12,
})
expect(s).not.toContain("container=0")
expect(s.match(/container=1/g)?.length).toBe(1)
})
it("records kind, dir and gap so the parser need not guess", () => {
const s = stampContainer(VPC_STYLE, {
kind: "group",

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest"
import type { Operation } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
} from "./fixtures/geometry"
const page = (x: string) => {
const m = x.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)!
return { w: +m[1], h: +m[2], aspect: +m[1] / +m[2] }
}
const ink = (x: string) => {
const p = page(x)
let a = 0
for (const [, r] of absoluteRects(x)) a += r.w * r.h
return a / (p.w * p.h)
}
/**
* The whole pipeline on one realistic diagram, built exactly the way the tool description
* tells the model to build a poster.
*
* Every other test here checks one field in isolation, and each of those passed while the
* realistic case was still wrong: the declared 2:1 columns came out 1:1 because the row
* holding them was never given a width, and the declared portrait page came out landscape
* because widening the page rewraps the text and shortens it, which one pass cannot account
* for. Neither showed up until the pieces were used together.
*/
describe("a poster built exactly as the tool description now instructs", () => {
const r = restructureDiagram("", [
{ op: "set_page", aspect: 0.8 },
{
op: "add_container",
id: "page",
label: "",
dir: "col",
class: "gap-4",
},
{
op: "add_box",
id: "mast",
parent: "page",
label: "Chain-of-Thought Prompting",
role: "banner",
class: "self-stretch",
},
{
op: "add_box",
id: "by",
parent: "page",
label: "Wei et al., 2022 · NeurIPS",
role: "muted",
},
{
op: "add_container",
id: "cols",
parent: "page",
label: "",
dir: "row",
class: "gap-4",
},
{
op: "add_container",
id: "left",
parent: "cols",
label: "",
dir: "col",
class: "grow-2 min-w-0 gap-3 items-stretch",
},
{
op: "add_container",
id: "right",
parent: "cols",
label: "",
dir: "col",
class: "grow-1 min-w-0 gap-3 items-stretch justify-between",
},
{
op: "add_box",
id: "h1",
parent: "left",
label: "What it is",
role: "heading",
group: "idea",
},
{
op: "add_box",
id: "p1",
parent: "left",
label: "Ask the model to lay out its intermediate steps before answering, instead of jumping straight to a result.",
group: "idea",
},
{
op: "add_box",
id: "h2",
parent: "left",
label: "Why it helps",
role: "heading",
group: "why",
},
{
op: "add_box",
id: "p2",
parent: "left",
label: "Breaking a hard problem into easy sub-steps makes the reasoning visible, so it can be checked and debugged. The biggest gains show up on maths, logic and multi-hop questions.",
group: "why",
},
{
op: "add_box",
id: "h3",
parent: "left",
label: "Worked example",
role: "heading",
group: "eg",
},
{
op: "add_box",
id: "p3",
parent: "left",
label: "Roger has 5 tennis balls and buys 2 cans of 3 balls each. 2 x 3 = 6 new balls; 5 + 6 = 11 balls.",
group: "eg",
},
{
op: "add_box",
id: "h4",
parent: "right",
label: "Costs & limits",
role: "heading",
group: "cost",
},
{
op: "add_box",
id: "p4",
parent: "right",
label: "More tokens, slower, pricier.",
group: "cost",
},
{
op: "add_box",
id: "p5",
parent: "right",
label: "Steps can look sound yet still be wrong.",
role: "bad",
},
{
op: "add_box",
id: "m1",
parent: "right",
label: "+40%",
role: "metric",
},
{
op: "add_box",
id: "p6",
parent: "right",
label: "Mainly emerges in large models.",
role: "muted",
},
] as Operation[])
it("builds cleanly, portrait, in proportion, with nothing spilling", () => {
expect(r.errors).toEqual([])
const xml = r.xml as string
const p = page(xml)
const rects = absoluteRects(xml)
console.log(" page:", p, " ink:", (ink(xml) * 100).toFixed(0) + "%")
console.log(
" columns — left:",
rects.get("left")!.w,
"right:",
rects.get("right")!.w,
"ratio:",
(rects.get("left")!.w / rects.get("right")!.w).toFixed(2),
)
console.log(" warnings:", r.warnings.length ? r.warnings : "none")
// Portrait was asked for.
expect(p.aspect).toBeLessThan(1.0)
// 2:1 was asked for, and min-w-0 was given on both columns.
const ratio = rects.get("left")!.w / rects.get("right")!.w
expect(ratio).toBeGreaterThan(1.8)
expect(ratio).toBeLessThan(2.2)
// Nothing broken.
expect(overlaps(rects, ["left", "right"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
expect(outsidePage(xml)).toEqual([])
})
it("re-reading the canvas gives the same diagram back", () => {
const again = restructureDiagram(r.xml as string, [])
expect(again.errors).toEqual([])
expect(page(again.xml as string)).toEqual(page(r.xml as string))
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
})

View File

@@ -348,6 +348,35 @@ describe("edges survive", () => {
})
expect(renderDiagram(t).xml).not.toContain('as="points"')
})
it("an edge's style does not grow across repeated re-layouts", () => {
// The router recomputes the connection points every pass, and an edge recovered from
// the canvas already carries the previous pass's set. Appending them added 76
// characters per round-trip forever. draw.io resolves duplicate keys last-wins, so
// the arrow always LOOKED right — only measuring the string catches it.
let t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
links: [{ source: "a", target: "b" }],
})
const styleOfEdge = (xml: string) =>
/<mxCell id="ed1"[^>]*style="([^"]*)"/.exec(xml)?.[1] ?? ""
const lengths: number[] = []
const portCounts: number[] = []
let xml = ""
for (let pass = 0; pass < 4; pass++) {
xml = renderDiagram(t).xml
const s = styleOfEdge(xml)
lengths.push(s.length)
portCounts.push((s.match(/exitX=/g) ?? []).length)
t = parseDiagram(xml).tree
}
// Same length every pass, and the port keys stated once rather than accumulating.
expect(new Set(lengths).size).toBe(1)
expect(portCounts).toEqual([1, 1, 1, 1])
// Which is what lets the XML itself reach a fixed point.
expect(renderDiagram(parseDiagram(xml).tree).xml).toBe(xml)
})
})
describe("foreign cells survive", () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
import { drawGraph, restructureDiagram } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine"
import {
absoluteRects,
escapesParent,
@@ -117,18 +117,22 @@ describe("roles", () => {
expect(third.xml).toBe(again.xml)
})
it("draw_graph nodes accept roles too", () => {
const r = drawGraph(
[
{ id: "t", label: "Pipeline", role: "heading" },
{ id: "a", label: "Build" },
{ id: "warn", label: "Flaky stage", role: "bad" },
],
[
{ source: "t", target: "a" },
{ source: "a", target: "warn" },
],
)
it("add_graph nodes accept roles too", () => {
const r = restructureDiagram("", [
{
op: "add_graph",
id: "g",
nodes: [
{ id: "t", label: "Pipeline", role: "heading" },
{ id: "a", label: "Build" },
{ id: "warn", label: "Flaky stage", role: "bad" },
],
edges: [
{ source: "t", target: "a" },
{ source: "a", target: "warn" },
],
},
])
expect(r.errors).toEqual([])
expect(last(styleOf(r.xml as string, "warn"), "fillColor")).toBe(
"#F8CECC",

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from "vitest"
import type { Operation } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine"
import { absoluteRects, escapesParent, outsidePage } from "./fixtures/geometry"
/**
* The text and border classes used together on a realistic diagram.
*
* Every class is unit-tested in isolation above; this checks they compose — that an explicit
* alignment survives alongside a role, that a dashed frame does not leak onto its sibling,
* and that none of it disturbs the geometry or the round-trip.
*/
describe("text classes on a real diagram", () => {
it("a comparison sheet using type, alignment and dashed borders", () => {
const r = restructureDiagram("", [
{ op: "set_page", aspect: 1.3 },
{
op: "add_container",
id: "page",
label: "",
dir: "col",
class: "gap-4",
},
{
op: "add_box",
id: "mast",
parent: "page",
label: "Deployment options",
role: "banner",
class: "self-stretch text-center text-2xl font-bold",
},
{
op: "add_container",
id: "cols",
parent: "page",
label: "",
dir: "row",
class: "gap-4",
},
{
op: "add_container",
id: "now",
parent: "cols",
label: "Today",
dir: "col",
class: "grow-1 min-w-0 gap-2 p-3 items-stretch",
group: "now",
},
{
op: "add_container",
id: "plan",
parent: "cols",
label: "Planned",
dir: "col",
class: "grow-1 min-w-0 gap-2 p-3 items-stretch border-2 border-dashed",
group: "plan",
},
{
op: "add_box",
id: "n1",
parent: "now",
label: "Single region",
class: "text-left",
group: "now",
},
{
op: "add_box",
id: "n2",
parent: "now",
label: "99.9% uptime",
class: "font-bold text-lg",
group: "now",
},
{
op: "add_box",
id: "p1",
parent: "plan",
label: "Multi region",
class: "text-left italic",
group: "plan",
},
{
op: "add_box",
id: "p2",
parent: "plan",
label: "99.99% uptime",
class: "font-bold text-lg",
group: "plan",
},
] as Operation[])
expect(r.errors).toEqual([])
expect(r.warnings).toEqual([])
const xml = r.xml as string
const st = (id: string) =>
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
const k = (s: string, key: string) => {
const all = [
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
]
return all.length ? all[all.length - 1][1] : undefined
}
// The masthead: centred 24px bold, and it spans the page.
expect(k(st("mast"), "align")).toBe("center")
expect(k(st("mast"), "fontSize")).toBe("24")
expect(k(st("mast"), "fontStyle")).toBe("1")
// The "planned" column is dashed; "today" is not.
expect(k(st("plan"), "dashed")).toBe("1")
expect(k(st("plan"), "strokeWidth")).toBe("2")
expect(k(st("now"), "dashed")).toBeUndefined()
// Type overrides land on the leaves too.
expect(k(st("n2"), "fontSize")).toBe("18")
expect(k(st("p1"), "fontStyle")).toBe("2") // italic only
expect(k(st("p1"), "align")).toBe("left")
// Nothing broken by any of it.
const rects = absoluteRects(xml)
expect(escapesParent(xml)).toEqual([])
expect(outsidePage(xml)).toEqual([])
expect(rects.get("mast")!.w).toBe(rects.get("cols")!.w)
// And it settles.
const again = restructureDiagram(xml, [])
expect(again.errors).toEqual([])
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
it("radius, shadow and borderless compose with roles, groups and shapes", () => {
const r = restructureDiagram("", [
{ op: "set_page", aspect: 1.2 },
{
op: "add_container",
id: "page",
label: "",
dir: "col",
class: "gap-4",
},
// A shape that ALREADY owns rounded/arcSize, with a radius class on top. The
// class is meant to win: a terminator is a rounded rectangle either way, and
// changing how round it is does not change what it is.
{
op: "add_box",
id: "start",
parent: "page",
label: "Start",
shape: "terminator",
class: "rounded-lg self-stretch",
},
{
op: "add_container",
id: "cards",
parent: "page",
label: "",
dir: "row",
class: "gap-4",
},
// A raised card: radius and shadow together, on top of a role and a group.
{
op: "add_box",
id: "card",
parent: "cards",
label: "Raised card",
role: "body",
group: "one",
class: "grow-1 min-w-0 rounded-xl shadow-md",
},
// A plain colour field: no outline at all, which nothing else could express.
{
op: "add_box",
id: "field",
parent: "cards",
label: "Colour field",
role: "callout",
class: "grow-1 min-w-0 border-none rounded-2xl",
},
// Struck-through beside bold, to prove the bits add rather than replace.
{
op: "add_box",
id: "gone",
parent: "page",
label: "Superseded step",
class: "line-through font-bold self-stretch",
},
] as Operation[])
expect(r.errors).toEqual([])
expect(r.warnings).toEqual([])
const xml = r.xml as string
const st = (id: string) =>
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
const k = (s: string, key: string) => {
const all = [
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
]
return all.length ? all[all.length - 1][1] : undefined
}
// The class wins over the shape's own proportional corner, and brings the flag that
// reinterprets the number as pixels. Without absoluteArcSize, 16 would mean 16% of
// the box — a different radius on every node.
expect(k(st("start"), "shape")).toBeUndefined() // terminator is rounded=1, not shape=
expect(k(st("start"), "rounded")).toBe("1")
expect(k(st("start"), "absoluteArcSize")).toBe("1")
expect(k(st("start"), "arcSize")).toBe("16")
// The card keeps its role's fill while taking the class's radius and shadow.
expect(k(st("card"), "arcSize")).toBe("24")
expect(k(st("card"), "shadow")).toBe("1")
expect(k(st("card"), "shadowBlur")).toBe("6")
expect(k(st("card"), "fillColor")).not.toBe("none")
// borderless beats the role's own stroke; the role's fill survives, which is the
// point of a colour field.
expect(k(st("field"), "strokeColor")).toBe("none")
expect(k(st("field"), "fillColor")).not.toBe("none")
expect(k(st("field"), "arcSize")).toBe("32")
// Bold (1) plus strikethrough (8) in one key.
expect(k(st("gone"), "fontStyle")).toBe("9")
// None of it disturbs the layout.
expect(escapesParent(xml)).toEqual([])
expect(outsidePage(xml)).toEqual([])
const rects = absoluteRects(xml)
expect(rects.get("card")!.w).toBeCloseTo(rects.get("field")!.w, 0)
// And it settles.
const again = restructureDiagram(xml, [])
expect(again.errors).toEqual([])
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
})

View File

@@ -0,0 +1,559 @@
import { describe, expect, it } from "vitest"
import type { Operation } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import { parseTw } from "@/lib/diagram-engine/tw"
import type { BoxNode } from "@/lib/diagram-engine/types"
import { absoluteRects, rectOf } from "./fixtures/geometry"
describe("parseTw", () => {
it("reads direction, weights, alignment and distribution", () => {
expect(
parseTw("flex flex-col grow-3 items-stretch justify-between"),
).toEqual({
dir: "col",
grow: 3,
alignItems: "stretch",
justify: "between",
ignored: [],
})
})
it("puts spacing on Tailwind's 4px scale", () => {
const r = parseTw("p-6 gap-4")
expect(r.pad).toBe(24)
expect(r.gap).toBe(16)
})
it("treats a width fraction as a share of the row", () => {
// w-2/3 beside w-1/3 has to be the same layout as grow-2 beside grow-1.
expect(parseTw("w-2/3").grow).toBe(2)
expect(parseTw("w-1/3").grow).toBe(1)
})
it("reads both named and numeric max-width", () => {
expect(parseTw("max-w-md").maxW).toBe(448)
expect(parseTw("max-w-96").maxW).toBe(384)
})
it("later classes win, the way Tailwind's own conflicts resolve", () => {
expect(parseTw("grow-1 grow-4").grow).toBe(4)
expect(parseTw("justify-start justify-evenly").justify).toBe("evenly")
})
it("collects what it cannot honour instead of failing", () => {
const r = parseTw(
"grow-2 uppercase bg-blue-500 rounded-tl-xl hover:p-4",
)
expect(r.grow).toBe(2)
expect(r.ignored).toEqual([
"uppercase",
"bg-blue-500",
"rounded-tl-xl",
"hover:p-4",
])
})
it("rejects arbitrary values, so the scale stays a scale", () => {
// The point of a scale is that there is no p-7.5 and no w-[137px].
expect(parseTw("p-[13px] w-[137px]").ignored).toEqual([
"p-[13px]",
"w-[137px]",
])
expect(parseTw("p-[13px]").pad).toBeUndefined()
})
})
describe("class on an operation", () => {
it("drives real geometry: w-3/4 beside w-1/4 splits 3:1", () => {
// min-w-0 on both, because a column will not otherwise shrink below its own text —
// real flexbox behaviour, and what makes an exact ratio opt-in.
const r = restructureDiagram("", [
{ op: "set_page", aspect: 1.3 },
{ op: "add_container", id: "row", label: "", dir: "row" },
{
op: "add_container",
id: "main",
parent: "row",
label: "Main",
dir: "col",
class: "w-3/4 min-w-0",
},
{
op: "add_container",
id: "side",
parent: "row",
label: "Side",
dir: "col",
class: "w-1/4 min-w-0",
},
{ op: "add_box", id: "a", parent: "main", label: "a" },
{ op: "add_box", id: "b", parent: "side", label: "b" },
] as Operation[])
expect(r.errors).toEqual([])
const rects = absoluteRects(r.xml as string)
const ratio = rectOf(rects, "main").w / rectOf(rects, "side").w
expect(ratio).toBeGreaterThan(2.7)
expect(ratio).toBeLessThan(3.3)
})
it("a max-width class caps a box and its text wraps instead", () => {
const long =
"A deliberately long single line that would otherwise stretch its box right across the page"
// max-w-48 is 192px — below the 260px a plain box already caps itself at, so this
// is a cap that actually bites. max-w-xs (320) would be a no-op here.
const capped = restructureDiagram("", [
{ op: "add_box", id: "x", label: long, class: "max-w-48" },
] as Operation[])
const free = restructureDiagram("", [
{ op: "add_box", id: "x", label: long },
] as Operation[])
const a = rectOf(absoluteRects(capped.xml as string), "x")
const b = rectOf(absoluteRects(free.xml as string), "x")
expect(a.w).toBeLessThanOrEqual(192)
// Same text in a narrower box means more lines, so it must be taller.
expect(a.h).toBeGreaterThan(b.h)
})
it("an explicit field outranks the class that says the same thing", () => {
const r = restructureDiagram("", [
{ op: "set_page", aspect: 1.3 },
{ op: "add_container", id: "row", label: "", dir: "row" },
{
op: "add_container",
id: "L",
parent: "row",
label: "L",
dir: "col",
class: "grow-1 min-w-0",
grow: 3,
},
{
op: "add_container",
id: "R",
parent: "row",
label: "R",
dir: "col",
grow: 1,
class: "min-w-0",
},
{ op: "add_box", id: "a", parent: "L", label: "a" },
{ op: "add_box", id: "b", parent: "R", label: "b" },
] as Operation[])
expect(r.errors).toEqual([])
const rects = absoluteRects(r.xml as string)
const ratio = rectOf(rects, "L").w / rectOf(rects, "R").w
expect(ratio).toBeGreaterThan(2.7)
})
it("tells the model which classes it dropped, once each", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "c", label: "C", dir: "col" },
{
op: "add_box",
id: "a",
parent: "c",
label: "a",
class: "tracking-wide p-4",
},
{
op: "add_box",
id: "b",
parent: "c",
label: "b",
class: "tracking-wide grow-2",
},
] as Operation[])
expect(r.errors).toEqual([])
const notes = r.warnings.join(" ")
expect(notes).toContain("tracking-wide")
// Reported once even though two cards carried it.
expect(notes.match(/tracking-wide/g)).toHaveLength(1)
})
it("a class-driven layout survives a round-trip through the canvas", () => {
const first = restructureDiagram("", [
{
op: "add_container",
id: "c",
label: "C",
dir: "col",
class: "gap-4 p-6 items-stretch justify-between max-w-lg",
},
{ op: "add_box", id: "a", parent: "c", label: "a" },
{ op: "add_box", id: "b", parent: "c", label: "b" },
] as Operation[])
expect(first.errors).toEqual([])
const again = restructureDiagram(first.xml as string, [])
expect(again.errors).toEqual([])
// Every field the classes set has to come back, or the next edit would silently
// drop it: re-reading the canvas is the ONLY place the structure comes from.
for (const marker of [
"dai_gap=16", // gap-4
"dai_pad=24", // p-6
"dai_aitems=stretch", // items-stretch
"dai_justify=between", // justify-between
"dai_maxw=512", // max-w-lg
])
expect(again.xml).toContain(marker)
// Geometry is the real test of a round-trip: same structure in, same boxes out.
// (The style string itself is not compared — re-stamping appends a duplicate
// container=1, which draw.io resolves last-value-wins. See markers.ts.)
const a = absoluteRects(first.xml as string)
const b = absoluteRects(again.xml as string)
for (const id of ["c", "a", "b"])
expect(rectOf(b, id)).toEqual(rectOf(a, id))
// And it must reach a fixed point rather than drifting one step per pass.
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
})
/**
* Text and border classes.
*
* The supported set was chosen by reading Tailwind's property index against draw.io's own
* style reference. These tests pin both halves: that what IS accepted reaches the XML, and
* that what was rejected stays rejected for the documented reason — the exclusions are the
* interesting part, because each one is a property draw.io either cannot express at all or
* can only express coarsely.
*/
describe("text and border classes", () => {
const styleOf = (xml: string, id: string) =>
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
const key = (style: string, k: string) => {
const all = [...style.matchAll(new RegExp(`(?:^|;)${k}=([^;]*)`, "g"))]
return all.length ? all[all.length - 1][1] : undefined
}
const build = (cls: string) =>
restructureDiagram("", [
{ op: "add_box", id: "x", label: "Hello", class: cls },
] as Operation[])
it("packs bold, italic and underline into one fontStyle bitmask", () => {
// draw.io adds the bits: 1 bold + 2 italic + 4 underline = 7. Three separate
// fontStyle keys would leave only the last one in effect.
const s = styleOf(
build("font-bold italic underline").xml as string,
"x",
)
expect(key(s, "fontStyle")).toBe("7")
expect(s.match(/fontStyle=/g)).toHaveLength(1)
expect(
key(styleOf(build("font-bold").xml as string, "x"), "fontStyle"),
).toBe("1")
expect(
key(styleOf(build("italic").xml as string, "x"), "fontStyle"),
).toBe("2")
expect(
key(styleOf(build("underline").xml as string, "x"), "fontStyle"),
).toBe("4")
expect(
key(
styleOf(build("font-bold italic").xml as string, "x"),
"fontStyle",
),
).toBe("3")
})
it("maps the type scale to Tailwind's own pixel values", () => {
for (const [cls, px] of [
["text-xs", "12"],
["text-base", "16"],
["text-2xl", "24"],
["text-4xl", "36"],
] as const)
expect(
key(styleOf(build(cls).xml as string, "x"), "fontSize"),
).toBe(px)
})
it("sets both text alignments", () => {
const s = styleOf(build("text-right align-bottom").xml as string, "x")
expect(key(s, "align")).toBe("right")
expect(key(s, "verticalAlign")).toBe("bottom")
})
it("an explicit alignment beats the paragraph heuristic", () => {
// A long label is set flush left automatically. Asking for centre has to win, or
// the class would silently do nothing on exactly the labels it matters for.
const long =
"A label long enough that the engine sets it flush left by itself, well past sixty characters"
const auto = restructureDiagram("", [
{ op: "add_box", id: "x", label: long },
] as Operation[])
const forced = restructureDiagram("", [
{ op: "add_box", id: "x", label: long, class: "text-center" },
] as Operation[])
expect(key(styleOf(auto.xml as string, "x"), "align")).toBe("left")
expect(key(styleOf(forced.xml as string, "x"), "align")).toBe("center")
})
it("border width and dash style reach the stroke", () => {
expect(
key(styleOf(build("border-4").xml as string, "x"), "strokeWidth"),
).toBe("4")
expect(
key(styleOf(build("border").xml as string, "x"), "strokeWidth"),
).toBe("1")
const dashed = styleOf(build("border-dashed").xml as string, "x")
expect(key(dashed, "dashed")).toBe("1")
expect(key(dashed, "dashPattern")).toBeUndefined()
// Dotted needs the pattern too, or draw.io draws a dash and calls it dotted.
const dotted = styleOf(build("border-dotted").xml as string, "x")
expect(key(dotted, "dashed")).toBe("1")
expect(key(dotted, "dashPattern")).toBe("1 3")
})
it("whitespace-nowrap keeps a label on one line", () => {
expect(
key(
styleOf(build("whitespace-nowrap").xml as string, "x"),
"whiteSpace",
),
).toBe("nowrap")
})
it("rejects the seven font weights draw.io cannot distinguish", () => {
// draw.io's fontStyle has ONE bold bit, so five of Tailwind's nine weights would
// collapse onto bold and four onto normal. Reporting them beats pretending.
for (const w of [
"font-thin",
"font-extralight",
"font-light",
"font-medium",
"font-semibold",
"font-extrabold",
"font-black",
])
expect(parseTw(w).ignored).toEqual([w])
// The two that do map are not reported.
expect(parseTw("font-bold font-normal").ignored).toEqual([])
})
it("rejects opacity, because Tailwind's is a free number rather than a scale", () => {
// draw.io's opacity is 0-100 and would map, but `opacity-<number>` accepts any
// number — admitting it gives up the constraint that justifies this vocabulary.
expect(parseTw("opacity-25 opacity-37").ignored).toEqual([
"opacity-25",
"opacity-37",
])
})
it("rejects truncate, which promises an ellipsis draw.io cannot draw", () => {
// Tailwind's truncate is overflow:hidden + text-overflow:ellipsis + nowrap, and
// draw.io's overflow has no ellipsis value — the text would just be cut.
expect(parseTw("truncate").ignored).toEqual(["truncate"])
})
it("rejects every outline class, which draw.io has no concept of", () => {
const outlines = [
"outline-2",
"outline-solid",
"outline-dashed",
"outline-offset-2",
]
expect(parseTw(outlines.join(" ")).ignored).toEqual(outlines)
})
it("rejects per-side borders, which would cost the shape slot", () => {
// draw.io draws these correctly via shape=partialRectangle, but that is a SHAPE
// name — a node cannot be both a diamond and left-edge-only, and what a node IS
// outranks how its border looks.
const sides = ["border-t", "border-l-4", "border-x", "border-y-2"]
expect(parseTw(sides.join(" ")).ignored).toEqual(sides)
})
it("rejects per-side padding, which draw.io only has for the label", () => {
// spacingTop/Right/Bottom/Left look like an exact match and are not: they pad the
// LABEL inside its cell, while this engine's `pad` is room for a container's
// CHILDREN. Accepting `pt-8` would imply it pushes child nodes down.
const pads = ["pt-8", "px-4", "pb-2", "ps-6"]
expect(parseTw(pads.join(" ")).ignored).toEqual(pads)
})
it("rejects per-corner radius while accepting the whole-shape one", () => {
// Per-corner lives only on the mxgraph.basic.rect template shape, which would take
// the node's own shape — the same trade the per-side borders lose.
expect(parseTw("rounded-tl-lg rounded-br-sm").ignored).toEqual([
"rounded-tl-lg",
"rounded-br-sm",
])
expect(parseTw("rounded-lg").radius).toBe(8)
})
it("rejects text-shadow, whose draw.io key really is one flag", () => {
// Unlike the box shadow family, `textShadow` has no offset or blur, so Tailwind's
// six sizes would all draw the same picture.
expect(parseTw("text-shadow-lg").ignored).toEqual(["text-shadow-lg"])
})
it("rejects letter-spacing, case and line-height — absent, not coarse", () => {
const absent = ["tracking-wide", "uppercase", "capitalize", "leading-6"]
expect(parseTw(absent.join(" ")).ignored).toEqual(absent)
})
it("accepts the radius scale in real pixels", () => {
// Tailwind's own values. These are pixels only because absoluteArcSize switches
// arcSize off its default percentage reading.
expect(parseTw("rounded").radius).toBe(4)
expect(parseTw("rounded-xs").radius).toBe(2)
expect(parseTw("rounded-md").radius).toBe(6)
expect(parseTw("rounded-xl").radius).toBe(12)
expect(parseTw("rounded-2xl").radius).toBe(16)
expect(parseTw("rounded-3xl").radius).toBe(24)
expect(parseTw("rounded-4xl").radius).toBe(32)
expect(parseTw("rounded-none").radius).toBe(0)
// `rounded-full` is calc(infinity * 1px) in CSS. draw.io clamps to half the shorter
// side, so the value only has to exceed half the tallest box a diagram ever has —
// and it must stay readable, because the Arrange panel shows it in an editable field.
const full = parseTw("rounded-full").radius as number
expect(full).toBeGreaterThan(200 / 2)
expect(full).toBeLessThan(1000)
})
it("accepts four shadow rungs and an explicit none", () => {
expect(parseTw("shadow-sm").shadow).toBe(1)
expect(parseTw("shadow-md").shadow).toBe(2)
expect(parseTw("shadow-lg").shadow).toBe(3)
expect(parseTw("shadow-xl").shadow).toBe(4)
expect(parseTw("shadow-none").shadow).toBe(0)
// The steps that would be indistinguishable at a diagram's scale, and the colour
// form, stay out.
expect(
parseTw("shadow-2xs shadow-xs shadow-2xl shadow-blue-500").ignored,
).toEqual(["shadow-2xs", "shadow-xs", "shadow-2xl", "shadow-blue-500"])
})
it("distinguishes no border from a zero-width one", () => {
// `border-0` must not read as "a 0px border": draw.io would still draw its default
// hairline. Both forms mean strokeColor=none.
expect(parseTw("border-none").borderless).toBe(true)
expect(parseTw("border-0").borderless).toBe(true)
expect(parseTw("border-0").borderWidth).toBeUndefined()
})
it("accepts line-through, and no-underline does not clear it", () => {
expect(parseTw("line-through").strike).toBe(true)
// Both are values of text-decoration-line in CSS, so "not underlined" is not
// "undecorated".
const r = parseTw("line-through no-underline")
expect(r.strike).toBe(true)
expect(r.underline).toBe(false)
})
it("does not mistake a colour class for a type size", () => {
// `text-` is three Tailwind properties at once: size, alignment and colour.
const r = parseTw("text-red-500 text-lg")
expect(r.fontSize).toBe(18)
expect(r.ignored).toEqual(["text-red-500"])
})
it("text and border survive a round-trip through the canvas", () => {
const first = restructureDiagram("", [
{
op: "add_box",
id: "x",
label: "Note",
class: "font-bold italic text-lg text-right align-top border-2 border-dashed",
},
] as Operation[])
expect(first.errors).toEqual([])
const again = restructureDiagram(first.xml as string, [])
expect(again.errors).toEqual([])
const s = styleOf(again.xml as string, "x")
expect(key(s, "fontStyle")).toBe("3")
expect(key(s, "fontSize")).toBe("18")
expect(key(s, "align")).toBe("right")
expect(key(s, "verticalAlign")).toBe("top")
expect(key(s, "strokeWidth")).toBe("2")
expect(key(s, "dashed")).toBe("1")
// And it settles rather than drifting a step per pass.
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
it("radius, shadow, strike and borderless survive a round-trip", () => {
const first = restructureDiagram("", [
{
op: "add_box",
id: "card",
label: "Card",
class: "rounded-lg shadow-md",
},
{
op: "add_box",
id: "field",
label: "Field",
class: "border-none",
},
{
op: "add_box",
id: "old",
label: "Superseded",
class: "line-through",
},
] as Operation[])
expect(first.errors).toEqual([])
const again = restructureDiagram(first.xml as string, [])
expect(again.errors).toEqual([])
const card = styleOf(again.xml as string, "card")
// A radius needs all three keys: arcSize alone would be read as a percentage.
expect(key(card, "rounded")).toBe("1")
expect(key(card, "absoluteArcSize")).toBe("1")
// Doubled on the way out because draw.io halves it on the way in.
expect(key(card, "arcSize")).toBe("16")
expect(key(card, "shadow")).toBe("1")
expect(key(card, "shadowOffsetY")).toBe("4")
expect(key(card, "shadowBlur")).toBe("6")
expect(key(styleOf(again.xml as string, "field"), "strokeColor")).toBe(
"none",
)
// Bit 8, on its own since nothing asked for bold or italic.
expect(key(styleOf(again.xml as string, "old"), "fontStyle")).toBe("8")
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
it("a theme's own borderless and square corners are not read as requests", () => {
// A heading is ghost text — no fill, no stroke, square — because of what it IS, and
// nearly every box carries `rounded=0` from the fallback style. Recording either as a
// declared override would outlive a later role change, since `set_role` clears a
// node's style but keeps its text overrides. Same trap `size` and `align` avoid by
// comparing against the defaults for the node's kind.
const first = restructureDiagram("", [
{ op: "add_box", id: "h", label: "Section", role: "heading" },
{ op: "add_box", id: "b", label: "Body", role: "body" },
] as Operation[])
expect(first.errors).toEqual([])
const t = parseDiagram(first.xml as string).tree
const heading = t.roots.find((n) => n.id === "h") as BoxNode
const body = t.roots.find((n) => n.id === "b") as BoxNode
expect(heading.role).toBe("heading")
expect(heading.text?.borderless).toBeUndefined()
expect(heading.text?.radius).toBeUndefined()
expect(body.text?.radius).toBeUndefined()
// A class-declared one IS recorded, so the distinction is real rather than a blanket
// refusal to read these keys.
const asked = restructureDiagram("", [
{ op: "add_box", id: "x", label: "Field", class: "border-none" },
] as Operation[])
const x = parseDiagram(asked.xml as string).tree.roots.find(
(n) => n.id === "x",
) as BoxNode
expect(x.text?.borderless).toBe(true)
})
})

View File

@@ -0,0 +1,210 @@
import { render } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
const dict = {
tools: { complete: "Complete" },
chat: { copied: "c", failedToCopy: "f", copyResponse: "r" },
}
const base = {
expandedTools: {},
setExpandedTools: () => {},
onCopy: () => {},
copiedToolCallId: null,
copyFailedToolCallId: null,
dict,
}
/**
* What the tool-call card shows in the chat.
*
* Both diagram tools happen to name their argument `operations`, but the items have
* different shapes: edit_diagram sends `operation`/`cell_id`/`new_xml` patches, while
* restructure_diagram sends `op`/`id` structural steps. The card used to dispatch on
* "does an operations key exist", so a restructure call was rendered as edit patches and
* every row printed a blank `cell_id:` label with nothing after it.
*/
describe("ToolCallCard", () => {
it("shows restructure_diagram operations, not blank cell_id rows", () => {
const { container } = render(
<ToolCallCard
{...base}
part={{
type: "tool-restructure_diagram",
toolCallId: "t1",
state: "output-available",
input: {
operations: [
{ op: "set_page", aspect: 0.8 },
{
op: "add_container",
id: "page",
label: "",
dir: "col",
class: "gap-4",
},
{
op: "add_box",
id: "mast",
parent: "page",
label: "Title",
role: "banner",
},
{
op: "add_graph",
id: "g",
nodes: [{ id: "a" }, { id: "b" }],
edges: [{ source: "a", target: "b" }],
},
],
},
output: 'Diagram updated.\n\npage: col (wrapper)\n mast: box "Title"',
}}
/>,
)
const text = container.textContent ?? ""
// The bug: every row printed "cell_id:" with nothing after it.
expect(text).not.toContain("cell_id:")
// Operation names and ids are visible.
for (const s of [
"set_page",
"add_container",
"add_box",
"add_graph",
"page",
"mast",
])
expect(text).toContain(s)
// Arguments are summarised.
expect(text).toContain("class=gap-4")
expect(text).toContain("2 nodes, 1 edge")
// The tool's own answer is shown, not thrown away.
expect(text).toContain("Diagram updated.")
// And it has a readable name.
expect(text).toContain("Build Diagram")
})
it("still renders edit_diagram patches the old way", () => {
const { container } = render(
<ToolCallCard
{...base}
part={{
type: "tool-edit_diagram",
toolCallId: "t2",
state: "output-available",
input: {
operations: [
{
operation: "update",
cell_id: "3",
new_xml: '<mxCell id="3"/>',
},
],
},
}}
/>,
)
const text = container.textContent ?? ""
expect(text).toContain("cell_id: 3")
expect(text).toContain("update")
})
})
/**
* Streaming: the tool input arrives character by character.
*
* The card renders on every frame of that, so it is handed JSON that has been repaired
* mid-flight — an operation may be `{}`, have a half-typed name, or be a hole in the array.
* The first version of the restructure renderer read `op.op.startsWith(...)` and crashed the
* whole message with "Cannot read properties of undefined". These cases are what the earlier
* tests missed by only ever passing complete input.
*/
describe("ToolCallCard while the input is still streaming", () => {
const partial = (operations: unknown[]) => ({
type: "tool-restructure_diagram",
toolCallId: "s1",
state: "input-streaming",
input: { operations },
})
it("renders an operation with no name yet", () => {
const { container } = render(
<ToolCallCard {...base} part={partial([{}]) as never} />,
)
expect(container.textContent).toContain("…")
})
it("renders a half-typed operation name", () => {
const { container } = render(
<ToolCallCard
{...base}
part={partial([{ op: "add_contai" }]) as never}
/>,
)
expect(container.textContent).toContain("add_contai")
})
it("survives a hole in the array", () => {
// A repaired JSON array can have missing entries, which arrive as undefined.
const { container } = render(
<ToolCallCard
{...base}
part={
partial([
{ op: "set_page", aspect: 0.8 },
undefined,
null,
{ op: "add_box", id: "a", label: "A" },
]) as never
}
/>,
)
const text = container.textContent ?? ""
expect(text).toContain("set_page")
expect(text).toContain("add_box")
})
it("survives an operation whose fields are half-formed", () => {
const { container } = render(
<ToolCallCard
{...base}
part={
partial([
{ op: "add_graph", id: "g", nodes: undefined },
{ op: "add_box", label: null },
{ op: 42 },
]) as never
}
/>,
)
expect(container.textContent).toContain("add_graph")
})
it("edit_diagram's renderer survives the same partial input", () => {
const { container } = render(
<ToolCallCard
{...base}
part={
{
type: "tool-edit_diagram",
toolCallId: "s2",
state: "input-streaming",
input: {
operations: [
{},
{ operation: "upda" },
undefined,
{ operation: "update", cell_id: "3" },
],
},
} as never
}
/>,
)
const text = container.textContent ?? ""
expect(text).toContain("cell_id: 3")
// Exactly one label, for the one entry that has an id — a half-formed entry must
// not print a bare "cell_id:" with nothing after it, which is the original bug.
expect(text.match(/cell_id:/g)).toHaveLength(1)
})
})