@zombie-mermaid/ascii-renderer 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.cjs +7 -7
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.js +2484 -1467
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -3
  6. package/src/__tests__/ascii-bt-label-order.test.ts +97 -0
  7. package/src/__tests__/ascii-cluster-exit-anchoring-1148.test.ts +774 -0
  8. package/src/__tests__/ascii-cluster-exit-lanes-1182.test.ts +375 -0
  9. package/src/__tests__/ascii-fan-in-then-fan-out-upstream-63.test.ts +78 -0
  10. package/src/__tests__/ascii-fan-out-upstream-113.test.ts +101 -0
  11. package/src/__tests__/ascii-role-colors.test.ts +328 -0
  12. package/src/__tests__/ascii-subgraph-back-edge-1197.test.ts +212 -0
  13. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +4 -2
  14. package/src/__tests__/ascii-subgraph-title-edge-1222.test.ts +106 -0
  15. package/src/__tests__/c4-ascii.test.ts +311 -0
  16. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
  17. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +1 -1
  18. package/src/__tests__/xychart-ascii-ticks.test.ts +34 -0
  19. package/src/ansi.ts +16 -2
  20. package/src/architecture.ts +33 -0
  21. package/src/c4-diagram.ts +1087 -0
  22. package/src/canvas.ts +78 -8
  23. package/src/cluster-boundary.ts +429 -0
  24. package/src/converter.ts +18 -2
  25. package/src/draw-arrows.ts +120 -18
  26. package/src/draw-boxes.ts +1 -0
  27. package/src/draw-subgraphs.ts +102 -2
  28. package/src/draw.ts +57 -3
  29. package/src/edge-cell-styles.ts +20 -1
  30. package/src/edge-routing.ts +77 -1
  31. package/src/flowchart.ts +28 -3
  32. package/src/frame-avoidance.ts +235 -0
  33. package/src/grid.ts +78 -19
  34. package/src/pathfinder.ts +22 -2
  35. package/src/registry.ts +8 -0
  36. package/src/types.ts +69 -0
  37. package/src/xychart.ts +24 -5
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Role-to-color mapping, checked glyph by glyph.
3
+ *
4
+ * The Playwright visual suite (`__tests__/visual/ascii-samples.visual.test.ts`)
5
+ * cannot see a line or arrowhead being recolored: both are a few thin pixels,
6
+ * under its font-jitter tolerance (recoloring every edge line failed 0 of 90
7
+ * samples; recoloring box borders failed 80 of 90). This test gives every role
8
+ * its own obviously different color and asserts, cell by cell, which role each
9
+ * glyph landed on, so a collapsed or swapped mapping fails without any pixels.
10
+ *
11
+ * The decisive property: `│` is a box side in a node (role `border`) and an
12
+ * edge segment between nodes (role `line`). With distinct role colors both
13
+ * colors must appear on that one glyph; if `line` is mapped to the border
14
+ * color (or the other way round) one of them disappears.
15
+ */
16
+ import { describe, it, expect } from 'vitest'
17
+ import {
18
+ renderMermaidASCII,
19
+ diagramColorsToAsciiTheme,
20
+ DEFAULT_ASCII_THEME,
21
+ type AsciiTheme,
22
+ } from '@zombie-mermaid/ascii-renderer'
23
+ import { CHART_ACCENT_FALLBACK } from '@zombie-mermaid/mermaid-parser'
24
+
25
+ /** One distinct, easy-to-tell-apart color per role. */
26
+ const ROLE_COLORS = {
27
+ fg: '#101010',
28
+ border: '#202020',
29
+ line: '#303030',
30
+ arrow: '#404040',
31
+ accent: '#505050',
32
+ corner: '#606060',
33
+ junction: '#707070',
34
+ } as const
35
+
36
+ type RoleName = keyof typeof ROLE_COLORS
37
+
38
+ const ROLE_BY_HEX = new Map<string, RoleName>(
39
+ (Object.entries(ROLE_COLORS) as [RoleName, string][]).map(([role, hex]) => [
40
+ hex,
41
+ role,
42
+ ]),
43
+ )
44
+
45
+ const THEME: AsciiTheme = {
46
+ fg: ROLE_COLORS.fg,
47
+ border: ROLE_COLORS.border,
48
+ line: ROLE_COLORS.line,
49
+ arrow: ROLE_COLORS.arrow,
50
+ accent: ROLE_COLORS.accent,
51
+ bg: '#000000',
52
+ corner: ROLE_COLORS.corner,
53
+ junction: ROLE_COLORS.junction,
54
+ }
55
+
56
+ /** Corner and junction omitted: the default theme's colors fill them in. */
57
+ const THEME_WITHOUT_OPTIONAL_ROLES: AsciiTheme = {
58
+ fg: ROLE_COLORS.fg,
59
+ border: ROLE_COLORS.border,
60
+ line: ROLE_COLORS.line,
61
+ arrow: ROLE_COLORS.arrow,
62
+ accent: ROLE_COLORS.accent,
63
+ bg: '#000000',
64
+ }
65
+
66
+ /** Role given to a glyph that carries no color at all. */
67
+ const UNCOLORED = 'uncolored'
68
+
69
+ interface Cell {
70
+ ch: string
71
+ /** Role name, the raw hex when it is none of ROLE_COLORS, or UNCOLORED. */
72
+ role: string
73
+ }
74
+
75
+ const decode = (s: string): string =>
76
+ s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
77
+
78
+ const hex2 = (n: number): string => n.toString(16).padStart(2, '0')
79
+
80
+ /**
81
+ * Every non-space glyph in the output, with the role its color maps to, or
82
+ * UNCOLORED when the glyph carries no color. Uncolored glyphs must be kept: a
83
+ * role that loses its color emits its glyphs bare, and a parser that skipped
84
+ * them would let every per-glyph assertion pass with nothing left to check.
85
+ */
86
+ function glyphCells(output: string, mode: 'html' | 'truecolor'): Cell[] {
87
+ const cells: Cell[] = []
88
+ const push = (hex: string | undefined, text: string): void => {
89
+ const role =
90
+ hex === undefined
91
+ ? UNCOLORED
92
+ : (ROLE_BY_HEX.get(hex.toLowerCase()) ?? hex.toLowerCase())
93
+ for (const ch of text) if (ch.trim() !== '') cells.push({ ch, role })
94
+ }
95
+ if (mode === 'html') {
96
+ // <span style="color:#rrggbb...">text</span>, or bare text between spans
97
+ const part =
98
+ /<span style="color:(#[0-9a-fA-F]{6})[^"]*">([^<]*)<\/span>|([^<]+)/g
99
+ for (const m of output.matchAll(part)) {
100
+ if (m[1] === undefined) push(undefined, decode(m[3]!))
101
+ else push(m[1], decode(m[2]!))
102
+ }
103
+ } else {
104
+ // ESC [ 38;2;R;G;B m <text> ESC [ 0 m, or bare text between them
105
+ const part = /\x1b\[38;2;(\d+);(\d+);(\d+)m([^\x1b]*)\x1b\[0m|([^\x1b]+)/g
106
+ for (const m of output.matchAll(part)) {
107
+ if (m[1] === undefined) push(undefined, m[5]!)
108
+ else push(`#${hex2(+m[1])}${hex2(+m[2]!)}${hex2(+m[3]!)}`, m[4]!)
109
+ }
110
+ }
111
+ return cells
112
+ }
113
+
114
+ const DIAGRAMS = {
115
+ 'straight flowchart': 'graph TD\n A[Start] -->|go| B[End]\n B --> C[Done]',
116
+ // Bends in the routed edges, so the `corner` role is exercised.
117
+ 'flowchart with bends':
118
+ 'graph TD\n A[Top] --> B[Left]\n A --> C[Right]\n B --> D[Join]\n C --> D',
119
+ 'left-to-right flowchart':
120
+ 'graph LR\n A[One] --> B[Two]\n A --> C[Three]\n B --> D[Four]\n C --> D',
121
+ 'sequence diagram':
122
+ 'sequenceDiagram\n participant Alice\n participant Bob\n Alice->>Bob: Hello\n Bob-->>Alice: Hi',
123
+ } as const
124
+
125
+ const ARROWHEADS = new Set('▲▼◄►◀▶△▽◁▷')
126
+ const ALNUM = /^[\p{L}\p{N}]$/u
127
+
128
+ for (const mode of ['html', 'truecolor'] as const) {
129
+ describe(`ASCII role colors (${mode})`, () => {
130
+ const render = (src: string, theme: AsciiTheme = THEME): Cell[] =>
131
+ glyphCells(renderMermaidASCII(src, { theme, colorMode: mode }), mode)
132
+
133
+ for (const [name, src] of Object.entries(DIAGRAMS)) {
134
+ describe(name, () => {
135
+ const cells = render(src)
136
+
137
+ it('colors every glyph with one of the theme role colors', () => {
138
+ // Includes UNCOLORED: a glyph with no color at all is a failure too.
139
+ const stray = cells.filter((c) => !(c.role in ROLE_COLORS))
140
+ expect(stray).toEqual([])
141
+ })
142
+
143
+ it('draws letters and digits in the text color', () => {
144
+ const letters = cells.filter((c) => ALNUM.test(c.ch))
145
+ // Guard against passing with nothing to check.
146
+ expect(letters.length).toBeGreaterThan(0)
147
+ expect(letters.filter((c) => c.role !== 'fg')).toEqual([])
148
+ })
149
+
150
+ it('draws arrowheads in the arrow color, and only arrowheads', () => {
151
+ const arrowheads = cells.filter((c) => ARROWHEADS.has(c.ch))
152
+ expect(arrowheads.length).toBeGreaterThan(0)
153
+ expect(arrowheads.filter((c) => c.role !== 'arrow')).toEqual([])
154
+ expect(
155
+ cells.filter((c) => c.role === 'arrow' && !ARROWHEADS.has(c.ch)),
156
+ ).toEqual([])
157
+ })
158
+
159
+ it('uses a line color distinct from the border color', () => {
160
+ const roles = new Set(cells.map((c) => c.role))
161
+ expect(roles.has('line')).toBe(true)
162
+ expect(roles.has('border')).toBe(true)
163
+ })
164
+ })
165
+ }
166
+
167
+ it('draws a vertical stroke as border in a box and as line on an edge', () => {
168
+ const bars = new Set(
169
+ render(DIAGRAMS['straight flowchart'])
170
+ .filter((c) => c.ch === '│')
171
+ .map((c) => c.role),
172
+ )
173
+ expect(bars).toEqual(new Set(['border', 'line']))
174
+ })
175
+
176
+ it('draws box sides and corners in the border color', () => {
177
+ const boxGlyphs = new Set('┌┐└┘')
178
+ const cells = render(DIAGRAMS['straight flowchart'])
179
+ const boxCorners = cells.filter((c) => boxGlyphs.has(c.ch))
180
+ expect(boxCorners.length).toBeGreaterThan(0)
181
+ expect(boxCorners.every((c) => c.role === 'border')).toBe(true)
182
+ })
183
+
184
+ it('uses the junction color where an edge meets a box', () => {
185
+ const roles = render(DIAGRAMS['straight flowchart'])
186
+ .filter((c) => c.ch === '┬')
187
+ .map((c) => c.role)
188
+ expect(roles.length).toBeGreaterThan(0)
189
+ expect(new Set(roles)).toEqual(new Set(['junction']))
190
+ })
191
+
192
+ it('uses the corner color where a routed edge bends', () => {
193
+ const corners = render(DIAGRAMS['flowchart with bends']).filter(
194
+ (c) => c.role === 'corner',
195
+ )
196
+ expect(corners.length).toBeGreaterThan(0)
197
+ expect(corners.every((c) => '┐┘└┌'.includes(c.ch))).toBe(true)
198
+ })
199
+
200
+ it('uses the accent color for bars, and only for bars', () => {
201
+ const cells = render('xychart-beta\n x-axis [A, B, C]\n bar [3, 5, 2]')
202
+ const accent = cells.filter((c) => c.role === 'accent')
203
+ expect(accent.length).toBeGreaterThan(0)
204
+ expect(accent.every((c) => c.ch === '█')).toBe(true)
205
+ })
206
+
207
+ it("takes the default theme's corner and junction colors when the theme omits them", () => {
208
+ // renderMermaidASCII merges the given theme over DEFAULT_ASCII_THEME, so
209
+ // an omitted `corner` or `junction` gets the default theme's color (not
210
+ // the custom `line` or `border`).
211
+ const cells = render(
212
+ DIAGRAMS['flowchart with bends'],
213
+ THEME_WITHOUT_OPTIONAL_ROLES,
214
+ )
215
+ const defaults = new Set([
216
+ DEFAULT_ASCII_THEME.corner!.toLowerCase(),
217
+ DEFAULT_ASCII_THEME.junction!.toLowerCase(),
218
+ ])
219
+ // Cells that are none of the custom role colors can only be the
220
+ // defaulted corner and junction roles.
221
+ const defaulted = cells.filter((c) => !(c.role in ROLE_COLORS))
222
+ expect(defaulted.length).toBeGreaterThan(0)
223
+ expect(defaulted.every((c) => defaults.has(c.role))).toBe(true)
224
+ expect(defaulted.every((c) => '┐┘└┌┬┴├┤┼'.includes(c.ch))).toBe(true)
225
+ // The custom roles are unaffected by the omission.
226
+ expect(cells.some((c) => c.role === 'line')).toBe(true)
227
+ expect(cells.some((c) => c.role === 'border')).toBe(true)
228
+ })
229
+ })
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // The accent role. Charts do not take it from `getRoleColor`: xychart.ts has
234
+ // its own series-color derivation (`getSeriesColors`) and its own copy of the
235
+ // role-to-color mapping (`roleToHex`) for axes, grid and labels, so neither is
236
+ // covered by the tests above.
237
+ // ---------------------------------------------------------------------------
238
+
239
+ const ONE_BAR_SERIES = 'xychart-beta\n x-axis [A, B, C]\n bar [3, 5, 2]'
240
+ const TWO_BAR_SERIES =
241
+ 'xychart-beta\n title "Sales"\n x-axis [Q1, Q2, Q3, Q4]\n bar [200, 250, 300, 280]\n bar [230, 280, 320, 350]'
242
+ const TWO_LINE_SERIES =
243
+ 'xychart-beta\n x-axis [A, B, C, D]\n line [3, 5, 2, 6]\n line [1, 2, 4, 3]'
244
+
245
+ /** A role-color theme without an accent, to exercise the chart's fallback. */
246
+ const THEME_WITHOUT_ACCENT: AsciiTheme = {
247
+ fg: ROLE_COLORS.fg,
248
+ border: ROLE_COLORS.border,
249
+ line: ROLE_COLORS.line,
250
+ arrow: ROLE_COLORS.arrow,
251
+ bg: '#000000',
252
+ corner: ROLE_COLORS.corner,
253
+ junction: ROLE_COLORS.junction,
254
+ }
255
+
256
+ for (const mode of ['html', 'truecolor'] as const) {
257
+ describe(`xychart accent and role colors (${mode})`, () => {
258
+ const render = (src: string, theme: AsciiTheme = THEME): Cell[] =>
259
+ glyphCells(renderMermaidASCII(src, { theme, colorMode: mode }), mode)
260
+ const colorsOf = (cells: Cell[], glyphs: string): Set<string> =>
261
+ new Set(cells.filter((c) => glyphs.includes(c.ch)).map((c) => c.role))
262
+
263
+ it('draws a single bar series in exactly the accent color', () => {
264
+ const bars = render(ONE_BAR_SERIES).filter((c) => c.ch === '█')
265
+ expect(bars.length).toBeGreaterThan(0)
266
+ expect(colorsOf(bars, '█')).toEqual(new Set(['accent']))
267
+ })
268
+
269
+ it('draws series 0 in the accent and later series in a different derived color', () => {
270
+ const barColors = colorsOf(render(TWO_BAR_SERIES), '█')
271
+ expect(barColors.size).toBe(2)
272
+ expect(barColors.has('accent')).toBe(true)
273
+ // The other series is a derived shade: neither the accent nor any role color.
274
+ const derived = [...barColors].filter((r) => r !== 'accent')
275
+ expect(derived).toHaveLength(1)
276
+ expect(derived[0]! in ROLE_COLORS).toBe(false)
277
+ expect(derived[0]).toMatch(/^#[0-9a-f]{6}$/)
278
+ })
279
+
280
+ it('draws line series with the same accent and derived colors', () => {
281
+ const cells = render(TWO_LINE_SERIES)
282
+ const lineGlyphs = '─╭│╮╯╰'
283
+ const roles = colorsOf(cells, lineGlyphs)
284
+ expect(roles.has('accent')).toBe(true)
285
+ // Series 0 (accent), series 1 (derived), and the axis (border): the same
286
+ // glyphs appear in all three, so the accent is not the border color.
287
+ expect([...roles].some((r) => !(r in ROLE_COLORS))).toBe(true)
288
+ expect(roles.has('border')).toBe(true)
289
+ expect(ROLE_COLORS.accent).not.toBe(ROLE_COLORS.border)
290
+ })
291
+
292
+ it('draws the axes in the border color, the grid in the line color and labels in the text color', () => {
293
+ const cells = render(ONE_BAR_SERIES)
294
+ expect(colorsOf(cells, '│┤┼┬─')).toEqual(new Set(['border']))
295
+ expect(colorsOf(cells, '·')).toEqual(new Set(['line']))
296
+ const labels = cells.filter((c) => ALNUM.test(c.ch))
297
+ expect(labels.length).toBeGreaterThan(0)
298
+ expect(labels.every((c) => c.role === 'fg')).toBe(true)
299
+ })
300
+
301
+ it('falls back to the default chart blue when the theme has no accent', () => {
302
+ const bars = render(ONE_BAR_SERIES, THEME_WITHOUT_ACCENT).filter(
303
+ (c) => c.ch === '█',
304
+ )
305
+ expect(bars.length).toBeGreaterThan(0)
306
+ expect(colorsOf(bars, '█')).toEqual(
307
+ new Set([CHART_ACCENT_FALLBACK.toLowerCase()]),
308
+ )
309
+ })
310
+ })
311
+ }
312
+
313
+ describe('diagramColorsToAsciiTheme accent', () => {
314
+ const colors = { bg: '#000000', fg: '#ffffff' }
315
+
316
+ it('passes the accent through and uses it for arrowheads', () => {
317
+ const theme = diagramColorsToAsciiTheme({ ...colors, accent: '#123456' })
318
+ expect(theme.accent).toBe('#123456')
319
+ expect(theme.arrow).toBe('#123456')
320
+ })
321
+
322
+ it('derives the arrow color from fg and bg when there is no accent', () => {
323
+ const theme = diagramColorsToAsciiTheme(colors)
324
+ expect(theme.accent).toBeUndefined()
325
+ expect(theme.arrow).not.toBe(theme.fg)
326
+ expect(theme.arrow).toMatch(/^#[0-9a-fA-F]{6}$/)
327
+ })
328
+ })
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Regression tests for #1197: a flowchart whose edges form a cycle across
3
+ * sibling subgraphs (`A --> C --> E --> A`, each node in its own subgraph)
4
+ * threw `Node "A" has no gridCoord` from `renderMermaidASCII`.
5
+ *
6
+ * Cause: `createMapping` seeds a cycle that has no true root with a
7
+ * pseudo-root, then dropped any root with an incoming edge from outside its
8
+ * own subgraph — which, in a cross-subgraph cycle, is always the pseudo-root,
9
+ * so nothing seeded the component and no node was ever placed.
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
13
+ import { parseMermaid } from '../../../../src/parser.ts'
14
+ import { convertToAsciiGraph } from '../converter.ts'
15
+ import { createMapping, gridToDrawingCoord } from '../grid.ts'
16
+ import {
17
+ DEFAULT_PADDING_X,
18
+ DEFAULT_PADDING_Y,
19
+ DEFAULT_BOX_BORDER_PADDING,
20
+ } from '../types.ts'
21
+
22
+ const lines = (s: string): string[] => s.split('\n')
23
+
24
+ const cases: Record<string, string> = {}
25
+ for (const dir of ['TD', 'LR']) {
26
+ for (const lbl of [false, true]) {
27
+ const l = (s: string): string => (lbl ? `|${s}|` : '')
28
+ const tag = `${dir}${lbl ? ' labelled' : ''}`
29
+ cases[`two sibling subgraphs ${tag}`] =
30
+ `graph ${dir}\nsubgraph S1[One]\nA\nend\nsubgraph S2[Two]\nB\nend\nA -->${l('x')} B\nB -->${l('y')} A`
31
+ cases[`three sibling subgraphs ${tag}`] =
32
+ `graph ${dir}\nsubgraph S1[One]\nA\nend\nsubgraph S2[Two]\nC\nend\nsubgraph S3[Three]\nE\nend\nA -->${l('x')} C\nC -->${l('y')} E\nE -->${l('z')} A`
33
+ cases[`nested subgraphs ${tag}`] =
34
+ `graph ${dir}\nsubgraph O[Outer]\nsubgraph I1[In1]\nA\nend\nsubgraph I2[In2]\nB\nend\nend\nsubgraph P[Pee]\nC\nend\nA -->${l('x')} B\nB --> C\nC --> A`
35
+ }
36
+ }
37
+
38
+ /** Node ids declared as a bare line or edge endpoint, per the sources above. */
39
+ const NODES = ['A', 'B', 'C', 'E']
40
+
41
+ describe('#1197: cycle across sibling subgraphs', () => {
42
+ for (const [name, src] of Object.entries(cases)) {
43
+ describe(name, () => {
44
+ for (const useAscii of [false, true]) {
45
+ it(`renders without throwing, one box per node (${useAscii ? 'ascii' : 'unicode'})`, () => {
46
+ const out = renderMermaidASCII(src, { useAscii, colorMode: 'none' })
47
+ for (const id of NODES) {
48
+ if (!new RegExp(`^\\s*${id}\\b`, 'm').test(src)) continue
49
+ // each node label appears exactly once (titles never contain it)
50
+ const hits = out.match(new RegExp(`\\s${id}\\s`, 'g')) ?? []
51
+ expect(hits, `node ${id}`).toHaveLength(1)
52
+ }
53
+ })
54
+ }
55
+ // A mutual `A --> B`, `B --> A` pair with labels shares one corridor and
56
+ // the label overwrites the next frame's title — an independent
57
+ // edge-routing defect that also reproduces on main when an unrelated root
58
+ // seeds the layout, so it is not asserted here.
59
+ const mutualLabelled =
60
+ name.startsWith('two sibling') && name.endsWith('labelled')
61
+ if (!mutualLabelled) {
62
+ it('every declared subgraph title is drawn', () => {
63
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
64
+ for (const m of src.matchAll(/subgraph \w+\[(\w+)\]/g)) {
65
+ expect(out).toContain(m[1]!)
66
+ }
67
+ })
68
+ }
69
+ })
70
+ }
71
+
72
+ it('the issue example (Layer One/Two/Three) renders every box inside its subgraph frame', () => {
73
+ const src = `graph TD
74
+ subgraph L1[Layer One]
75
+ A
76
+ end
77
+ subgraph L2[Layer Two]
78
+ C
79
+ end
80
+ subgraph L3[Layer Three]
81
+ E
82
+ end
83
+ A --> C
84
+ C --> E
85
+ E --> A`
86
+ const out = lines(renderMermaidASCII(src, { colorMode: 'none' }))
87
+ for (const [title, id] of [
88
+ ['Layer One', 'A'],
89
+ ['Layer Two', 'C'],
90
+ ['Layer Three', 'E'],
91
+ ] as const) {
92
+ // a forward edge entering the frame may split the title (#1222)
93
+ const titleRow = out.findIndex((ln) =>
94
+ ln.replace(/│/g, ' ').includes(title),
95
+ )
96
+ const nodeRow = out.findIndex((ln) =>
97
+ new RegExp(`│\\s+${id}\\s+[│├]`).test(ln),
98
+ )
99
+ expect(titleRow, title).toBeGreaterThanOrEqual(0)
100
+ expect(nodeRow, id).toBeGreaterThan(titleRow)
101
+ // the next subgraph title (if any) comes after the node: box is inside its own frame
102
+ const nextTitleRow = out.findIndex(
103
+ (ln, i) => i > titleRow && /Layer (One|Two|Three)/.test(ln),
104
+ )
105
+ if (nextTitleRow !== -1) expect(nodeRow).toBeLessThan(nextTitleRow)
106
+ }
107
+ })
108
+ })
109
+
110
+ /**
111
+ * Defects found reviewing the #1197 render in a real terminal: the back-edge
112
+ * ran up through the interior of the frames it merely passes (so it read as
113
+ * belonging to them) and through their titles. Mermaid routes it outside.
114
+ */
115
+ function drawnEdgeCells(src: string) {
116
+ const graph = convertToAsciiGraph(parseMermaid(src), {
117
+ useAscii: false,
118
+ paddingX: DEFAULT_PADDING_X,
119
+ paddingY: DEFAULT_PADDING_Y,
120
+ boxBorderPadding: DEFAULT_BOX_BORDER_PADDING,
121
+ graphDirection: /^\s*graph\s+LR/.test(src) ? 'LR' : 'TD',
122
+ })
123
+ createMapping(graph)
124
+ const out: { edge: string; x: number; y: number; sg: string }[] = []
125
+ for (const edge of graph.edges) {
126
+ const pts = edge.path.map((c) => gridToDrawingCoord(graph, c))
127
+ for (let i = 1; i < pts.length; i++) {
128
+ const a = pts[i - 1]!
129
+ const b = pts[i]!
130
+ const n = Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y))
131
+ for (let k = 0; k <= n; k++) {
132
+ const x = a.x + Math.sign(b.x - a.x) * Math.min(k, Math.abs(b.x - a.x))
133
+ const y = a.y + Math.sign(b.y - a.y) * Math.min(k, Math.abs(b.y - a.y))
134
+ for (const sg of graph.subgraphs) {
135
+ // Unrelated frame: neither endpoint lives in it.
136
+ if (sg.nodes.includes(edge.from) || sg.nodes.includes(edge.to)) {
137
+ continue
138
+ }
139
+ if (x > sg.minX && x < sg.maxX && y > sg.minY && y < sg.maxY) {
140
+ out.push({
141
+ edge: `${edge.from.name}->${edge.to.name}`,
142
+ x,
143
+ y,
144
+ sg: sg.name,
145
+ })
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ const LAYERS = (dir: string): string => `graph ${dir}
155
+ subgraph L1[Layer One]
156
+ A
157
+ end
158
+ subgraph L2[Layer Two]
159
+ C
160
+ end
161
+ subgraph L3[Layer Three]
162
+ E
163
+ end
164
+ A --> C
165
+ C --> E
166
+ E --> A`
167
+
168
+ const outsideCases: Record<string, string> = {
169
+ ...cases,
170
+ 'issue example TD (long titles)': LAYERS('TD'),
171
+ 'issue example LR (long titles)': LAYERS('LR'),
172
+ 'nested, long titles': `graph TD
173
+ subgraph O[Outer Frame]
174
+ subgraph I1[Inner One]
175
+ A
176
+ end
177
+ subgraph I2[Inner Two]
178
+ B
179
+ end
180
+ end
181
+ subgraph P[Peer Frame]
182
+ C
183
+ end
184
+ A --> B
185
+ B --> C
186
+ C --> A`,
187
+ 'four layers, long titles': `graph TD
188
+ subgraph L1[Layer One]
189
+ A
190
+ end
191
+ subgraph L2[Layer Two]
192
+ B
193
+ end
194
+ subgraph L3[Layer Three]
195
+ C
196
+ end
197
+ subgraph L4[Layer Four]
198
+ D
199
+ end
200
+ A --> B
201
+ B --> C
202
+ C --> D
203
+ D --> A`,
204
+ }
205
+
206
+ describe('#1197: edges stay out of frames they neither start nor end in', () => {
207
+ for (const [name, src] of Object.entries(outsideCases)) {
208
+ it(`no edge cell (hence no title overdraw) inside an unrelated frame: ${name}`, () => {
209
+ expect(drawnEdgeCells(src)).toEqual([])
210
+ })
211
+ }
212
+ })
@@ -121,13 +121,15 @@ describe('ASCII subgraph label does not truncate a label wider than its child no
121
121
  D --> F[Sink]`
122
122
  const output = renderMermaidASCII(mermaid)
123
123
 
124
- expect(output).toContain('Processing Pipeline')
124
+ // A forward edge entering the frame may split the title on its space (#1222).
125
+ const flat = output.replace(/│/g, ' ')
126
+ expect(flat).toContain('Processing Pipeline')
125
127
  expect(output).not.toContain('Processing Pipe│')
126
128
  expect(output).not.toContain('Processing Pipe\n')
127
129
 
128
130
  const titleLine = output
129
131
  .split('\n')
130
- .find((l) => l.includes('Processing Pipeline'))
132
+ .find((l) => l.replace(/│/g, ' ').includes('Processing Pipeline'))
131
133
  expect(titleLine).toBeDefined()
132
134
  // The label sits fully inside the border on both sides, not clipped
133
135
  // against (or spilling onto) either one.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Regression tests for #1222 (follow-up to #1197): a forward edge entering a
3
+ * titled subgraph frame from above must stay continuous through the title
4
+ * row. The title used to be drawn on top of the edge column (titles are
5
+ * centred, the entry column usually is too), so the edge showed a junction
6
+ * tick on the border, vanished behind the title, and resumed below.
7
+ *
8
+ * Titles now slide aside, or split on a space ("Layer│Three"), instead of
9
+ * covering the stroke. When neither is possible without dropping a letter the
10
+ * title still wins and stays intact (asserted separately).
11
+ */
12
+ import { describe, it, expect } from 'vitest'
13
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
14
+
15
+ interface Case {
16
+ name: string
17
+ src: string
18
+ /** Title text left / right of the stroke on the title row ('' = no split). */
19
+ left: string
20
+ right: string
21
+ }
22
+
23
+ const chain = (second: string, third: string): string =>
24
+ `graph TD\nsubgraph L1[One]\nA\nend\nsubgraph L2[${second}]\nC\nend\nsubgraph L3[${third}]\nE\nend\nA --> C\nC --> E\nE --> A`
25
+
26
+ const cases: Case[] = [
27
+ {
28
+ name: 'title splits on its centre space',
29
+ src: chain('x', 'Layer Three'),
30
+ left: 'Layer',
31
+ right: 'Three',
32
+ },
33
+ {
34
+ name: 'a title filling the frame widens it so the stroke lands on a space',
35
+ src: chain('Layer Two', 'Layer Three'),
36
+ left: 'Layer',
37
+ right: 'Two',
38
+ },
39
+ {
40
+ name: 'short title slides aside',
41
+ src: 'graph TD\nsubgraph O[Outer]\nsubgraph I1[In1]\nA\nend\nsubgraph I2[In2]\nB\nend\nend\nsubgraph P[Pee]\nC\nend\nA --> B\nB --> C\nC --> A',
42
+ left: 'In2',
43
+ right: '',
44
+ },
45
+ {
46
+ name: 'architecture-beta group title splits on its space',
47
+ src: 'architecture-beta\ngroup g1(cloud)[Group One]\ngroup g2(cloud)[Group Two]\ngroup g3(cloud)[Group Three]\nservice a(server)[A] in g1\nservice b(server)[B] in g2\nservice c(server)[C] in g3\na:B --> T:b\nb:B --> T:c',
48
+ left: 'Group',
49
+ right: 'Three',
50
+ },
51
+ ]
52
+
53
+ const STROKES = new Set(['│', '|'])
54
+ const ARROWS = new Set(['▼', 'v'])
55
+
56
+ describe('#1222: forward edge stays continuous through a frame title row', () => {
57
+ for (const c of cases) {
58
+ for (const useAscii of [false, true]) {
59
+ it(`${c.name} (${useAscii ? 'ascii' : 'unicode'})`, () => {
60
+ const stroke = useAscii ? '|' : '│'
61
+ const lines = renderMermaidASCII(c.src, {
62
+ useAscii,
63
+ colorMode: 'none',
64
+ }).split('\n')
65
+ const title = lines.findIndex((l) =>
66
+ l.includes(`${c.left}${c.right ? stroke : ''}${c.right}`),
67
+ )
68
+ // the title is intact on one row, with at most the stroke inside it
69
+ expect(title, 'title row present and not garbled').toBeGreaterThan(0)
70
+ const edgeCol = lines[title]!.indexOf(c.left) + c.left.length
71
+ // the border tick above, the title row, then every row down to the arrow
72
+ expect(
73
+ STROKES.has(lines[title - 1]![edgeCol]!) ||
74
+ lines[title - 1]![edgeCol] === '┼',
75
+ ).toBe(true)
76
+ expect(STROKES.has(lines[title]![edgeCol]!)).toBe(true)
77
+ let j = title + 1
78
+ while (!ARROWS.has(lines[j]![edgeCol]!)) {
79
+ expect(STROKES.has(lines[j]![edgeCol]!), `row ${j}`).toBe(true)
80
+ j++
81
+ expect(j - title).toBeLessThan(8)
82
+ }
83
+ })
84
+ }
85
+ }
86
+
87
+ it('slides a short title aside, leaving one clear column before the stroke', () => {
88
+ const src =
89
+ 'graph TD\nsubgraph F[Frontend]\nA[React App] --> B[State Manager]\nend\nsubgraph K[Backend]\nC[API Server]\nend\nB --> C'
90
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
91
+ expect(out).toContain('│ Backend │')
92
+ })
93
+
94
+ it('does not resize a frame that has no collision', () => {
95
+ const src =
96
+ 'graph TD\nsubgraph F[Frontend]\nA[React App] --> B[State Manager]\nend\nsubgraph K[Backend]\nC[API Server]\nend\nB --> C'
97
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
98
+ expect(out.split('\n')[0]).toBe('┌───────────────────┐')
99
+ })
100
+
101
+ it('leaves LR frames alone (edges enter on the node row)', () => {
102
+ const src = chain('Layer Two', 'Layer Three').replace('TD', 'LR')
103
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
104
+ expect(out).not.toMatch(/Layer│/)
105
+ })
106
+ })