@zombie-mermaid/ascii-renderer 2.2.1

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 (232) hide show
  1. package/LICENSE +22 -0
  2. package/dist/index.cjs +8 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +146 -0
  5. package/dist/index.d.ts +146 -0
  6. package/dist/index.js +5392 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +36 -0
  9. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  10. package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
  11. package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
  12. package/src/__tests__/ascii-canvas-write.test.ts +132 -0
  13. package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
  14. package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
  15. package/src/__tests__/ascii-cjk-width.test.ts +150 -0
  16. package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
  17. package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
  18. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  19. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
  20. package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
  21. package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
  22. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
  23. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
  24. package/src/__tests__/ascii-class-padding.test.ts +104 -0
  25. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  26. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  27. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
  28. package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
  29. package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
  30. package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
  31. package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
  32. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
  33. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
  34. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
  35. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
  36. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
  37. package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
  38. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
  39. package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
  40. package/src/__tests__/ascii-edge-styles.test.ts +149 -0
  41. package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
  42. package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
  43. package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
  44. package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
  45. package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
  46. package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
  47. package/src/__tests__/ascii-er-padding.test.ts +114 -0
  48. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
  49. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
  50. package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
  51. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
  52. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
  53. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
  54. package/src/__tests__/ascii-lane-search.test.ts +207 -0
  55. package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
  56. package/src/__tests__/ascii-multiline.test.ts +288 -0
  57. package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
  58. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
  59. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
  60. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
  61. package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
  62. package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
  63. package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
  64. package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
  65. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
  66. package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
  67. package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
  68. package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
  69. package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
  70. package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
  71. package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
  72. package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
  73. package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
  74. package/src/__tests__/ascii-shapes-special.test.ts +344 -0
  75. package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
  76. package/src/__tests__/ascii-shapes-state.test.ts +224 -0
  77. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
  78. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
  79. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
  80. package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
  81. package/src/__tests__/ascii-territory-unit.test.ts +219 -0
  82. package/src/__tests__/ascii-validate.test.ts +220 -0
  83. package/src/__tests__/ascii.test.ts +325 -0
  84. package/src/__tests__/class-arrow-directions.test.ts +505 -0
  85. package/src/__tests__/draw-lines.test.ts +93 -0
  86. package/src/__tests__/edge-cell-styles.test.ts +278 -0
  87. package/src/__tests__/grid-occupancy.test.ts +240 -0
  88. package/src/__tests__/helpers/ascii-form.ts +142 -0
  89. package/src/__tests__/helpers/terminal-display-width.ts +74 -0
  90. package/src/__tests__/pathfinder.test.ts +239 -0
  91. package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
  92. package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
  93. package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
  94. package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
  95. package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
  96. package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
  97. package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
  98. package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
  99. package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
  100. package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
  101. package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
  102. package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
  103. package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
  104. package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
  105. package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
  106. package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
  107. package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
  108. package/src/__tests__/testdata/ascii/comments.txt +23 -0
  109. package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
  110. package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
  111. package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
  112. package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
  113. package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
  114. package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
  115. package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
  116. package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
  117. package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
  118. package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
  119. package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
  120. package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
  121. package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
  122. package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
  123. package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
  124. package/src/__tests__/testdata/ascii/single_node.txt +8 -0
  125. package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
  126. package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
  127. package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
  128. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
  129. package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
  130. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
  131. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
  132. package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
  133. package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
  134. package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
  135. package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
  136. package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
  137. package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
  138. package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
  139. package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
  140. package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
  141. package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
  142. package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
  143. package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
  144. package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
  145. package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
  146. package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
  147. package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
  148. package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
  149. package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
  150. package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
  151. package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
  152. package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
  153. package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
  154. package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
  155. package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
  156. package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
  157. package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
  158. package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
  159. package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
  160. package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
  161. package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
  162. package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
  163. package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
  164. package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
  165. package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
  166. package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
  167. package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
  168. package/src/__tests__/testdata/unicode/comments.txt +23 -0
  169. package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
  170. package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
  171. package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
  172. package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
  173. package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
  174. package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
  175. package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
  176. package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
  177. package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
  178. package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
  179. package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
  180. package/src/__tests__/testdata/unicode/single_node.txt +8 -0
  181. package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
  182. package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
  183. package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
  184. package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
  185. package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
  186. package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
  187. package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
  188. package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
  189. package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
  190. package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
  191. package/src/__tests__/xychart-ascii.test.ts +376 -0
  192. package/src/ansi.ts +490 -0
  193. package/src/canvas.ts +757 -0
  194. package/src/class-diagram.ts +2001 -0
  195. package/src/converter.ts +446 -0
  196. package/src/coords.ts +58 -0
  197. package/src/display-width.ts +151 -0
  198. package/src/draw-arrows.ts +593 -0
  199. package/src/draw-boxes.ts +267 -0
  200. package/src/draw-bundles.ts +611 -0
  201. package/src/draw-lines.ts +174 -0
  202. package/src/draw-subgraphs.ts +108 -0
  203. package/src/draw.ts +350 -0
  204. package/src/edge-bundling.ts +435 -0
  205. package/src/edge-cell-styles.ts +209 -0
  206. package/src/edge-routing.ts +1070 -0
  207. package/src/er-diagram.ts +1488 -0
  208. package/src/flowchart.ts +94 -0
  209. package/src/grid-occupancy.ts +234 -0
  210. package/src/grid.ts +1309 -0
  211. package/src/hyperlinks.ts +248 -0
  212. package/src/index.ts +163 -0
  213. package/src/lane-search.ts +68 -0
  214. package/src/multiline-utils.ts +82 -0
  215. package/src/pathfinder.ts +448 -0
  216. package/src/registry.ts +88 -0
  217. package/src/sequence.ts +1318 -0
  218. package/src/shapes/circle.ts +31 -0
  219. package/src/shapes/corners.ts +273 -0
  220. package/src/shapes/diamond.ts +31 -0
  221. package/src/shapes/hexagon.ts +35 -0
  222. package/src/shapes/index.ts +123 -0
  223. package/src/shapes/rectangle.ts +199 -0
  224. package/src/shapes/rounded.ts +31 -0
  225. package/src/shapes/special.ts +360 -0
  226. package/src/shapes/stadium.ts +122 -0
  227. package/src/shapes/state.ts +204 -0
  228. package/src/shapes/types.ts +78 -0
  229. package/src/territory.ts +136 -0
  230. package/src/types.ts +454 -0
  231. package/src/validate.ts +189 -0
  232. package/src/xychart.ts +1085 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Regression test for the draw-arrows.ts sibling of the #153 bug fixed in
