@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,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
|
+
})
|