@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,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster exits mixed with parallel-lane siblings (#1182, follow-up to
|
|
3
|
+
* #1148). Two edges with the same stand-in and target (`S -->|first| T`,
|
|
4
|
+
* `S -->|second| T`) are lane siblings; beside other exits they used to
|
|
5
|
+
* either keep the whole cluster off the shared trunk (one other exit: it
|
|
6
|
+
* punched through the wall) or leave the second sibling through the
|
|
7
|
+
* cluster's side wall with both lane labels on the wall row (two others).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
11
|
+
import { parseMermaid } from '../../../../src/parser.ts'
|
|
12
|
+
import { convertToAsciiGraph } from '../converter.ts'
|
|
13
|
+
import { createMapping } from '../grid.ts'
|
|
14
|
+
|
|
15
|
+
type Dir = 'TD' | 'LR'
|
|
16
|
+
|
|
17
|
+
const head = (dir: Dir): string => `flowchart ${dir}
|
|
18
|
+
subgraph S [Cluster]
|
|
19
|
+
a
|
|
20
|
+
end
|
|
21
|
+
Start --> S
|
|
22
|
+
S -->|first| T
|
|
23
|
+
S -->|second| T
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
const CASES: Array<[string, Dir, string[]]> = [
|
|
27
|
+
['TD, one other exit', 'TD', ['other']],
|
|
28
|
+
['TD, two other exits', 'TD', ['other', 'more']],
|
|
29
|
+
['LR, one other exit', 'LR', ['other']],
|
|
30
|
+
['LR, two other exits', 'LR', ['other', 'more']],
|
|
31
|
+
]
|
|
32
|
+
const TD_CASES = CASES.filter(([, d]) => d === 'TD')
|
|
33
|
+
const LR_CASES = CASES.filter(([, d]) => d === 'LR')
|
|
34
|
+
|
|
35
|
+
const source = (dir: Dir, others: string[]): string =>
|
|
36
|
+
head(dir) + others.map((o, i) => ` S -->|${o}| ${'UVW'[i]}\n`).join('')
|
|
37
|
+
|
|
38
|
+
const render = (src: string, useAscii = false): string[] =>
|
|
39
|
+
renderMermaidASCII(src, { colorMode: 'none', useAscii })
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((l) => l.trimEnd())
|
|
42
|
+
|
|
43
|
+
const count = (lines: string[], word: string): number =>
|
|
44
|
+
lines.join('\n').split(word).length - 1
|
|
45
|
+
|
|
46
|
+
describe('cluster exits with parallel-lane siblings (#1182)', () => {
|
|
47
|
+
it.each(CASES)(
|
|
48
|
+
'%s: the cluster engages and owns every exit',
|
|
49
|
+
(_n, dir, others) => {
|
|
50
|
+
const graph = convertToAsciiGraph(parseMermaid(source(dir, others)), {
|
|
51
|
+
useAscii: false,
|
|
52
|
+
paddingX: 6,
|
|
53
|
+
paddingY: 5,
|
|
54
|
+
boxBorderPadding: 1,
|
|
55
|
+
graphDirection: dir,
|
|
56
|
+
})
|
|
57
|
+
createMapping(graph)
|
|
58
|
+
const plan = [...(graph.clusterExitPlans?.values() ?? [])][0]
|
|
59
|
+
expect(plan).toBeDefined()
|
|
60
|
+
expect(plan!.edges.size).toBe(2 + others.length)
|
|
61
|
+
expect(graph.edges.filter((e) => e.parallelLane)).toHaveLength(2)
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it.each(CASES)(
|
|
66
|
+
'%s: every node and label appears exactly once',
|
|
67
|
+
(_n, dir, others) => {
|
|
68
|
+
for (const useAscii of [false, true]) {
|
|
69
|
+
const lines = render(source(dir, others), useAscii)
|
|
70
|
+
for (const word of ['Start', 'Cluster', 'first', 'second', ...others]) {
|
|
71
|
+
expect(count(lines, word), `${word} (ascii=${useAscii})`).toBe(1)
|
|
72
|
+
}
|
|
73
|
+
for (const node of ['T', 'U', 'V'].slice(0, 1 + others.length)) {
|
|
74
|
+
expect(
|
|
75
|
+
lines.filter((l) => l.split(/\W+/).includes(node)),
|
|
76
|
+
node,
|
|
77
|
+
).toHaveLength(1)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it.each(TD_CASES)(
|
|
84
|
+
'%s: only the trunk crosses the bottom wall, and no label is on it',
|
|
85
|
+
(_n, dir, others) => {
|
|
86
|
+
for (const useAscii of [false, true]) {
|
|
87
|
+
const lines = render(source(dir, others), useAscii)
|
|
88
|
+
// The cluster's bottom wall is the last full-width border row
|
|
89
|
+
// (the top border, with the entry trunk, matches too).
|
|
90
|
+
const wall = lines.findLastIndex((l) =>
|
|
91
|
+
useAscii ? /^\+[-|]+\+$/.test(l) : /^└[─┼]+┘$/.test(l),
|
|
92
|
+
)
|
|
93
|
+
expect(wall).toBeGreaterThan(-1)
|
|
94
|
+
const text = lines[wall]!
|
|
95
|
+
expect(text).not.toMatch(/[a-z]/)
|
|
96
|
+
const crossings = useAscii
|
|
97
|
+
? (text.match(/\|/g) ?? []).length
|
|
98
|
+
: (text.match(/[┼┬┴├┤]/g) ?? []).length
|
|
99
|
+
expect(crossings).toBe(1)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
it.each(LR_CASES)(
|
|
105
|
+
'%s: only the trunk crosses the right wall, and no label is on it',
|
|
106
|
+
(_n, dir, others) => {
|
|
107
|
+
const lines = render(source(dir, others))
|
|
108
|
+
const top = lines.find((l) => l.includes('┐'))!
|
|
109
|
+
const col = top.indexOf('┐')
|
|
110
|
+
const crossing = lines
|
|
111
|
+
.map((l) => l[col] ?? ' ')
|
|
112
|
+
.filter((ch) => ch !== ' ' && ch !== '│' && ch !== '┐' && ch !== '┘')
|
|
113
|
+
expect(crossing).toEqual(['┼'])
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
it.each(TD_CASES)(
|
|
118
|
+
'%s: no exit leaves through the stand-in node side',
|
|
119
|
+
(_n, dir, others) => {
|
|
120
|
+
const lines = render(source(dir, others))
|
|
121
|
+
const aRow = lines.findIndex((l) => /│\s+a\s+│/.test(l))
|
|
122
|
+
for (const l of lines.slice(aRow - 2, aRow + 4)) {
|
|
123
|
+
expect(l).not.toMatch(/a\s+├─|┴─┼/)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// A third sibling has no free side face left on the target, so the group
|
|
129
|
+
// keeps today's routing rather than running a lane along a node border.
|
|
130
|
+
it.each([
|
|
131
|
+
['TD', ''],
|
|
132
|
+
['LR', ''],
|
|
133
|
+
['TD', ' S -->|fourth| T\n'],
|
|
134
|
+
] as const)(
|
|
135
|
+
'%s: a three- or four-sibling lane group does not engage the cluster (%j)',
|
|
136
|
+
(dir, extra) => {
|
|
137
|
+
const src = `${head(dir)} S -->|third| T\n${extra} S -->|other| U\n S -->|more| V\n`
|
|
138
|
+
const graph = convertToAsciiGraph(parseMermaid(src), {
|
|
139
|
+
useAscii: false,
|
|
140
|
+
paddingX: 6,
|
|
141
|
+
paddingY: 5,
|
|
142
|
+
boxBorderPadding: 1,
|
|
143
|
+
graphDirection: dir,
|
|
144
|
+
})
|
|
145
|
+
createMapping(graph)
|
|
146
|
+
const plan = [...(graph.clusterExitPlans?.values() ?? [])][0]
|
|
147
|
+
expect(plan).toBeDefined()
|
|
148
|
+
expect([...plan!.edges].some((e) => e.parallelLane)).toBe(false)
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// A lane label short enough to sit clear of the junction unaided.
|
|
153
|
+
it('LR: a short second-lane label keeps its place beside the junction', () => {
|
|
154
|
+
const src = `flowchart LR
|
|
155
|
+
subgraph S [Cluster]
|
|
156
|
+
a
|
|
157
|
+
end
|
|
158
|
+
Start --> S
|
|
159
|
+
S -->|a1| T
|
|
160
|
+
S -->|b1| T
|
|
161
|
+
S -->|other| U
|
|
162
|
+
`
|
|
163
|
+
const lines = render(src)
|
|
164
|
+
const row = lines.find((l) => l.includes('b1'))!
|
|
165
|
+
expect(row[row.indexOf('b1') - 1]).toBe('─')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('node borders and label placement', () => {
|
|
169
|
+
const NODE_NAMES = ['Start', 'a', 'T', 'U', 'V']
|
|
170
|
+
|
|
171
|
+
// Where `name` sits as a whole token (not inside a longer word).
|
|
172
|
+
const findToken = (
|
|
173
|
+
lines: string[],
|
|
174
|
+
name: string,
|
|
175
|
+
): { row: number; col: number } | undefined => {
|
|
176
|
+
for (const [row, text] of lines.entries()) {
|
|
177
|
+
let from = 0
|
|
178
|
+
for (;;) {
|
|
179
|
+
const col = text.indexOf(name, from)
|
|
180
|
+
if (col < 0) break
|
|
181
|
+
const before = text[col - 1] ?? ' '
|
|
182
|
+
const after = text[col + name.length] ?? ' '
|
|
183
|
+
if (!/\w/.test(before) && !/\w/.test(after)) return { row, col }
|
|
184
|
+
from = col + 1
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const boxProblems = (
|
|
191
|
+
lines: string[],
|
|
192
|
+
useAscii: boolean,
|
|
193
|
+
names: string[],
|
|
194
|
+
): string[] => {
|
|
195
|
+
const problems: string[] = []
|
|
196
|
+
const at = (r: number, c: number): string => lines[r]?.[c] ?? ' '
|
|
197
|
+
const corners = useAscii ? ['+', '+', '+', '+'] : ['┌', '┐', '└', '┘']
|
|
198
|
+
// What an edge may legitimately merge into on each kind of border cell.
|
|
199
|
+
const horizontalOk = useAscii ? '-+' : '─┬┴┼'
|
|
200
|
+
const verticalOk = useAscii ? '|+' : '│├┤┼'
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
const tok = findToken(lines, name)
|
|
203
|
+
if (!tok) {
|
|
204
|
+
problems.push(`${name}: not found`)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const { row, col } = tok
|
|
208
|
+
let left = col
|
|
209
|
+
while (left > 0 && !'│|├┤+'.includes(at(row, left))) left--
|
|
210
|
+
let right = col
|
|
211
|
+
while (
|
|
212
|
+
right < (lines[row]?.length ?? 0) &&
|
|
213
|
+
!'│|├┤+'.includes(at(row, right))
|
|
214
|
+
)
|
|
215
|
+
right++
|
|
216
|
+
let top = row - 1
|
|
217
|
+
while (top > 0 && at(top, col) === ' ') top--
|
|
218
|
+
let bottom = row + 1
|
|
219
|
+
while (bottom < lines.length && at(bottom, col) === ' ') bottom++
|
|
220
|
+
const where = `${name} (box rows ${top}-${bottom}, cols ${left}-${right})`
|
|
221
|
+
const cs = [
|
|
222
|
+
at(top, left),
|
|
223
|
+
at(top, right),
|
|
224
|
+
at(bottom, left),
|
|
225
|
+
at(bottom, right),
|
|
226
|
+
]
|
|
227
|
+
for (const [i, glyph] of cs.entries()) {
|
|
228
|
+
if (glyph !== corners[i]) {
|
|
229
|
+
problems.push(
|
|
230
|
+
`${where}: corner ${i} is '${glyph}', expected '${corners[i]}'`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (let c = left + 1; c < right; c++) {
|
|
235
|
+
for (const r of [top, bottom]) {
|
|
236
|
+
if (!horizontalOk.includes(at(r, c))) {
|
|
237
|
+
problems.push(
|
|
238
|
+
`${where}: border cell (${r},${c}) is '${at(r, c)}'`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (let r = top + 1; r < bottom; r++) {
|
|
244
|
+
for (const c of [left, right]) {
|
|
245
|
+
if (!verticalOk.includes(at(r, c))) {
|
|
246
|
+
problems.push(`${where}: wall cell (${r},${c}) is '${at(r, c)}'`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return problems
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Every node box keeps its four corners and its walls: an edge may
|
|
255
|
+
// merge a junction into a border but never replace a corner or put an
|
|
256
|
+
// arrowhead on it. The second lane used to run its last approach along
|
|
257
|
+
// T's own border (left wall in LR, top wall in TD).
|
|
258
|
+
it.each(CASES)('%s: every node box border is intact', (_n, dir, others) => {
|
|
259
|
+
for (const useAscii of [false, true]) {
|
|
260
|
+
expect(
|
|
261
|
+
boxProblems(
|
|
262
|
+
render(source(dir, others), useAscii),
|
|
263
|
+
useAscii,
|
|
264
|
+
NODE_NAMES.slice(0, 3 + others.length),
|
|
265
|
+
),
|
|
266
|
+
).toEqual([])
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// With the other exits declared first the target of the lane group sits
|
|
271
|
+
// past the trunk, so the second lane takes the target's near (low) side.
|
|
272
|
+
it.each(['TD', 'LR'] as const)(
|
|
273
|
+
'%s, other exits first: lane enters a side face, labels clear',
|
|
274
|
+
(dir) => {
|
|
275
|
+
const src = `flowchart ${dir}
|
|
276
|
+
subgraph S [Cluster]
|
|
277
|
+
a
|
|
278
|
+
end
|
|
279
|
+
Start --> S
|
|
280
|
+
S -->|other| U
|
|
281
|
+
S -->|first| T
|
|
282
|
+
S -->|second| T
|
|
283
|
+
S -->|more| V
|
|
284
|
+
`
|
|
285
|
+
for (const useAscii of [false, true]) {
|
|
286
|
+
const lines = render(src, useAscii)
|
|
287
|
+
expect(boxProblems(lines, useAscii, NODE_NAMES)).toEqual([])
|
|
288
|
+
for (const [r, text] of lines.entries()) {
|
|
289
|
+
const spans = ['other', 'first', 'second', 'more']
|
|
290
|
+
.map((w) => [text.indexOf(w), w.length] as const)
|
|
291
|
+
.filter(([i]) => i >= 0)
|
|
292
|
+
.sort((x, y) => x[0] - y[0])
|
|
293
|
+
for (let i = 1; i < spans.length; i++) {
|
|
294
|
+
const gap = spans[i]![0] - (spans[i - 1]![0] + spans[i - 1]![1])
|
|
295
|
+
expect(gap, `row ${r}`).toBeGreaterThanOrEqual(2)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Two edge labels on one row need clear line between them, or they
|
|
303
|
+
// read as one run ("other-more").
|
|
304
|
+
it.each(CASES)('%s: no two labels abut on a row', (_n, dir, others) => {
|
|
305
|
+
for (const useAscii of [false, true]) {
|
|
306
|
+
const lines = render(source(dir, others), useAscii)
|
|
307
|
+
const words = ['first', 'second', ...others]
|
|
308
|
+
for (const [r, text] of lines.entries()) {
|
|
309
|
+
const spans = words
|
|
310
|
+
.map((w) => [text.indexOf(w), w.length] as const)
|
|
311
|
+
.filter(([i]) => i >= 0)
|
|
312
|
+
.sort((x, y) => x[0] - y[0])
|
|
313
|
+
for (let i = 1; i < spans.length; i++) {
|
|
314
|
+
const prev = spans[i - 1]!
|
|
315
|
+
const gap = spans[i]![0] - (prev[0] + prev[1])
|
|
316
|
+
expect(
|
|
317
|
+
gap,
|
|
318
|
+
`row ${r} (ascii=${useAscii}):\n${text}`,
|
|
319
|
+
).toBeGreaterThanOrEqual(2)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Each exit's label sits on that exit's own final leg, never on a run
|
|
326
|
+
// another edge shares or owns.
|
|
327
|
+
it.each(LR_CASES)('%s: each label is on its own leg', (_n, dir, others) => {
|
|
328
|
+
const lines = render(source(dir, others))
|
|
329
|
+
const rowOf = (w: string): string => lines.find((l) => l.includes(w))!
|
|
330
|
+
expect(rowOf('first')).toContain('T')
|
|
331
|
+
expect(rowOf('first')).not.toContain('second')
|
|
332
|
+
for (const [i, o] of others.entries()) {
|
|
333
|
+
expect(rowOf(o), o).toContain('UVW'[i]!)
|
|
334
|
+
}
|
|
335
|
+
// The second lane's label is on a row that carries no other label
|
|
336
|
+
// and no node, below T.
|
|
337
|
+
const secondRow = rowOf('second')
|
|
338
|
+
for (const w of ['first', ...others, 'T', 'U', 'V']) {
|
|
339
|
+
expect(secondRow, w).not.toContain(w)
|
|
340
|
+
}
|
|
341
|
+
// ...and it starts a line cell clear of the junction it turns off.
|
|
342
|
+
expect(secondRow[secondRow.indexOf('second') - 1]).toBe('─')
|
|
343
|
+
expect(lines.findIndex((l) => l.includes('second'))).toBeGreaterThan(
|
|
344
|
+
lines.findIndex((l) => l.includes('T')),
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// TD: every exit drops from the shared fan-out row, and nothing is
|
|
349
|
+
// written on that row between the trunk and the last drop - so the
|
|
350
|
+
// second lane visibly leaves the trunk's fan-out, not another edge's
|
|
351
|
+
// labelled run.
|
|
352
|
+
it.each(TD_CASES)(
|
|
353
|
+
'%s: the second lane leaves the unlabelled shared fan-out',
|
|
354
|
+
(_n, dir, others) => {
|
|
355
|
+
for (const useAscii of [false, true]) {
|
|
356
|
+
const lines = render(source(dir, others), useAscii)
|
|
357
|
+
const start = findToken(lines, 'Start')!
|
|
358
|
+
const trunk = start.col + 2
|
|
359
|
+
const second = findToken(lines, 'second')!
|
|
360
|
+
const secondCol = second.col + 3
|
|
361
|
+
const vertical = useAscii ? '|' : '│'
|
|
362
|
+
let r = second.row - 1
|
|
363
|
+
while (lines[r]?.[secondCol] === vertical) r--
|
|
364
|
+
const fanOut = lines[r]!
|
|
365
|
+
expect(fanOut, 'fan-out row').not.toMatch(/[a-z]/)
|
|
366
|
+
expect(fanOut[trunk], 'fan-out row meets the trunk').not.toBe(' ')
|
|
367
|
+
expect(
|
|
368
|
+
fanOut[secondCol],
|
|
369
|
+
'second lane starts on the fan-out row',
|
|
370
|
+
).not.toBe(' ')
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for upstream lukilabs/beautiful-mermaid#63: an LR fan-in
|
|
3
|
+
* followed by a fan-out (`A --> C`, `B --> C`, `C --> D`, `C --> E`).
|
|
4
|
+
*
|
|
5
|
+
* Upstream's bug: C->E was routed along B->C's corridor, so B's row rendered a
|
|
6
|
+
* misleading `├───┴──►E`, as if B pointed at E. The fixed shape has C->E on its
|
|
7
|
+
* own trunk (`┬` off C's right side, then `└────┐` down to E) while B->C ends
|
|
8
|
+
* at `┘` under C.
|
|
9
|
+
*
|
|
10
|
+
* This fork fixes it through findUnrelatedOverlap / createEdgeCellOwners
|
|
11
|
+
* (edge-cell-styles.ts) via rerouteAroundStyleConflicts (grid.ts), not by
|
|
12
|
+
* porting upstream's routing change; these tests pin the rendered outcome, not
|
|
13
|
+
* the mechanism.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
17
|
+
|
|
18
|
+
const lines = (s: string): string[] => s.split('\n').map((l) => l.trimEnd())
|
|
19
|
+
|
|
20
|
+
const SOURCE = `graph LR
|
|
21
|
+
A --> C
|
|
22
|
+
B --> C
|
|
23
|
+
C --> D
|
|
24
|
+
C --> E`
|
|
25
|
+
|
|
26
|
+
describe('upstream #63: LR fan-in then fan-out keeps C->E off B->C corridor', () => {
|
|
27
|
+
it('renders C->E on its own trunk (unicode)', () => {
|
|
28
|
+
expect(lines(renderMermaidASCII(SOURCE, { colorMode: 'none' }))).toEqual([
|
|
29
|
+
'┌───┐ ┌───┐ ┌───┐',
|
|
30
|
+
'│ │ │ │ │ │',
|
|
31
|
+
'│ A ├────►│ C ├──┬─►│ D │',
|
|
32
|
+
'│ │ │ │ │ │ │',
|
|
33
|
+
'└───┘ └───┘ │ └───┘',
|
|
34
|
+
' ▲ │',
|
|
35
|
+
' │ │',
|
|
36
|
+
' │ └────┐',
|
|
37
|
+
' │ │',
|
|
38
|
+
' │ ▼',
|
|
39
|
+
'┌───┐ │ ┌───┐',
|
|
40
|
+
'│ │ │ │ │',
|
|
41
|
+
'│ B ├───────┘ │ E │',
|
|
42
|
+
'│ │ │ │',
|
|
43
|
+
'└───┘ └───┘',
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('B row ends at the corner under C and never reaches E (unicode)', () => {
|
|
48
|
+
const out = lines(renderMermaidASCII(SOURCE, { colorMode: 'none' }))
|
|
49
|
+
const bRow = out.find((l) => l.includes('│ B ├'))!
|
|
50
|
+
// B's edge terminates at exactly one `┘`; nothing horizontal after it.
|
|
51
|
+
expect(bRow.match(/┘/g)).toHaveLength(1)
|
|
52
|
+
expect(bRow.slice(bRow.indexOf('┘') + 1)).not.toMatch(/[─├┴┬►]/)
|
|
53
|
+
// The misleading tee joining B's corridor to E must not appear anywhere.
|
|
54
|
+
expect(out.join('\n')).not.toContain('┴')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the same shape with the ASCII charset', () => {
|
|
58
|
+
expect(
|
|
59
|
+
lines(renderMermaidASCII(SOURCE, { colorMode: 'none', useAscii: true })),
|
|
60
|
+
).toEqual([
|
|
61
|
+
'+---+ +---+ +---+',
|
|
62
|
+
'| | | | | |',
|
|
63
|
+
'| A +---->| C +--+->| D |',
|
|
64
|
+
'| | | | | | |',
|
|
65
|
+
'+---+ +---+ | +---+',
|
|
66
|
+
' ^ |',
|
|
67
|
+
' | |',
|
|
68
|
+
' | +----+',
|
|
69
|
+
' | |',
|
|
70
|
+
' | v',
|
|
71
|
+
'+---+ | +---+',
|
|
72
|
+
'| | | | |',
|
|
73
|
+
'| B +-------+ | E |',
|
|
74
|
+
'| | | |',
|
|
75
|
+
'+---+ +---+',
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for upstream lukilabs/beautiful-mermaid#113 (fixing
|
|
3
|
+
* upstream issues #111 and #112): ASCII fan-out routing.
|
|
4
|
+
*
|
|
5
|
+
* - #111: sibling edges from one source (TB, labelled) must share a single
|
|
6
|
+
* horizontal trunk leaving the source's right border, each label sitting on
|
|
7
|
+
* its own vertical branch, instead of later edges detouring to a lower row.
|
|
8
|
+
* - #112: in an LR fan-out, a sibling edge's label widens the source's border
|
|
9
|
+
* column; the first edge's box-start connector must stay flush on the
|
|
10
|
+
* source box's real border rather than drifting right with the grid cell.
|
|
11
|
+
*
|
|
12
|
+
* This fork solves these through edge-bundling / draw-bundles / draw-arrows
|
|
13
|
+
* rather than porting upstream's pathfinder changes; these tests pin the
|
|
14
|
+
* rendered outcome, not the mechanism.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
18
|
+
|
|
19
|
+
const lines = (s: string): string[] => s.split('\n').map((l) => l.trimEnd())
|
|
20
|
+
|
|
21
|
+
const TD_FANOUT = `flowchart TB
|
|
22
|
+
Src["Source"]
|
|
23
|
+
Left["Left Target"]
|
|
24
|
+
Center["Center Target"]
|
|
25
|
+
Right["Right Target"]
|
|
26
|
+
Src -->|left*| Left
|
|
27
|
+
Src -->|center*| Center
|
|
28
|
+
Src -->|right*| Right`
|
|
29
|
+
|
|
30
|
+
const LR_FANOUT = `flowchart LR
|
|
31
|
+
Src["Source"]
|
|
32
|
+
Top["Top Target"]
|
|
33
|
+
Mid["Middle Target"]
|
|
34
|
+
Bot["Bottom Target"]
|
|
35
|
+
Src -->|top*| Top
|
|
36
|
+
Src -->|mid*| Mid
|
|
37
|
+
Src -->|bot*| Bot`
|
|
38
|
+
|
|
39
|
+
describe('upstream #111: TB fan-out siblings share one trunk', () => {
|
|
40
|
+
it('renders the shared trunk with labels on their own vertical branches (unicode)', () => {
|
|
41
|
+
const out = renderMermaidASCII(TD_FANOUT, { colorMode: 'none' })
|
|
42
|
+
expect(lines(out)).toEqual([
|
|
43
|
+
'┌─────────────┐',
|
|
44
|
+
'│ │',
|
|
45
|
+
'│ Source ├─────────────┬─────────────────────┐',
|
|
46
|
+
'│ │ │ │',
|
|
47
|
+
'└──────┬──────┘ center* right*',
|
|
48
|
+
' │ │ │',
|
|
49
|
+
' left* │ │',
|
|
50
|
+
' │ │ │',
|
|
51
|
+
' │ │ │',
|
|
52
|
+
' ▼ ▼ ▼',
|
|
53
|
+
'┌─────────────┐ ┌───────────────┐ ┌──────────────┐',
|
|
54
|
+
'│ │ │ │ │ │',
|
|
55
|
+
'│ Left Target │ │ Center Target │ │ Right Target │',
|
|
56
|
+
'│ │ │ │ │ │',
|
|
57
|
+
'└─────────────┘ └───────────────┘ └──────────────┘',
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('has exactly one horizontal trunk row off the source border (ASCII)', () => {
|
|
62
|
+
const out = lines(
|
|
63
|
+
renderMermaidASCII(TD_FANOUT, { colorMode: 'none', useAscii: true }),
|
|
64
|
+
)
|
|
65
|
+
// Box border rows start with '+'; only the source-border row carries a long horizontal run; the buggy
|
|
66
|
+
// upstream output put a second run (the "right*" detour) on a lower row.
|
|
67
|
+
const rowsWithRun = out.filter((l) => !l.startsWith('+') && /-{5,}/.test(l))
|
|
68
|
+
expect(rowsWithRun).toHaveLength(1)
|
|
69
|
+
expect(rowsWithRun[0]).toMatch(/^\|\s+Source\s+\+-+\+-+\+$/)
|
|
70
|
+
// No label is stranded on a horizontal segment.
|
|
71
|
+
expect(out.join('\n')).not.toMatch(/[-─]\/?(center|right)\*[-─]/)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('upstream #112: LR fan-out box-start connector stays on the border', () => {
|
|
76
|
+
it('places ├ directly under the source box right border column (unicode)', () => {
|
|
77
|
+
const out = lines(renderMermaidASCII(LR_FANOUT, { colorMode: 'none' }))
|
|
78
|
+
const sourceRow = out.find((l) => l.includes('Source'))!
|
|
79
|
+
const borderCol = sourceRow.indexOf('├')
|
|
80
|
+
expect(borderCol).toBe(out[0]!.indexOf('┐'))
|
|
81
|
+
// Every row of the source box (top border down to bottom border) agrees
|
|
82
|
+
// on the border column.
|
|
83
|
+
expect(out[0]![borderCol]).toBe('┐')
|
|
84
|
+
expect(out[4]![borderCol]).toBe('┘')
|
|
85
|
+
// The connector is the border glyph itself, not preceded by a gap.
|
|
86
|
+
expect(sourceRow[borderCol - 1]).toBe(' ')
|
|
87
|
+
expect(sourceRow.slice(0, borderCol)).toBe('│ Source ')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders the full fan-out with ├ flush on the border (unicode)', () => {
|
|
91
|
+
const out = renderMermaidASCII(LR_FANOUT, { colorMode: 'none' })
|
|
92
|
+
expect(lines(out).slice(0, 6)).toEqual([
|
|
93
|
+
'┌────────┐ ┌───────────────┐',
|
|
94
|
+
'│ │ │ │',
|
|
95
|
+
'│ Source ├top*─►│ Top Target │',
|
|
96
|
+
'│ │ │ │',
|
|
97
|
+
'└────┬───┘ └───────────────┘',
|
|
98
|
+
' │',
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
})
|