mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS aggregation. The engine allowed exactly one arrowhead; this opens the vocabulary the same way shapes were opened: - LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset- gated against style injection) and headFill/tailFill — fill is written explicitly whenever a head is declared, because UML composition and aggregation differ ONLY by fill and draw.io's per-head default would flip the meaning. bold (4px amber, for THE key relationship) included. - Parallel edges: a second link between the same pair is allowed when it carries an id (ER's 'places' and 'cancels' between the same two entities); without one it stays an error, since two identical overlapping lines is a mistake. Edge ids are also what later operations address. - Sequence messages respect a declared head (an async message's open arrow is UML notation) while defaulting to the solid block as before. - draw_graph's edge schema extended to match; GraphEdge passes the new fields through to the link operations it generates. 5 new tests: crow's foot style emission, hollow-vs-filled round trip, injection rejection, parallel-edge gating, bold round trip. 561 unit tests green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow inheritance triangle, filled composition diamond) verified in the real editor.
117 lines
3.8 KiB
TypeScript
117 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest"
|
|
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
|
|
/**
|
|
* The connection vocabulary: arrowheads carry meaning. A crow's foot IS "many", a
|
|
* hollow diamond IS aggregation — notation, not decoration. The engine passes the
|
|
* head/tail tokens through to draw.io and writes fill explicitly, because UML
|
|
* composition and aggregation differ ONLY by fill.
|
|
*/
|
|
|
|
const styleOfEdge = (xml: string, source: string, target: string): string => {
|
|
const m = xml.match(
|
|
new RegExp(
|
|
`<mxCell [^>]*style="([^"]*)"[^>]*source="${source}" target="${target}"`,
|
|
),
|
|
)
|
|
return m?.[1] ?? ""
|
|
}
|
|
|
|
const two = [
|
|
{ op: "add_box" as const, id: "a", label: "customer" },
|
|
{ op: "add_box" as const, id: "b", label: "order" },
|
|
]
|
|
|
|
describe("arrowheads", () => {
|
|
it("passes ER crow's foot through with explicit fill", () => {
|
|
const r = restructureDiagram("", [
|
|
...two,
|
|
{
|
|
op: "link",
|
|
source: "a",
|
|
target: "b",
|
|
tail: "ERone",
|
|
head: "ERoneToMany",
|
|
},
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
const s = styleOfEdge(r.xml as string, "a", "b")
|
|
expect(s).toContain("endArrow=ERoneToMany")
|
|
expect(s).toContain("endFill=0")
|
|
expect(s).toContain("startArrow=ERone")
|
|
})
|
|
|
|
it("UML: hollow vs filled diamond survive the round trip distinctly", () => {
|
|
const r = restructureDiagram("", [
|
|
...two,
|
|
{
|
|
op: "link",
|
|
source: "a",
|
|
target: "b",
|
|
head: "diamondThin",
|
|
headFill: true,
|
|
},
|
|
])
|
|
const back = parseDiagram(r.xml as string)
|
|
const l = back.tree.links.find(
|
|
(x) => x.source === "a" && x.target === "b",
|
|
)
|
|
expect(l?.head).toBe("diamondThin")
|
|
expect(l?.headFill).toBe(true)
|
|
})
|
|
|
|
it("rejects an injection-capable arrowhead token", () => {
|
|
const r = restructureDiagram("", [
|
|
...two,
|
|
{ op: "link", source: "a", target: "b", head: "block;dashed=1" },
|
|
])
|
|
expect(r.errors.join(" ")).toContain("not allowed")
|
|
})
|
|
})
|
|
|
|
describe("parallel edges", () => {
|
|
it("a second edge between the same pair needs an id, then both render", () => {
|
|
const rejected = restructureDiagram("", [
|
|
...two,
|
|
{ op: "link", source: "a", target: "b", label: "places" },
|
|
{ op: "link", source: "a", target: "b", label: "cancels" },
|
|
])
|
|
expect(rejected.errors.join(" ")).toContain("give this one an id")
|
|
|
|
const r = restructureDiagram("", [
|
|
...two,
|
|
{ op: "link", source: "a", target: "b", label: "places" },
|
|
{
|
|
op: "link",
|
|
id: "e2",
|
|
source: "a",
|
|
target: "b",
|
|
label: "cancels",
|
|
dashed: true,
|
|
},
|
|
])
|
|
expect(r.errors).toEqual([])
|
|
const xml = r.xml as string
|
|
expect(xml).toContain('value="places"')
|
|
expect(xml).toContain('value="cancels"')
|
|
// and both come back as separate links
|
|
const back = parseDiagram(xml)
|
|
expect(
|
|
back.tree.links.filter((l) => l.source === "a" && l.target === "b"),
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it("bold renders thick and survives the round trip", () => {
|
|
const r = restructureDiagram("", [
|
|
...two,
|
|
{ op: "link", source: "a", target: "b", bold: true },
|
|
])
|
|
expect(styleOfEdge(r.xml as string, "a", "b")).toContain(
|
|
"strokeWidth=4",
|
|
)
|
|
const back = parseDiagram(r.xml as string)
|
|
expect(back.tree.links[0]?.bold).toBe(true)
|
|
})
|
|
})
|