@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,278 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createEdgeCellStyles,
4
+ findStyleConflict,
5
+ claimPathCells,
6
+ createEdgeCellOwners,
7
+ findUnrelatedOverlap,
8
+ claimPathOwners,
9
+ } from '../edge-cell-styles.ts'
10
+ import { createGrid, placeBlock } from '../grid-occupancy.ts'
11
+ import type { AsciiEdge, AsciiNode } from '../types.ts'
12
+ import { Down } from '../types.ts'
13
+
14
+ describe('edge-cell-styles', () => {
15
+ it('reports no conflict against an empty map', () => {
16
+ const grid = createGrid()
17
+ const cellStyles = createEdgeCellStyles()
18
+ const path = [
19
+ { x: 0, y: 0 },
20
+ { x: 3, y: 0 },
21
+ ]
22
+ expect(findStyleConflict(grid, cellStyles, path, 'solid')).toBeNull()
23
+ })
24
+
25
+ it('does not flag a second same-style edge through claimed cells', () => {
26
+ const grid = createGrid()
27
+ const cellStyles = createEdgeCellStyles()
28
+ const path = [
29
+ { x: 0, y: 0 },
30
+ { x: 3, y: 0 },
31
+ ]
32
+ claimPathCells(grid, cellStyles, path, 'solid')
33
+ // A second solid edge reusing the same cells is exactly the "sibling
34
+ // edges share a trunk" / "LR routing merges naturally at corners"
35
+ // behavior other tests rely on — must stay unflagged.
36
+ expect(findStyleConflict(grid, cellStyles, path, 'solid')).toBeNull()
37
+ })
38
+
39
+ it('flags a different-style edge crossing already-claimed cells', () => {
40
+ const grid = createGrid()
41
+ const cellStyles = createEdgeCellStyles()
42
+ const path = [
43
+ { x: 0, y: 0 },
44
+ { x: 3, y: 0 },
45
+ ]
46
+ claimPathCells(grid, cellStyles, path, 'solid')
47
+ expect(findStyleConflict(grid, cellStyles, path, 'dotted')).toEqual({
48
+ x: 0,
49
+ y: 0,
50
+ })
51
+ })
52
+
53
+ it('reports the first conflicting cell in path order', () => {
54
+ const grid = createGrid()
55
+ const cellStyles = createEdgeCellStyles()
56
+ claimPathCells(
57
+ grid,
58
+ cellStyles,
59
+ [
60
+ { x: 5, y: 0 },
61
+ { x: 8, y: 0 },
62
+ ],
63
+ 'thick',
64
+ )
65
+ const crossing = [
66
+ { x: 0, y: 0 },
67
+ { x: 10, y: 0 },
68
+ ]
69
+ expect(findStyleConflict(grid, cellStyles, crossing, 'solid')).toEqual({
70
+ x: 5,
71
+ y: 0,
72
+ })
73
+ })
74
+
75
+ it('first claim wins — claiming a cell twice with different styles keeps the original', () => {
76
+ const grid = createGrid()
77
+ const cellStyles = createEdgeCellStyles()
78
+ const cell = [{ x: 1, y: 1 }]
79
+ claimPathCells(grid, cellStyles, cell, 'solid')
80
+ claimPathCells(grid, cellStyles, cell, 'dotted')
81
+ // Still solid from the first claim, so a *third* dotted edge is still
82
+ // reported as a conflict rather than silently "winning" the cell.
83
+ expect(findStyleConflict(grid, cellStyles, cell, 'dotted')).toEqual({
84
+ x: 1,
85
+ y: 1,
86
+ })
87
+ })
88
+
89
+ it('does not flag cells the path never touches', () => {
90
+ const grid = createGrid()
91
+ const cellStyles = createEdgeCellStyles()
92
+ claimPathCells(
93
+ grid,
94
+ cellStyles,
95
+ [
96
+ { x: 0, y: 0 },
97
+ { x: 0, y: 5 },
98
+ ],
99
+ 'solid',
100
+ )
101
+ const unrelated = [
102
+ { x: 10, y: 10 },
103
+ { x: 12, y: 10 },
104
+ ]
105
+ expect(findStyleConflict(grid, cellStyles, unrelated, 'dotted')).toBeNull()
106
+ })
107
+
108
+ /**
109
+ * Regression: an edge's path always includes its own source/target
110
+ * node's border cell — that's how it connects to the box at all — so
111
+ * two edges sharing a node (one incoming, one outgoing, as in a retry
112
+ * loop's `B -->|No| D; D -.-> A`) legitimately share that exact port
113
+ * cell. An earlier version tracked *every* path cell including
114
+ * node-owned ones, which flagged this as a conflict — and since an edge
115
+ * always returns to its own node's fixed attachment point regardless of
116
+ * grid occupancy, "blocking" a node cell and re-routing found the
117
+ * identical cell again every time, so the fix never actually converged.
118
+ */
119
+ it('does not flag a shared node-border cell as a conflict', () => {
120
+ const grid = createGrid()
121
+ placeBlock(grid, { x: 4, y: 8 }) // node D's reserved 3x3 block
122
+ const cellStyles = createEdgeCellStyles()
123
+
124
+ // Incoming solid edge terminating at D's top-center port, (5, 8).
125
+ claimPathCells(
126
+ grid,
127
+ cellStyles,
128
+ [
129
+ { x: 2, y: 5 },
130
+ { x: 5, y: 5 },
131
+ { x: 5, y: 8 },
132
+ ],
133
+ 'solid',
134
+ )
135
+
136
+ // Outgoing dotted edge leaving from D's right-side port, (6, 9) —
137
+ // inside the same reserved block, so still node-owned — then diverging
138
+ // into its own open column (7) rather than reusing (5, *).
139
+ const conflict = findStyleConflict(
140
+ grid,
141
+ cellStyles,
142
+ [
143
+ { x: 6, y: 9 },
144
+ { x: 7, y: 9 },
145
+ { x: 7, y: 1 },
146
+ ],
147
+ 'dotted',
148
+ )
149
+ expect(conflict).toBeNull()
150
+ })
151
+
152
+ it('still flags a genuine open-space conflict alongside a shared node border', () => {
153
+ const grid = createGrid()
154
+ placeBlock(grid, { x: 4, y: 8 }) // node D's reserved 3x3 block
155
+ const cellStyles = createEdgeCellStyles()
156
+
157
+ claimPathCells(
158
+ grid,
159
+ cellStyles,
160
+ [
161
+ { x: 2, y: 5 },
162
+ { x: 5, y: 5 },
163
+ { x: 5, y: 8 },
164
+ ],
165
+ 'solid',
166
+ )
167
+
168
+ // A different-style edge that reaches D via the *same open-space*
169
+ // column (5, 5)-(5, 8), not just the shared port cell, is still a
170
+ // real conflict — reported at the first open cell it crosses.
171
+ const conflict = findStyleConflict(
172
+ grid,
173
+ cellStyles,
174
+ [
175
+ { x: 5, y: 1 },
176
+ { x: 5, y: 8 },
177
+ ],
178
+ 'dotted',
179
+ )
180
+ expect(conflict).toEqual({ x: 5, y: 5 })
181
+ })
182
+ })
183
+
184
+ function makeNode(name: string): AsciiNode {
185
+ return {
186
+ name,
187
+ displayLabel: name,
188
+ shape: 'rectangle',
189
+ index: 0,
190
+ gridCoord: null,
191
+ drawingCoord: null,
192
+ drawing: null,
193
+ drawn: false,
194
+ styleClassName: '',
195
+ styleClass: { name: '', styles: {} },
196
+ }
197
+ }
198
+
199
+ function makeEdge(from: AsciiNode, to: AsciiNode): AsciiEdge {
200
+ return {
201
+ from,
202
+ to,
203
+ text: '',
204
+ path: [],
205
+ labelLine: [],
206
+ startDir: Down,
207
+ endDir: Down,
208
+ style: 'solid',
209
+ hasArrowStart: false,
210
+ hasArrowEnd: true,
211
+ }
212
+ }
213
+
214
+ describe('edge-cell-styles — chain overlap (multi-owner cells)', () => {
215
+ /**
216
+ * Regression for a CodeRabbit finding on PR #1093 (the #1067 fix):
217
+ * `claimPathOwners` originally stored only the *first* edge to claim a
218
+ * cell (`if (!owners.has(key)) owners.set(key, edge)`). When an edge
219
+ * unrelated to any chain claims a shared cell before the real chain
220
+ * partner does (an ordering `graph.edges` doesn't control), the chain
221
+ * partner's own claim was silently dropped — so `findUnrelatedOverlap`
222
+ * only ever saw the unrelated edge's claim, never found a chain pair, and
223
+ * missed the real overlap entirely. Cells must track every edge that
224
+ * claims them, not just the first.
225
+ */
226
+ it('detects a chain-pair overlap even when an unrelated edge claimed the corridor first', () => {
227
+ const grid = createGrid()
228
+ const owners = createEdgeCellOwners()
229
+
230
+ const a = makeNode('A')
231
+ const b = makeNode('B')
232
+ const c = makeNode('C')
233
+ const x = makeNode('X')
234
+ const y = makeNode('Y')
235
+
236
+ const edgeAB = makeEdge(a, b)
237
+ const edgeBC = makeEdge(b, c)
238
+ const edgeXY = makeEdge(x, y) // unrelated to the A->B->C chain
239
+
240
+ const corridor = [
241
+ { x: 0, y: 0 },
242
+ { x: 0, y: 1 },
243
+ { x: 0, y: 2 },
244
+ ]
245
+
246
+ // The unrelated edge claims the corridor first.
247
+ claimPathOwners(grid, owners, corridor, edgeXY)
248
+ // The true chain partner (A->B) also routes through it.
249
+ claimPathOwners(grid, owners, corridor, edgeAB)
250
+
251
+ // B->C, checked against the same corridor, must still find its A->B
252
+ // chain partner's overlap — not be hidden behind X->Y's earlier claim.
253
+ const conflict = findUnrelatedOverlap(grid, owners, corridor, edgeBC)
254
+ expect(conflict).toEqual({ x: 0, y: 0 })
255
+ })
256
+
257
+ it('still finds nothing when only an unrelated edge claimed the corridor', () => {
258
+ const grid = createGrid()
259
+ const owners = createEdgeCellOwners()
260
+
261
+ const b = makeNode('B')
262
+ const c = makeNode('C')
263
+ const x = makeNode('X')
264
+ const y = makeNode('Y')
265
+
266
+ const edgeBC = makeEdge(b, c)
267
+ const edgeXY = makeEdge(x, y)
268
+
269
+ const corridor = [
270
+ { x: 0, y: 0 },
271
+ { x: 0, y: 1 },
272
+ ]
273
+
274
+ claimPathOwners(grid, owners, corridor, edgeXY)
275
+
276
+ expect(findUnrelatedOverlap(grid, owners, corridor, edgeBC)).toBeNull()
277
+ })
278
+ })
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createGrid,
4
+ isOccupied,
5
+ isFree,
6
+ isBlockFree,
7
+ placeBlock,
8
+ pathCells,
9
+ cloneGrid,
10
+ NODE_BLOCK_SIZE,
11
+ } from '../grid-occupancy.ts'
12
+
13
+ describe('grid-occupancy', () => {
14
+ it('createGrid starts empty', () => {
15
+ const grid = createGrid()
16
+ expect(isOccupied(grid, { x: 0, y: 0 })).toBe(false)
17
+ expect(isFree(grid, { x: 0, y: 0 })).toBe(true)
18
+ })
19
+
20
+ describe('isFree', () => {
21
+ it('is false for negative coordinates even when unoccupied', () => {
22
+ const grid = createGrid()
23
+ expect(isOccupied(grid, { x: -1, y: 0 })).toBe(false)
24
+ expect(isFree(grid, { x: -1, y: 0 })).toBe(false)
25
+ expect(isFree(grid, { x: 0, y: -1 })).toBe(false)
26
+ })
27
+
28
+ it('is false for an occupied non-negative cell', () => {
29
+ const grid = createGrid()
30
+ placeBlock(grid, { x: 2, y: 2 }, 1)
31
+ expect(isFree(grid, { x: 2, y: 2 })).toBe(false)
32
+ })
33
+
34
+ it('is true for a free non-negative cell', () => {
35
+ const grid = createGrid()
36
+ expect(isFree(grid, { x: 5, y: 5 })).toBe(true)
37
+ })
38
+ })
39
+
40
+ describe('isBlockFree / placeBlock', () => {
41
+ it('reports a fresh block as free, then occupied after placing', () => {
42
+ const grid = createGrid()
43
+ const origin = { x: 0, y: 0 }
44
+ expect(isBlockFree(grid, origin)).toBe(true)
45
+ placeBlock(grid, origin)
46
+ expect(isBlockFree(grid, origin)).toBe(false)
47
+ // Every cell in the default NODE_BLOCK_SIZE x NODE_BLOCK_SIZE block
48
+ // is reserved, not just the origin cell.
49
+ for (let dx = 0; dx < NODE_BLOCK_SIZE; dx++) {
50
+ for (let dy = 0; dy < NODE_BLOCK_SIZE; dy++) {
51
+ expect(isOccupied(grid, { x: origin.x + dx, y: origin.y + dy })).toBe(
52
+ true,
53
+ )
54
+ }
55
+ }
56
+ })
57
+
58
+ it('detects partial overlap with an already-placed block', () => {
59
+ const grid = createGrid()
60
+ placeBlock(grid, { x: 0, y: 0 }) // occupies (0,0)..(2,2)
61
+ // Shifted by 1 in each axis: overlaps the first block at (1,1)-(2,2)
62
+ // even though its own origin cell (1,1) is inside the first block too.
63
+ expect(isBlockFree(grid, { x: 1, y: 1 })).toBe(false)
64
+ // Fully disjoint block (mod-4 pitch) is free.
65
+ expect(isBlockFree(grid, { x: 4, y: 0 })).toBe(true)
66
+ })
67
+
68
+ it('throws rather than silently overwriting on collision', () => {
69
+ const grid = createGrid()
70
+ placeBlock(grid, { x: 0, y: 0 })
71
+ expect(() => placeBlock(grid, { x: 1, y: 1 })).toThrow(/already occupied/)
72
+ })
73
+
74
+ it('supports a single-cell block via size=1', () => {
75
+ const grid = createGrid()
76
+ placeBlock(grid, { x: 3, y: 3 }, 1)
77
+ expect(isOccupied(grid, { x: 3, y: 3 })).toBe(true)
78
+ expect(isOccupied(grid, { x: 4, y: 3 })).toBe(false)
79
+ expect(isOccupied(grid, { x: 3, y: 4 })).toBe(false)
80
+ })
81
+ })
82
+
83
+ describe('Grid.delete', () => {
84
+ it('releases a cell so it reports free again', () => {
85
+ const grid = createGrid()
86
+ grid.add('5,5')
87
+ expect(grid.has('5,5')).toBe(true)
88
+ grid.delete('5,5')
89
+ expect(grid.has('5,5')).toBe(false)
90
+ })
91
+
92
+ it('is a no-op for a cell that was never reserved', () => {
93
+ const grid = createGrid()
94
+ expect(() => grid.delete('9,9')).not.toThrow()
95
+ expect(grid.has('9,9')).toBe(false)
96
+ })
97
+ })
98
+
99
+ describe('pathCells', () => {
100
+ it('returns an empty array for an empty path', () => {
101
+ expect(pathCells([])).toEqual([])
102
+ })
103
+
104
+ it('returns the single point for a one-point path', () => {
105
+ expect(pathCells([{ x: 4, y: 7 }])).toEqual([{ x: 4, y: 7 }])
106
+ })
107
+
108
+ it('walks every intermediate cell of a horizontal segment', () => {
109
+ const cells = pathCells([
110
+ { x: 0, y: 0 },
111
+ { x: 3, y: 0 },
112
+ ])
113
+ expect(cells).toEqual([
114
+ { x: 0, y: 0 },
115
+ { x: 1, y: 0 },
116
+ { x: 2, y: 0 },
117
+ { x: 3, y: 0 },
118
+ ])
119
+ })
120
+
121
+ it('walks every intermediate cell of a vertical segment', () => {
122
+ const cells = pathCells([
123
+ { x: 2, y: 5 },
124
+ { x: 2, y: 2 },
125
+ ])
126
+ expect(cells).toEqual([
127
+ { x: 2, y: 5 },
128
+ { x: 2, y: 4 },
129
+ { x: 2, y: 3 },
130
+ { x: 2, y: 2 },
131
+ ])
132
+ })
133
+
134
+ it('walks a clean 45° diagonal', () => {
135
+ const cells = pathCells([
136
+ { x: 0, y: 0 },
137
+ { x: 3, y: 3 },
138
+ ])
139
+ expect(cells).toEqual([
140
+ { x: 0, y: 0 },
141
+ { x: 1, y: 1 },
142
+ { x: 2, y: 2 },
143
+ { x: 3, y: 3 },
144
+ ])
145
+ })
146
+
147
+ /**
148
+ * Regression for a real crash: `determinePath`'s Case-4 direct fallback
149
+ * (`edge.path = [prefFrom, prefTo]`, used when A* finds no route at
150
+ * all) can produce a segment whose dx/dy ratio is anything, not just
151
+ * 0, equal, or axis-aligned. An earlier version of `pathCells` stepped
152
+ * both axes by `Math.sign(delta)` every iteration, which only ever
153
+ * reaches the target when `|dx| === |dy|` — for a 10-wide, 3-tall
154
+ * segment like this one it walks (10,10), (11,11)... forever, since x
155
+ * and y can never both equal (10,3) on the same iteration. That
156
+ * doesn't just produce a wrong path — it hung the real render until a
157
+ * backing `Set` threw "maximum size exceeded" (see
158
+ * ascii-edge-cross-style-overlap.test.ts for the end-to-end repro).
159
+ * Bresenham handles any ratio and is guaranteed to land exactly on the
160
+ * endpoint in `max(|dx|, |dy|) + 1` steps.
161
+ */
162
+ it('terminates and lands exactly on the endpoint for a non-45° diagonal', () => {
163
+ const cells = pathCells([
164
+ { x: 0, y: 0 },
165
+ { x: 10, y: 3 },
166
+ ])
167
+ expect(cells[cells.length - 1]).toEqual({ x: 10, y: 3 })
168
+ expect(cells.length).toBe(11) // max(|10|, |3|) + 1
169
+ // Every step must move by at most one cell on each axis (no skips,
170
+ // no doubling back) — Bresenham can step both axes in the same
171
+ // iteration to stay close to the true line, so Chebyshev distance
172
+ // between consecutive cells is always exactly 1, though Manhattan
173
+ // distance can be 1 or 2.
174
+ for (let i = 1; i < cells.length; i++) {
175
+ const a = cells[i - 1]!
176
+ const b = cells[i]!
177
+ expect(Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y))).toBe(1)
178
+ }
179
+ })
180
+
181
+ it('walks a multi-segment path corner-to-corner without duplicating the shared corner', () => {
182
+ const cells = pathCells([
183
+ { x: 0, y: 0 },
184
+ { x: 2, y: 0 },
185
+ { x: 2, y: 2 },
186
+ ])
187
+ expect(cells).toEqual([
188
+ { x: 0, y: 0 },
189
+ { x: 1, y: 0 },
190
+ { x: 2, y: 0 },
191
+ { x: 2, y: 1 },
192
+ { x: 2, y: 2 },
193
+ ])
194
+ })
195
+
196
+ /**
197
+ * Regression: an earlier version silently returned a path truncated at
198
+ * `MAX_WALK_STEPS`, which under-reports to a caller checking "every
199
+ * cell this path touches" (edge-cell-styles.ts's conflict detection,
200
+ * specifically) — a conflict past the cutoff would simply never be
201
+ * seen. Throwing converts that silent blind spot into a loud failure.
202
+ */
203
+ it('throws rather than silently returning a truncated path past the walk limit', () => {
204
+ expect(() =>
205
+ pathCells([
206
+ { x: 0, y: 0 },
207
+ { x: 200_000, y: 0 },
208
+ ]),
209
+ ).toThrow(/did not reach its endpoint/)
210
+ })
211
+ })
212
+
213
+ describe('Grid.keys / cloneGrid', () => {
214
+ it('keys() iterates every reserved cell', () => {
215
+ const grid = createGrid()
216
+ grid.add('1,1')
217
+ grid.add('2,2')
218
+ expect(new Set(grid.keys())).toEqual(new Set(['1,1', '2,2']))
219
+ })
220
+
221
+ it('cloneGrid copies current reservations into an independent grid', () => {
222
+ const original = createGrid()
223
+ original.add('1,1')
224
+ const clone = cloneGrid(original)
225
+ expect(isOccupied(clone, { x: 1, y: 1 })).toBe(true)
226
+
227
+ // Independent: mutating one after cloning doesn't affect the other.
228
+ original.add('2,2')
229
+ expect(isOccupied(clone, { x: 2, y: 2 })).toBe(false)
230
+
231
+ clone.add('3,3')
232
+ expect(isOccupied(original, { x: 3, y: 3 })).toBe(false)
233
+ })
234
+
235
+ it('cloneGrid on an empty grid produces an empty grid', () => {
236
+ const clone = cloneGrid(createGrid())
237
+ expect([...clone.keys()]).toEqual([])
238
+ })
239
+ })
240
+ })
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Structural fact extraction for rendered ASCII diagrams — shared by the
3
+ * "form invariant" suites (ascii-sequence-form-invariants.test.ts,
4
+ * ascii-class-box-occupancy.test.ts, ascii-er-box-occupancy.test.ts).
5
+ *
6
+ * These suites assert structural properties of the rendered output (does
7
+ * this box overlap that one, does this label fit inside its container)
8
+ * rather than diffing against a committed fixture. `svg-samples.visual.test.ts`
9
+ * / `ascii-samples.visual.test.ts` only self-diff against their own baseline
10
+ * screenshot, which can never catch a defect that was already present when
11
+ * that baseline was captured — these helpers exist to check the renderer
12
+ * against its own stated semantics instead (e.g. "a box must be wide enough
13
+ * to hold its label", "an unrelated box must never be overwritten").
14
+ *
15
+ * Deliberately works on the *rendered string*, not renderer internals:
16
+ * `renderSequenceAscii`/`renderClassAscii`/`renderErAscii` each do
17
+ * parse→layout→draw→stringify in one function with no layout object
18
+ * exposed, and (checked directly) `src/ascii/er-diagram.ts`'s own occupancy
19
+ * guard (`boxCells`) is a flat, entity-agnostic occupied-cell set — even the
20
+ * renderer's own internals can't answer "which entity owns this cell"
21
+ * without a scan shaped like the one below. Matches this repo's existing
22
+ * convention of small, local, string-based test helpers (see
23
+ * `helpers/terminal-display-width.ts`).
24
+ */
25
+
26
+ export interface Rect {
27
+ x0: number
28
+ x1: number
29
+ y0: number
30
+ y1: number
31
+ }
32
+
33
+ /** Border-ish characters in both Unicode and `useAscii: true` box-drawing. */
34
+ const BORDER_CHARS = new Set([
35
+ '│',
36
+ '┌',
37
+ '└',
38
+ '├',
39
+ '┤',
40
+ '┐',
41
+ '┘',
42
+ '─',
43
+ '┬',
44
+ '┴',
45
+ '┼',
46
+ '+',
47
+ '|',
48
+ '-',
49
+ ])
50
+
51
+ function isBorderish(ch: string | undefined): boolean {
52
+ return ch !== undefined && BORDER_CHARS.has(ch)
53
+ }
54
+
55
+ function lineAt(lines: string[], y: number, x: number): string | undefined {
56
+ return lines[y]?.[x]
57
+ }
58
+
59
+ /**
60
+ * Locate a named, bordered box (an actor box, a class box, an entity box) by
61
+ * its header text.
62
+ *
63
+ * Finds the row where `name` sits with a border-ish character immediately to
64
+ * its left and right (skipping padding spaces), *and* a border-ish character
65
+ * directly above that same column (the box's top border or a section
66
+ * divider) — the second check keeps a name that's a substring of another
67
+ * name, or that happens to appear inside an attribute/label string, from
68
+ * false-matching a header row it isn't actually part of. Then walks outward
69
+ * from that row along the left/right border columns until the border stops
70
+ * holding, to find the box's full vertical extent.
71
+ *
72
+ * Works identically in Unicode and `useAscii: true` mode: it only asks "is
73
+ * this column border-ish at this row," never which specific glyph is there.
74
+ */
75
+ export function findBoxRect(ascii: string, name: string): Rect {
76
+ const lines = ascii.split('\n')
77
+
78
+ for (let y = 0; y < lines.length; y++) {
79
+ const line = lines[y]!
80
+ let searchFrom = 0
81
+ for (;;) {
82
+ const idx = line.indexOf(name, searchFrom)
83
+ if (idx === -1) break
84
+ searchFrom = idx + 1
85
+
86
+ let left = idx - 1
87
+ while (left >= 0 && line[left] === ' ') left--
88
+ let right = idx + name.length
89
+ while (right < line.length && line[right] === ' ') right++
90
+
91
+ if (!isBorderish(line[left]) || !isBorderish(line[right])) continue
92
+ // Require a border-ish char directly above this header row (top
93
+ // border or a divider) — rejects a match inside a box's body text.
94
+ if (!isBorderish(lineAt(lines, y - 1, left))) continue
95
+
96
+ const x0 = left
97
+ const x1 = right
98
+
99
+ let y0 = y
100
+ while (
101
+ isBorderish(lineAt(lines, y0 - 1, x0)) &&
102
+ isBorderish(lineAt(lines, y0 - 1, x1))
103
+ ) {
104
+ y0--
105
+ }
106
+ let y1 = y
107
+ while (
108
+ isBorderish(lineAt(lines, y1 + 1, x0)) &&
109
+ isBorderish(lineAt(lines, y1 + 1, x1))
110
+ ) {
111
+ y1++
112
+ }
113
+
114
+ return { x0, x1, y0, y1 }
115
+ }
116
+ }
117
+
118
+ throw new Error(`findBoxRect: no bordered box found for "${name}"`)
119
+ }
120
+
121
+ /**
122
+ * Locate free-floating text (a relationship label, a note's own text) that
123
+ * isn't itself inside a bordered box header — same row/column scan as
124
+ * `findBoxRect` but without the border precondition. Returns the tightest
125
+ * rectangle containing every occurrence-adjacent character of `text` on its
126
+ * line (just the matched span, not a box).
127
+ */
128
+ export function findTextRect(ascii: string, text: string): Rect {
129
+ const lines = ascii.split('\n')
130
+ for (let y = 0; y < lines.length; y++) {
131
+ const idx = lines[y]!.indexOf(text)
132
+ if (idx !== -1) {
133
+ return { x0: idx, x1: idx + text.length - 1, y0: y, y1: y }
134
+ }
135
+ }
136
+ throw new Error(`findTextRect: "${text}" not found`)
137
+ }
138
+
139
+ /** Half-open interval overlap check between two rectangles. */
140
+ export function rectsOverlap(a: Rect, b: Rect): boolean {
141
+ return a.x0 <= b.x1 && a.x1 >= b.x0 && a.y0 <= b.y1 && a.y1 >= b.y0
142
+ }