@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,774 @@
1
+ /**
2
+ * Cluster-boundary edge exits (#1148, fixing #1135 / #1156): when 2+ edges
3
+ * leave a cluster, they share one stub across the cluster's flow-side wall
4
+ * to a gutter cell past it, instead of each edge being routed independently
5
+ * from the stand-in member node (which punched some of them through the
6
+ * member's own side).
7
+ *
8
+ * Test order follows the design note
9
+ * (docs/decisions/cluster-exit-anchoring-1148-prerequisites-1164-1165.md).
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 { planClusterExits } from '../cluster-boundary.ts'
17
+ import { determinePath } from '../edge-routing.ts'
18
+ import { gridKey } from '../types.ts'
19
+ import type { AsciiConfig } from '../types.ts'
20
+
21
+ const render = (src: string, opts: Record<string, unknown> = {}): string[] =>
22
+ renderMermaidASCII(src, { colorMode: 'none', ...opts })
23
+ .split('\n')
24
+ .map((l) => l.trimEnd())
25
+
26
+ /** Lay out `src` and return the graph, to inspect plans and boxes. */
27
+ function layout(src: string, config: Partial<AsciiConfig> = {}) {
28
+ const graph = convertToAsciiGraph(parseMermaid(src), {
29
+ useAscii: false,
30
+ paddingX: 6,
31
+ paddingY: 5,
32
+ boxBorderPadding: 1,
33
+ graphDirection: /^\s*(flowchart|graph)\s+LR/.test(src) ? 'LR' : 'TD',
34
+ ...config,
35
+ })
36
+ createMapping(graph)
37
+ return graph
38
+ }
39
+
40
+ const FLOW_TWIN = `flowchart TD
41
+ Start --> Idle
42
+ Idle --> Processing
43
+ subgraph Processing
44
+ N
45
+ end
46
+ Processing -->|done| Done
47
+ Processing -->|fail| Error
48
+ `
49
+
50
+ const COMPOSITE_STATE = `stateDiagram-v2
51
+ [*] --> Idle
52
+ Idle --> Processing
53
+ state Processing {
54
+ [*] --> execute
55
+ }
56
+ Processing --> Done: done
57
+ Processing --> Error: fail
58
+ `
59
+
60
+ /** Row index of the cluster's bottom wall: a `└──…──┘` row. */
61
+ const wallRow = (lines: string[]): number =>
62
+ lines.findIndex((l) => /^└[─┼┬┴┤├]+┘/.test(l))
63
+
64
+ describe('cluster-exit anchoring: labeled multi-exit primary repro (#1135)', () => {
65
+ it.each([
66
+ ['plain flowchart twin', FLOW_TWIN],
67
+ ['composite state', COMPOSITE_STATE],
68
+ ])('%s: both exits leave through the flow-side wall', (_name, src) => {
69
+ const lines = render(src)
70
+ const wall = wallRow(lines)
71
+ expect(wall).toBeGreaterThan(-1)
72
+
73
+ // No label overwrites the cluster's border row.
74
+ expect(lines[wall]).not.toMatch(/done|fail/)
75
+ // Both labels are drawn, below the wall.
76
+ const below = lines.slice(wall + 1).join('\n')
77
+ expect(below).toContain('done')
78
+ expect(below).toContain('fail')
79
+
80
+ // Nothing leaves through the stand-in node's own side: no `├─` port on
81
+ // a row inside the cluster.
82
+ const inside = lines.slice(0, wall)
83
+ expect(inside.some((l) => /├─+┼/.test(l))).toBe(false)
84
+
85
+ // The wall is crossed once, by the shared trunk.
86
+ expect(lines[wall]!.match(/┼/g)).toHaveLength(1)
87
+
88
+ // Both edges still arrive: two arrowheads above the two targets.
89
+ const targetsRow = lines.findIndex((l) => /Done/.test(l) && /Error/.test(l))
90
+ expect(targetsRow).toBeGreaterThan(-1)
91
+ const arrowRow = lines
92
+ .slice(wall + 1, targetsRow)
93
+ .find((l) => (l.match(/▼/g) ?? []).length === 2)
94
+ expect(arrowRow).toBeDefined()
95
+ })
96
+
97
+ it('shares one trunk: the fan-out forks at a tee directly on the trunk column', () => {
98
+ // Unlabeled, so no label sits on the fork itself.
99
+ const lines = render(
100
+ FLOW_TWIN.replace('-->|done|', '-->').replace('-->|fail|', '-->'),
101
+ )
102
+ const wall = wallRow(lines)
103
+ const trunkCol = lines[wall]!.indexOf('┼')
104
+ // The row below the wall forks at the trunk column (a tee) and runs to
105
+ // the other target's column.
106
+ const fork = lines[wall + 1]!
107
+ expect(fork[trunkCol]).toBe('├')
108
+ expect(fork).toMatch(/├─+┐/)
109
+ })
110
+
111
+ it('LR mirror: both exits leave through the right wall', () => {
112
+ const lines = render(`flowchart LR
113
+ Processing
114
+ subgraph Processing
115
+ N
116
+ end
117
+ Processing -->|done| Done
118
+ Processing -->|fail| Error
119
+ `)
120
+ // The right wall is the last `│` column of the cluster box; the stub
121
+ // crosses it once, as a `┼` on N's row.
122
+ const nRow = lines.findIndex((l) => /│ N /.test(l))
123
+ expect(nRow).toBeGreaterThan(-1)
124
+ expect(lines[nRow]).toMatch(/N ├─+┼/)
125
+ // Labels sit outside the wall: `fail` must not start at or before it.
126
+ const wallCol = lines[nRow]!.indexOf('┼')
127
+ const failRow = lines.find((l) => l.includes('fail'))!
128
+ expect(failRow.indexOf('fail')).toBeGreaterThan(wallCol)
129
+ // Both targets are reached.
130
+ expect(lines.join('\n')).toMatch(/►│\s+Done/)
131
+ expect(lines.join('\n')).toMatch(/►│\s+Error/)
132
+ })
133
+ })
134
+
135
+ describe('cluster-exit anchoring: engagement and fallbacks', () => {
136
+ it('engages for 2+ exits and records a plan on the graph', () => {
137
+ const graph = layout(FLOW_TWIN)
138
+ expect(graph.clusterExitPlans?.size).toBe(1)
139
+ const [plan] = [...graph.clusterExitPlans!.values()]
140
+ expect(plan!.edges.size).toBe(2)
141
+ })
142
+
143
+ it('single-exit cluster: untouched, byte-identical to the pre-#1148 render', () => {
144
+ const src = `flowchart TD
145
+ subgraph S
146
+ a
147
+ end
148
+ S --> C
149
+ a --> D
150
+ `
151
+ // `S --> C` is the cluster's only subgraph-addressed exit; `a --> D` is
152
+ // an ordinary edge from the member node itself.
153
+ expect(layout(src).clusterExitPlans).toBeUndefined()
154
+ expect(render(src).join('\n')).toBe(
155
+ [
156
+ '┌───────┐',
157
+ '│ S │',
158
+ '│ │',
159
+ '│ │',
160
+ '│ ┌───┐ │',
161
+ '│ │ │ │',
162
+ '│ │ a ├─┼─────┐',
163
+ '│ │ │ │ │',
164
+ '│ └─┬─┘ │ │',
165
+ '│ │ │ │',
166
+ '└───┼───┘ │',
167
+ ' │ │',
168
+ ' │ │',
169
+ ' ▼ ▼',
170
+ ' ┌───┐ ┌───┐',
171
+ ' │ │ │ │',
172
+ ' │ C │ │ D │',
173
+ ' │ │ │ │',
174
+ ' └───┘ └───┘',
175
+ '',
176
+ '',
177
+ '',
178
+ ]
179
+ .join('\n')
180
+ .trimEnd(),
181
+ )
182
+ })
183
+
184
+ it('a graph with no subgraph-addressed edges never builds a plan', () => {
185
+ expect(
186
+ layout('flowchart TD\n A --> B\n A --> C\n').clusterExitPlans,
187
+ ).toBe(undefined)
188
+ })
189
+
190
+ it('backward target: one forward + one backward exit does not engage', () => {
191
+ const graph = layout(`flowchart TD
192
+ U --> S
193
+ subgraph S
194
+ a
195
+ end
196
+ S --> C
197
+ S --> U
198
+ `)
199
+ expect(graph.clusterExitPlans).toBeUndefined()
200
+ })
201
+
202
+ it('lane siblings alone (no other exit) do not engage; they keep lane routing', () => {
203
+ const graph = layout(`flowchart TD
204
+ subgraph S
205
+ a
206
+ end
207
+ S -->|x| C
208
+ S -->|y| C
209
+ `)
210
+ expect(graph.clusterExitPlans).toBeUndefined()
211
+ const lanes = graph.edges.filter((e) => e.parallelLane)
212
+ expect(lanes).toHaveLength(2)
213
+ })
214
+
215
+ it('empty cluster: edges from it are dropped as before, without error', () => {
216
+ const graph = layout(`flowchart TD
217
+ subgraph S
218
+ end
219
+ S --> C
220
+ S --> D
221
+ `)
222
+ expect(graph.clusterExitPlans).toBeUndefined()
223
+ expect(() =>
224
+ render(`flowchart TD
225
+ subgraph S
226
+ end
227
+ S --> C
228
+ S --> D
229
+ `),
230
+ ).not.toThrow()
231
+ })
232
+
233
+ it('a direct edge from the member node is not cluster-addressed', () => {
234
+ const graph = layout(`flowchart TD
235
+ subgraph S
236
+ a
237
+ end
238
+ a --> C
239
+ a --> D
240
+ `)
241
+ expect(graph.edges.every((e) => e.clusterSource === undefined)).toBe(true)
242
+ expect(graph.clusterExitPlans).toBeUndefined()
243
+ })
244
+ })
245
+
246
+ describe('cluster-exit anchoring: overlapping root subgraphs (#1165)', () => {
247
+ // S1's long title widens its box on both sides until it collides with its
248
+ // neighbour S2, which is exactly what ensureSubgraphSpacing resolves.
249
+ const src = `flowchart TD
250
+ subgraph S1["Long cluster title"]
251
+ a
252
+ end
253
+ subgraph S2
254
+ b
255
+ end
256
+ X --> a
257
+ X --> b
258
+ S1 -->|one| C
259
+ S1 -->|two| D
260
+ `
261
+
262
+ it('the fixture makes ensureSubgraphSpacing fire, and exits still cross the wall', () => {
263
+ const graph = layout(src)
264
+ const s1 = graph.subgraphs.find((sg) => sg.name === 'Long cluster title')!
265
+ const s2 = graph.subgraphs.find((sg) => sg.name === 'S2')!
266
+ // Without the pass, S1's widened box would start at or before S2's right
267
+ // wall. Its post-pass left wall is exactly maxX + minSpacing + 1.
268
+ const widenedMinX =
269
+ Math.min(...s1.nodes.map((n) => n.drawingCoord!.x)) - 2 - 6
270
+ expect(widenedMinX).toBeLessThanOrEqual(s2.maxX + 1)
271
+ expect(s1.minX).toBe(s2.maxX + 2)
272
+
273
+ expect(graph.clusterExitPlans?.size).toBe(1)
274
+ const lines = render(src)
275
+ const text = lines.join('\n')
276
+ // S1's bottom wall is crossed by the trunk, and both targets are reached.
277
+ expect(text).toMatch(/└─+┼─+┘/)
278
+ expect((text.match(/▼/g) ?? []).length).toBeGreaterThanOrEqual(3)
279
+ expect(text).toContain('one')
280
+ expect(text).toContain('two')
281
+ })
282
+ })
283
+
284
+ describe('cluster-exit anchoring: gutter sizing', () => {
285
+ const src = `flowchart TD
286
+ subgraph S
287
+ a
288
+ end
289
+ S -->|one| C
290
+ S -->|two| D
291
+ `
292
+
293
+ it.each([2, 3, 5, 8])(
294
+ 'paddingY %i: the trunk crosses the wall and labels stay below it',
295
+ (paddingY) => {
296
+ const lines = render(src, { paddingY, paddingX: paddingY })
297
+ const wall = wallRow(lines)
298
+ expect(wall).toBeGreaterThan(-1)
299
+ expect(lines[wall]).not.toMatch(/one|two/)
300
+ expect(lines[wall]!.match(/┼/g)).toHaveLength(1)
301
+ expect(lines.slice(wall + 1).join('\n')).toContain('one')
302
+ expect(lines.slice(wall + 1).join('\n')).toContain('two')
303
+ },
304
+ )
305
+
306
+ it('nested cluster sharing the bottom edge (2 boxes): still clears both walls', () => {
307
+ const lines = render(
308
+ `flowchart TD
309
+ subgraph Outer
310
+ subgraph Inner
311
+ x
312
+ end
313
+ end
314
+ Outer -->|one| C
315
+ Outer -->|two| D
316
+ `,
317
+ { paddingY: 2, paddingX: 2 },
318
+ )
319
+ // Two nested bottom walls, each crossed by the trunk.
320
+ const walls = lines
321
+ .map((l, i) => [l, i] as const)
322
+ .filter(([l]) => /└─+┼─+┘/.test(l))
323
+ expect(walls.length).toBeGreaterThanOrEqual(2)
324
+ const outerWall = Math.max(...walls.map(([, i]) => i))
325
+ expect(lines[outerWall]).not.toMatch(/one|two/)
326
+ expect(lines.slice(outerWall + 1).join('\n')).toContain('one')
327
+ expect(lines.slice(outerWall + 1).join('\n')).toContain('two')
328
+ })
329
+
330
+ it('LR with a long cluster label: the gutter clears the label-widened wall', () => {
331
+ const lines = render(`flowchart LR
332
+ subgraph AVeryLongClusterLabelName
333
+ N
334
+ end
335
+ AVeryLongClusterLabelName -->|done| Done
336
+ AVeryLongClusterLabelName -->|fail| Error
337
+ `)
338
+ const text = lines.join('\n')
339
+ // The cluster box is drawn intact: its top wall's closing corner exists,
340
+ // and the label `done` is not adjacent to a wall glyph on its left.
341
+ expect(text).toMatch(/┐/)
342
+ const doneRow = lines.find((l) => l.includes('done'))!
343
+ expect(doneRow).toMatch(/┼[─┬]*done─*►/)
344
+ })
345
+ })
346
+
347
+ describe('cluster-exit anchoring: style-conflict reroute keeps the cluster shape', () => {
348
+ it('a dotted exit alongside a solid one still forks from the shared trunk', () => {
349
+ const src = `flowchart TD
350
+ subgraph S
351
+ a
352
+ end
353
+ S --> C
354
+ S -.-> D
355
+ `
356
+ const lines = render(src)
357
+ const wall = wallRow(lines)
358
+ expect(wall).toBeGreaterThan(-1)
359
+ expect(lines.slice(0, wall).some((l) => /├─+┼/.test(l))).toBe(false)
360
+ // The dotted leg forks off the trunk on the gutter row and reaches D.
361
+ expect(lines[wall + 1]).toMatch(/├┄+┐/)
362
+ expect(lines.join('\n')).toMatch(/▼[\s\S]*▼/)
363
+ })
364
+ })
365
+
366
+ describe('cluster-exit anchoring: chain partner leaving the target', () => {
367
+ it("a later edge out of the target doesn't retrace the cluster exit's last leg", () => {
368
+ const lines = render(`stateDiagram-v2
369
+ [*] --> Idle
370
+ Idle --> Processing : submit
371
+ state Processing {
372
+ parse --> validate
373
+ validate --> execute
374
+ }
375
+ Processing --> Complete : done
376
+ Processing --> Error : fail
377
+ Error --> Idle : retry
378
+ Complete --> [*]`)
379
+ // `fail` arrives at Error's top face. Were `retry` routed out of the same
380
+ // face, its line would merge into the arrow above Error and turn its top
381
+ // border into a `┴` junction; it must leave through Error's right side.
382
+ const top = lines.findIndex((l) => /╭─+╮\s+╭─+╮/.test(l))
383
+ expect(top).toBeGreaterThan(-1)
384
+ expect(lines[top]).not.toMatch(/┴/)
385
+ expect(lines.find((l) => l.includes('Error'))).toMatch(/Error ├/)
386
+ })
387
+ })
388
+
389
+ describe('cluster-exit anchoring: BT and ASCII modes', () => {
390
+ it('BT flips the finished layout: exits leave through the (now top) wall', () => {
391
+ const lines = render(FLOW_TWIN.replace('flowchart TD', 'flowchart BT'))
392
+ const text = lines.join('\n')
393
+ expect(text).toContain('done')
394
+ expect(text).toContain('fail')
395
+ expect((text.match(/▲/g) ?? []).length).toBeGreaterThanOrEqual(2)
396
+ })
397
+
398
+ it('ASCII mode renders both exits with labels and arrowheads', () => {
399
+ const text = render(FLOW_TWIN, { useAscii: true }).join('\n')
400
+ expect(text).toContain('done')
401
+ expect(text).toContain('fail')
402
+ expect((text.match(/v/g) ?? []).length).toBeGreaterThanOrEqual(2)
403
+ })
404
+ })
405
+
406
+ describe('cluster-exit anchoring: eligibility edge cases', () => {
407
+ const engagedEdgeCounts = (src: string): number[] =>
408
+ [...(layout(src).clusterExitPlans?.values() ?? [])].map((p) => p.edges.size)
409
+
410
+ it('a self-loop on the cluster is not an exit: the other two still engage', () => {
411
+ const src = `flowchart TD
412
+ subgraph S
413
+ a
414
+ end
415
+ S --> S
416
+ S --> C
417
+ S --> D
418
+ `
419
+ expect(engagedEdgeCounts(src)).toEqual([2])
420
+ expect(() => render(src)).not.toThrow()
421
+ })
422
+
423
+ it('an edge into the cluster’s own member is not an exit', () => {
424
+ const src = `flowchart TD
425
+ subgraph S
426
+ a
427
+ end
428
+ S --> a
429
+ S --> C
430
+ S --> D
431
+ `
432
+ expect(engagedEdgeCounts(src)).toEqual([2])
433
+ })
434
+
435
+ it('an exit with a single eligible edge is left on ordinary routing', () => {
436
+ const src = `flowchart TD
437
+ subgraph S
438
+ a
439
+ end
440
+ S --> C
441
+ S --> S
442
+ `
443
+ expect(layout(src).clusterExitPlans).toBeUndefined()
444
+ })
445
+
446
+ it('a multi-member cluster plans its stub from the stand-in node', () => {
447
+ const src = `flowchart TD
448
+ subgraph S
449
+ a --> b
450
+ end
451
+ S --> C
452
+ S --> D
453
+ `
454
+ expect(engagedEdgeCounts(src)).toEqual([2])
455
+ const lines = render(src)
456
+ expect(wallRow(lines)).toBeGreaterThan(-1)
457
+ })
458
+
459
+ it('a foreign node inside the cluster box blocks the stub: no plan, no throw', () => {
460
+ const src = `flowchart TD
461
+ subgraph S
462
+ a
463
+ b
464
+ end
465
+ subgraph T
466
+ x
467
+ end
468
+ a --> x
469
+ S --> C
470
+ S --> D
471
+ `
472
+ const graph = layout(src)
473
+ // Whether the stub is clear depends on the layout; either way the
474
+ // render must succeed and draw both exits' arrowheads.
475
+ for (const plan of graph.clusterExitPlans?.values() ?? []) {
476
+ expect(plan.edges.size).toBeGreaterThanOrEqual(2)
477
+ }
478
+ expect(() => render(src)).not.toThrow()
479
+ })
480
+
481
+ it('nested clusters that both engage: widening clears each wall (LR, long labels)', () => {
482
+ const src = `flowchart LR
483
+ subgraph Outer [A very long outer label here]
484
+ subgraph Inner [Another very long inner label]
485
+ a --> b
486
+ end
487
+ end
488
+ Inner --> C
489
+ Inner --> D
490
+ Outer --> E
491
+ Outer --> F
492
+ `
493
+ expect(layout(src).clusterExitPlans?.size).toBe(2)
494
+ const text = render(src).join('\n')
495
+ for (const id of ['C', 'D', 'E', 'F']) expect(text).toContain(id)
496
+ })
497
+
498
+ it('outer cluster wall is checked after inner widening shifts it (#1213)', () => {
499
+ // Outer has a member (z) right of Inner, so widening Inner's gutter
500
+ // shifts Outer's wall. Outer's gutter must be checked against that final
501
+ // wall, which needs Outer processed before Inner.
502
+ const src = `flowchart LR
503
+ subgraph Outer [A very long outer label here]
504
+ subgraph Inner [Inner label]
505
+ a
506
+ end
507
+ b --> z
508
+ end
509
+ Inner --> C
510
+ Inner --> D
511
+ Outer --> E
512
+ Outer --> F
513
+ `
514
+ const graph = layout(src)
515
+ expect(graph.clusterExitPlans?.size).toBe(2)
516
+ for (const [sg, plan] of graph.clusterExitPlans ?? []) {
517
+ expect(gridToDrawingCoord(graph, plan.gutter).x).toBeGreaterThan(sg.maxX)
518
+ }
519
+ // Rendered: Outer's exits share one trunk past the wall (┼ then ┬)
520
+ // instead of fanning out of the wall column itself.
521
+ const zRow = render(src).find((l) => l.includes('z'))
522
+ expect(zRow).toMatch(/┼─+┬/)
523
+ })
524
+ })
525
+
526
+ describe('cluster-exit anchoring: planner guards (graph mutated after layout)', () => {
527
+ const TWIN = `flowchart TD
528
+ subgraph S
529
+ a --> b
530
+ end
531
+ S --> C
532
+ S --> D
533
+ `
534
+ /** A laid-out graph with its engaged plan cleared, ready to re-plan. */
535
+ function replanTarget() {
536
+ const graph = layout(TWIN)
537
+ const sg = graph.subgraphs[0]!
538
+ const exits = graph.edges.filter((e) => e.clusterSource === sg)
539
+ graph.clusterExitPlans = undefined
540
+ return { graph, sg, exits }
541
+ }
542
+
543
+ it('baseline: re-planning the untouched layout engages both exits', () => {
544
+ const { graph } = replanTarget()
545
+ planClusterExits(graph)
546
+ expect(graph.clusterExitPlans?.size).toBe(1)
547
+ })
548
+
549
+ it('a member without a grid coordinate is skipped when measuring the box', () => {
550
+ const { graph, sg } = replanTarget()
551
+ const dropped = sg.nodes.find((n) => n !== sg.nodes[0])!
552
+ const saved = dropped.gridCoord
553
+ dropped.gridCoord = null
554
+ try {
555
+ expect(() => planClusterExits(graph)).not.toThrow()
556
+ } finally {
557
+ dropped.gridCoord = saved
558
+ }
559
+ })
560
+
561
+ it('a cluster none of whose members were placed gets no plan', () => {
562
+ const { graph, sg } = replanTarget()
563
+ for (const n of sg.nodes) n.gridCoord = null
564
+ planClusterExits(graph)
565
+ expect(graph.clusterExitPlans).toBeUndefined()
566
+ })
567
+
568
+ it('an edge whose target was never placed is not eligible', () => {
569
+ const { graph, exits } = replanTarget()
570
+ exits[0]!.to.gridCoord = null
571
+ planClusterExits(graph)
572
+ expect(graph.clusterExitPlans).toBeUndefined()
573
+ })
574
+
575
+ it('an edge whose source is not a member of the cluster is not eligible', () => {
576
+ const { graph, sg, exits } = replanTarget()
577
+ sg.nodes = sg.nodes.filter((n) => n !== exits[0]!.from)
578
+ planClusterExits(graph)
579
+ expect(graph.clusterExitPlans).toBeUndefined()
580
+ })
581
+
582
+ it('exits that do not share one stand-in node do not engage', () => {
583
+ const { graph, sg, exits } = replanTarget()
584
+ const other = sg.nodes.find((n) => n !== exits[0]!.from)!
585
+ exits[1]!.from = other
586
+ planClusterExits(graph)
587
+ expect(graph.clusterExitPlans).toBeUndefined()
588
+ })
589
+
590
+ it('a stub blocked by a foreign cell is rejected', () => {
591
+ const { graph, sg, exits } = replanTarget()
592
+ const anchor = exits[0]!.from.gridCoord!
593
+ // The gutter cell: one row below the lowest member, in the stub's column.
594
+ const boxMaxY = Math.max(...sg.nodes.map((n) => n.gridCoord!.y + 2))
595
+ graph.grid.add(gridKey({ x: anchor.x + 1, y: boxMaxY + 1 }))
596
+ planClusterExits(graph)
597
+ expect(graph.clusterExitPlans).toBeUndefined()
598
+ })
599
+
600
+ /**
601
+ * Fill the band between the cluster and the targets (everything on the
602
+ * rows above the targets except the gutter cell itself), so the outside
603
+ * leg to a target that is not directly below the gutter has no route.
604
+ */
605
+ function blockBand(
606
+ graph: ReturnType<typeof layout>,
607
+ gutter: { x: number; y: number },
608
+ toY: number,
609
+ ): void {
610
+ for (let x = 0; x <= 14; x++) {
611
+ for (let y = gutter.y - 2; y < toY; y++) {
612
+ if (x === gutter.x && y === gutter.y) continue
613
+ graph.grid.add(gridKey({ x, y }))
614
+ }
615
+ }
616
+ }
617
+
618
+ it('an unroutable outside leg rejects the whole plan (all-or-nothing)', () => {
619
+ const { graph, exits } = replanTarget()
620
+ const far = exits.find((e) => e.to.gridCoord!.x > 2)!
621
+ blockBand(graph, { x: 1, y: 7 }, far.to.gridCoord!.y)
622
+ planClusterExits(graph)
623
+ expect(graph.clusterExitPlans).toBeUndefined()
624
+ })
625
+
626
+ it('determinePath drops an engaged edge whose outside leg became unroutable', () => {
627
+ const graph = layout(TWIN)
628
+ const [plan] = [...graph.clusterExitPlans!.values()]
629
+ const edge = [...plan!.edges].find((e) => e.to.gridCoord!.x > 2)!
630
+ blockBand(graph, plan!.gutter, edge.to.gridCoord!.y)
631
+ expect(() => determinePath(graph, edge)).not.toThrow()
632
+ expect(plan!.edges.has(edge)).toBe(false)
633
+ expect(edge.labelLine).toEqual([])
634
+ })
635
+ })
636
+
637
+ describe('chain-overlap threshold only tightens for engaged cluster exits', () => {
638
+ // `A --> X` is cluster-addressed but the only exit of A, so no plan is
639
+ // made and it routes ordinarily. The chain partner `A --> a1` (via `Y`)
640
+ // sharing a single open cell with it is an ordinary crossing, exactly as
641
+ // on main (threshold 2) — it must not be rerouted into a garbled box.
642
+ const SINGLE_EXIT = `flowchart LR
643
+ subgraph A
644
+ a1
645
+ end
646
+ A --> X
647
+ A --> a1
648
+ Y --> A
649
+ `
650
+
651
+ it('a single-exit cluster edge keeps the ordinary 2-cell chain threshold', () => {
652
+ const graph = layout(SINGLE_EXIT)
653
+ expect(graph.clusterExitPlans).toBeUndefined()
654
+ const out = render(SINGLE_EXIT).join('\n')
655
+ expect(out).toContain('│ a1 ├')
656
+ expect(out).not.toContain('a┌')
657
+ })
658
+ })
659
+
660
+ describe('cluster-exit anchoring: shape of the whole composite-states render', () => {
661
+ // The "State: Composite States" sample: two cluster exits (`done`, `fail`)
662
+ // plus `retry`, an outside edge from `Error` back up to `Idle` that runs
663
+ // alongside the cluster.
664
+ const COMPOSITE_SAMPLE = `stateDiagram-v2
665
+ [*] --> Idle
666
+ Idle --> Processing : submit
667
+ state Processing {
668
+ parse --> validate
669
+ validate --> execute
670
+ }
671
+ Processing --> Complete : done
672
+ Processing --> Error : fail
673
+ Error --> Idle : retry
674
+ Complete --> [*]
675
+ `
676
+
677
+ // A flowchart whose outside edge (`Aux`..`Last` chain) and `retry` pass the
678
+ // cluster, with `Idle --> Aux` forcing `retry` to hug the cluster wall.
679
+ const WALL_HUGGER = `flowchart TD
680
+ Start --> Idle
681
+ Idle --> Processing
682
+ Idle --> Aux
683
+ subgraph Processing
684
+ a --> b
685
+ b --> c
686
+ end
687
+ Aux --> Cache
688
+ Cache --> Last
689
+ Processing -->|done| Done
690
+ Processing -->|fail| Error
691
+ Error -->|retry| Idle
692
+ Last --> Done
693
+ `
694
+
695
+ /** Rows strictly between the first `┌…┐` top wall and its `└…┘` bottom wall. */
696
+ const clusterBody = (lines: string[]) => {
697
+ const top = lines.findIndex((l) => /^┌─/.test(l))
698
+ const bottom = lines.findIndex((l) => /^└[─┼┬┴┤├]+┘/.test(l))
699
+ expect(top).toBeGreaterThan(-1)
700
+ expect(bottom).toBeGreaterThan(top)
701
+ return {
702
+ rightCol: lines[top]!.indexOf('┐'),
703
+ rows: lines.slice(top + 1, bottom),
704
+ }
705
+ }
706
+
707
+ it.each([
708
+ ['composite sample', COMPOSITE_SAMPLE],
709
+ ['outside edge hugging the wall', WALL_HUGGER],
710
+ ])(
711
+ '%s: both side walls are intact on every cluster row (no label overwrites one)',
712
+ (_name, src) => {
713
+ const lines = render(src)
714
+ const { rightCol, rows } = clusterBody(lines)
715
+ expect(rows.length).toBeGreaterThan(0)
716
+ for (const row of rows) {
717
+ expect(row[0]).toBe('│')
718
+ expect(row[rightCol]).toBe('│')
719
+ }
720
+ // The `retry` label is still drawn, outside the wall.
721
+ const retryRow = lines.find((l) => l.includes('retry'))!
722
+ expect(retryRow.indexOf('retry')).toBeGreaterThan(rightCol)
723
+ },
724
+ )
725
+
726
+ it('retry runs as one clean route, not a staircase hugging the cluster', () => {
727
+ const graph = layout(COMPOSITE_SAMPLE.replace(/\n$/, ''))
728
+ const retry = graph.edges.find((e) => e.text === 'retry')!
729
+ // Out of Error's side, one vertical run, into Idle's side: at most two
730
+ // bends, i.e. four merged points. The staircase had ten.
731
+ expect(retry.path.length).toBeLessThanOrEqual(4)
732
+
733
+ // Rendered: one column of `│` right of the cluster carries the run.
734
+ const lines = render(COMPOSITE_SAMPLE)
735
+ const { rightCol } = clusterBody(lines)
736
+ const idleRow = lines.findIndex((l) => l.includes('retry'))
737
+ const columns = new Set<number>()
738
+ // Beside the cluster body, where the run used to jog.
739
+ for (const row of lines.slice(idleRow + 1, wallRow(lines))) {
740
+ const col = row.indexOf('│', rightCol + 1)
741
+ if (col > -1) columns.add(col)
742
+ }
743
+ expect([...columns]).toHaveLength(1)
744
+ })
745
+
746
+ it('done and fail labels do not share a row (no `done────fail` run)', () => {
747
+ const lines = render(COMPOSITE_SAMPLE)
748
+ const doneRow = lines.findIndex((l) => l.includes('done'))
749
+ const failRow = lines.findIndex((l) => l.includes('fail'))
750
+ expect(doneRow).toBeGreaterThan(-1)
751
+ expect(failRow).toBeGreaterThan(-1)
752
+ expect(doneRow).not.toBe(failRow)
753
+ expect(lines.join('\n')).not.toMatch(/done─+fail/)
754
+ })
755
+
756
+ it('3-exit cluster: every exit label sits on its own row', () => {
757
+ const lines = render(`stateDiagram-v2
758
+ state Processing {
759
+ parse --> execute
760
+ }
761
+ Processing --> Complete : done
762
+ Processing --> Error : fail
763
+ Processing --> Aborted : abort
764
+ `)
765
+ const rows = ['done', 'fail', 'abort'].map((t) =>
766
+ lines.findIndex((l) => l.includes(t)),
767
+ )
768
+ expect(rows.every((r) => r > -1)).toBe(true)
769
+ // `fail` and `abort` fan out on the gutter row together, as before; the
770
+ // straight-ahead `done` is the one that must leave it.
771
+ expect(rows[0]).not.toBe(rows[1])
772
+ expect(rows[0]).not.toBe(rows[2])
773
+ })
774
+ })