@zombie-mermaid/svg-renderer 4.2.2 → 4.2.3
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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +34 -1
- package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
- package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
- package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
- package/src/__tests__/c4-svg.test.ts +0 -337
- package/src/__tests__/c4-text-width-1210.test.ts +0 -43
- package/src/__tests__/class-component-order-1249.test.ts +0 -40
- package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
- package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
- package/src/__tests__/class-namespace-1196.test.ts +0 -165
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
- package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
- package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
- package/src/__tests__/elk-adapter-utils.test.ts +0 -166
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
- package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
- package/src/__tests__/er-default-direction-1250.test.ts +0 -69
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
- package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
- package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
- package/src/__tests__/pie-layout.test.ts +0 -215
- package/src/__tests__/pie-svg.test.ts +0 -194
- package/src/__tests__/render-mermaid-svg.test.ts +0 -64
- package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
- package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
- package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
- package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
|
@@ -1,337 +0,0 @@
|
|
|
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<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
|
-
})
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import { layoutC4DiagramSync } from '../c4/layout.ts'
|
|
5
|
-
import { C4, C4_TEXT_WIDTH, c4TextWidth, wrapToWidth } from '../c4/metrics.ts'
|
|
6
|
-
|
|
7
|
-
// Mermaid 11.17.2 measures C4 text in the browser. The widths below are read
|
|
8
|
-
// back from its SVG (see c4-mermaid-reference.test.ts for the full set), and
|
|
9
|
-
// text near the wrap limit is where an estimate that is a few percent off
|
|
10
|
-
// changes a shape's width (#1210).
|
|
11
|
-
const shapes = (src: string) =>
|
|
12
|
-
layoutC4DiagramSync(parseC4Diagram(splitStatements(src))).elements
|
|
13
|
-
|
|
14
|
-
describe('C4 text widths (#1210)', () => {
|
|
15
|
-
it('measures a bold name as Mermaid does', () => {
|
|
16
|
-
// Mermaid made the shape for this name 227.6px wide: text plus 2 x 20.
|
|
17
|
-
expect(
|
|
18
|
-
c4TextWidth('Personal Banking Customer', C4.nameSize, 700),
|
|
19
|
-
).toBeCloseTo(227.6 - 2 * C4.shapePadding, 0)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('wraps at the shape width less its padding, measured at regular weight', () => {
|
|
23
|
-
expect(C4_TEXT_WIDTH).toBe(176)
|
|
24
|
-
// 187.6px in bold, 175.9px in regular: it stays on one line, as in
|
|
25
|
-
// Mermaid, even though the drawn name is wider than the limit.
|
|
26
|
-
const name = 'Personal Banking Customer'
|
|
27
|
-
expect(wrapToWidth(name, C4_TEXT_WIDTH, C4.nameSize)).toEqual([name])
|
|
28
|
-
expect(c4TextWidth(name, C4.nameSize, 700)).toBeGreaterThan(C4_TEXT_WIDTH)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('keeps a person with a three-line description at the standard width', () => {
|
|
32
|
-
const [p] = shapes(
|
|
33
|
-
'C4Context\n Person(p, "Customer", "A description that is long enough to wrap into three lines at the standard width of a shape")',
|
|
34
|
-
)
|
|
35
|
-
expect(p!.descriptionLines).toHaveLength(3)
|
|
36
|
-
// Mermaid: 216px. A wider estimate widened it to 222px.
|
|
37
|
-
expect(p!.width).toBeCloseTo(216, 1)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('falls back to a scaled estimate outside printable ASCII', () => {
|
|
41
|
-
expect(c4TextWidth('中文', C4.descrSize, 400)).toBeGreaterThan(0)
|
|
42
|
-
})
|
|
43
|
-
})
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { parseClassDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import { layoutClassDiagramSync } from '../class/layout.ts'
|
|
5
|
-
|
|
6
|
-
const layout = (src: string) =>
|
|
7
|
-
layoutClassDiagramSync(parseClassDiagram(splitStatements(src)))
|
|
8
|
-
|
|
9
|
-
// Mermaid places disconnected components left to right in declaration order
|
|
10
|
-
// (#1249). ELK's layered algorithm otherwise orders them by its own graph
|
|
11
|
-
// traversal, so the layout has to keep the declared order.
|
|
12
|
-
describe('class diagram disconnected components (#1249)', () => {
|
|
13
|
-
it('places each component left to right in source order, in one row', () => {
|
|
14
|
-
const p = layout(`classDiagram
|
|
15
|
-
A <|-- B : inheritance
|
|
16
|
-
C *-- D : composition
|
|
17
|
-
E o-- F : aggregation
|
|
18
|
-
G --> H : association
|
|
19
|
-
I ..> J : dependency
|
|
20
|
-
K ..|> L : realization`)
|
|
21
|
-
const at = (id: string) => p.classes.find((c) => c.id === id)!
|
|
22
|
-
const roots = ['A', 'C', 'E', 'G', 'I', 'K'].map((id) => at(id))
|
|
23
|
-
const lefts = roots.map((c, i) => Math.min(c.x, at('BDFHJL'[i]!).x))
|
|
24
|
-
for (let i = 1; i < lefts.length; i++) {
|
|
25
|
-
expect(lefts[i]!).toBeGreaterThan(lefts[i - 1]!)
|
|
26
|
-
}
|
|
27
|
-
// Single row: every component starts at the same top.
|
|
28
|
-
expect(new Set(roots.map((c) => c.y)).size).toBe(1)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('orders components by first appearance, not by relationship order', () => {
|
|
32
|
-
const p = layout(`classDiagram
|
|
33
|
-
class Z
|
|
34
|
-
class Y
|
|
35
|
-
Z <|-- Q
|
|
36
|
-
Y <|-- R`)
|
|
37
|
-
const x = (id: string) => p.classes.find((c) => c.id === id)!.x
|
|
38
|
-
expect(x('Z')).toBeLessThan(x('Y'))
|
|
39
|
-
})
|
|
40
|
-
})
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
3
|
-
|
|
4
|
-
// Mono glyphs advance 0.6em (6.6px at the 11px member size). Chromium without
|
|
5
|
-
// subpixel glyph positioning (headless Linux) rounds each advance to a whole
|
|
6
|
-
// pixel, so the worst-case drawn width is chars * ceil(6.6) = chars * 7. A box
|
|
7
|
-
// sized from the unrounded estimate let a long member run into the right
|
|
8
|
-
// border in the Linux baselines (#1238).
|
|
9
|
-
const WORST_CASE_ADVANCE = 7
|
|
10
|
-
const MIN_RIGHT_PADDING = 8
|
|
11
|
-
|
|
12
|
-
const SRC = `classDiagram
|
|
13
|
-
class Controller {
|
|
14
|
-
-model Model
|
|
15
|
-
+handleInput(event) void
|
|
16
|
-
+updateModel(data) void
|
|
17
|
-
}`
|
|
18
|
-
|
|
19
|
-
const attr = (tag: string, name: string) => {
|
|
20
|
-
const start = tag.indexOf(`${name}="`) + name.length + 2
|
|
21
|
-
return Number(tag.slice(start, tag.indexOf('"', start)))
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe('class member right padding (#1238)', () => {
|
|
25
|
-
const svg = renderMermaidSVG(SRC)
|
|
26
|
-
const box =
|
|
27
|
-
(svg.split('<g class="class-node"')[1] ?? '').split('<rect')[1] ?? ''
|
|
28
|
-
const text = svg
|
|
29
|
-
.split('<text')
|
|
30
|
-
.find((t) => t.includes('handleInput(event)')) as string
|
|
31
|
-
const drawnChars = '+ handleInput(event): void'.length
|
|
32
|
-
|
|
33
|
-
it('finds the box and the member text', () => {
|
|
34
|
-
expect(box).not.toBe('')
|
|
35
|
-
expect(text).toBeDefined()
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('keeps the minimum right padding even with whole-pixel glyph advances', () => {
|
|
39
|
-
const boxRight = attr(box, 'x') + attr(box, 'width')
|
|
40
|
-
const textRight = attr(text, 'x') + drawnChars * WORST_CASE_ADVANCE
|
|
41
|
-
expect(boxRight - textRight).toBeGreaterThanOrEqual(MIN_RIGHT_PADDING)
|
|
42
|
-
})
|
|
43
|
-
})
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { parseClassDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import { layoutClassDiagramSync } from '../class/layout.ts'
|
|
5
|
-
import { renderMermaidSVG } from '../index.ts'
|
|
6
|
-
|
|
7
|
-
const SRC = `classDiagram
|
|
8
|
-
namespace Core {
|
|
9
|
-
class A
|
|
10
|
-
class B
|
|
11
|
-
}
|
|
12
|
-
namespace Extras {
|
|
13
|
-
class C
|
|
14
|
-
}
|
|
15
|
-
class D
|
|
16
|
-
A --> C
|
|
17
|
-
A --> B
|
|
18
|
-
D --> B`
|
|
19
|
-
|
|
20
|
-
const layout = (src: string) =>
|
|
21
|
-
layoutClassDiagramSync(parseClassDiagram(splitStatements(src)))
|
|
22
|
-
|
|
23
|
-
describe('class diagram namespaces (#1196)', () => {
|
|
24
|
-
const p = layout(SRC)
|
|
25
|
-
const cls = (id: string) => p.classes.find((c) => c.id === id)!
|
|
26
|
-
const ns = (name: string) => p.namespaces.find((n) => n.name === name)!
|
|
27
|
-
|
|
28
|
-
it('lays out one frame per namespace', () => {
|
|
29
|
-
expect(p.namespaces.map((n) => n.name)).toEqual(['Core', 'Extras'])
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('encloses each member class and no outsider', () => {
|
|
33
|
-
const inside = (n: string, id: string) => {
|
|
34
|
-
const f = ns(n)
|
|
35
|
-
const c = cls(id)
|
|
36
|
-
return (
|
|
37
|
-
c.x >= f.x &&
|
|
38
|
-
c.y >= f.y &&
|
|
39
|
-
c.x + c.width <= f.x + f.width &&
|
|
40
|
-
c.y + c.height <= f.y + f.height
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
expect(inside('Core', 'A')).toBe(true)
|
|
44
|
-
expect(inside('Core', 'B')).toBe(true)
|
|
45
|
-
expect(inside('Extras', 'C')).toBe(true)
|
|
46
|
-
expect(inside('Core', 'C')).toBe(false)
|
|
47
|
-
expect(inside('Core', 'D')).toBe(false)
|
|
48
|
-
expect(inside('Extras', 'D')).toBe(false)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('keeps frames inside the canvas and apart from each other', () => {
|
|
52
|
-
for (const f of p.namespaces) {
|
|
53
|
-
expect(f.x).toBeGreaterThanOrEqual(0)
|
|
54
|
-
expect(f.y).toBeGreaterThanOrEqual(0)
|
|
55
|
-
expect(f.x + f.width).toBeLessThanOrEqual(p.width)
|
|
56
|
-
expect(f.y + f.height).toBeLessThanOrEqual(p.height)
|
|
57
|
-
}
|
|
58
|
-
const a = ns('Core')
|
|
59
|
-
const b = ns('Extras')
|
|
60
|
-
const disjoint =
|
|
61
|
-
a.x + a.width <= b.x ||
|
|
62
|
-
b.x + b.width <= a.x ||
|
|
63
|
-
a.y + a.height <= b.y ||
|
|
64
|
-
b.y + b.height <= a.y
|
|
65
|
-
expect(disjoint).toBe(true)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('leaves room for the title above the classes', () => {
|
|
69
|
-
expect(cls('A').y - ns('Core').y).toBeGreaterThanOrEqual(28)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('routes edges to the class boxes, including edges inside a namespace', () => {
|
|
73
|
-
const touches = (pt: { x: number; y: number }, id: string) => {
|
|
74
|
-
const c = cls(id)
|
|
75
|
-
const tol = 1.5
|
|
76
|
-
return (
|
|
77
|
-
pt.x >= c.x - tol &&
|
|
78
|
-
pt.x <= c.x + c.width + tol &&
|
|
79
|
-
pt.y >= c.y - tol &&
|
|
80
|
-
pt.y <= c.y + c.height + tol
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
for (const rel of p.relationships) {
|
|
84
|
-
const first = rel.points[0]!
|
|
85
|
-
const last = rel.points[rel.points.length - 1]!
|
|
86
|
-
expect(touches(first, rel.from)).toBe(true)
|
|
87
|
-
expect(touches(last, rel.to)).toBe(true)
|
|
88
|
-
}
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('draws the namespace names and frames in SVG', () => {
|
|
92
|
-
const svg = renderMermaidSVG(SRC)
|
|
93
|
-
expect(svg).toContain('>Core</text>')
|
|
94
|
-
expect(svg).toContain('>Extras</text>')
|
|
95
|
-
expect(svg.match(/class="class-namespace"/g)).toHaveLength(2)
|
|
96
|
-
// Frames sit behind the class boxes.
|
|
97
|
-
expect(svg.indexOf('class-namespace')).toBeLessThan(
|
|
98
|
-
svg.indexOf('class-node'),
|
|
99
|
-
)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('escapes the namespace name', () => {
|
|
103
|
-
const svg = renderMermaidSVG(`classDiagram
|
|
104
|
-
namespace a&b {
|
|
105
|
-
class A
|
|
106
|
-
}`)
|
|
107
|
-
expect(svg).toContain('>a&b</text>')
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('ignores a namespace with no classes and a class claimed twice', () => {
|
|
111
|
-
const q = layout(`classDiagram
|
|
112
|
-
namespace Empty {
|
|
113
|
-
}
|
|
114
|
-
namespace One {
|
|
115
|
-
class A
|
|
116
|
-
}
|
|
117
|
-
namespace Two {
|
|
118
|
-
class A
|
|
119
|
-
class B
|
|
120
|
-
}`)
|
|
121
|
-
expect(q.namespaces.map((n) => [n.name, n.classIds])).toEqual([
|
|
122
|
-
['One', ['A']],
|
|
123
|
-
['Two', ['B']],
|
|
124
|
-
])
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('leaves diagrams without namespaces unframed', () => {
|
|
128
|
-
expect(layout('classDiagram\nA --> B').namespaces).toEqual([])
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('returns no frames for an empty diagram', () => {
|
|
132
|
-
expect(layout('classDiagram').namespaces).toEqual([])
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it('keeps a note link attached to a class inside a namespace', () => {
|
|
136
|
-
const q = layout(`classDiagram
|
|
137
|
-
namespace Core {
|
|
138
|
-
class A
|
|
139
|
-
class B
|
|
140
|
-
}
|
|
141
|
-
A --> B
|
|
142
|
-
note for A "hello"`)
|
|
143
|
-
const a = q.classes.find((c) => c.id === 'A')!
|
|
144
|
-
const note = q.notes[0]!
|
|
145
|
-
expect(note.linkPoints).toBeDefined()
|
|
146
|
-
const pts = note.linkPoints!
|
|
147
|
-
const end = pts[pts.length - 1]!
|
|
148
|
-
const tol = 1.5
|
|
149
|
-
const touchesA =
|
|
150
|
-
end.x >= a.x - tol &&
|
|
151
|
-
end.x <= a.x + a.width + tol &&
|
|
152
|
-
end.y >= a.y - tol &&
|
|
153
|
-
end.y <= a.y + a.height + tol
|
|
154
|
-
expect(touchesA).toBe(true)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('lays out a note that has no link', () => {
|
|
158
|
-
const q = layout(`classDiagram
|
|
159
|
-
namespace Core {
|
|
160
|
-
class A
|
|
161
|
-
}
|
|
162
|
-
note "free floating"`)
|
|
163
|
-
expect(q.notes[0]!.linkPoints).toBeUndefined()
|
|
164
|
-
})
|
|
165
|
-
})
|