mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
199
tests/unit/diagram-engine-poster.test.ts
Normal file
199
tests/unit/diagram-engine-poster.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal file
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
559
tests/unit/diagram-engine-tw.test.ts
Normal file
559
tests/unit/diagram-engine-tw.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
210
tests/unit/tool-call-card.test.tsx
Normal file
210
tests/unit/tool-call-card.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user