@zombie-mermaid/ascii-renderer 3.1.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 (35) 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-role-colors.test.ts +328 -0
  10. package/src/__tests__/ascii-subgraph-back-edge-1197.test.ts +212 -0
  11. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +4 -2
  12. package/src/__tests__/ascii-subgraph-title-edge-1222.test.ts +106 -0
  13. package/src/__tests__/c4-ascii.test.ts +311 -0
  14. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
  15. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +1 -1
  16. package/src/__tests__/xychart-ascii-ticks.test.ts +34 -0
  17. package/src/ansi.ts +16 -2
  18. package/src/architecture.ts +33 -0
  19. package/src/c4-diagram.ts +1087 -0
  20. package/src/canvas.ts +78 -8
  21. package/src/cluster-boundary.ts +429 -0
  22. package/src/converter.ts +18 -2
  23. package/src/draw-arrows.ts +120 -18
  24. package/src/draw-boxes.ts +1 -0
  25. package/src/draw-subgraphs.ts +102 -2
  26. package/src/draw.ts +57 -3
  27. package/src/edge-cell-styles.ts +20 -1
  28. package/src/edge-routing.ts +77 -1
  29. package/src/flowchart.ts +28 -3
  30. package/src/frame-avoidance.ts +235 -0
  31. package/src/grid.ts +78 -19
  32. package/src/pathfinder.ts +22 -2
  33. package/src/registry.ts +8 -0
  34. package/src/types.ts +69 -0
  35. 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
+ })