@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.
- package/dist/index.cjs +7 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2484 -1467
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ascii-bt-label-order.test.ts +97 -0
- package/src/__tests__/ascii-cluster-exit-anchoring-1148.test.ts +774 -0
- package/src/__tests__/ascii-cluster-exit-lanes-1182.test.ts +375 -0
- package/src/__tests__/ascii-fan-in-then-fan-out-upstream-63.test.ts +78 -0
- package/src/__tests__/ascii-fan-out-upstream-113.test.ts +101 -0
- package/src/__tests__/ascii-role-colors.test.ts +328 -0
- package/src/__tests__/ascii-subgraph-back-edge-1197.test.ts +212 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +4 -2
- package/src/__tests__/ascii-subgraph-title-edge-1222.test.ts +106 -0
- package/src/__tests__/c4-ascii.test.ts +311 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +1 -1
- package/src/__tests__/xychart-ascii-ticks.test.ts +34 -0
- package/src/ansi.ts +16 -2
- package/src/architecture.ts +33 -0
- package/src/c4-diagram.ts +1087 -0
- package/src/canvas.ts +78 -8
- package/src/cluster-boundary.ts +429 -0
- package/src/converter.ts +18 -2
- package/src/draw-arrows.ts +120 -18
- package/src/draw-boxes.ts +1 -0
- package/src/draw-subgraphs.ts +102 -2
- package/src/draw.ts +57 -3
- package/src/edge-cell-styles.ts +20 -1
- package/src/edge-routing.ts +77 -1
- package/src/flowchart.ts +28 -3
- package/src/frame-avoidance.ts +235 -0
- package/src/grid.ts +78 -19
- package/src/pathfinder.ts +22 -2
- package/src/registry.ts +8 -0
- package/src/types.ts +69 -0
- 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(/</g, '<').replace(/>/g, '>').replace(/&/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
|
-
|
|
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
|
+
})
|