Files
next-ai-draw-io/tests/unit/tool-call-card.test.tsx
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

211 lines
7.5 KiB
TypeScript

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