zombie-mermaid 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.
Files changed (92) hide show
  1. package/package.json +9 -7
  2. package/src/__tests__/architecture-integration.test.ts +0 -217
  3. package/src/__tests__/ascii-ansi.test.ts +0 -539
  4. package/src/__tests__/ascii-draw-bundles.test.ts +0 -849
  5. package/src/__tests__/ascii-edge-bundling-routing.test.ts +0 -214
  6. package/src/__tests__/ascii-edge-cross-style-overlap.test.ts +0 -216
  7. package/src/__tests__/ascii-grid-deep-chain.test.ts +0 -64
  8. package/src/__tests__/ascii-hyperlinks.test.ts +0 -364
  9. package/src/__tests__/ascii-nested-subgraph-sibling-order-444.test.ts +0 -108
  10. package/src/__tests__/ascii-nonmember-beside-frame-1283.test.ts +0 -113
  11. package/src/__tests__/ascii-package-boundary.test.ts +0 -291
  12. package/src/__tests__/ascii-parallel-edges-329.test.ts +0 -315
  13. package/src/__tests__/ascii-parallel-lane-border-1230.test.ts +0 -71
  14. package/src/__tests__/ascii-reverse-direction-edges-629.test.ts +0 -282
  15. package/src/__tests__/ascii-stacked-frame-geometry-1285.test.ts +0 -107
  16. package/src/__tests__/ascii-subgraph-bbox.test.ts +0 -139
  17. package/src/__tests__/ascii-subgraph-later-edgeless-301.test.ts +0 -277
  18. package/src/__tests__/browser.test.ts +0 -34
  19. package/src/__tests__/c4-integration.test.ts +0 -161
  20. package/src/__tests__/c4-orientation.test.ts +0 -162
  21. package/src/__tests__/c4-upstream-integration.test.ts +0 -211
  22. package/src/__tests__/class-click-interactions.test.ts +0 -232
  23. package/src/__tests__/class-integration.test.ts +0 -154
  24. package/src/__tests__/class-notes.test.ts +0 -317
  25. package/src/__tests__/class-parser-error-messages.test.ts +0 -162
  26. package/src/__tests__/class-parser.test.ts +0 -347
  27. package/src/__tests__/class-styling.test.ts +0 -485
  28. package/src/__tests__/cli-diagrams.test.ts +0 -106
  29. package/src/__tests__/cli-e2e.test.ts +0 -275
  30. package/src/__tests__/cli-entrypoint.test.ts +0 -146
  31. package/src/__tests__/cli-mcp-dispatch.test.ts +0 -68
  32. package/src/__tests__/cli-mcp.test.ts +0 -44
  33. package/src/__tests__/cli-png.test.ts +0 -35
  34. package/src/__tests__/cli-render.test.ts +0 -988
  35. package/src/__tests__/cli-test-helpers.ts +0 -54
  36. package/src/__tests__/cli-web-dispatch.test.ts +0 -62
  37. package/src/__tests__/cli-web.test.ts +0 -203
  38. package/src/__tests__/cli.test.ts +0 -1027
  39. package/src/__tests__/compound-flat-layout.test.ts +0 -901
  40. package/src/__tests__/direction-override.test.ts +0 -397
  41. package/src/__tests__/edge-approach-direction.test.ts +0 -295
  42. package/src/__tests__/edge-bundling-node-clearance.test.ts +0 -179
  43. package/src/__tests__/elk-layout-cache.test.ts +0 -223
  44. package/src/__tests__/er-click-unsupported.test.ts +0 -69
  45. package/src/__tests__/er-integration.test.ts +0 -531
  46. package/src/__tests__/er-parser-error-messages.test.ts +0 -82
  47. package/src/__tests__/er-parser.test.ts +0 -294
  48. package/src/__tests__/expanded-shapes.test.ts +0 -229
  49. package/src/__tests__/flowchart-back-edge-prereversal-1239.test.ts +0 -212
  50. package/src/__tests__/flowchart-syntax-gaps.test.ts +0 -371
  51. package/src/__tests__/guides-examples.test.ts +0 -181
  52. package/src/__tests__/integration.test.ts +0 -729
  53. package/src/__tests__/interactivity-config.test.ts +0 -723
  54. package/src/__tests__/interactivity-option.test.ts +0 -173
  55. package/src/__tests__/layout-disconnected.test.ts +0 -384
  56. package/src/__tests__/layout-engine.test.ts +0 -91
  57. package/src/__tests__/linkstyle.test.ts +0 -133
  58. package/src/__tests__/mcp-render-ascii-tool.test.ts +0 -95
  59. package/src/__tests__/mcp-render-svg-theme-guard.test.ts +0 -36
  60. package/src/__tests__/mcp-render-svg-tool.test.ts +0 -118
  61. package/src/__tests__/mermaid-entity-codes.test.ts +0 -114
  62. package/src/__tests__/monospace-metrics.test.ts +0 -74
  63. package/src/__tests__/multiline-labels.test.ts +0 -924
  64. package/src/__tests__/nested-subgraph-direction-crossing.test.ts +0 -322
  65. package/src/__tests__/nested-subgraph-edge-container-offset.test.ts +0 -151
  66. package/src/__tests__/package-info.test.ts +0 -51
  67. package/src/__tests__/parser.test.ts +0 -1544
  68. package/src/__tests__/render-options-overrides.test.ts +0 -257
  69. package/src/__tests__/render-options-per-type.test.ts +0 -178
  70. package/src/__tests__/renderer.test.ts +0 -743
  71. package/src/__tests__/resolve-colors.test.ts +0 -448
  72. package/src/__tests__/self-loop-edges.test.ts +0 -112
  73. package/src/__tests__/sequence-activation-check.test.ts +0 -219
  74. package/src/__tests__/sequence-integration.test.ts +0 -312
  75. package/src/__tests__/sequence-layout.test.ts +0 -1103
  76. package/src/__tests__/sequence-parser-syntax-errors.test.ts +0 -179
  77. package/src/__tests__/sequence-parser.test.ts +0 -821
  78. package/src/__tests__/start-marker-only-links-1386.test.ts +0 -104
  79. package/src/__tests__/state-composite-order-1287.test.ts +0 -132
  80. package/src/__tests__/state-flow-order-1240.test.ts +0 -113
  81. package/src/__tests__/state-styling.test.ts +0 -150
  82. package/src/__tests__/statements.test.ts +0 -264
  83. package/src/__tests__/styles.test.ts +0 -220
  84. package/src/__tests__/subgraph-title-edge-gap-1239.test.ts +0 -238
  85. package/src/__tests__/svg-accessible-name-conformance.test.ts +0 -276
  86. package/src/__tests__/svg-accessible-name.test.ts +0 -421
  87. package/src/__tests__/svg-csp-style-options.test.ts +0 -427
  88. package/src/__tests__/workspace-package-boundaries.test.ts +0 -336
  89. package/src/__tests__/xychart-integration.test.ts +0 -137
  90. package/src/__tests__/xychart-layout.test.ts +0 -205
  91. package/src/__tests__/xychart-parser-categories.test.ts +0 -78
  92. package/src/__tests__/xychart-parser-errors.test.ts +0 -95
