@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,184 @@
1
+ /**
2
+ * Direct coverage for pathfinder.ts's routeEdge (and the tryDirectPath fast
3
+ * path it wraps) — previously untested (PR #178 review). routeEdge is the
4
+ * shared seam edge-routing.ts's determinePath and edge-bundling.ts's
5
+ * routeBundledEdges both route through: try an unobstructed direct L-shaped
6
+ * path first, falling back to A* search, and require a render-wide
7
+ * pathBudget rather than silently degrading when one is absent.
8
+ *
9
+ * The "try both L orientations" behavior below is the fix for the
10
+ * arrival-vs-departure direction bug the review found: `dir` picks a
11
+ * *preferred* corner order, but when that orientation is blocked the other
12
+ * orientation is tried too, so routeEdge produces a correct direct path
13
+ * even when a caller's `dir` reflects an arrival anchor rather than the
14
+ * true departure direction (see edge-bundling.ts's routeBundledEdges).
15
+ */
16
+ import { describe, it, expect } from 'vitest'
17
+ import { routeEdge, createPathBudget } from '../pathfinder.ts'
18
+ import {
19
+ Up,
20
+ Down,
21
+ Left,
22
+ Right,
23
+ Middle,
24
+ UpperRight,
25
+ requireCardinalDirection,
26
+ } from '../types.ts'
27
+ import type { AsciiGraph, PathBudget } from '../types.ts'
28
+ import { createGrid, placeBlock, type Grid } from '../grid-occupancy.ts'
29
+
30
+ function makeGraph(grid: Grid, pathBudget: PathBudget | undefined): AsciiGraph {
31
+ return {
32
+ nodes: [],
33
+ edges: [],
34
+ canvas: [],
35
+ roleCanvas: [],
36
+ grid,
37
+ columnWidth: new Map(),
38
+ rowHeight: new Map(),
39
+ subgraphs: [],
40
+ config: {
41
+ useAscii: false,
42
+ paddingX: 5,
43
+ paddingY: 5,
44
+ boxBorderPadding: 1,
45
+ graphDirection: 'TD',
46
+ },
47
+ offsetX: 0,
48
+ offsetY: 0,
49
+ bundles: [],
50
+ pathBudget,
51
+ }
52
+ }
53
+
54
+ describe('routeEdge', () => {
55
+ it('takes the horizontal-first direct L when dir is Right', () => {
56
+ const graph = makeGraph(createGrid(), createPathBudget())
57
+ const path = routeEdge(
58
+ graph,
59
+ { x: 0, y: 0 },
60
+ { x: 4, y: 4 },
61
+ requireCardinalDirection(Right),
62
+ )
63
+ expect(path).toEqual([
64
+ { x: 0, y: 0 },
65
+ { x: 4, y: 0 },
66
+ { x: 4, y: 4 },
67
+ ])
68
+ })
69
+
70
+ it('takes the vertical-first direct L when dir is Down', () => {
71
+ const graph = makeGraph(createGrid(), createPathBudget())
72
+ const path = routeEdge(
73
+ graph,
74
+ { x: 0, y: 0 },
75
+ { x: 4, y: 4 },
76
+ requireCardinalDirection(Down),
77
+ )
78
+ expect(path).toEqual([
79
+ { x: 0, y: 0 },
80
+ { x: 0, y: 4 },
81
+ { x: 4, y: 4 },
82
+ ])
83
+ })
84
+
85
+ it('falls back to the other L orientation when the preferred one is blocked', () => {
86
+ // dir=Right prefers the {4,0} corner; occupy it so only the {0,4}
87
+ // corner (vertical-first) is clear. This is exactly the scenario the
88
+ // arrival-vs-departure bug hit: a caller passes a `dir` that doesn't
89
+ // match the true departure axis, but the direct path is still found
90
+ // because both orientations are tried.
91
+ const grid = createGrid()
92
+ placeBlock(grid, { x: 4, y: 0 }, 1)
93
+ const graph = makeGraph(grid, createPathBudget())
94
+ const path = routeEdge(
95
+ graph,
96
+ { x: 0, y: 0 },
97
+ { x: 4, y: 4 },
98
+ requireCardinalDirection(Right),
99
+ )
100
+ expect(path).toEqual([
101
+ { x: 0, y: 0 },
102
+ { x: 0, y: 4 },
103
+ { x: 4, y: 4 },
104
+ ])
105
+ })
106
+
107
+ it('falls back to A* when both direct L orientations are blocked', () => {
108
+ const grid = createGrid()
109
+ placeBlock(grid, { x: 4, y: 0 }, 1)
110
+ placeBlock(grid, { x: 0, y: 4 }, 1)
111
+ const graph = makeGraph(grid, createPathBudget())
112
+ const path = routeEdge(
113
+ graph,
114
+ { x: 0, y: 0 },
115
+ { x: 4, y: 4 },
116
+ requireCardinalDirection(Right),
117
+ )
118
+ // Not a 3-point direct L — A* found a route around the two blockers.
119
+ expect(path).not.toBeNull()
120
+ expect(path!.length).toBeGreaterThan(3)
121
+ expect(path![0]).toEqual({ x: 0, y: 0 })
122
+ expect(path![path!.length - 1]).toEqual({ x: 4, y: 4 })
123
+ })
124
+
125
+ it('falls back to A* (trivial straight line) when from/to are already axis-aligned', () => {
126
+ const graph = makeGraph(createGrid(), createPathBudget())
127
+ const path = routeEdge(
128
+ graph,
129
+ { x: 0, y: 0 },
130
+ { x: 0, y: 6 },
131
+ requireCardinalDirection(Down),
132
+ )
133
+ expect(path).toEqual([
134
+ { x: 0, y: 0 },
135
+ { x: 0, y: 6 },
136
+ ])
137
+ })
138
+
139
+ it('returns null when the destination is unreachable through free cells', () => {
140
+ // Occupy all 4 neighbors of the destination — no legal final hop exists
141
+ // from any direction, so neither the direct path nor A* can reach it.
142
+ const grid = createGrid()
143
+ placeBlock(grid, { x: 9, y: 10 }, 1)
144
+ placeBlock(grid, { x: 11, y: 10 }, 1)
145
+ placeBlock(grid, { x: 10, y: 9 }, 1)
146
+ placeBlock(grid, { x: 10, y: 11 }, 1)
147
+ const graph = makeGraph(grid, createPathBudget())
148
+ const path = routeEdge(
149
+ graph,
150
+ { x: 0, y: 0 },
151
+ { x: 10, y: 10 },
152
+ requireCardinalDirection(Right),
153
+ )
154
+ expect(path).toBeNull()
155
+ })
156
+
157
+ it('throws when graph.pathBudget is not set, instead of silently searching unbudgeted', () => {
158
+ const graph = makeGraph(createGrid(), undefined)
159
+ expect(() =>
160
+ routeEdge(
161
+ graph,
162
+ { x: 0, y: 0 },
163
+ { x: 4, y: 4 },
164
+ requireCardinalDirection(Right),
165
+ ),
166
+ ).toThrow(/pathBudget/)
167
+ })
168
+ })
169
+
170
+ describe('requireCardinalDirection', () => {
171
+ it('passes through each of the 4 cardinal directions', () => {
172
+ for (const d of [Up, Down, Left, Right]) {
173
+ expect(requireCardinalDirection(d)).toBe(d)
174
+ }
175
+ })
176
+
177
+ it('throws for a diagonal direction', () => {
178
+ expect(() => requireCardinalDirection(UpperRight)).toThrow(/cardinal/)
179
+ })
180
+
181
+ it('throws for Middle', () => {
182
+ expect(() => requireCardinalDirection(Middle)).toThrow(/cardinal/)
183
+ })
184
+ })
@@ -0,0 +1,236 @@
1
+ // ============================================================================
2
+ // ASCII sequence diagram — alt/else block label truncation regression tests
3
+ // (issue #352)
4
+ //
5
+ // The alt/loop/opt/par block's wall (bLeft/bRight) was sized purely from the
6
+ // span of the lifelines its messages touch (plus self-arrow extent, fixed
7
+ // for #68). Header ("alt [label]") and divider ("[else label]") text is
8
+ // drawn starting at bLeft + 1 and hard-clipped at bRight — so a label longer
9
+ // than that message-driven span lost its tail with no ellipsis or other
10
+ // marker, and because the cut lands mid-word the truncated text can still
11
+ // read as a complete (but wrong) phrase, e.g. "alt [credentials" instead of
12
+ // "alt [credentials valid]".
13
+ //
14
+ // The fix measures the longest label among the block's header and every
15
+ // divider up front and widens the wall (growing the canvas too, if needed)
16
+ // to fit it, mirroring the self-arrow widening done for #68.
17
+ // ============================================================================
18
+
19
+ import { describe, it, expect } from 'vitest'
20
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
21
+
22
+ describe('ASCII sequence diagrams – alt/else block labels (issue #352)', () => {
23
+ it('does not truncate a long alt/else label mid-word (exact issue repro)', () => {
24
+ const result = renderMermaidASCII(
25
+ `
26
+ sequenceDiagram
27
+ participant A
28
+ participant B
29
+ participant C
30
+ alt credentials valid
31
+ A->>B: x
32
+ else credentials rejected
33
+ A->>B: y
34
+ end
35
+ `,
36
+ { useAscii: false },
37
+ )
38
+
39
+ // Full, unclipped labels must appear.
40
+ expect(result).toContain('alt [credentials valid]')
41
+ expect(result).toContain('[credentials rejected]')
42
+
43
+ // The exact mid-word-truncated strings from the issue must be gone —
44
+ // checked with a trailing wall/junction character so a match against
45
+ // the (correct) full label's shared prefix doesn't false-positive.
46
+ expect(result).not.toContain('┌alt [credentials┐')
47
+ expect(result).not.toContain('[credentials rej┤')
48
+ })
49
+
50
+ it('does not let the widened wall swallow an uninvolved participant lifeline (#387)', () => {
51
+ // C has no messages inside the block, so widening the wall to fit the
52
+ // long label must push C's lifeline clear of it rather than land on
53
+ // top of/next to it. Before this fix, the wall's right edge landed one
54
+ // column past C's already-placed lifeline, drawing the block's own
55
+ // wall and C's lifeline back-to-back on every interior row with no gap
56
+ // between them — two adjacent "│" characters that read as one thing
57
+ // enclosing a participant the block has nothing to do with.
58
+ const result = renderMermaidASCII(
59
+ `
60
+ sequenceDiagram
61
+ participant A
62
+ participant B
63
+ participant C
64
+ alt credentials valid
65
+ A->>B: x
66
+ else credentials rejected
67
+ A->>B: y
68
+ end
69
+ `,
70
+ { useAscii: false },
71
+ )
72
+
73
+ for (const line of result.split('\n')) {
74
+ expect(line).not.toMatch(/││/)
75
+ }
76
+ })
77
+
78
+ it('leaves short labels that already fit unchanged (no regression)', () => {
79
+ const result = renderMermaidASCII(
80
+ `
81
+ sequenceDiagram
82
+ participant A
83
+ participant B
84
+ participant C
85
+ alt yes
86
+ A->>B: x
87
+ else no
88
+ A->>B: y
89
+ end
90
+ `,
91
+ { useAscii: false },
92
+ )
93
+
94
+ // Matches the issue's own "short labels, for reference" expected output.
95
+ expect(result).toContain('alt [yes]───────┐')
96
+ expect(result).toContain('[no]╌╌╌╌╌╌╌╌╌╌╌╌┤')
97
+ })
98
+
99
+ it('widens the wall for a very long single-condition label', () => {
100
+ const longLabel =
101
+ 'this condition label is extremely long and needs the canvas to grow to fit it properly'
102
+ const result = renderMermaidASCII(
103
+ `
104
+ sequenceDiagram
105
+ participant A
106
+ participant B
107
+ alt ${longLabel}
108
+ A->>B: x
109
+ end
110
+ `,
111
+ { useAscii: false },
112
+ )
113
+
114
+ expect(result).toContain(`alt [${longLabel}]`)
115
+
116
+ // The header line's own right wall must close immediately after the
117
+ // label — proves the wall grew to fit rather than the label being cut.
118
+ const headerLine = result
119
+ .split('\n')
120
+ .find((l) => l.includes(`alt [${longLabel}]`))
121
+ expect(headerLine).toBeDefined()
122
+ expect(headerLine!.endsWith('┐')).toBe(true)
123
+ })
124
+
125
+ it('widens the wall to fit the longest of several else branches with varying label lengths', () => {
126
+ const result = renderMermaidASCII(
127
+ `
128
+ sequenceDiagram
129
+ participant A
130
+ participant B
131
+ alt short
132
+ A->>B: w
133
+ else a moderately long condition
134
+ A->>B: x
135
+ else an even longer condition than the previous one
136
+ A->>B: y
137
+ else tiny
138
+ A->>B: z
139
+ end
140
+ `,
141
+ { useAscii: false },
142
+ )
143
+
144
+ expect(result).toContain('alt [short]')
145
+ expect(result).toContain('[a moderately long condition]')
146
+ expect(result).toContain('[an even longer condition than the previous one]')
147
+ expect(result).toContain('[tiny]')
148
+
149
+ // None of the labels should be cut short — checked with the closing
150
+ // bracket so a match against the (correct) full label's shared prefix
151
+ // doesn't false-positive.
152
+ expect(result).not.toContain('[a moderately long conditio]')
153
+ expect(result).not.toContain('[an even longer condition than the previous]')
154
+ })
155
+
156
+ it('widens loop/opt block walls too, not just alt', () => {
157
+ const loopResult = renderMermaidASCII(
158
+ `
159
+ sequenceDiagram
160
+ participant A
161
+ participant B
162
+ loop a fairly long loop condition label here
163
+ A->>B: x
164
+ end
165
+ `,
166
+ { useAscii: false },
167
+ )
168
+ expect(loopResult).toContain(
169
+ 'loop [a fairly long loop condition label here]',
170
+ )
171
+
172
+ const optResult = renderMermaidASCII(
173
+ `
174
+ sequenceDiagram
175
+ participant A
176
+ participant B
177
+ opt a fairly long optional condition label here
178
+ A->>B: x
179
+ end
180
+ `,
181
+ { useAscii: false },
182
+ )
183
+ expect(optResult).toContain(
184
+ 'opt [a fairly long optional condition label here]',
185
+ )
186
+ })
187
+
188
+ it('handles a block with no header label at all (bare "par")', () => {
189
+ // `block.label` is '' (falsy) here — exercises the `block.label ? ... :
190
+ // block.type` branch that a labelled alt/loop never reaches.
191
+ const result = renderMermaidASCII(
192
+ `
193
+ sequenceDiagram
194
+ participant A
195
+ participant B
196
+ par
197
+ A->>B: x
198
+ and
199
+ A->>B: y
200
+ end
201
+ `,
202
+ { useAscii: false },
203
+ )
204
+
205
+ expect(result).toContain('┌par')
206
+ expect(result).not.toContain('par [')
207
+ })
208
+
209
+ it('handles a bare else divider with no label text', () => {
210
+ // `divider.label` is '' (falsy) here — exercises the `if (divider.label)`
211
+ // false branch that a labelled else never reaches.
212
+ const result = renderMermaidASCII(
213
+ `
214
+ sequenceDiagram
215
+ participant A
216
+ participant B
217
+ alt some long condition that needs the wall widened
218
+ A->>B: x
219
+ else
220
+ A->>B: y
221
+ end
222
+ `,
223
+ { useAscii: false },
224
+ )
225
+
226
+ expect(result).toContain(
227
+ 'alt [some long condition that needs the wall widened]',
228
+ )
229
+ // The bare divider must still render as a plain divider row — checked
230
+ // positively (a dashed line actually exists) as well as negatively (it
231
+ // carries no "[...]" label of its own). The negative check alone would
232
+ // still pass if the renderer silently dropped the divider entirely.
233
+ expect(result).toMatch(/├╌+┤/)
234
+ expect(result).not.toMatch(/├\[.*\]/)
235
+ })
236
+ })
@@ -0,0 +1,217 @@
1
+ // ============================================================================
2
+ // ASCII sequence diagram — block-wall lifeline clearance (issue #353)
3
+ //
4
+ // `loop`/`alt`/`opt`/etc. share a single wall-extent calculation
5
+ // (src/ascii/sequence.ts's "DRAW: blocks" pass): a fixed BLOCK_WALL_MARGIN
6
+ // padding around the leftmost/rightmost lifeline the block's own messages
7
+ // touch. That calculation only ever looks at lifelines the block's messages
8
+ // reach — so the fixed margin could coincidentally land a wall exactly on a
9
+ // *different*, untouched lifeline's column, subsuming it (wall glyph and
10
+ // lifeline glyph sharing the same cell) for the block's whole vertical span.
11
+ //
12
+ // This bit `alt` in the issue's repro (a diagram combining `loop`, `alt`,
13
+ // and `opt`, where only `alt` doesn't reach the diagram's rightmost
14
+ // lifeline) while `loop`/`opt` happened to clear it, because their own
15
+ // messages already reached that lifeline and so were naturally padded past
16
+ // it by the same margin. It is not a per-block-type bug: all three share
17
+ // identical wall-calculation code.
18
+ //
19
+ // The fix pulls a wall back short of an untouched lifeline rather than
20
+ // pushing it past one — verified against real mermaid.js's own SVG output
21
+ // for this exact diagram (see scripts/lib/real-mermaid.ts): `loop`/`opt`
22
+ // there enclose `Database` because their own messages touch it directly,
23
+ // but `alt` — which never messages `Database` — stops well short of it
24
+ // (124px of real clearance, not a few px of overshoot), even though
25
+ // `loop`/`opt` in the same diagram extend ~11px *past* Database's lifeline
26
+ // to enclose it. Real mermaid never widens a block's wall to enclose a
27
+ // lifeline its own messages don't touch. An earlier version of this test
28
+ // asserted the opposite (`alt` should match `loop`/`opt` by enclosing
29
+ // Database) — that was a reasonable-sounding but unverified guess at the
30
+ // "consistent" fix; it wasn't checked against real mermaid at the time.
31
+ // ============================================================================
32
+
33
+ import { describe, it, expect } from 'vitest'
34
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
35
+
36
+ /** Column indices (0-based) of every lifeline, read off the actor header's
37
+ * bottom border row (the one carrying `┬` junctions). */
38
+ function lifelineColumns(lines: string[]): number[] {
39
+ const row = lines[2]!
40
+ const cols: number[] = []
41
+ for (let i = 0; i < row.length; i++) if (row[i] === '┬') cols.push(i)
42
+ return cols
43
+ }
44
+
45
+ /** Right-wall column of a block, read from the row containing `marker`
46
+ * (its own top-border/header row, e.g. "┌alt [...]" or a divider row like
47
+ * "├[else label]"). The block's own right corner/junction is the last
48
+ * box-drawing glyph on that row. */
49
+ function blockWallRightColumn(lines: string[], marker: string): number {
50
+ const row = lines.find((l) => l.includes(marker))
51
+ expect(row, `expected a line containing "${marker}"`).toBeDefined()
52
+ const candidates = ['┐', '┤', '┘']
53
+ let col = -1
54
+ for (const ch of candidates) {
55
+ const idx = row!.lastIndexOf(ch)
56
+ if (idx > col) col = idx
57
+ }
58
+ expect(col, `expected a right-wall glyph on line: ${row}`).toBeGreaterThan(-1)
59
+ return col
60
+ }
61
+
62
+ describe('ASCII sequence diagrams – block wall lifeline clearance (issue #353)', () => {
63
+ it('clears the rightmost lifeline consistently across loop/alt/opt in the same diagram', () => {
64
+ const src = `
65
+ sequenceDiagram
66
+ participant U as User
67
+ participant C as Client
68
+ participant A as Auth Service
69
+ participant D as Database
70
+
71
+ U->>C: click sign in
72
+ C->>A: POST /authorize
73
+
74
+ loop token refresh
75
+ A->>A: validate signature<br/>and check expiry
76
+ A->>D: lookup session
77
+ D-->>A: session row
78
+ end
79
+
80
+ alt credentials valid
81
+ A->>A: mint access token<br/>mint refresh token
82
+ A-->>C: 200 + tokens
83
+ C-->>U: redirect to app
84
+ else credentials rejected
85
+ A->>A: increment<br/>failure counter
86
+ A-->>C: 401 unauthorized
87
+ C-->>U: show error
88
+ end
89
+
90
+ opt remember me
91
+ C->>A: POST /persist
92
+ A->>D: store long-lived token
93
+ end
94
+ `
95
+ const result = renderMermaidASCII(src, { useAscii: false })
96
+ const lines = result.split('\n')
97
+
98
+ const llCols = lifelineColumns(lines)
99
+ const rightmostLL = Math.max(...llCols) // Database
100
+ const authServiceLL = llCols[2]! // touched by alt directly
101
+
102
+ const loopRight = blockWallRightColumn(lines, 'loop [token refresh]')
103
+ const altRight = blockWallRightColumn(lines, 'alt [credentials valid]')
104
+ const optRight = blockWallRightColumn(lines, 'opt [remember me]')
105
+
106
+ // None of the three walls may land on any lifeline column — the
107
+ // original bug manifested as alt's right wall landing exactly on the
108
+ // Database lifeline's column.
109
+ for (const wallCol of [loopRight, altRight, optRight]) {
110
+ expect(llCols).not.toContain(wallCol)
111
+ }
112
+
113
+ // loop/opt both message Database directly, so both enclose it with
114
+ // clearance — matching real mermaid.
115
+ expect(loopRight).toBeGreaterThan(rightmostLL)
116
+ expect(optRight).toBeGreaterThan(rightmostLL)
117
+
118
+ // alt never messages Database — real mermaid stops alt's wall well
119
+ // short of it rather than widening to enclose it, so alt must NOT
120
+ // match loop/opt's right column here. It still clears the lifeline it
121
+ // does touch (Auth Service).
122
+ expect(altRight).toBeGreaterThan(authServiceLL)
123
+ expect(altRight).toBeLessThan(rightmostLL)
124
+ expect(altRight).not.toBe(loopRight)
125
+ expect(altRight).not.toBe(optRight)
126
+
127
+ // The Database lifeline must still be drawn as a lifeline (not
128
+ // subsumed into the alt block's wall) for a row inside the alt block's
129
+ // span, e.g. its "mint access token" self-arrow row.
130
+ const selfArrowRow = lines.find((l) => l.includes('mint access token'))
131
+ expect(selfArrowRow).toBeDefined()
132
+ expect(selfArrowRow![rightmostLL]).toBe('│')
133
+ // ...and that lifeline sits outside (to the right of) alt's own right
134
+ // wall on that row — two distinct glyphs, not one, and not enclosed.
135
+ expect(altRight).toBeLessThan(rightmostLL)
136
+ })
137
+
138
+ it('keeps a consistent right wall across every else-branch of a multi-branch alt', () => {
139
+ // Branch labels/content of increasing width so a wall computed only
140
+ // from the *first* branch (rather than reused consistently across all
141
+ // of them) would drift from branch to branch.
142
+ const src = `
143
+ sequenceDiagram
144
+ participant A
145
+ participant B
146
+ participant C
147
+
148
+ alt first branch
149
+ A->>B: short
150
+ else second branch is quite a bit longer than the first
151
+ B->>C: also a fairly long message label here
152
+ else third
153
+ A->>A: a long self-arrow label in the third branch
154
+ end
155
+ `
156
+ const result = renderMermaidASCII(src, { useAscii: false })
157
+ const lines = result.split('\n')
158
+ const llCols = lifelineColumns(lines)
159
+
160
+ const headerRight = blockWallRightColumn(lines, 'alt [first branch]')
161
+ const else1Right = blockWallRightColumn(
162
+ lines,
163
+ '[second branch is quite a bit longer than the first]',
164
+ )
165
+ const else2Right = blockWallRightColumn(lines, '[third]')
166
+
167
+ // The wall stays at the same column for the header and every divider —
168
+ // it must be computed once from the whole block's extent, not
169
+ // per-branch (which would produce a jagged/misaligned right edge).
170
+ expect(else1Right).toBe(headerRight)
171
+ expect(else2Right).toBe(headerRight)
172
+
173
+ // And, as above, it must not coincide with any lifeline column.
174
+ for (const wallCol of [headerRight, else1Right, else2Right]) {
175
+ expect(llCols).not.toContain(wallCol)
176
+ }
177
+ })
178
+
179
+ it('does not let the pulled-back wall overwrite a self-message label (CodeRabbit)', () => {
180
+ // A short self-arrow label on the only touched actor, with an
181
+ // untouched actor sitting just past the label's own extent: the
182
+ // pull-back fix above can compute a wall column that lands inside the
183
+ // self-arrow's own label span rather than after it, and the later
184
+ // block-border draw then silently overwrites the label's first
185
+ // character(s). B is untouched by the loop's only message (a self-arrow
186
+ // on A) and sits close enough (default lifeline spacing) to trigger the
187
+ // pull-back for a short label.
188
+ const src = `
189
+ sequenceDiagram
190
+ participant A
191
+ participant B
192
+ loop x
193
+ A->>A: abc
194
+ end
195
+ `
196
+ const result = renderMermaidASCII(src, { useAscii: false })
197
+ const lines = result.split('\n')
198
+
199
+ // The label must survive intact and contiguous — a pull-back that
200
+ // clips into it would replace its leading character(s) with the
201
+ // block's own border glyph instead.
202
+ const labelRow = lines.find((l) => l.includes('abc'))
203
+ expect(labelRow).toBeDefined()
204
+
205
+ // The block's own right wall must sit at or after the label's last
206
+ // column, never inside its span.
207
+ const llCols = lifelineColumns(lines)
208
+ const wallRight = blockWallRightColumn(lines, 'loop [x]')
209
+ const labelEndCol = labelRow!.lastIndexOf('abc') + 'abc'.length - 1
210
+ expect(wallRight).toBeGreaterThanOrEqual(labelEndCol)
211
+ // ...and strictly before B's own column — not just "not exactly on
212
+ // it," which would also pass if the wall were pushed past B instead
213
+ // (enclosing the untouched lifeline, the opposite regression this
214
+ // suite exists to catch).
215
+ expect(wallRight).toBeLessThan(llCols[1]!)
216
+ })
217
+ })