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,364 +0,0 @@
1
- /**
2
- * Opt-in OSC 8 terminal hyperlinks in ASCII output (issue #216, piece 4).
3
- *
4
- * `renderMermaidASCII(text, { hyperlinks: true })` wraps the label of every
5
- * node whose `click` directive declared a safe href in an OSC 8 escape
6
- * pair. The sequences are zero-width, so the load-bearing property is that
7
- * stripping them yields exactly the non-hyperlinked output — checked here
8
- * per feature, and against every gallery sample in
9
- * __tests__/ascii-hyperlinks-samples.test.ts (which lives outside src/ so it
10
- * can import samples-data.ts without tripping tsconfig's rootDir).
11
- */
12
- import { describe, it, expect } from 'vitest'
13
- import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
14
- import {
15
- OSC8_CLOSE,
16
- OSC8_SEQUENCE,
17
- osc8Open,
18
- stripOsc8,
19
- joinWithLinks,
20
- LinkRunTracker,
21
- markBoxLabelLinks,
22
- mkLinkCanvas,
23
- } from '../../packages/ascii-renderer/src/hyperlinks.ts'
24
- import {
25
- colorizeLine,
26
- DEFAULT_ASCII_THEME,
27
- } from '../../packages/ascii-renderer/src/ansi.ts'
28
- import { mkCanvas, write } from '../../packages/ascii-renderer/src/canvas.ts'
29
- import { addCoordsOverlay } from '../../packages/ascii-renderer/src/coords.ts'
30
- import { runRender } from '../cli/render.ts'
31
- import { createMockStdout, renderArgs } from './cli-test-helpers.ts'
32
- import type { CharRole } from '../../packages/ascii-renderer/src/types.ts'
33
-
34
- const OSC8_PREFIX = '\x1b]8;;'
35
-
36
- /** Every `open … close` span in `output`: [href, visible text]. */
37
- function linkSpans(output: string): [string, string][] {
38
- const spans: [string, string][] = []
39
- const re = /\x1b\]8;;([^\x1b]*)\x1b\\([\s\S]*?)\x1b\]8;;\x1b\\/g
40
- for (const m of output.matchAll(re)) spans.push([m[1]!, m[2]!])
41
- return spans
42
- }
43
-
44
- const LINKED_FLOW = `graph LR
45
- A[Docs] --> B[Other]
46
- click A "https://example.com" "Docs"`
47
-
48
- describe('renderMermaidASCII – hyperlinks option', () => {
49
- it('is off by default: no OSC 8 anywhere, even with click hrefs present', () => {
50
- expect(renderMermaidASCII(LINKED_FLOW)).not.toContain(OSC8_PREFIX)
51
- expect(
52
- renderMermaidASCII(LINKED_FLOW, { hyperlinks: false }),
53
- ).not.toContain(OSC8_PREFIX)
54
- })
55
-
56
- it('wraps only the linked node label when enabled', () => {
57
- const out = renderMermaidASCII(LINKED_FLOW, { hyperlinks: true })
58
- expect(linkSpans(out)).toEqual([['https://example.com', 'Docs']])
59
- // The unlinked node's label is present and bare.
60
- expect(stripOsc8(out)).toContain('Other')
61
- expect(out).toContain(`${osc8Open('https://example.com')}Docs${OSC8_CLOSE}`)
62
- })
63
-
64
- it('never changes layout: stripping the sequences gives the plain render', () => {
65
- const plain = renderMermaidASCII(LINKED_FLOW)
66
- const linked = renderMermaidASCII(LINKED_FLOW, { hyperlinks: true })
67
- expect(linked).not.toBe(plain)
68
- expect(stripOsc8(linked)).toBe(plain)
69
- })
70
-
71
- it('links a multi-word label as one span, interior space included', () => {
72
- const out = renderMermaidASCII(
73
- `graph TD
74
- A[Web Server] --> B[DB]
75
- click A "https://example.com/web"`,
76
- { hyperlinks: true },
77
- )
78
- expect(linkSpans(out)).toEqual([['https://example.com/web', 'Web Server']])
79
- })
80
-
81
- it('links each line of a multi-line label separately', () => {
82
- const out = renderMermaidASCII(
83
- `graph TD
84
- A["Line one<br/>Line two"] --> B[Next]
85
- click A "https://example.com/multi"`,
86
- { hyperlinks: true },
87
- )
88
- expect(linkSpans(out)).toEqual([
89
- ['https://example.com/multi', 'Line one'],
90
- ['https://example.com/multi', 'Line two'],
91
- ])
92
- expect(stripOsc8(out)).toBe(
93
- renderMermaidASCII(
94
- `graph TD
95
- A["Line one<br/>Line two"] --> B[Next]
96
- click A "https://example.com/multi"`,
97
- ),
98
- )
99
- })
100
-
101
- it('links only the node that declared the click when labels repeat', () => {
102
- const src = `graph LR
103
- A[Same] --> B[Same]
104
- click B "https://example.com/b"`
105
- const out = renderMermaidASCII(src, { hyperlinks: true })
106
- expect(linkSpans(out)).toEqual([['https://example.com/b', 'Same']])
107
- // Both labels are still rendered; exactly one of them is wrapped.
108
- expect(stripOsc8(out).match(/Same/g)).toHaveLength(2)
109
- })
110
-
111
- it('links the whole label when another label is a prefix of it', () => {
112
- const out = renderMermaidASCII(
113
- `graph LR
114
- A[Doc] --> B[Docs]
115
- click B "https://example.com/docs"`,
116
- { hyperlinks: true },
117
- )
118
- expect(linkSpans(out)).toEqual([['https://example.com/docs', 'Docs']])
119
- })
120
-
121
- it.each([
122
- ['javascript:alert(1)'],
123
- ['data:text/html,<script>alert(1)</script>'],
124
- ['vbscript:msgbox'],
125
- ])('never emits an unsafe href (%s)', (href) => {
126
- const out = renderMermaidASCII(
127
- `graph LR
128
- A[Docs] --> B[Other]
129
- click A "${href}"`,
130
- { hyperlinks: true },
131
- )
132
- expect(out).not.toContain(OSC8_PREFIX)
133
- expect(out).not.toContain(href)
134
- })
135
-
136
- it('emits nothing for a callback-only interaction', () => {
137
- const out = renderMermaidASCII(
138
- `graph LR
139
- A[Docs] --> B[Other]
140
- click A call showDetail() "A tooltip"`,
141
- { hyperlinks: true },
142
- )
143
- expect(out).not.toContain(OSC8_PREFIX)
144
- expect(out).not.toContain('showDetail')
145
- })
146
-
147
- it('accepts mailto and relative hrefs', () => {
148
- const out = renderMermaidASCII(
149
- `graph LR
150
- A[Mail] --> B[Local]
151
- click A "mailto:me@example.com"
152
- click B "./guide.html"`,
153
- { hyperlinks: true },
154
- )
155
- expect(linkSpans(out)).toEqual([
156
- ['mailto:me@example.com', 'Mail'],
157
- ['./guide.html', 'Local'],
158
- ])
159
- })
160
-
161
- it('keeps links on their labels after the BT vertical flip', () => {
162
- const src = `graph BT
163
- A[Bottom] --> B[Top]
164
- click B "https://example.com/top"`
165
- const out = renderMermaidASCII(src, { hyperlinks: true })
166
- expect(linkSpans(out)).toEqual([['https://example.com/top', 'Top']])
167
- expect(stripOsc8(out)).toBe(renderMermaidASCII(src))
168
- })
169
-
170
- it('links a class name in a class diagram, but not its annotation or members', () => {
171
- const src = `classDiagram
172
- class Animal {
173
- <<interface>>
174
- +String name
175
- +speak()
176
- }
177
- class Dog
178
- Animal <|-- Dog
179
- click Animal href "https://example.com/animal"`
180
- const out = renderMermaidASCII(src, { hyperlinks: true })
181
- expect(linkSpans(out)).toEqual([['https://example.com/animal', 'Animal']])
182
- expect(stripOsc8(out)).toBe(renderMermaidASCII(src))
183
- })
184
-
185
- it("is ignored in 'html' color mode (browser mockups never get escapes)", () => {
186
- const out = renderMermaidASCII(LINKED_FLOW, {
187
- hyperlinks: true,
188
- colorMode: 'html',
189
- })
190
- expect(out).not.toContain('\x1b')
191
- expect(out).toContain('<span')
192
- })
193
-
194
- it('composes with ANSI colors without disturbing the SGR runs', () => {
195
- const plain = renderMermaidASCII(LINKED_FLOW, { colorMode: 'truecolor' })
196
- const linked = renderMermaidASCII(LINKED_FLOW, {
197
- colorMode: 'truecolor',
198
- hyperlinks: true,
199
- })
200
- expect(plain).toContain('\x1b[38;2;')
201
- expect(linkSpans(linked).map(([href]) => href)).toEqual([
202
- 'https://example.com',
203
- ])
204
- expect(stripOsc8(linked)).toBe(plain)
205
- })
206
-
207
- it('does not widen the --coords ruler', () => {
208
- const plain = addCoordsOverlay(renderMermaidASCII(LINKED_FLOW))
209
- const linked = addCoordsOverlay(
210
- renderMermaidASCII(LINKED_FLOW, { hyperlinks: true }),
211
- )
212
- expect(linked.split('\n')[0]).toBe(plain.split('\n')[0])
213
- expect(stripOsc8(linked)).toBe(plain)
214
- })
215
- })
216
-
217
- describe('hyperlinks – escape sequence helpers', () => {
218
- it('uses the ESC \\ string terminator, not BEL', () => {
219
- expect(osc8Open('https://example.com')).toBe(
220
- '\x1b]8;;https://example.com\x1b\\',
221
- )
222
- expect(OSC8_CLOSE).toBe('\x1b]8;;\x1b\\')
223
- expect(osc8Open('https://example.com')).not.toContain('\x07')
224
- })
225
-
226
- it('percent-encodes characters outside printable ASCII, leaving the rest alone', () => {
227
- expect(osc8Open('https://例.jp/a b?q=1&r=%20#x')).toBe(
228
- '\x1b]8;;https://%E4%BE%8B.jp/a%20b?q=1&r=%20#x\x1b\\',
229
- )
230
- // A surrogate pair encodes as one code point, not two lone surrogates.
231
- expect(osc8Open('https://x.test/😀')).toBe(
232
- '\x1b]8;;https://x.test/%F0%9F%98%80\x1b\\',
233
- )
234
- })
235
-
236
- it('stripOsc8 removes ST- and BEL-terminated sequences and nothing else', () => {
237
- const st = `${osc8Open('https://a.test')}A${OSC8_CLOSE} \x1b[31mB\x1b[0m`
238
- expect(stripOsc8(st)).toBe('A \x1b[31mB\x1b[0m')
239
- const bel = '\x1b]8;;https://a.test\x07A\x1b]8;;\x07'
240
- expect(stripOsc8(bel)).toBe('A')
241
- expect(OSC8_SEQUENCE.flags).toContain('g')
242
- })
243
-
244
- it('LinkRunTracker closes and reopens between adjacent different links', () => {
245
- const t = new LinkRunTracker()
246
- expect(t.advance(null)).toBe('')
247
- expect(t.advance('https://a.test')).toBe(osc8Open('https://a.test'))
248
- expect(t.advance('https://a.test')).toBe('')
249
- expect(t.advance('https://b.test')).toBe(
250
- OSC8_CLOSE + osc8Open('https://b.test'),
251
- )
252
- expect(t.advance(null)).toBe(OSC8_CLOSE)
253
- expect(t.finish()).toBe('')
254
- t.advance('https://c.test')
255
- expect(t.finish()).toBe(OSC8_CLOSE)
256
- })
257
-
258
- it('joinWithLinks wraps runs and closes a run open at end of line', () => {
259
- const chars = ['x', 'y', 'z', 'w']
260
- const links = [null, 'https://a.test', 'https://a.test', 'https://b.test']
261
- expect(joinWithLinks(chars, links)).toBe(
262
- `x${osc8Open('https://a.test')}yz${OSC8_CLOSE}${osc8Open('https://b.test')}w${OSC8_CLOSE}`,
263
- )
264
- expect(joinWithLinks(chars, [null, null, null, null])).toBe('xyzw')
265
- })
266
-
267
- it('colorizeLine keeps SGR grouping intact around a link inside a color run', () => {
268
- // Zero border padding: the label touches the border, so the link
269
- // boundary falls inside what would otherwise be one 'border' SGR run if
270
- // the label's first char were border-like. Assert the general property.
271
- const chars = ['│', '-', 'x', '│']
272
- const roles: (CharRole | null)[] = ['border', 'border', 'text', 'border']
273
- const links = [null, 'https://a.test', 'https://a.test', null]
274
- const plain = colorizeLine(chars, roles, DEFAULT_ASCII_THEME, 'truecolor')
275
- const linked = colorizeLine(
276
- chars,
277
- roles,
278
- DEFAULT_ASCII_THEME,
279
- 'truecolor',
280
- links,
281
- )
282
- expect(stripOsc8(linked)).toBe(plain)
283
- expect(linkSpans(stripSgr(linked))).toEqual([['https://a.test', '-x']])
284
- })
285
-
286
- it('markBoxLabelLinks marks first..last non-space cells of the requested interior rows', () => {
287
- // 7 wide x 5 tall box: row 1 "ab cd", row 2 blank, row 3 "e"
288
- const box = mkCanvas(6, 4)
289
- for (let x = 1; x <= 5; x++) {
290
- write(box, x, 0, '─')
291
- write(box, x, 4, '─')
292
- }
293
- for (let y = 1; y <= 3; y++) {
294
- write(box, 0, y, '│')
295
- write(box, 6, y, '│')
296
- }
297
- for (const [i, ch] of ['a', 'b', ' ', 'c', 'd'].entries())
298
- write(box, 1 + i, 1, ch)
299
- write(box, 3, 3, 'e')
300
-
301
- const all = mkLinkCanvas(20, 20)
302
- markBoxLabelLinks(all, box, { x: 10, y: 5 }, 'https://a.test')
303
- const marked = (lc: (string | null)[][]) =>
304
- lc.flatMap((col, x) =>
305
- col.flatMap((v, y) => (v === null ? [] : [`${x},${y}`])),
306
- )
307
- expect(marked(all).sort()).toEqual(
308
- ['11,6', '12,6', '13,6', '14,6', '15,6', '13,8'].sort(),
309
- )
310
-
311
- const headerOnly = mkLinkCanvas(20, 20)
312
- markBoxLabelLinks(headerOnly, box, { x: 0, y: 0 }, 'https://a.test', {
313
- from: 3,
314
- to: 3,
315
- })
316
- expect(marked(headerOnly)).toEqual(['3,3'])
317
-
318
- // Out-of-range cells are clipped, not thrown on.
319
- const tiny = mkLinkCanvas(1, 1)
320
- expect(() =>
321
- markBoxLabelLinks(tiny, box, { x: 0, y: 0 }, 'https://a.test'),
322
- ).not.toThrow()
323
- })
324
- })
325
-
326
- describe('CLI – render --ascii --hyperlinks', () => {
327
- it('emits OSC 8 only when the flag is set', async () => {
328
- const off = createMockStdout()
329
- await runRender(renderArgs({ ascii: true }), off, LINKED_FLOW)
330
- expect(off.output()).not.toContain(OSC8_PREFIX)
331
-
332
- const on = createMockStdout()
333
- await runRender(
334
- renderArgs({ ascii: true, hyperlinks: true }),
335
- on,
336
- LINKED_FLOW,
337
- )
338
- expect(linkSpans(on.output())).toEqual([['https://example.com', 'Docs']])
339
- expect(stripOsc8(on.output())).toBe(off.output())
340
- })
341
-
342
- it('measures --max-width on visible columns, ignoring the escapes', async () => {
343
- const stderr = createMockStdout()
344
- const out = createMockStdout()
345
- // Wide enough that the plain render fits; the escape bytes alone would
346
- // push a naive length measurement past the limit and trigger a warning.
347
- const width = renderMermaidASCII(LINKED_FLOW)
348
- .split('\n')
349
- .reduce((m, l) => Math.max(m, l.length), 0)
350
- await runRender(
351
- renderArgs({ ascii: true, hyperlinks: true, maxWidth: width }),
352
- out,
353
- LINKED_FLOW,
354
- stderr,
355
- )
356
- expect(stderr.output()).toBe('')
357
- expect(out.output()).toContain(OSC8_PREFIX)
358
- })
359
- })
360
-
361
- /** SGR-only strip, for asserting link spans inside colored output. */
362
- function stripSgr(text: string): string {
363
- return text.replace(/\x1b\[[0-9;]*m/g, '')
364
- }
@@ -1,108 +0,0 @@
1
- /**
2
- * Regression test for #444 — "Flowchart: nested subgraph left-right order
3
- * doesn't match mermaid.js."
4
- *
5
- * Root cause: `placeReachableChildren` (src/ascii/grid.ts) placed a node's
6
- * children in raw edge-declaration order, so for
7
- *
8
- * subgraph Cloud
9
- * subgraph us-east [US East Region]
10
- * A[Web Server] --> B[App Server]
11
- * end
12
- * subgraph us-west [US West Region]
13
- * C[Web Server] --> D[App Server]
14
- * end
15
- * end
16
- * E[Load Balancer] --> A
17
- * E --> C
18
- *
19
- * `E`'s children were placed in the order its outgoing edges were declared
20
- * (`E --> A` before `E --> C`), putting `us-east` on the left and `us-west`
21
- * on the right. Real mermaid.js (verified against its bundled
22
- * flowDb.getData() — see the comment on `compareBySiblingSubgraphOrder` in
23
- * src/ascii/grid.ts, and the matching comment in
24
- * packages/svg-renderer/src/layout-engine/to-elk.ts's `mermaidToElk`) instead orders sibling
25
- * subgraphs in *reversed declaration order*, independent of which one a
26
- * shared parent's edges happen to target first — putting `us-west` (the
27
- * second-declared sibling) on the left and `us-east` on the right.
28
- *
29
- * The fix adds `compareBySiblingSubgraphOrder` to reorder same-level
30
- * children that land in different sibling subgraphs, plus a matching fix to
31
- * `placeReachableChildren`'s outer traversal order (visiting placed nodes in
32
- * cross-axis order rather than raw declaration order) so a child's
33
- * column/row stays aligned with its own parent once sibling order can
34
- * diverge from declaration order.
35
- */
36
- import { describe, it, expect } from 'vitest'
37
- import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
38
- import { convertToAsciiGraph } from '../../packages/ascii-renderer/src/converter.ts'
39
- import { createMapping } from '../../packages/ascii-renderer/src/grid.ts'
40
- import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
41
- import type {
42
- AsciiConfig,
43
- AsciiGraph,
44
- } from '../../packages/ascii-renderer/src/types.ts'
45
- import { requireGridCoord } from '../../packages/ascii-renderer/src/types.ts'
46
-
47
- function layoutFlowchart(source: string): AsciiGraph {
48
- const parsed = parseMermaid(source)
49
- const config: AsciiConfig = {
50
- useAscii: false,
51
- paddingX: 5,
52
- paddingY: 5,
53
- boxBorderPadding: 1,
54
- graphDirection: 'TD',
55
- }
56
- const graph = convertToAsciiGraph(parsed, config)
57
- createMapping(graph)
58
- return graph
59
- }
60
-
61
- function findNode(graph: AsciiGraph, name: string) {
62
- const node = graph.nodes.find((n) => n.name === name)
63
- if (!node) throw new Error(`node "${name}" not found`)
64
- return node
65
- }
66
-
67
- describe('ASCII nested-subgraph sibling left-right order matches mermaid.js (#444)', () => {
68
- const repro = `graph TD
69
- subgraph Cloud
70
- subgraph us-east [US East Region]
71
- A[Web Server] --> B[App Server]
72
- end
73
- subgraph us-west [US West Region]
74
- C[Web Server] --> D[App Server]
75
- end
76
- end
77
- E[Load Balancer] --> A
78
- E --> C`
79
-
80
- it('places us-west (declared second) left of us-east (declared first)', () => {
81
- const graph = layoutFlowchart(repro)
82
- const a = requireGridCoord(findNode(graph, 'A')) // us-east member
83
- const c = requireGridCoord(findNode(graph, 'C')) // us-west member
84
- expect(c.x).toBeLessThan(a.x)
85
- })
86
-
87
- it('keeps each nested subgraph member aligned in the same column as its sibling', () => {
88
- const graph = layoutFlowchart(repro)
89
- const a = requireGridCoord(findNode(graph, 'A'))
90
- const b = requireGridCoord(findNode(graph, 'B'))
91
- const c = requireGridCoord(findNode(graph, 'C'))
92
- const d = requireGridCoord(findNode(graph, 'D'))
93
- expect(b.x).toBe(a.x) // A --> B stay in us-east's column
94
- expect(d.x).toBe(c.x) // C --> D stay in us-west's column
95
- })
96
-
97
- it('renders "US West" before "US East" in the ASCII output', () => {
98
- // The label may wrap around a routed edge line (e.g. "US East│Region"),
99
- // so match on the unambiguous "US West"/"US East" prefix rather than
100
- // the full two-word label.
101
- const output = renderMermaidASCII(repro)
102
- const westIdx = output.indexOf('US West')
103
- const eastIdx = output.indexOf('US East')
104
- expect(westIdx).toBeGreaterThan(-1)
105
- expect(eastIdx).toBeGreaterThan(-1)
106
- expect(westIdx).toBeLessThan(eastIdx)
107
- })
108
- })
@@ -1,113 +0,0 @@
1
- /**
2
- * Regression tests for #1283 — layout of a non-member node placed beside a
3
- * subgraph frame (follow-up to #1252/#1278).
4
- *
5
- * `separateNonMembersFromFrames` re-places `Y` just past the frame, but left
6
- * its parent `W` where it was, so `W --> Y` wrapped around from W's side
7
- * instead of dropping straight down. A free-standing parent that only feeds
8
- * the moved node now moves to the same column.
9
- */
10
- import { describe, it, expect } from 'vitest'
11
- import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
12
- import { convertToAsciiGraph } from '../../packages/ascii-renderer/src/converter.ts'
13
- import { createMapping } from '../../packages/ascii-renderer/src/grid.ts'
14
- import type {
15
- AsciiConfig,
16
- AsciiGraph,
17
- } from '../../packages/ascii-renderer/src/types.ts'
18
-
19
- function layout(source: string, direction: 'TD' | 'LR'): AsciiGraph {
20
- const config: AsciiConfig = {
21
- useAscii: false,
22
- paddingX: 5,
23
- paddingY: 5,
24
- boxBorderPadding: 1,
25
- graphDirection: direction,
26
- }
27
- const graph = convertToAsciiGraph(parseMermaid(source), config)
28
- createMapping(graph)
29
- return graph
30
- }
31
-
32
- function grid(graph: AsciiGraph, name: string) {
33
- const gc = graph.nodes.find((n) => n.name === name)?.gridCoord
34
- if (!gc) throw new Error(`node ${name} not placed`)
35
- return gc
36
- }
37
-
38
- describe('ASCII: a non-member moved off a frame keeps its parent aligned (#1283)', () => {
39
- it('keeps W directly above Y in TD', () => {
40
- const graph = layout(
41
- `graph TD
42
- subgraph Sub
43
- A-->B
44
- A-->C
45
- end
46
- X-->A
47
- W-->Y
48
- Y-->A`,
49
- 'TD',
50
- )
51
- expect(grid(graph, 'W').x).toBe(grid(graph, 'Y').x)
52
- expect(grid(graph, 'W').y).toBeLessThan(grid(graph, 'Y').y)
53
- const edge = graph.edges.find(
54
- (e) => e.from.name === 'W' && e.to.name === 'Y',
55
- )!
56
- // A straight drop: every path point shares one column.
57
- expect(new Set(edge.path.map((p) => p.x)).size).toBe(1)
58
- })
59
-
60
- it('keeps W level with Y in LR', () => {
61
- const graph = layout(
62
- `graph LR
63
- subgraph Sub
64
- A-->B
65
- A-->C
66
- end
67
- X-->A
68
- W-->Y
69
- Y-->A`,
70
- 'LR',
71
- )
72
- expect(grid(graph, 'W').y).toBe(grid(graph, 'Y').y)
73
- })
74
- const frame = `
75
- subgraph Sub
76
- A-->B
77
- A-->C
78
- end`
79
-
80
- it('leaves a parent with two children where it is', () => {
81
- const base = layout(`graph TD${frame}\nX-->A\nW-->Y\nW-->Q\nY-->A`, 'TD')
82
- expect(grid(base, 'W').x).not.toBe(grid(base, 'Y').x)
83
- })
84
-
85
- it('leaves a parent that has its own incoming edge where it is', () => {
86
- const g = layout(`graph TD${frame}\nX-->A\nR-->W\nW-->Y\nY-->A`, 'TD')
87
- expect(g.nodes.every((n) => n.gridCoord)).toBe(true)
88
- })
89
-
90
- it('does not move anything when Y has two parents', () => {
91
- const g = layout(`graph TD${frame}\nX-->A\nW-->Y\nV-->Y\nY-->A`, 'TD')
92
- // Longest-path layering ranks A below Y, so Y no longer lands in the
93
- // frame's span and nothing is moved: both parents stay on the root row
94
- // in their own columns.
95
- expect(grid(g, 'W').y).toBe(grid(g, 'V').y)
96
- expect(grid(g, 'W').x).not.toBe(grid(g, 'V').x)
97
- expect(grid(g, 'Y').y).toBeGreaterThan(grid(g, 'W').y)
98
- })
99
-
100
- it('leaves a parent that is itself in a subgraph alone', () => {
101
- const g = layout(
102
- `graph TD${frame}\nX-->A\nsubgraph Other\nW\nend\nW-->Y\nY-->A`,
103
- 'TD',
104
- )
105
- expect(g.nodes.every((n) => n.gridCoord)).toBe(true)
106
- })
107
-
108
- it('leaves a parent that is already aligned in place', () => {
109
- const g = layout(`graph TD${frame}\nX-->A\nZ\nW-->Y\nY-->A`, 'TD')
110
- expect(grid(g, 'W').x).toBe(grid(g, 'Y').x)
111
- expect(grid(g, 'W').y).toBeLessThan(grid(g, 'Y').y)
112
- })
113
- })