Files
next-ai-draw-io/tests/unit/diagram-engine-theme.test.ts
dayuan.jiang 0b03e15336 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.
2026-08-11 09:09:45 +09:00

261 lines
8.5 KiB
TypeScript

import { describe, expect, it } from "vitest"
import { restructureDiagram } from "@/lib/diagram-engine"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
rectOf,
} from "./fixtures/geometry"
/**
* Roles: a node says WHAT IT IS and the engine's theme decides how that looks.
*
* This is what lets one engine cover poster-style content — paper summaries, cheat
* sheets — that previously had to be hand-written XML with no layout guarantees. The
* model judges "this is a heading, this is a warning"; the theme guarantees the same
* role always renders the same way.
*/
const styleOf = (xml: string, id: string): string =>
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))?.[1] ?? ""
const last = (style: string, key: string): string | undefined =>
[...style.matchAll(new RegExp(`${key}=([^;]*)`, "g"))].pop()?.[1]
describe("roles", () => {
it("styles each role from the theme, and measures type at its real size", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
{
op: "add_box",
id: "mast",
parent: "page",
label: "Title",
role: "banner",
},
{
op: "add_box",
id: "note",
parent: "page",
label: "fine print",
role: "muted",
},
{
op: "add_box",
id: "num",
parent: "page",
label: "18% -> 57%",
role: "metric",
},
{ op: "add_box", id: "plain", parent: "page", label: "ordinary" },
])
expect(r.errors).toEqual([])
const xml = r.xml as string
expect(last(styleOf(xml, "mast"), "fontSize")).toBe("20")
expect(last(styleOf(xml, "mast"), "fillColor")).toBe("#1A237E")
expect(last(styleOf(xml, "note"), "fontSize")).toBe("9")
expect(last(styleOf(xml, "num"), "fontSize")).toBe("20")
// The default look is unchanged: the fallback's own 11px, no theme tokens.
expect(last(styleOf(xml, "plain"), "fontSize")).toBe("11")
expect(styleOf(xml, "plain")).not.toContain("dai_role")
// Layout reserved room for the larger type: the banner's cell is taller than a
// plain box, or its 20px line would overflow.
const rects = absoluteRects(xml)
expect(rectOf(rects, "mast").h).toBeGreaterThan(
rectOf(rects, "plain").h,
)
})
it("a banner spans its column; a heading spans its section", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
{
op: "add_box",
id: "mast",
parent: "page",
label: "T",
role: "banner",
},
{
op: "add_container",
id: "wide",
parent: "page",
label: "",
dir: "row",
gap: 12,
},
{
op: "add_box",
id: "a",
parent: "wide",
label: "left column content here",
},
{
op: "add_box",
id: "b",
parent: "wide",
label: "right column content here",
},
])
const rects = absoluteRects(r.xml as string)
const mast = rectOf(rects, "mast")
const wide = rectOf(rects, "wide")
// The masthead fills the page column's width, not just its own text width.
expect(mast.w).toBeGreaterThanOrEqual(wide.w - 1)
})
it("roles survive the round trip", () => {
const r = restructureDiagram("", [
{ op: "add_box", id: "m", label: "Title", role: "banner" },
])
const again = restructureDiagram(r.xml as string, [])
expect(styleOf(again.xml as string, "m")).toContain("dai_role=banner")
expect(last(styleOf(again.xml as string, "m"), "fontSize")).toBe("20")
// Fixed point: a third pass matches the second.
const third = restructureDiagram(again.xml as string, [])
expect(third.xml).toBe(again.xml)
})
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",
)
})
it("a full poster lays out with no sibling overlaps and nothing outside the page", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
{
op: "add_box",
id: "mast",
parent: "page",
label: "Chain-of-Thought Prompting",
role: "banner",
},
{
op: "add_box",
id: "byline",
parent: "page",
label: "Wei et al. | NeurIPS 2022",
role: "muted",
},
{
op: "add_container",
id: "cols",
parent: "page",
label: "",
dir: "row",
gap: 20,
},
{
op: "add_container",
id: "c1",
parent: "cols",
label: "",
dir: "col",
gap: 14,
},
{
op: "add_container",
id: "c2",
parent: "cols",
label: "",
dir: "col",
gap: 14,
},
{
op: "add_container",
id: "core",
parent: "c1",
label: "Core Idea",
role: "heading",
dir: "col",
gap: 8,
},
{
op: "add_box",
id: "def",
parent: "core",
label: "CoT = intermediate reasoning steps",
role: "callout",
},
{
op: "add_container",
id: "vs",
parent: "c1",
label: "Standard vs CoT",
role: "heading",
dir: "row",
gap: 8,
},
{
op: "add_box",
id: "std",
parent: "vs",
label: "Often wrong",
role: "bad",
},
{
op: "add_box",
id: "cot",
parent: "vs",
label: "Correct",
role: "good",
},
{
op: "add_container",
id: "res",
parent: "c2",
label: "Key Results",
role: "heading",
dir: "col",
gap: 8,
},
{
op: "add_box",
id: "m1",
parent: "res",
label: "GSM8K: 18% -> 57%",
role: "metric",
},
])
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
// Overlaps are judged among siblings — containment of children is by design.
const parentOfId = new Map<string, string>()
for (const m of xml.matchAll(
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
))
parentOfId.set(m[1], m[2])
const byParent = new Map<string, string[]>()
for (const [id, p] of parentOfId) {
if (id.startsWith("__")) continue
const l = byParent.get(p) ?? []
l.push(id)
byParent.set(p, l)
}
for (const sibs of byParent.values())
expect(overlaps(rects, sibs)).toEqual([])
expect(outsidePage(xml)).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
})