Files
next-ai-draw-io/tests/unit/diagram-engine-theme.test.ts

257 lines
8.4 KiB
TypeScript
Raw Normal View History

feat(diagram-engine): design tokens + role/group composition, engine-wide theming Paper-summary posters previously required hand-written XML: every engine box rendered identically (white, 11px), so anything whose meaning lives in visual hierarchy came out flat. This makes presentation a first-class, generalised part of the declaration - not a poster feature. Structure/presentation separation, the same split HTML and CSS settled on: - ROLE says what a node IS: banner, heading, body, callout, good, bad, metric, muted. Maps to a type scale and an emphasis (filled / tinted / outlined / ghost), never to a colour. - GROUP says which semantic zone a node belongs to. Each distinct group name gets one hue ramp (tint / base / dark), assigned in document order. Promoted from a draw_graph-only field to BoxNode and GroupNode, round-tripped via dai_group. - themedStyle(role, hue, kind) composes the two by rule - there is no per-combination table to extend, so a new diagram kind gets full theming by tagging nodes. The model never sees a hex value. A heading container plus a group yields the tinted section panel with a dark title; a grouped body box takes its zone's tint; verdict roles stay green/red regardless of zone; the banner is the page's one dark field. Also fixed, found while building the acceptance poster: - autoBoxSize only counted explicit newlines, so a long single-line label wrapped to six lines in draw.io but got a one-line-tall box, and the text overflowed the cell. - Marker stamping appended without replacing, so every render of a recovered style grew it by one duplicate dai_* token per key - unnoticed because draw.io resolves duplicates last-wins. dai_* keys are now replaced in place; mxGraph keys still append, because last-wins is load-bearing for container=1 normalisation. - Banner/heading/metric roles stretch across their container's cross axis, the way a masthead spans its page. - Prompt: a poster's banner IS its title (no set_title alongside), and sections get their colour by naming groups. 537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e tests in a real browser. Verified visually: the Transformer-paper poster renders with a navy masthead, three hue-coded section panels, metric, verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
import { describe, expect, it } from "vitest"
import { drawGraph, 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("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" },
],
)
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([])
})
})