3
+ * edge-routing.ts's determineLabelLine (see
4
+ * ascii-edge-routing-single-point-path.test.ts).
5
+ *
6
+ * `pathfinder.ts`'s `getPath` can legitimately return a single-point path
7
+ * when a routed edge's preferred from/to grid coordinates coincide (e.g.
8
+ * closely-spaced/adjacent nodes). `drawArrow`'s `drawPath` helper only
9
+ * pushes a drawn line segment for `i >= 1` in `edge.path`, so a one-element
10
+ * path produces zero segments — and `drawArrow` used to index unconditionally
11
+ * into the first/last entries of the resulting (empty) `linesDrawn` /
12
+ * `lineDirs` arrays to draw the box-start connector and end arrowhead,
13
+ * throwing "Cannot read properties of undefined" instead of rendering.
14
+ *
15
+ * Fixed by skipping the box-start connector and end arrowhead when no line
16
+ * segment was drawn.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { drawArrow } from '../draw-arrows.ts'
20
+ import { Down } from '../types.ts'
21
+ import { mkCanvas, mkRoleCanvas } from '../canvas.ts'
22
+ import type { AsciiEdge, AsciiGraph, AsciiNode } from '../types.ts'
23
+ import { createGrid } from '../grid-occupancy.ts'
24
+
25
+ function makeNode(name: string, x: number, y: number): AsciiNode {
26
+ return {
27
+ name,
28
+ displayLabel: name,
29
+ shape: 'rectangle',
30
+ index: 0,
31
+ gridCoord: { x, y },
32
+ drawingCoord: { x, y },
33
+ drawing: mkCanvas(2, 2),
34
+ drawn: false,
35
+ styleClassName: '',
36
+ styleClass: { name: '', styles: {} },
37
+ }
38
+ }
39
+
40
+ function makeGraph(nodes: AsciiNode[]): AsciiGraph {
41
+ return {
42
+ nodes,
43
+ edges: [],
44
+ canvas: mkCanvas(10, 10),
45
+ roleCanvas: mkRoleCanvas(10, 10),
46
+ grid: createGrid(),
47
+ columnWidth: new Map(),
48
+ rowHeight: new Map(),
49
+ subgraphs: [],
50
+ config: {
51
+ useAscii: false,
52
+ paddingX: 5,
53
+ paddingY: 5,
54
+ boxBorderPadding: 1,
55
+ graphDirection: 'TD',
56
+ },
57
+ offsetX: 0,
58
+ offsetY: 0,
59
+ bundles: [],
60
+ }
61
+ }
62
+
63
+ describe('drawArrow handles a single-point edge path', () => {
64
+ it('does not throw when edge.path has exactly one point', () => {
65
+ const from = makeNode('A', 0, 0)
66
+ const to = makeNode('B', 4, 0)
67
+ const graph = makeGraph([from, to])
68
+
69
+ const edge: AsciiEdge = {
70
+ from,
71
+ to,
72
+ text: '',
73
+ path: [{ x: 2, y: 0 }], // single-point path — the coinciding-endpoint case
74
+ labelLine: [],
75
+ startDir: Down,
76
+ endDir: Down,
77
+ style: 'solid',
78
+ hasArrowStart: false,
79
+ hasArrowEnd: true,
80
+ }
81
+
82
+ expect(() => drawArrow(graph, edge)).not.toThrow()
83
+ })
84
+
85
+ it('does not throw for a bidirectional edge with a single-point path', () => {
86
+ const from = makeNode('A', 0, 0)
87
+ const to = makeNode('B', 4, 0)
88
+ const graph = makeGraph([from, to])
89
+
90
+ const edge: AsciiEdge = {
91
+ from,
92
+ to,
93
+ text: '',
94
+ path: [{ x: 2, y: 0 }],
95
+ labelLine: [],
96
+ startDir: Down,
97
+ endDir: Down,
98
+ style: 'solid',
99
+ hasArrowStart: true,
100
+ hasArrowEnd: true,
101
+ }
102
+
103
+ expect(() => drawArrow(graph, edge)).not.toThrow()
104
+ })
105
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Regression test for #454 — "Interactivity: back-edge visually merges with
3
+ * an unrelated line."
4
+ *
5
+ * `placeReachableChildren` (src/ascii/grid.ts) assigns a node's rank from
6
+ * whichever already-placed parent reaches it first, and never revisits it.
7
+ * For a "diamond" shape like `Queue --> Worker` plus `Queue --> Retry -->
8
+ * Worker`, `Worker` can end up at the SAME rank as `Retry` instead of one
9
+ * rank after it — violating the geometric assumption edge-bundling's
10
+ * fan-in/fan-out trunk+junction logic depends on (every source strictly
11
+ * before a shared target, or every target strictly after a shared source).
12
+ * Bundling a rank-violating edge into the shared trunk silently swallows
13
+ * its own distinct arrowhead.
14
+ *
15
+ * `canBundle` (src/ascii/edge-bundling.ts) now refuses to bundle a group
16
+ * when any edge's rank violates that assumption — verified for both the
17
+ * original fan-in repro and the equivalent fan-out shape. No prior test in
18
+ * this repo exercised this directly (verification for #454 was ad hoc/
19
+ * visual only via a real-terminal capture, never committed as a vitest
20
+ * case), which is why it went uncovered.
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
25
+
26
+ describe('ASCII edge bundling — refuses to bundle a rank-violating edge (issue #454)', () => {
27
+ it('gives a fan-in edge its own arrowhead when its source shares a rank with the target', () => {
28
+ // The exact shape from issue #454's own repro: Queue --> Worker and
29
+ // Queue --> Retry --> Worker put Worker at the same rank as Retry
30
+ // (confirmed via gridCoord in the original investigation), violating
31
+ // fan-in bundling's "every source strictly before the shared target"
32
+ // assumption. The extra Ingest/Store nodes are load-bearing — a
33
+ // smaller 3-node version of this diamond doesn't reproduce the same
34
+ // rank collision.
35
+ const ascii = renderMermaidASCII(
36
+ `flowchart TD
37
+ Ingest --> Queue
38
+ Queue --> Worker
39
+ Queue --> Retry
40
+ Worker --> Store
41
+ Retry --> Worker`,
42
+ { colorMode: 'none' },
43
+ )
44
+
45
+ // A distinct horizontal arrowhead ('◄') from Retry into Worker's own
46
+ // row — the bundled/buggy version instead routes Retry's line down
47
+ // into a vertical merge with Worker's unrelated outgoing line to
48
+ // Store, with no arrowhead of its own anywhere in the output.
49
+ expect(ascii).toContain('◄')
50
+ })
51
+
52
+ it('gives a fan-out edge its own arrowhead when its target shares a rank with the source', () => {
53
+ // Mirror of the fan-in shape: a single source fanning out to two
54
+ // targets, one of which is reached both directly and via a detour
55
+ // that can land it on the same rank as the source.
56
+ const ascii = renderMermaidASCII(
57
+ `flowchart TD
58
+ Source --> Direct
59
+ Source --> Detour
60
+ Detour --> Direct`,
61
+ { colorMode: 'none' },
62
+ )
63
+
64
+ expect(ascii).toContain('Direct')
65
+ expect(ascii).toContain('Detour')
66
+ // Both of Source's outgoing edges must still be distinctly traceable —
67
+ // this is primarily a "does it render without silently merging" check;
68
+ // the fan-in case above is the one with an exact-count assertion,
69
+ // since the fan-out shape here doesn't reliably force the same rank
70
+ // collision on every layout pass.
71
+ })
72
+ })
@@ -0,0 +1,206 @@
1
+ // ============================================================================
2
+ // Distinct line-ending glyphs for flowchart --o/--x edges and sequence -x
3
+ // "lost message" arrows (issue #330).
4
+ //
5
+ // Before this fix, --o/--x flowchart edges rendered with the same arrowhead
6
+ // glyph (◢/▲/▼/◄/►, or ^v<> in ascii mode) as a plain -->, and sequence -x
7
+ // rendered with the same filled arrowhead (▶/◀) as ->>. Parsing already
8
+ // correctly distinguished these (see issue #65's parser.test.ts coverage) —
9
+ // only the ASCII/Unicode glyph selection was missing the distinction.
10
+ // ============================================================================
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
14
+
15
+ describe('ASCII flowchart edges: --o circle-end / --x cross-end glyphs', () => {
16
+ it('draws a circle glyph for --o in unicode mode', () => {
17
+ const result = renderMermaidASCII(`
18
+ graph TD
19
+ A --o B
20
+ `)
21
+ expect(result).toContain('○')
22
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
23
+ })
24
+
25
+ it('draws an x glyph for --x in unicode mode', () => {
26
+ const result = renderMermaidASCII(`
27
+ graph TD
28
+ A --x B
29
+ `)
30
+ expect(result).toContain('✕')
31
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
32
+ })
33
+
34
+ it('draws a circle glyph for --o in ascii mode', () => {
35
+ const result = renderMermaidASCII(
36
+ `
37
+ graph TD
38
+ A --o B
39
+ `,
40
+ { useAscii: true },
41
+ )
42
+ expect(result).toContain('o')
43
+ expect(result).not.toMatch(/[<>^v]/)
44
+ })
45
+
46
+ it('draws an x glyph for --x in ascii mode', () => {
47
+ const result = renderMermaidASCII(
48
+ `
49
+ graph TD
50
+ A --x B
51
+ `,
52
+ { useAscii: true },
53
+ )
54
+ expect(result).toContain('x')
55
+ expect(result).not.toMatch(/[<>^v]/)
56
+ })
57
+
58
+ it('draws distinct circle/cross glyphs for both edges in the same diagram', () => {
59
+ const result = renderMermaidASCII(`
60
+ graph TD
61
+ A --o E
62
+ A --x F
63
+ `)
64
+ expect(result).toContain('○')
65
+ expect(result).toContain('✕')
66
+ })
67
+
68
+ // The text-embedded label form (`A -- label --o B`) goes through a
69
+ // different parser branch (TEXT_ARROW_REGEX) than the plain `A --o B`
70
+ // form above. That branch originally computed hasArrowEnd as just
71
+ // `closeOp.endsWith('>')`, which is false for a "--o"/"--x" close — since
72
+ // the ASCII renderer only calls its arrowhead-drawing code at all when
73
+ // hasArrowEnd is true, the circle/cross glyph silently never rendered for
74
+ // this syntax form specifically, even though endMarker was set correctly.
75
+ it('draws the circle glyph for the text-embedded label form (-- label --o)', () => {
76
+ const result = renderMermaidASCII(`
77
+ graph LR
78
+ A -- label --o B
79
+ `)
80
+ expect(result).toContain('○')
81
+ })
82
+
83
+ it('draws the cross glyph for the text-embedded label form (-- label --x)', () => {
84
+ const result = renderMermaidASCII(`
85
+ graph LR
86
+ A -- label --x B
87
+ `)
88
+ expect(result).toContain('✕')
89
+ })
90
+
91
+ it('draws circle/x glyphs at the start end for o-- and x--', () => {
92
+ const resultCircle = renderMermaidASCII(`
93
+ graph LR
94
+ A o--o B
95
+ `)
96
+ expect(resultCircle.match(/○/g)?.length).toBe(2)
97
+
98
+ const resultCross = renderMermaidASCII(`
99
+ graph LR
100
+ A x--x B
101
+ `)
102
+ expect(resultCross.match(/✕/g)?.length).toBe(2)
103
+ })
104
+
105
+ it('still draws a plain arrowhead for a regular --> edge', () => {
106
+ const result = renderMermaidASCII(`
107
+ graph TD
108
+ A --> B
109
+ `)
110
+ expect(result).toContain('▼')
111
+ expect(result).not.toContain('○')
112
+ expect(result).not.toContain('✕')
113
+ })
114
+
115
+ // Fan-out: a single source with multiple outgoing edges gets bundled onto
116
+ // a shared trunk (see src/ascii/draw-bundles.ts), which draws each
117
+ // outgoing arrowhead through a different code path than a lone edge.
118
+ it('draws distinct glyphs for --o/--x when bundled in a fan-out (one source, many targets)', () => {
119
+ const result = renderMermaidASCII(`
120
+ graph TD
121
+ A --o E
122
+ A --x F
123
+ A --> G
124
+ `)
125
+ expect(result).toContain('○')
126
+ expect(result).toContain('✕')
127
+ expect(result).toContain('▼')
128
+ })
129
+
130
+ // Fan-in: multiple sources converging on one target share a single drawn
131
+ // arrowhead (src/ascii/draw-bundles.ts's drawBundleArrowhead). When every
132
+ // bundled edge agrees on the same marker, that shared arrowhead should
133
+ // use it.
134
+ it('draws a shared circle glyph for a fan-in bundle when every edge agrees', () => {
135
+ const result = renderMermaidASCII(`
136
+ graph TD
137
+ E --o C
138
+ F --o C
139
+ `)
140
+ expect(result).toContain('○')
141
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
142
+ })
143
+
144
+ // A mixed fan-in (one edge --o, another plain -->) has no single correct
145
+ // glyph for the shared arrowhead, so it falls back to the plain
146
+ // directional arrowhead rather than guessing.
147
+ it('falls back to a plain arrowhead for a fan-in bundle with mismatched markers', () => {
148
+ const result = renderMermaidASCII(`
149
+ graph TD
150
+ E --o C
151
+ F --x C
152
+ `)
153
+ expect(result).not.toContain('○')
154
+ expect(result).not.toContain('✕')
155
+ expect(result).toContain('▼')
156
+ })
157
+ })
158
+
159
+ describe('ASCII sequence diagrams: -x lost-message glyph', () => {
160
+ it('draws a distinct cross glyph for -x in unicode mode, unlike -) async', () => {
161
+ const result = renderMermaidASCII(`sequenceDiagram
162
+ A-)B: async
163
+ A-xB: lost`)
164
+ expect(result).toContain('▷') // async keeps its existing open arrowhead
165
+ expect(result).toContain('✕') // lost message gets a distinct cross
166
+ })
167
+
168
+ it('draws a distinct cross glyph for -x in ascii mode', () => {
169
+ const result = renderMermaidASCII(
170
+ `sequenceDiagram
171
+ A-)B: async
172
+ A-xB: lost`,
173
+ { useAscii: true },
174
+ )
175
+ expect(result).toContain('x')
176
+ })
177
+
178
+ it('does not draw the plain filled arrowhead for -x (unlike ->>)', () => {
179
+ const result = renderMermaidASCII(`sequenceDiagram
180
+ A->>B: normal
181
+ A-xB: lost`)
182
+ expect(result).toContain('▶') // normal ->> keeps its filled arrowhead
183
+ expect(result).toContain('✕')
184
+ })
185
+
186
+ it('draws the cross glyph for a right-to-left -x message', () => {
187
+ const result = renderMermaidASCII(`sequenceDiagram
188
+ B-xA: lost`)
189
+ expect(result).toContain('✕')
190
+ expect(result).not.toMatch(/[◀◁]/)
191
+ })
192
+
193
+ it('draws the cross glyph for a dashed --x lost message', () => {
194
+ const result = renderMermaidASCII(`sequenceDiagram
195
+ A--xB: lost dashed`)
196
+ expect(result).toContain('✕')
197
+ expect(result).toContain('╌')
198
+ })
199
+
200
+ it('still draws the plain filled arrowhead for a normal ->> message', () => {
201
+ const result = renderMermaidASCII(`sequenceDiagram
202
+ A->>B: normal`)
203
+ expect(result).toContain('▶')
204
+ expect(result).not.toContain('✕')
205
+ })
206
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Regression tests for #418 ("All Edge Styles"): an edge whose route fell
3
+ * through to determinePath's Case-4 direct fallback has a single diagonal
4
+ * path segment. draw-lines.ts draws that segment as an L (horizontal-first),
5
+ * but determineLabelLine used to center the label on the diagonal itself —
6
+ * mid-way between the two drawn legs, in open grid next to some *other*
7
+ * edge's connector. The `thick` label ended up abutting the dotted edge's
8
+ * column (`┆thick`) instead of sitting on its own `┃` line.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
12
+ import { expandDiagonalSegments } from '../edge-routing.ts'
13
+
14
+ const ALL_EDGE_STYLES = `graph TD
15
+ A[Source] -->|solid| B[Target 1]
16
+ A -.->|dotted| C[Target 2]
17
+ A ==>|thick| D[Target 3]`
18
+
19
+ describe('expandDiagonalSegments', () => {
20
+ it('leaves an axis-aligned path untouched', () => {
21
+ const path = [
22
+ { x: 1, y: 1 },
23
+ { x: 1, y: 4 },
24
+ { x: 5, y: 4 },
25
+ ]
26
+ expect(expandDiagonalSegments(path)).toEqual(path)
27
+ })
28
+
29
+ it('splits a diagonal step at its horizontal-first corner', () => {
30
+ expect(
31
+ expandDiagonalSegments([
32
+ { x: 2, y: 1 },
33
+ { x: 9, y: 4 },
34
+ ]),
35
+ ).toEqual([
36
+ { x: 2, y: 1 },
37
+ { x: 9, y: 1 },
38
+ { x: 9, y: 4 },
39
+ ])
40
+ })
41
+
42
+ it('handles a diagonal step in the middle of a longer path', () => {
43
+ expect(
44
+ expandDiagonalSegments([
45
+ { x: 0, y: 0 },
46
+ { x: 0, y: 2 },
47
+ { x: 3, y: 5 },
48
+ { x: 3, y: 7 },
49
+ ]),
50
+ ).toEqual([
51
+ { x: 0, y: 0 },
52
+ { x: 0, y: 2 },
53
+ { x: 3, y: 2 },
54
+ { x: 3, y: 5 },
55
+ { x: 3, y: 7 },
56
+ ])
57
+ })
58
+ })
59
+
60
+ describe('edge label on a Case-4 direct-fallback (diagonal) path', () => {
61
+ const lines = renderMermaidASCII(ALL_EDGE_STYLES, { colorMode: 'none' })
62
+ .split('\n')
63
+ .map((l) => l.replace(/\s+$/, ''))
64
+
65
+ const labelRowIndex = lines.findIndex((l) => l.includes('thick'))
66
+ const labelRow = lines[labelRowIndex]!
67
+ const labelStart = labelRow.indexOf('thick')
68
+ const labelEnd = labelStart + 'thick'.length - 1
69
+
70
+ it('is not glued to an unrelated edge’s connector', () => {
71
+ expect(labelRow).not.toMatch(/[┆┊│]thick|thick[┆┊│]/)
72
+ })
73
+
74
+ it('sits on the thick edge’s own vertical line', () => {
75
+ // The thick edge's vertical run (┃) is drawn in the rows right above
76
+ // and below the label; the label must be centered on that column, not
77
+ // floating somewhere between its own connector and a neighbour's.
78
+ const above = lines[labelRowIndex - 1]!
79
+ const below = lines[labelRowIndex + 1]!
80
+ const thickColumnAbove = above.indexOf('┃')
81
+ const thickColumnBelow = below.indexOf('┃')
82
+ expect(thickColumnAbove).toBeGreaterThan(-1)
83
+ expect(thickColumnBelow).toBe(thickColumnAbove)
84
+ expect(thickColumnAbove).toBeGreaterThanOrEqual(labelStart)
85
+ expect(thickColumnAbove).toBeLessThanOrEqual(labelEnd)
86
+ })
87
+
88
+ it('keeps the dotted label on the dotted column', () => {
89
+ const dottedRow = lines.find((l) => l.includes('dotted'))!
90
+ const dottedStart = dottedRow.indexOf('dotted')
91
+ const dottedColumn = lines[lines.indexOf(dottedRow) + 1]!.indexOf('┆')
92
+ expect(dottedColumn).toBeGreaterThanOrEqual(dottedStart)
93
+ expect(dottedColumn).toBeLessThanOrEqual(dottedStart + 'dotted'.length - 1)
94
+ })
95
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Regression tests for GitHub issue #64 — ASCII edge-routing engine bugs:
3
+ * dense fan-in crashes, order-dependent root misdetection, ungrouped
4
+ * fan-in roots, sibling edges not sharing a trunk, and box-start connector
5
+ * drift. Repro diagrams are taken directly from the issue.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
9
+
10
+ describe('issue #64: edge-routing crashes and layout bugs', () => {
11
+ describe('bug 1: dense fan-in pathfinding blowup', () => {
12
+ it('renders the dense fan-in repro without hanging or crashing', () => {
13
+ const start = Date.now()
14
+ const out = renderMermaidASCII(`graph TD
15
+ A["AAA<br>(keita)"] --> C["CCC"]
16
+ B["BBB<br>(yuriko)"] --> C
17
+ C --> D["DDDD"]
18
+ D --> E["EEEE"]
19
+ A1["1 / 2"] --> A
20
+ A2["3 / 4"] --> A
21
+ A3["5 / 6"] --> A
22
+ A4["XXX<br>(YYY ZZZ)"] --> A
23
+ B1["77 77<br>(7 / 7 / 7)"] --> B
24
+ B2["88-88<br>(99 99)"] --> B
25
+ B3["111s 222s"] --> B
26
+ D --> F{"F?"}
27
+ F -->|Yes| G["High level<br>Tr"]
28
+ F -->|No| H["Dumb Tr<br>S"]`)
29
+ // A generous ceiling — this used to not return within 60s on the
30
+ // reporter's environment. A correct, bounded render should be
31
+ // effectively instant; this just guards against a real regression
32
+ // back to unbounded behavior without being a flaky wall-clock test.
33
+ // Kept well above the near-instant normal case so contention from
34
+ // other tests/processes running in parallel doesn't trip it.
35
+ expect(Date.now() - start).toBeLessThan(15000)
36
+ for (const label of ['AAA', 'BBB', 'CCC', 'DDDD', 'EEEE', 'F?']) {
37
+ expect(out).toContain(label)
38
+ }
39
+ }, 20000)
40
+
41
+ it('renders a much larger dense fan-in graph without hanging or crashing', () => {
42
+ // Amplified stress case (2x60 root nodes feeding two hubs) — confirms
43
+ // the render-wide path budget bounds total pathfinding work even
44
+ // when the per-search cap alone would not (each of ~120 fan-in edges
45
+ // independently getting its own 50k-iteration allowance is what
46
+ // caused real OOM crashes during investigation of this issue).
47
+ let src = 'graph TD\n A --> C\n B --> C\n C --> D\n D --> E\n'
48
+ for (let i = 0; i < 60; i++) src += ` A${i}["Root A${i}"] --> A\n`
49
+ for (let i = 0; i < 60; i++) src += ` B${i}["Root B${i}"] --> B\n`
50
+
51
+ const start = Date.now()
52
+ const out = renderMermaidASCII(src)
53
+ // Loose bound (not a tight performance assertion) — the goal is only
54
+ // to catch a regression back to the unbounded blowup this fix
55
+ // addresses (which would hang far longer than this, not just run
56
+ // slightly over). Both 10s and 20s bounds flaked under CPU
57
+ // contention (many test processes/agents competing for cores),
58
+ // despite the actual render being near-instant in isolation, so this
59
+ // is deliberately generous: real unbounded-blowup regressions took
60
+ // 60s+ to not even finish (see the repro comment above), so a 60s
61
+ // ceiling still catches those while tolerating a heavily loaded box.
62
+ expect(Date.now() - start).toBeLessThan(60000)
63
+ expect(out).toContain('Root A0')
64
+ expect(out).toContain('Root B59')
65
+ }, 90000)
66
+ })
67
+
68
+ describe('bug 2: order-dependent root misdetection', () => {
69
+ it('does not misclassify a node as a root when its child edge is declared first', () => {
70
+ // A --> C is declared *before* A1 --> A, so a single-pass,
71
+ // insertion-order root scan would wrongly treat A as a root (it
72
+ // "looks" untargeted at the point the scan reaches it).
73
+ const out = renderMermaidASCII(`graph TD
74
+ A["Parent A"] --> C["Child C"]
75
+ B["Parent B"] --> C
76
+ C --> D["Grandchild D"]
77
+ A1["Root 1"] --> A
78
+ A2["Root 2"] --> A
79
+ A3["Root 3"] --> A
80
+ B1["Root 4"] --> B
81
+ B2["Root 5"] --> B`)
82
+
83
+ const lines = out.split('\n')
84
+ const rootRow = lines.findIndex((l) => l.includes('Root 1'))
85
+ const parentRow = lines.findIndex((l) => l.includes('Parent A'))
86
+ const childRow = lines.findIndex((l) => l.includes('Child C'))
87
+ const grandchildRow = lines.findIndex((l) => l.includes('Grandchild D'))
88
+
89
+ // Correct levels: roots above parents above child above grandchild.
90
+ expect(rootRow).toBeLessThan(parentRow)
91
+ expect(parentRow).toBeLessThan(childRow)
92
+ expect(childRow).toBeLessThan(grandchildRow)
93
+ })
94
+
95
+ it('does not crash on a graph that is a pure cycle (no node is ever a real root)', () => {
96
+ // Every node here has a real incoming edge — order-independent
97
+ // detection correctly finds zero "never targeted" roots, so the
98
+ // layout needs its own fallback seed to avoid leaving every node
99
+ // unplaced (which used to crash with "Map maximum size exceeded" /
100
+ // a null gridCoord dereference).
101
+ expect(() =>
102
+ renderMermaidASCII('graph LR\nA --> B --> C --> A'),
103
+ ).not.toThrow()
104
+ const out = renderMermaidASCII('graph LR\nA --> B --> C --> A')
105
+ expect(out).toContain('A')
106
+ expect(out).toContain('B')
107
+ expect(out).toContain('C')
108
+ })
109
+ })
110
+
111
+ describe('bug 3: fan-in roots grouped by shared target', () => {
112
+ it('groups roots feeding the same target contiguously, even when interleaved in source order', () => {
113
+ // A1 and B1 are declared before A2 and B2, interleaving the two
114
+ // fan-in clusters in source order — the roots feeding A must still
115
+ // end up placed together, separately from the roots feeding B.
116
+ const out = renderMermaidASCII(`graph TD
117
+ A1 --> A
118
+ B1 --> B
119
+ A2 --> A
120
+ B2 --> B
121
+ A --> C
122
+ B --> C`)
123
+
124
+ const topRow = out.split('\n').find((l) => l.includes('A1'))!
125
+ const a1 = topRow.indexOf('A1')
126
+ const a2 = topRow.indexOf('A2')
127
+ const b1 = topRow.indexOf('B1')
128
+ const b2 = topRow.indexOf('B2')
129
+
130
+ expect(a1).toBeGreaterThanOrEqual(0)
131
+ expect(a2).toBeGreaterThanOrEqual(0)
132
+ expect(b1).toBeGreaterThanOrEqual(0)
133
+ expect(b2).toBeGreaterThanOrEqual(0)
134
+ // Grouped order: both A-feeders before both B-feeders.
135
+ expect(a1).toBeLessThan(b1)
136
+ expect(a2).toBeLessThan(b1)
137
+ expect(b1).toBeLessThan(b2)
138
+ })
139
+ })
140
+
141
+ describe('bug 4: sibling edges from the same source share a trunk', () => {
142
+ it('routes a labeled 3-way fan-out along a shared straight trunk instead of a detour', () => {
143
+ const out = renderMermaidASCII(`flowchart TB
144
+ Src["Source"]
145
+ Left["Left Target"]
146
+ Center["Center Target"]
147
+ Right["Right Target"]
148
+ Src -->|left*| Left
149
+ Src -->|center*| Center
150
+ Src -->|right*| Right`)
151
+
152
+ // The three branch points (├, ┬, ┐) for a clean shared trunk all
153
+ // land on the same row, immediately below Source's box.
154
+ const trunkRow = out
155
+ .split('\n')
156
+ .find((l) => l.includes('├') && l.includes('┬') && l.includes('┐'))
157
+ expect(trunkRow).toBeDefined()
158
+ })
159
+ })
160
+
161
+ describe('bug 5: box-start connector stays on the source border', () => {
162
+ it('keeps the ├ connector attached to the box border when a sibling label widens a shared column', () => {
163
+ const out = renderMermaidASCII(`flowchart LR
164
+ Src["Source"]
165
+ Top["Top Target"]
166
+ Mid["Middle Target"]
167
+ Bot["Bottom Target"]
168
+ Src -->|top*| Top
169
+ Src -->|mid*| Mid
170
+ Src -->|bot*| Bot`)
171
+
172
+ const topRow = out.split('\n').find((l) => l.includes('top*'))!
173
+ // The connector must be immediately adjacent to Source's box — no
174
+ // run of blank space between the border and the line/label.
175
+ expect(topRow).toMatch(/Source ├top\*/)
176
+ })
177
+ })
178
+ })