@@ -1,743 +0,0 @@
1
- /**
2
- * Tests for the SVG renderer.
3
- *
4
- * Uses hand-crafted PositionedGraph data to test SVG output without
5
- * depending on the layout engine.
6
- */
7
- import { describe, it, expect } from 'vitest'
8
- import { renderSvg } from '@zombie-mermaid/svg-renderer'
9
- import type {
10
- DiagramColors,
11
- PositionedGraph,
12
- PositionedNode,
13
- PositionedEdge,
14
- PositionedGroup,
15
- } from '@zombie-mermaid/core'
16
-
17
- /** Minimal positioned graph for testing */
18
- function makeGraph(overrides: Partial<PositionedGraph> = {}): PositionedGraph {
19
- return {
20
- width: 400,
21
- height: 300,
22
- nodes: [],
23
- edges: [],
24
- groups: [],
25
- ...overrides,
26
- }
27
- }
28
-
29
- /** Helper to build a positioned node */
30
- function makeNode(overrides: Partial<PositionedNode> = {}): PositionedNode {
31
- return {
32
- id: 'A',
33
- label: 'Test',
34
- shape: 'rectangle',
35
- x: 100,
36
- y: 100,
37
- width: 80,
38
- height: 40,
39
- ...overrides,
40
- }
41
- }
42
-
43
- /** Helper to build a positioned edge with arrow defaults */
44
- function makeEdge(overrides: Partial<PositionedEdge> = {}): PositionedEdge {
45
- return {
46
- source: 'A',
47
- target: 'B',
48
- style: 'solid',
49
- hasArrowStart: false,
50
- hasArrowEnd: true,
51
- points: [
52
- { x: 100, y: 120 },
53
- { x: 100, y: 200 },
54
- ],
55
- ...overrides,
56
- }
57
- }
58
-
59
- /** Default light colors — CSS custom properties handle actual styling */
60
- const lightColors: DiagramColors = { bg: '#FFFFFF', fg: '#27272A' }
61
- const darkColors: DiagramColors = { bg: '#18181B', fg: '#FAFAFA' }
62
-
63
- // ============================================================================
64
- // SVG structure
65
- // ============================================================================
66
-
67
- describe('renderSvg – SVG structure', () => {
68
- it('produces a valid SVG root element', () => {
69
- const svg = renderSvg(makeGraph(), lightColors)
70
- expect(svg).toContain('<svg xmlns="http://www.w3.org/2000/svg"')
71
- expect(svg).toContain('viewBox="0 0 400 300"')
72
- expect(svg).toContain('width="400"')
73
- expect(svg).toContain('height="300"')
74
- expect(svg).toContain('</svg>')
75
- })
76
-
77
- it('includes <defs> with arrow markers', () => {
78
- const svg = renderSvg(makeGraph(), lightColors)
79
- expect(svg).toContain('<defs>')
80
- expect(svg).toContain('<marker id="arrowhead"')
81
- expect(svg).toContain('<marker id="arrowhead-start"')
82
- expect(svg).toContain('</defs>')
83
- })
84
-
85
- it('start marker is not double-reversed (regression)', () => {
86
- // orient="auto-start-reverse" already rotates the head 180° to point back
87
- // out of the start node, so the polygon must match the forward (end)
88
- // marker. Reversing BOTH is a double reversal: the head points into the
89
- // line and disappears in librsvg/Inkscape/browsers.
90
- //
91
- // Extracted with plain string search (not a dynamically-built RegExp)
92
- // since `id` here is always one of two hardcoded marker ids below, not
93
- // attacker-controlled input.
94
- const svg = renderSvg(makeGraph(), lightColors)
95
- const markerBlock = (id: string): string => {
96
- const openTag = `<marker id="${id}"`
97
- const start = svg.indexOf(openTag)
98
- expect(start).toBeGreaterThanOrEqual(0)
99
- const end = svg.indexOf('</marker>', start)
100
- return svg.slice(start, end)
101
- }
102
- const attr = (block: string, name: string): string | undefined =>
103
- block.split(`${name}="`)[1]?.split('"')[0]
104
- const polygonPoints = (block: string): string | undefined =>
105
- block.split('<polygon points="')[1]?.split('"')[0]
106
-
107
- const startBlock = markerBlock('arrowhead-start')
108
- const endBlock = markerBlock('arrowhead')
109
-
110
- expect(attr(startBlock, 'orient')).toBe('auto-start-reverse')
111
- expect(polygonPoints(startBlock)).toBe(polygonPoints(endBlock))
112
- })
113
-
114
- it('includes embedded Google Fonts import', () => {
115
- const svg = renderSvg(makeGraph(), lightColors, 'Inter')
116
- expect(svg).toContain('fonts.googleapis.com')
117
- expect(svg).toContain('Inter')
118
- })
119
-
120
- it('uses custom font name when specified', () => {
121
- const svg = renderSvg(makeGraph(), lightColors, 'Roboto Mono')
122
- // encodeURIComponent turns spaces into %20
123
- expect(svg).toContain('Roboto%20Mono')
124
- expect(svg).toContain("'Roboto Mono'")
125
- })
126
-
127
- it('sets CSS color variables in inline style', () => {
128
- const light = renderSvg(makeGraph(), lightColors)
129
- expect(light).toContain('--bg:#FFFFFF')
130
- expect(light).toContain('--fg:#27272A')
131
-
132
- const dark = renderSvg(makeGraph(), darkColors)
133
- expect(dark).toContain('--bg:#18181B')
134
- expect(dark).toContain('--fg:#FAFAFA')
135
- })
136
- })
137
-
138
- // ============================================================================
139
- // Original node shapes
140
- // ============================================================================
141
-
142
- describe('renderSvg – node shapes', () => {
143
- it('renders rectangle with rx=0', () => {
144
- const graph = makeGraph({ nodes: [makeNode({ shape: 'rectangle' })] })
145
- const svg = renderSvg(graph, lightColors)
146
- expect(svg).toContain('rx="0" ry="0"')
147
- })
148
-
149
- it('renders rounded rectangle with rx=6', () => {
150
- const graph = makeGraph({ nodes: [makeNode({ shape: 'rounded' })] })
151
- const svg = renderSvg(graph, lightColors)
152
- expect(svg).toContain('rx="6" ry="6"')
153
- })
154
-
155
- it('renders stadium with rx=height/2', () => {
156
- const node = makeNode({ shape: 'stadium', height: 40 })
157
- const graph = makeGraph({ nodes: [node] })
158
- const svg = renderSvg(graph, lightColors)
159
- expect(svg).toContain('rx="20" ry="20"')
160
- })
161
-
162
- it('renders circle with <circle> element', () => {
163
- const node = makeNode({ shape: 'circle', width: 60, height: 60 })
164
- const graph = makeGraph({ nodes: [node] })
165
- const svg = renderSvg(graph, lightColors)
166
- expect(svg).toContain('<circle')
167
- expect(svg).toContain('r="30"')
168
- })
169
-
170
- it('renders diamond with <polygon>', () => {
171
- const node = makeNode({ shape: 'diamond', width: 80, height: 80 })
172
- const graph = makeGraph({ nodes: [node] })
173
- const svg = renderSvg(graph, lightColors)
174
- expect(svg).toContain('<polygon')
175
- expect(svg).toContain('points="140,100 180,140 140,180 100,140"')
176
- })
177
-
178
- it('renders node labels as <text> elements', () => {
179
- const graph = makeGraph({ nodes: [makeNode({ label: 'My Node' })] })
180
- const svg = renderSvg(graph, lightColors)
181
- expect(svg).toContain('>My Node</text>')
182
- })
183
- })
184
-
185
- // ============================================================================
186
- // New Batch 1 shapes
187
- // ============================================================================
188
-
189
- describe('renderSvg – new shapes (Batch 1)', () => {
190
- it('renders subroutine with outer rect and inset vertical lines', () => {
191
- const node = makeNode({ shape: 'subroutine', width: 100, height: 40 })
192
- const graph = makeGraph({ nodes: [node] })
193
- const svg = renderSvg(graph, lightColors)
194
- // Outer rect
195
- expect(svg).toContain('<rect x="100" y="100" width="100" height="40"')
196
- // Left inset line at x=108 (100+8)
197
- expect(svg).toContain('x1="108"')
198
- // Right inset line at x=192 (100+100-8)
199
- expect(svg).toContain('x1="192"')
200
- expect(svg).toContain('<line')
201
- })
202
-
203
- it('renders double circle with two <circle> elements', () => {
204
- const node = makeNode({ shape: 'doublecircle', width: 80, height: 80 })
205
- const graph = makeGraph({ nodes: [node] })
206
- const svg = renderSvg(graph, lightColors)
207
- const circleMatches = svg.match(/<circle/g) ?? []
208
- expect(circleMatches.length).toBe(2)
209
- // Outer radius: min(80,80)/2 = 40, inner = 35
210
- expect(svg).toContain('r="40"')
211
- expect(svg).toContain('r="35"')
212
- })
213
-
214
- it('renders hexagon with 6-point <polygon>', () => {
215
- const node = makeNode({ shape: 'hexagon', width: 100, height: 40 })
216
- const graph = makeGraph({ nodes: [node] })
217
- const svg = renderSvg(graph, lightColors)
218
- expect(svg).toContain('<polygon')
219
- // 6 points means 6 coordinate pairs separated by spaces
220
- const polygonMatch = svg.match(/points="([^"]+)"/)
221
- const points = polygonMatch?.[1]?.split(' ') ?? []
222
- expect(points.length).toBe(6)
223
- })
224
- })
225
-
226
- // ============================================================================
227
- // New Batch 2 shapes
228
- // ============================================================================
229
-
230
- describe('renderSvg – new shapes (Batch 2)', () => {
231
- it('renders cylinder with ellipses and body rect', () => {
232
- const node = makeNode({ shape: 'cylinder', width: 80, height: 50 })
233
- const graph = makeGraph({ nodes: [node] })
234
- const svg = renderSvg(graph, lightColors)
235
- // Should contain ellipses for top and bottom caps
236
- const ellipseMatches = svg.match(/<ellipse/g) ?? []
237
- expect(ellipseMatches.length).toBe(2)
238
- // Body rect
239
- expect(svg).toContain('<rect')
240
- })
241
-
242
- it('renders asymmetric / flag with 5-point <polygon>', () => {
243
- const node = makeNode({ shape: 'asymmetric', width: 100, height: 40 })
244
- const graph = makeGraph({ nodes: [node] })
245
- const svg = renderSvg(graph, lightColors)
246
- expect(svg).toContain('<polygon')
247
- // Match the shape polygon (comma-separated "x,y" pairs), not the arrowhead polygons
248
- // Arrowhead polygons use space-separated "x y," format, while shape polygons use "x,y x,y" format
249
- const allPolygons = [...svg.matchAll(/points="([^"]+)"/g)]
250
- // The shape polygon is the last one (rendered after defs/arrowheads)
251
- const shapePolygon = allPolygons[allPolygons.length - 1]
252
- const points = shapePolygon?.[1]?.split(' ') ?? []
253
- expect(points.length).toBe(5)
254
- })
255
-
256
- it('renders trapezoid with 4-point <polygon>', () => {
257
- const node = makeNode({ shape: 'trapezoid', width: 100, height: 40 })
258
- const graph = makeGraph({ nodes: [node] })
259
- const svg = renderSvg(graph, lightColors)
260
- expect(svg).toContain('<polygon')
261
- const allPolygons = [...svg.matchAll(/points="([^"]+)"/g)]
262
- const shapePolygon = allPolygons[allPolygons.length - 1]
263
- const points = shapePolygon?.[1]?.split(' ') ?? []
264
- expect(points.length).toBe(4)
265
- })
266
-
267
- it('renders trapezoid-alt with 4-point <polygon>', () => {
268
- const node = makeNode({ shape: 'trapezoid-alt', width: 100, height: 40 })
269
- const graph = makeGraph({ nodes: [node] })
270
- const svg = renderSvg(graph, lightColors)
271
- expect(svg).toContain('<polygon')
272
- const allPolygons = [...svg.matchAll(/points="([^"]+)"/g)]
273
- const shapePolygon = allPolygons[allPolygons.length - 1]
274
- const points = shapePolygon?.[1]?.split(' ') ?? []
275
- expect(points.length).toBe(4)
276
- })
277
- })
278
-
279
- // ============================================================================
280
- // Batch 3: State diagram pseudostates
281
- // ============================================================================
282
-
283
- describe('renderSvg – state pseudostates', () => {
284
- it('renders state-start as a filled circle', () => {
285
- const node = makeNode({
286
- shape: 'state-start',
287
- label: '',
288
- width: 28,
289
- height: 28,
290
- })
291
- const graph = makeGraph({ nodes: [node] })
292
- const svg = renderSvg(graph, lightColors)
293
- expect(svg).toContain('<circle')
294
- expect(svg).toContain('fill="var(--_text)"')
295
- expect(svg).toContain('stroke="none"')
296
- })
297
-
298
- it('renders state-end as bullseye (two circles)', () => {
299
- const node = makeNode({
300
- shape: 'state-end',
301
- label: '',
302
- width: 28,
303
- height: 28,
304
- })
305
- const graph = makeGraph({ nodes: [node] })
306
- const svg = renderSvg(graph, lightColors)
307
- const circleMatches = svg.match(/<circle/g) ?? []
308
- expect(circleMatches.length).toBe(2)
309
- // Outer is stroked, inner is filled
310
- expect(svg).toContain('fill="none"')
311
- expect(svg).toContain('fill="var(--_text)"')
312
- })
313
- })
314
-
315
- // ============================================================================
316
- // Edge rendering
317
- // ============================================================================
318
-
319
- describe('renderSvg – edges', () => {
320
- it('renders a solid edge as <polyline> with end arrow', () => {
321
- const edge = makeEdge({ style: 'solid', hasArrowEnd: true })
322
- const graph = makeGraph({ edges: [edge] })
323
- const svg = renderSvg(graph, lightColors)
324
- expect(svg).toContain('<polyline')
325
- expect(svg).toContain('points="100,120 100,200"')
326
- expect(svg).toContain('marker-end="url(#arrowhead)"')
327
- })
328
-
329
- it('renders dotted edges with stroke-dasharray', () => {
330
- const edge = makeEdge({ style: 'dotted' })
331
- const graph = makeGraph({ edges: [edge] })
332
- const svg = renderSvg(graph, lightColors)
333
- expect(svg).toContain('stroke-dasharray="4 4"')
334
- })
335
-
336
- it('renders thick edges with doubled stroke width', () => {
337
- const edge = makeEdge({ style: 'thick' })
338
- const graph = makeGraph({ edges: [edge] })
339
- const svg = renderSvg(graph, lightColors)
340
- // Base connector stroke is 1px, thick is doubled to 2px
341
- expect(svg).toContain('stroke-width="2"')
342
- })
343
-
344
- it('does not add dasharray to solid edges', () => {
345
- const edge = makeEdge({ style: 'solid' })
346
- const graph = makeGraph({ edges: [edge] })
347
- const svg = renderSvg(graph, lightColors)
348
- expect(svg).not.toContain('dasharray')
349
- })
350
-
351
- it('skips edges with fewer than 2 points', () => {
352
- const edge = makeEdge({ points: [{ x: 0, y: 0 }] })
353
- const graph = makeGraph({ edges: [edge] })
354
- const svg = renderSvg(graph, lightColors)
355
- expect(svg).not.toContain('<polyline')
356
- })
357
-
358
- it('renders no-arrow edge without marker-end', () => {
359
- const edge = makeEdge({ hasArrowEnd: false, hasArrowStart: false })
360
- const graph = makeGraph({ edges: [edge] })
361
- const svg = renderSvg(graph, lightColors)
362
- expect(svg).toContain('<polyline')
363
- expect(svg).not.toContain('marker-end')
364
- expect(svg).not.toContain('marker-start')
365
- })
366
-
367
- it('renders bidirectional edge with both markers', () => {
368
- const edge = makeEdge({ hasArrowStart: true, hasArrowEnd: true })
369
- const graph = makeGraph({ edges: [edge] })
370
- const svg = renderSvg(graph, lightColors)
371
- expect(svg).toContain('marker-end="url(#arrowhead)"')
372
- expect(svg).toContain('marker-start="url(#arrowhead-start)"')
373
- })
374
- })
375
-
376
- // ============================================================================
377
- // Edge labels
378
- // ============================================================================
379
-
380
- describe('renderSvg – edge labels', () => {
381
- it('renders edge label with background pill', () => {
382
- const edge = makeEdge({ label: 'Yes' })
383
- const graph = makeGraph({ edges: [edge] })
384
- const svg = renderSvg(graph, lightColors)
385
- expect(svg).toContain('>Yes</text>')
386
- expect(svg).toContain('rx="2" ry="2"')
387
- })
388
-
389
- it('does not render label elements for edges without labels', () => {
390
- const edge = makeEdge({ label: undefined })
391
- const graph = makeGraph({ edges: [edge] })
392
- const svg = renderSvg(graph, lightColors)
393
- const textMatches = svg.match(/<text[^>]*>.*?<\/text>/g) ?? []
394
- expect(textMatches).toHaveLength(0)
395
- })
396
-
397
- it('uses labelPosition when provided instead of edge midpoint', () => {
398
- // Edge midpoint would be at (100, 160) given these points.
399
- // labelPosition overrides to (50, 80) — verify the SVG uses that coordinate.
400
- const edge = makeEdge({
401
- label: 'Go',
402
- points: [
403
- { x: 100, y: 120 },
404
- { x: 100, y: 200 },
405
- ],
406
- labelPosition: { x: 50, y: 80 },
407
- })
408
- const graph = makeGraph({ edges: [edge] })
409
- const svg = renderSvg(graph, lightColors)
410
-
411
- // The label text should be centered at the labelPosition (x=50, y=80)
412
- expect(svg).toContain('x="50" y="80"')
413
- // The midpoint y=160 should NOT appear in any label-related element
414
- expect(svg).not.toContain('y="160"')
415
- })
416
- })
417
-
418
- // ============================================================================
419
- // Group rendering (subgraphs)
420
- // ============================================================================
421
-
422
- describe('renderSvg – groups', () => {
423
- it('renders group with outer rectangle and header band', () => {
424
- const group: PositionedGroup = {
425
- id: 'sg1',
426
- label: 'Backend',
427
- x: 20,
428
- y: 20,
429
- width: 200,
430
- height: 150,
431
- children: [],
432
- }
433
- const graph = makeGraph({ groups: [group] })
434
- const svg = renderSvg(graph, lightColors)
435
- const rectCount = (svg.match(/x="20" y="20"/g) ?? []).length
436
- expect(rectCount).toBeGreaterThanOrEqual(2)
437
- expect(svg).toContain('>Backend</text>')
438
- })
439
-
440
- it('renders nested groups recursively', () => {
441
- const inner: PositionedGroup = {
442
- id: 'inner',
443
- label: 'Inner',
444
- x: 40,
445
- y: 60,
446
- width: 120,
447
- height: 80,
448
- children: [],
449
- }
450
- const outer: PositionedGroup = {
451
- id: 'outer',
452
- label: 'Outer',
453
- x: 20,
454
- y: 20,
455
- width: 200,
456
- height: 150,
457
- children: [inner],
458
- }
459
- const graph = makeGraph({ groups: [outer] })
460
- const svg = renderSvg(graph, lightColors)
461
- expect(svg).toContain('>Outer</text>')
462
- expect(svg).toContain('>Inner</text>')
463
- })
464
- })
465
-
466
- // ============================================================================
467
- // Inline style support
468
- // ============================================================================
469
-
470
- describe('renderSvg – inline styles', () => {
471
- it('applies inline fill override', () => {
472
- const node = makeNode({ inlineStyle: { fill: '#ff0000' } })
473
- const graph = makeGraph({ nodes: [node] })
474
- const svg = renderSvg(graph, lightColors)
475
- expect(svg).toContain('fill="#ff0000"')
476
- })
477
-
478
- it('applies inline stroke override', () => {
479
- const node = makeNode({ inlineStyle: { stroke: '#00ff00' } })
480
- const graph = makeGraph({ nodes: [node] })
481
- const svg = renderSvg(graph, lightColors)
482
- expect(svg).toContain('stroke="#00ff00"')
483
- })
484
-
485
- it('applies inline text color override', () => {
486
- const node = makeNode({ inlineStyle: { color: '#0000ff' } })
487
- const graph = makeGraph({ nodes: [node] })
488
- const svg = renderSvg(graph, lightColors)
489
- expect(svg).toContain('fill="#0000ff"')
490
- })
491
-
492
- it('falls back to theme when no inline style', () => {
493
- const node = makeNode()
494
- const graph = makeGraph({ nodes: [node] })
495
- const svg = renderSvg(graph, lightColors)
496
- expect(svg).toContain('fill="var(--_node-fill)"')
497
- })
498
-
499
- it('applies inline font-family override to the node text element (issue #57)', () => {
500
- const node = makeNode({ inlineStyle: { 'font-family': 'monospace' } })
501
- const graph = makeGraph({ nodes: [node] })
502
- const svg = renderSvg(graph, lightColors)
503
- expect(svg).toContain('style="font-family: monospace;"')
504
- })
505
-
506
- it('renders no per-node font-family override when no inline style is set', () => {
507
- const node = makeNode()
508
- const graph = makeGraph({ nodes: [node] })
509
- const svg = renderSvg(graph, lightColors)
510
- // The global `text { font-family: ... }` rule from the theme is still
511
- // present; only the per-node inline `style="font-family: ...` override
512
- // should be absent.
513
- expect(svg).not.toContain('style="font-family:')
514
- })
515
- })
516
-
517
- // ============================================================================
518
- // Fill → text color contrast (issue #55)
519
- // ============================================================================
520
-
521
- describe('renderSvg – readable text color for custom fills', () => {
522
- it('picks dark text for a light pastel fill in dark mode', () => {
523
- // Matches the issue #55 repro: classDef external fill:#FF6B6B in dark mode.
524
- const node = makeNode({ inlineStyle: { fill: '#FF6B6B' } })
525
- const graph = makeGraph({ nodes: [node] })
526
- const svg = renderSvg(graph, darkColors)
527
- expect(svg).toContain('fill="#000000"')
528
- expect(svg).not.toContain('fill="var(--_text)"')
529
- })
530
-
531
- it('picks dark text for a light green fill in dark mode', () => {
532
- const node = makeNode({ inlineStyle: { fill: '#90EE90' } })
533
- const graph = makeGraph({ nodes: [node] })
534
- const svg = renderSvg(graph, darkColors)
535
- expect(svg).toContain('fill="#000000"')
536
- })
537
-
538
- it('picks light text for a dark fill in light mode', () => {
539
- const node = makeNode({ inlineStyle: { fill: '#111111' } })
540
- const graph = makeGraph({ nodes: [node] })
541
- const svg = renderSvg(graph, lightColors)
542
- expect(svg).toContain('fill="#FFFFFF"')
543
- expect(svg).not.toContain('fill="var(--_text)"')
544
- })
545
-
546
- it('keeps using the theme foreground when there is no custom fill', () => {
547
- const node = makeNode()
548
- const graph = makeGraph({ nodes: [node] })
549
- const svg = renderSvg(graph, darkColors)
550
- expect(svg).toContain('fill="var(--_text)"')
551
- })
552
-
553
- it('keeps using the theme foreground when the fill is unresolvable (CSS variable reference)', () => {
554
- const node = makeNode({
555
- inlineStyle: { fill: 'var(--some-other-var)' },
556
- })
557
- const graph = makeGraph({ nodes: [node] })
558
- const svg = renderSvg(graph, darkColors)
559
- expect(svg).toContain('fill="var(--_text)"')
560
- })
561
-
562
- it('keeps using the theme foreground when the fill is a named CSS color, not a hex literal', () => {
563
- const node = makeNode({ inlineStyle: { fill: 'lightpink' } })
564
- const graph = makeGraph({ nodes: [node] })
565
- const svg = renderSvg(graph, darkColors)
566
- expect(svg).toContain('fill="var(--_text)"')
567
- })
568
-
569
- it('still respects an explicit inline color override even with a custom fill', () => {
570
- const node = makeNode({
571
- inlineStyle: { fill: '#FF6B6B', color: '#0000ff' },
572
- })
573
- const graph = makeGraph({ nodes: [node] })
574
- const svg = renderSvg(graph, darkColors)
575
- expect(svg).toContain('fill="#0000ff"')
576
- expect(svg).not.toContain('fill="#000000"')
577
- })
578
- })
579
-
580
- // ============================================================================
581
- // className (from :::className shorthand or `class A className`)
582
- // ============================================================================
583
-
584
- describe('renderSvg – custom class names', () => {
585
- it('emits the class name onto the rendered node alongside the base class', () => {
586
- const node = makeNode({ className: 'highlight' })
587
- const graph = makeGraph({ nodes: [node] })
588
- const svg = renderSvg(graph, lightColors)
589
- expect(svg).toContain('<g class="node highlight" data-id="A"')
590
- })
591
-
592
- it('renders only the base class when the node has no custom class', () => {
593
- const node = makeNode()
594
- const graph = makeGraph({ nodes: [node] })
595
- const svg = renderSvg(graph, lightColors)
596
- expect(svg).toContain('<g class="node" data-id="A"')
597
- expect(svg).not.toMatch(/<g class="node [^"]/)
598
- })
599
-
600
- it('drops a class name that is not a valid CSS identifier rather than emitting it raw', () => {
601
- const node = makeNode({ className: 'evil" onmouseover="alert(1)' })
602
- const graph = makeGraph({ nodes: [node] })
603
- const svg = renderSvg(graph, lightColors)
604
- expect(svg).toContain('<g class="node" data-id="A"')
605
- expect(svg).not.toContain('onmouseover')
606
- })
607
- })
608
-
609
- // ============================================================================
610
- // XML escaping
611
- // ============================================================================
612
-
613
- describe('renderSvg – XML escaping', () => {
614
- it('escapes special characters in node labels', () => {
615
- const node = makeNode({ label: '<script> & "quotes" \'apos\'' })
616
- const graph = makeGraph({ nodes: [node] })
617
- const svg = renderSvg(graph, lightColors)
618
- expect(svg).toContain('&lt;script&gt;')
619
- expect(svg).toContain('&amp;')
620
- expect(svg).toContain('&quot;quotes&quot;')
621
- expect(svg).toContain('&#39;apos&#39;')
622
- })
623
-
624
- it('escapes special characters in edge labels', () => {
625
- const edge = makeEdge({ label: 'A & B > C' })
626
- const graph = makeGraph({ edges: [edge] })
627
- const svg = renderSvg(graph, lightColors)
628
- expect(svg).toContain('A &amp; B &gt; C')
629
- })
630
-
631
- it('escapes special characters in group labels', () => {
632
- const group: PositionedGroup = {
633
- id: 'g1',
634
- label: 'A < B',
635
- x: 0,
636
- y: 0,
637
- width: 100,
638
- height: 100,
639
- children: [],
640
- }
641
- const graph = makeGraph({ groups: [group] })
642
- const svg = renderSvg(graph, lightColors)
643
- expect(svg).toContain('A &lt; B')
644
- })
645
- })
646
-
647
- // ============================================================================
648
- // Security: inline style injection prevention
649
- // ============================================================================
650
-
651
- describe('renderSvg – inline style XSS prevention', () => {
652
- it('escapes attribute injection in inline style fill', () => {
653
- const node = makeNode({
654
- inlineStyle: { fill: 'red" onmouseover="alert(1)' },
655
- })
656
- const graph = makeGraph({ nodes: [node] })
657
- const svg = renderSvg(graph, lightColors)
658
- expect(svg).not.toContain('onmouseover="alert')
659
- expect(svg).toContain('red&quot; onmouseover=&quot;alert(1)')
660
- })
661
-
662
- it('escapes element injection in inline style fill', () => {
663
- const node = makeNode({
664
- inlineStyle: { fill: 'red"/><svg onload="alert(1)"><rect fill="x' },
665
- })
666
- const graph = makeGraph({ nodes: [node] })
667
- const svg = renderSvg(graph, lightColors)
668
- expect(svg).not.toContain('<svg onload')
669
- expect(svg).toContain('&lt;svg onload=')
670
- })
671
-
672
- it('escapes injection in inline style stroke', () => {
673
- const node = makeNode({
674
- inlineStyle: { stroke: 'blue" onclick="alert(1)' },
675
- })
676
- const graph = makeGraph({ nodes: [node] })
677
- const svg = renderSvg(graph, lightColors)
678
- expect(svg).not.toContain('onclick="alert')
679
- expect(svg).toContain('blue&quot; onclick=&quot;alert(1)')
680
- })
681
-
682
- it('escapes injection in inline style stroke-width', () => {
683
- const node = makeNode({
684
- inlineStyle: { 'stroke-width': '2" onmouseover="alert(1)' },
685
- })
686
- const graph = makeGraph({ nodes: [node] })
687
- const svg = renderSvg(graph, lightColors)
688
- expect(svg).not.toContain('onmouseover="alert')
689
- expect(svg).toContain('2&quot; onmouseover=&quot;alert(1)')
690
- })
691
-
692
- it('escapes injection in inline style color', () => {
693
- const node = makeNode({
694
- inlineStyle: { color: 'green" onfocus="alert(1)' },
695
- })
696
- const graph = makeGraph({ nodes: [node] })
697
- const svg = renderSvg(graph, lightColors)
698
- expect(svg).not.toContain('onfocus="alert')
699
- expect(svg).toContain('green&quot; onfocus=&quot;alert(1)')
700
- })
701
-
702
- it('escapes injection in inline style font-family', () => {
703
- const node = makeNode({
704
- inlineStyle: { 'font-family': 'monospace" onmouseover="alert(1)' },
705
- })
706
- const graph = makeGraph({ nodes: [node] })
707
- const svg = renderSvg(graph, lightColors)
708
- expect(svg).not.toContain('onmouseover="alert')
709
- expect(svg).toContain('monospace&quot; onmouseover=&quot;alert(1)')
710
- })
711
- })
712
-
713
- // ============================================================================
714
- // Theme application
715
- // ============================================================================
716
-
717
- describe('renderSvg – CSS variable theming', () => {
718
- it('uses CSS variables for styling (light colors)', () => {
719
- const node = makeNode()
720
- const graph = makeGraph({ nodes: [node] })
721
- const svg = renderSvg(graph, lightColors)
722
- // SVG uses CSS custom property references, not hardcoded colors
723
- expect(svg).toContain('var(--_node-fill)')
724
- expect(svg).toContain('var(--_node-stroke)')
725
- expect(svg).toContain('var(--_text)')
726
- })
727
-
728
- it('uses same CSS variables with dark colors', () => {
729
- const node = makeNode()
730
- const graph = makeGraph({ nodes: [node] })
731
- const svg = renderSvg(graph, darkColors)
732
- // Same CSS variable structure — colors differ via --bg/--fg on the SVG tag
733
- expect(svg).toContain('var(--_node-fill)')
734
- expect(svg).toContain('var(--_node-stroke)')
735
- expect(svg).toContain('var(--_text)')
736
- expect(svg).toContain('--bg:#18181B')
737
- })
738
-
739
- it('arrow marker uses CSS variable for fill', () => {
740
- const svg = renderSvg(makeGraph(), lightColors)
741
- expect(svg).toContain('fill="var(--_arrow)"')
742
- })
743
- })