Files
next-ai-draw-io/tests/unit/diagram-engine-poster.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

200 lines
6.1 KiB
TypeScript

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)
})
})