@zombie-mermaid/svg-renderer 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
39
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "3.0.0",
41
- "@zombie-mermaid/mermaid-parser": "3.0.0"
40
+ "@zombie-mermaid/core": "3.2.0",
41
+ "@zombie-mermaid/mermaid-parser": "3.2.0"
42
42
  }
43
43
  }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { splitStatements } from '@zombie-mermaid/core'
4
+ import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
5
+ import { layoutC4DiagramSync } from '../c4/layout.ts'
6
+
7
+ // Each case is a C4 source rendered by real Mermaid 11.17.2 (headless Chrome,
8
+ // screen width 1440), with the numbers read back from its SVG: the diagram
9
+ // size, the centre of every shape, and each relationship's line and label.
10
+ // The sources vary on purpose (shapes of every kind, wrapped names and
11
+ // descriptions, boundaries nested and side by side, relationships in every
12
+ // direction), so a constant tuned to one sample cannot satisfy all of them.
13
+ //
14
+ // Vertical numbers depend only on heights and gaps, so they must agree to a
15
+ // few pixels. Horizontal ones depend on text widths, which this package
16
+ // estimates (Mermaid measures them in a browser), and relationship ends depend
17
+ // on each shape's own outline, so those get a wider allowance.
18
+ interface Reference {
19
+ name: string
20
+ source: string
21
+ width: number
22
+ height: number
23
+ elements: Record<string, [number, number]>
24
+ relationships: { line: number[]; label?: [number, number] }[]
25
+ }
26
+
27
+ const cases: Reference[] = JSON.parse(
28
+ readFileSync(
29
+ new URL('./fixtures/c4-mermaid-reference.json', import.meta.url),
30
+ 'utf8',
31
+ ),
32
+ )
33
+
34
+ const Y = 3
35
+ const X = 11
36
+ const LINE = 16
37
+ const LABEL = 10
38
+
39
+ describe('C4 SVG layout against Mermaid on varied diagrams', () => {
40
+ it('covers a spread of diagrams', () => {
41
+ expect(cases.length).toBeGreaterThanOrEqual(30)
42
+ })
43
+
44
+ for (const ref of cases) {
45
+ it(`${ref.name} matches Mermaid`, () => {
46
+ const d = layoutC4DiagramSync(parseC4Diagram(splitStatements(ref.source)))
47
+ expect(Math.abs(d.height - ref.height), 'diagram height').toBeLessThan(Y)
48
+ expect(Math.abs(d.width - ref.width), 'diagram width').toBeLessThan(X)
49
+
50
+ for (const e of d.elements) {
51
+ const r = ref.elements[e.alias]
52
+ if (!r) continue
53
+ const cx = e.x + e.width / 2
54
+ const cy = e.y + e.height / 2
55
+ expect(Math.abs(cx - r[0]), `${e.alias} centre x`).toBeLessThan(X)
56
+ expect(Math.abs(cy - r[1]), `${e.alias} centre y`).toBeLessThan(Y)
57
+ }
58
+
59
+ // Mermaid merges two relationships between the same pair; this draws
60
+ // both, so the lines only line up when the counts agree.
61
+ if (ref.relationships.length !== d.relationships.length) return
62
+ d.relationships.forEach((rel, i) => {
63
+ const r = ref.relationships[i]!
64
+ const [s, e] = rel.points
65
+ const got = [s!.x, s!.y, e!.x, e!.y]
66
+ got.forEach((v, k) => {
67
+ expect(
68
+ Math.abs(v - r.line[k]!),
69
+ `relationship ${i + 1} line[${k}]`,
70
+ ).toBeLessThan(LINE)
71
+ })
72
+ if (r.label && rel.labelPosition) {
73
+ expect(
74
+ Math.abs(rel.labelPosition.x - r.label[0]),
75
+ `relationship ${i + 1} label x`,
76
+ ).toBeLessThan(LABEL)
77
+ expect(
78
+ Math.abs(rel.labelPosition.y - r.label[1]),
79
+ `relationship ${i + 1} label y`,
80
+ ).toBeLessThan(LABEL)
81
+ }
82
+ })
83
+ })
84
+ }
85
+ })
@@ -0,0 +1,337 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
+ import type { PositionedC4Diagram } from '@zombie-mermaid/mermaid-parser'
5
+ import { renderMermaidSVG } from '../../../../src/index.ts'
6
+ import { layoutC4DiagramSync } from '../c4/layout.ts'
7
+
8
+ function layout(src: string, direction?: 'LR'): PositionedC4Diagram {
9
+ const d = parseC4Diagram(splitStatements(src))
10
+ return layoutC4DiagramSync(direction ? { ...d, direction } : d)
11
+ }
12
+
13
+ const el = (p: PositionedC4Diagram, alias: string) =>
14
+ p.elements.find((e) => e.alias === alias)!
15
+ const centerX = (e: { x: number; width: number }) => e.x + e.width / 2
16
+
17
+ // The layout follows Mermaid's own C4 renderer: shapes go in rows in
18
+ // declaration order, relationships are drawn afterwards and move nothing.
19
+ describe('C4 SVG layout (Mermaid grid)', () => {
20
+ const SYSTEMS = (n: number) =>
21
+ `C4Context\n${Array.from({ length: n }, (_, i) => ` System(s${i}, "S${i}")`).join('\n')}`
22
+
23
+ it('fills a row left to right in declaration order, four to a row', () => {
24
+ const p = layout(SYSTEMS(5))
25
+ const xs = [0, 1, 2, 3, 4].map((i) => el(p, `s${i}`).x)
26
+ // Columns are one shape plus two margins apart: 216 + 100.
27
+ expect(xs[1]! - xs[0]!).toBeCloseTo(316, 0)
28
+ expect(xs[2]! - xs[1]!).toBeCloseTo(316, 0)
29
+ expect(xs[3]! - xs[2]!).toBeCloseTo(316, 0)
30
+ const ys = [0, 1, 2, 3].map((i) => el(p, `s${i}`).y)
31
+ expect(new Set(ys).size).toBe(1)
32
+ // The fifth wraps to the next row, back under the first.
33
+ expect(el(p, 's4').x).toBeCloseTo(xs[0]!, 0)
34
+ expect(el(p, 's4').y).toBeGreaterThan(ys[0]! + 60)
35
+ })
36
+
37
+ it('ignores Rel_U/D/L/R hints and the direction, as Mermaid does', () => {
38
+ const plain = `C4Context
39
+ System(a, "A")
40
+ System(b, "B")
41
+ System(c, "C")
42
+ Rel(a, b, "x")
43
+ Rel(a, c, "y")`
44
+ const hinted = plain
45
+ .replace('Rel(a, b', 'Rel_D(a, b')
46
+ .replace('Rel(a, c', 'Rel_U(a, c')
47
+ const pos = (p: PositionedC4Diagram) =>
48
+ p.elements.map((e) => [e.alias, e.x, e.y])
49
+ expect(pos(layout(hinted))).toEqual(pos(layout(plain)))
50
+ expect(pos(layout(plain, 'LR'))).toEqual(pos(layout(plain)))
51
+ })
52
+
53
+ it('widens a shape whose text is wider than the standard width', () => {
54
+ const p = layout(`C4Context
55
+ Person(a, "Personal Banking Customer", "A customer of the bank")
56
+ System(b, "B")`)
57
+ expect(el(p, 'a').width).toBeGreaterThan(216)
58
+ expect(el(p, 'b').width).toBe(216)
59
+ // The next shape moves right by the same amount.
60
+ expect(el(p, 'b').x - (el(p, 'a').x + el(p, 'a').width)).toBeCloseTo(100, 0)
61
+ })
62
+
63
+ it('wraps a long name to the text width and grows the box by a line', () => {
64
+ const one = layout('C4Context\nSystem(a, "Banking Banking Banking")')
65
+ const two = layout(
66
+ 'C4Context\nSystem(a, "Banking Banking Banking Banking")',
67
+ )
68
+ expect(el(one, 'a').nameLines).toHaveLength(1)
69
+ expect(el(two, 'a').nameLines).toHaveLength(2)
70
+ expect(el(two, 'a').height - el(one, 'a').height).toBeCloseTo(15.35, 1)
71
+ })
72
+
73
+ it('gives shapes the heights Mermaid does', () => {
74
+ const p = layout(`C4Container
75
+ System(plain, "Plain")
76
+ System(desc, "Described", "One line")
77
+ ContainerDb(db, "DB", "PG", "Rows")
78
+ ContainerQueue(q, "Q", "Kafka")`)
79
+ expect(el(p, 'plain').height).toBeCloseTo(71.07, 0)
80
+ expect(el(p, 'desc').height).toBeCloseTo(88.2, 0)
81
+ expect(el(p, 'db').height).toBeCloseTo(115.8, 0)
82
+ expect(el(p, 'q').height).toBeCloseTo(41.07, 0)
83
+ })
84
+
85
+ it('reproduces the size and frame of a real Mermaid render', () => {
86
+ // The context sample, measured on Mermaid 11.17.2.
87
+ const p = layout(`C4Context
88
+ title System Context for Internet Banking
89
+ Person(customer, "Personal Banking Customer", "A customer of the bank")
90
+ Enterprise_Boundary(b0, "Bank") {
91
+ System(banking, "Internet Banking System", "Lets customers view accounts")
92
+ SystemDb(ledger, "Ledger", "Stores transactions")
93
+ }
94
+ System_Ext(mail, "E-mail System", "Sends e-mails")
95
+ Rel(customer, banking, "Uses", "HTTPS")`)
96
+ expect(p.width).toBeCloseTo(932, -1)
97
+ expect(p.height).toBeCloseTo(888.4, -1)
98
+ const frame = p.boundaries[0]!
99
+ expect(frame.x).toBeCloseTo(150, 0)
100
+ expect(frame.width).toBeCloseTo(632, 0)
101
+ expect(frame.height).toBeCloseTo(265.8, 0)
102
+ expect(centerX(el(p, 'banking'))).toBeCloseTo(308, 0)
103
+ expect(centerX(el(p, 'ledger'))).toBeCloseTo(624, 0)
104
+ })
105
+
106
+ it('puts two boundaries on a row and keeps nested elements inside', () => {
107
+ const p = layout(`C4Container
108
+ Boundary(outer, "Outer", "System") {
109
+ Boundary(inner, "Inner") {
110
+ Container(a, "A", "Go")
111
+ }
112
+ Container(b, "B", "Go")
113
+ }
114
+ Boundary(other, "Other") {
115
+ Container(c, "C", "Go")
116
+ }
117
+ Boundary(third, "Third") {
118
+ Container(d, "D", "Go")
119
+ }
120
+ Rel(a, b, "x")`)
121
+ const by = (alias: string) => p.boundaries.find((b) => b.alias === alias)!
122
+ const outer = by('outer')
123
+ const inner = by('inner')
124
+ expect(outer.depth).toBe(0)
125
+ expect(inner.depth).toBe(1)
126
+ // Outer boundaries are listed first so inner ones paint on top.
127
+ expect(p.boundaries.indexOf(outer)).toBeLessThan(
128
+ p.boundaries.indexOf(inner),
129
+ )
130
+ const inside = (
131
+ o: { x: number; y: number; width: number; height: number },
132
+ i: { x: number; y: number; width: number; height: number },
133
+ ) =>
134
+ i.x >= o.x &&
135
+ i.y >= o.y &&
136
+ i.x + i.width <= o.x + o.width &&
137
+ i.y + i.height <= o.y + o.height
138
+ expect(inside(outer, inner)).toBe(true)
139
+ expect(inside(inner, el(p, 'a'))).toBe(true)
140
+ expect(inside(outer, el(p, 'b'))).toBe(true)
141
+ expect(inside(inner, el(p, 'b'))).toBe(false)
142
+ // Mermaid lays boundaries out two to a row: the third starts a new row.
143
+ expect(by('other').y).toBeCloseTo(outer.y, 0)
144
+ expect(by('other').x).toBeGreaterThan(outer.x + outer.width)
145
+ expect(by('third').y).toBeGreaterThan(outer.y + outer.height)
146
+ })
147
+
148
+ it('draws the first relationship straight and later ones as curves', () => {
149
+ const p = layout(`C4Context
150
+ System(a, "A")
151
+ System(b, "B")
152
+ System(c, "C")
153
+ Rel(a, b, "first")
154
+ Rel(a, c, "second")
155
+ Rel(b, c, "third")`)
156
+ const [first, second, third] = p.relationships
157
+ expect(first!.curve).toBeUndefined()
158
+ const [s, e] = second!.points
159
+ expect(second!.curve).toEqual({
160
+ x: s!.x + (e!.x - s!.x) / 4,
161
+ y: s!.y + (e!.y - s!.y) / 2,
162
+ })
163
+ expect(third!.curve).toBeDefined()
164
+ })
165
+
166
+ it('starts a relationship label at the middle of its chord', () => {
167
+ const p = layout(`C4Context
168
+ System(a, "A")
169
+ System(b, "B")
170
+ Rel(a, b, "label", "tech")`)
171
+ const rel = p.relationships[0]!
172
+ const [s, e] = rel.points
173
+ const midX = (s!.x + e!.x) / 2
174
+ // Mermaid gives its text routine the midpoint as the block's left edge, so
175
+ // the label is centred half its own width to the right of it.
176
+ expect(rel.labelPosition!.x).toBeGreaterThan(midX + 5)
177
+ expect(rel.labelPosition!.x).toBeLessThan(midX + 40)
178
+ expect(rel.labelPosition!.y).toBeCloseTo((s!.y + e!.y) / 2, 5)
179
+ // The technology is centred on its own, at least as far along.
180
+ expect(rel.technologyX!).toBeGreaterThanOrEqual(rel.labelPosition!.x)
181
+ })
182
+
183
+ it('leaves a person along the drawn head and pill, not the box around them', () => {
184
+ const p = layout(`C4Context
185
+ Person(u, "Customer", "Buys things")
186
+ System(s, "Shop", "Online store")
187
+ Rel(u, s, "Orders from")`)
188
+ const u = el(p, 'u')
189
+ const start = p.relationships[0]!.points[0]!
190
+ const headCx = u.x + u.width / 2
191
+ const headCy = u.y + 49.68
192
+ // Mermaid's line leaves on the head circle (in its own drawing, at 295.7),
193
+ // well inside the box's right edge rather than in the air beside the head.
194
+ expect(start.x).toBeLessThan(u.x + u.width - 40)
195
+ expect(Math.hypot(start.x - headCx, start.y - headCy)).toBeCloseTo(49.68, 0)
196
+ })
197
+
198
+ it('routes a relationship that ends on a populated boundary to its frame', () => {
199
+ const p = layout(`C4Context
200
+ System(o, "O")
201
+ Boundary(b, "B") {
202
+ System(s, "S")
203
+ }
204
+ Rel(o, b, "into the boundary")`)
205
+ const rel = p.relationships[0]!
206
+ const b = p.boundaries[0]!
207
+ const end = rel.points[rel.points.length - 1]!
208
+ const onFrame =
209
+ Math.abs(end.x - b.x) < 1 ||
210
+ Math.abs(end.x - (b.x + b.width)) < 1 ||
211
+ Math.abs(end.y - b.y) < 1 ||
212
+ Math.abs(end.y - (b.y + b.height)) < 1
213
+ expect(onFrame).toBe(true)
214
+ })
215
+
216
+ it('shifts everything below a title, as Mermaid does', () => {
217
+ const plain = layout('C4Context\nSystem(a, "A")')
218
+ const titled = layout('C4Context\ntitle T\nSystem(a, "A")')
219
+ expect(el(titled, 'a').y - el(plain, 'a').y).toBeCloseTo(60, 0)
220
+ expect(titled.titlePosition).toBeDefined()
221
+ expect(titled.height - plain.height).toBeCloseTo(60, 0)
222
+ })
223
+ })
224
+
225
+ describe('C4 SVG rendering', () => {
226
+ const SRC = `C4Container
227
+ title Shop
228
+ Person(u, "User")
229
+ Person_Ext(x, "Partner")
230
+ System_Boundary(sys, "Shop") {
231
+ Container(web, "Web App", "React", "Storefront")
232
+ ContainerDb(db, "Database", "PostgreSQL")
233
+ ContainerQueue(q, "Events", "Kafka")
234
+ Component(c, "Comp", "TS")
235
+ }
236
+ Rel(u, web, "Browses", "HTTPS")
237
+ BiRel(web, q, "Publishes")
238
+ Rel(web, db, "Reads")`
239
+
240
+ it('draws a person as a pill with a round head, only for persons', () => {
241
+ const svg = renderMermaidSVG(SRC)
242
+ // One circle per person (the user and the external partner).
243
+ expect(svg.match(/<circle/g)).toHaveLength(2)
244
+ expect(svg).not.toContain('c4-person-glyph')
245
+ expect(renderMermaidSVG('C4Context\nSystem(s, "S")')).not.toContain(
246
+ '<circle',
247
+ )
248
+ })
249
+
250
+ it('draws cylinders for databases and pipes for queues', () => {
251
+ const svg = renderMermaidSVG(SRC)
252
+ expect(svg.match(/<ellipse/g)).toHaveLength(2) // one cap each: db, queue
253
+ expect(svg).toContain('data-shape="db"')
254
+ expect(svg).toContain('data-shape="queue"')
255
+ })
256
+
257
+ it("shows a queue's ellipse face at its right end, as Mermaid does", () => {
258
+ const svg = renderMermaidSVG('C4Container\nContainerQueue(q, "Events")')
259
+ expect(svg).toContain('data-shape="queue"')
260
+ const ell = /<ellipse cx="([\d.-]+)" cy="[\d.-]+" rx="([\d.-]+)"/.exec(svg)!
261
+ const path = /<path d="M ([\d.-]+) [\d.-]+ H ([\d.-]+) A/.exec(svg)!
262
+ const cx = +ell[1]!
263
+ const rx = +ell[2]!
264
+ // The body starts at the face centre (right) and runs left to its
265
+ // rounded end.
266
+ expect(+path[1]!).toBeCloseTo(cx, 1)
267
+ expect(+path[2]!).toBeLessThan(cx - rx)
268
+ })
269
+
270
+ it('uses Mermaid’s palette by kind, lighter for external elements', () => {
271
+ const svg = renderMermaidSVG(SRC)
272
+ expect(svg).toContain('#08427b') // person
273
+ expect(svg).toContain('#686868') // external person
274
+ expect(svg).toContain('#438dd5') // container
275
+ expect(svg).toContain('#85bbf0') // component
276
+ })
277
+
278
+ it('draws the diagram title, boundary title and technology text', () => {
279
+ const svg = renderMermaidSVG(SRC)
280
+ expect(svg).toContain('class="c4-title"')
281
+ expect(svg).toContain('>Shop</text>')
282
+ expect(svg).toContain('[HTTPS]')
283
+ expect(svg).toContain('[Container: React]')
284
+ expect(svg).toContain('[Person]')
285
+ })
286
+
287
+ it('puts arrowheads at both ends of a BiRel and one end otherwise', () => {
288
+ const svg = renderMermaidSVG(
289
+ 'C4Context\nSystem(a, "A")\nSystem(b, "B")\nRel(a, b, "one")',
290
+ )
291
+ const bi = renderMermaidSVG(
292
+ 'C4Context\nSystem(a, "A")\nSystem(b, "B")\nBiRel(a, b, "two")',
293
+ )
294
+ expect(svg.match(/<polygon/g)).toHaveLength(1)
295
+ expect(bi.match(/<polygon/g)).toHaveLength(2)
296
+ })
297
+
298
+ it('draws the first relationship as a line and the rest as quadratic curves', () => {
299
+ const svg = renderMermaidSVG(`C4Context
300
+ System(a, "A")
301
+ System(b, "B")
302
+ System(c, "C")
303
+ Rel(a, b, "one")
304
+ Rel(a, c, "two")`)
305
+ const ds = [
306
+ ...svg.matchAll(/<path class="c4-relationship"[^>]* d="([^"]+)"/g),
307
+ ]
308
+ expect(ds).toHaveLength(2)
309
+ expect(ds[0]![1]).toMatch(/^M [\d.-]+,[\d.-]+ L /)
310
+ expect(ds[1]![1]).toMatch(/^M [\d.-]+,[\d.-]+ Q /)
311
+ })
312
+
313
+ it('escapes user text', () => {
314
+ const svg = renderMermaidSVG(
315
+ 'C4Context\nSystem(a, "A <b> & \\"q\\"")\nSystem(b, "B")\nRel(a, b, "x<y")',
316
+ )
317
+ expect(svg).not.toContain('A <b>')
318
+ expect(svg).toContain('x&lt;y')
319
+ })
320
+
321
+ it('uses the diagram title as the accessible name when no title option is set', () => {
322
+ expect(renderMermaidSVG(SRC)).toContain('<title')
323
+ expect(renderMermaidSVG(SRC, { title: 'Mine' })).toContain('>Mine</title>')
324
+ })
325
+
326
+ it('renders empty and boundary-only diagrams', () => {
327
+ expect(renderMermaidSVG('C4Context')).toContain('<svg')
328
+ const svg = renderMermaidSVG('C4Context\nBoundary(b, "Empty", "Type")')
329
+ expect(svg).toContain('class="c4-boundary"')
330
+ })
331
+
332
+ it('is deterministic', () => {
333
+ // The accessible-name id counter advances per render; everything else is fixed.
334
+ const strip = (svg: string) => svg.replace(/zm-title-\d+/g, 'zm-title')
335
+ expect(strip(renderMermaidSVG(SRC))).toBe(strip(renderMermaidSVG(SRC)))
336
+ })
337
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMermaidSVG } from '../../../../src/index.ts'
3
+
4
+ // String slicing rather than RegExp(variable): the repo's Semgrep gate blocks
5
+ // non-literal regexes (detect-non-literal-regexp), even in tests.
6
+ const marker = (svg: string, id: string) => {
7
+ const start = svg.indexOf(`<marker id="${id}"`)
8
+ const end = svg.indexOf('</marker>', start)
9
+ return start === -1 || end === -1 ? '' : svg.slice(start, end)
10
+ }
11
+
12
+ const attrValue = (def: string, attr: string) => {
13
+ const start = def.indexOf(`${attr}="`) + attr.length + 2
14
+ return def.slice(start, def.indexOf('"', start))
15
+ }
16
+
17
+ const num = (def: string, attr: string) => Number(attrValue(def, attr))
18
+
19
+ const relationship = (svg: string, type: string) =>
20
+ svg.split('<polyline').find((p) => p.includes(`data-type="${type}"`)) ?? ''
21
+
22
+ // The diamond's tip must sit on the line's endpoint. With orient
23
+ // auto-start-reverse the marker's +x points away from the line at the start,
24
+ // so refX has to be the tip's x; refX=0 pushes the whole diamond outward
25
+ // under the class box (#1232). The polygon must also be inset by half its
26
+ // stroke, or the marker viewport clips the diamond's points flat.
27
+ describe('class diagram diamond markers (#1232)', () => {
28
+ const cases = [
29
+ ['composition', 'C *-- D', 'cls-composition', 'var(--_arrow)'],
30
+ ['aggregation', 'E o-- F', 'cls-aggregation', 'var(--bg)'],
31
+ ] as const
32
+
33
+ for (const [type, line, id, fill] of cases) {
34
+ it(`${type} references its marker at the owning end`, () => {
35
+ const svg = renderMermaidSVG(`classDiagram\n ${line}`)
36
+ expect(relationship(svg, type)).toContain(`marker-start="url(#${id})"`)
37
+ expect(marker(svg, id)).toContain(`fill="${fill}"`)
38
+ })
39
+
40
+ it(`${type} anchors its tip on the endpoint without clipping the stroke`, () => {
41
+ const def = marker(renderMermaidSVG(`classDiagram\n ${line}`), id)
42
+ const points = attrValue(def, 'points')
43
+ .split(',')
44
+ .map((p) => p.trim().split(/\s+/).map(Number) as [number, number])
45
+ const xs = points.map((p) => p[0])
46
+ const ys = points.map((p) => p[1])
47
+ const half = num(def, 'stroke-width') / 2
48
+ expect(num(def, 'refX')).toBe(Math.max(...xs))
49
+ expect(Math.min(...xs) - half).toBeGreaterThanOrEqual(0)
50
+ expect(Math.min(...ys) - half).toBeGreaterThanOrEqual(0)
51
+ expect(Math.max(...xs) + half).toBeLessThanOrEqual(
52
+ num(def, 'markerWidth'),
53
+ )
54
+ expect(Math.max(...ys) + half).toBeLessThanOrEqual(
55
+ num(def, 'markerHeight'),
56
+ )
57
+ })
58
+ }
59
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * #1233: one-or-many (`|{`) was drawn as a bare crow's foot. The visual suite's
3
+ * tolerance can't see a missing bar, so assert the drawn primitives directly.
4
+ */
5
+ import { describe, it, expect } from 'vitest'
6
+ import { renderMermaidSVG } from '@zombie-mermaid/svg-renderer'
7
+
8
+ function count(src: string): { lines: number; circles: number } {
9
+ const svg = renderMermaidSVG(src)
10
+ return {
11
+ lines: (svg.match(/<line /g) ?? []).length,
12
+ circles: (svg.match(/<circle /g) ?? []).length,
13
+ }
14
+ }
15
+
16
+ /** Primitives drawn for the right-hand cardinality token, net of an `||` end. */
17
+ function rightEnd(token: string): { lines: number; circles: number } {
18
+ const base = count('erDiagram\n A ||--|| B : r')
19
+ const v = count(`erDiagram\n A ||--${token} B : r`)
20
+ return { lines: v.lines - base.lines, circles: v.circles - base.circles }
21
+ }
22
+
23
+ describe('ER cardinality markers (#1233)', () => {
24
+ it('draws one-or-many as foot (3 lines) + bar, vs 2 lines for exactly one', () => {
25
+ expect(rightEnd('|{')).toEqual({ lines: 2, circles: 0 })
26
+ })
27
+
28
+ it('draws zero-or-many as foot + circle, with no bar', () => {
29
+ expect(rightEnd('o{')).toEqual({ lines: 1, circles: 1 })
30
+ })
31
+
32
+ it('distinguishes one-or-many from zero-or-many', () => {
33
+ expect(rightEnd('|{')).not.toEqual(rightEnd('o{'))
34
+ })
35
+ })