@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,325 @@
1
+ // ============================================================================
2
+ // ASCII ER diagram: relationship draws must not overwrite already-placed
3
+ // text (GitHub issue #392)
4
+ //
5
+ // Relationships are drawn in declaration order. A later relationship's
6
+ // straight connecting line, crow's-foot marker, or label can land on the
7
+ // exact canvas cell an earlier relationship's label (or an entity's own
8
+ // header/attribute text) already wrote, silently corrupting it — a stray
9
+ // line glyph mid-word, or one label's characters spliced into another's.
10
+ // This is a distinct mechanism from #351's jog-row collisions (fixed by
11
+ // chooseFreeRow in #390): here nothing routes around anything, a later
12
+ // write simply stomps an earlier one with no occupancy check at all.
13
+ //
14
+ // A vertical relationship's label goes one step further than plain
15
+ // overwrite-avoidance: when multiple vertical relationships share the same
16
+ // upper/lower entity "rows" (and so the same natural label row), simply
17
+ // refusing to overwrite would silently drop every label but the first —
18
+ // a real information loss, not just a corruption risk. Those labels
19
+ // instead search nearby rows within the relationship's own vertical run
20
+ // for a free spot before giving up.
21
+ // ============================================================================
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
25
+
26
+ describe('ASCII ER relationship draws do not overwrite existing text (issue #392)', () => {
27
+ it('does not let a later vertical relationship line cut through an earlier label', () => {
28
+ // A-D's label "helloworld" and B-E's vertical connector line land on
29
+ // the same canvas cell (both components span the same two rows), with
30
+ // B-E's own label positioned clear of A-D's label span so this isolates
31
+ // the line-vs-label collision from any label-vs-label collision.
32
+ const ascii = renderMermaidASCII(
33
+ `erDiagram
34
+ A ||--o{ B : ab
35
+ A ||--o{ C : ac
36
+ A ||--o{ D : helloworld
37
+ B ||--o{ E : be`,
38
+ { colorMode: 'none' },
39
+ )
40
+
41
+ expect(ascii).toContain('helloworld')
42
+ expect(ascii).toContain('be')
43
+ })
44
+
45
+ it('does not let one relationship label overwrite another relationship label', () => {
46
+ // A-D's label "longlabelhere" and B-E's label "be" are both centered on
47
+ // the same row (both components span the same two rows) and, without a
48
+ // guard, B-E's label characters land on top of the tail of A-D's label.
49
+ const ascii = renderMermaidASCII(
50
+ `erDiagram
51
+ A ||--o{ B : ab
52
+ A ||--o{ C : ac
53
+ A ||--o{ D : longlabelhere
54
+ B ||--o{ E : be`,
55
+ { colorMode: 'none' },
56
+ )
57
+
58
+ expect(ascii).toContain('longlabelhere')
59
+ })
60
+
61
+ it('does not let a same-row relationship line overwrite an unrelated entity name it must cross', () => {
62
+ // A-C is a direct (non-adjacent) same-row relationship; entity B sits
63
+ // physically between A and C, so A-C's straight line crosses B's box.
64
+ // The routing itself (line running through the box) is a distinct,
65
+ // out-of-scope defect (see #390's "known limitation" on non-adjacent
66
+ // same-row collisions) — this test only asserts the crossing line
67
+ // doesn't corrupt B's own header text.
68
+ const ascii = renderMermaidASCII(
69
+ `erDiagram
70
+ A ||--o{ B : ab
71
+ A ||--o{ C : ac
72
+ A ||--o{ D : helloworld
73
+ B ||--o{ E : be`,
74
+ { colorMode: 'none' },
75
+ )
76
+
77
+ // The A-C line still crosses straight through B's row (the crossing
78
+ // itself is the out-of-scope routing defect) — but B's own header
79
+ // character must survive that crossing intact rather than being
80
+ // replaced by a line glyph.
81
+ const lines = ascii.split('\n')
82
+ const crossingLine = lines.find(
83
+ (l) => l.includes('A') && l.includes('C') && l.includes('B'),
84
+ )
85
+ expect(crossingLine).toBeDefined()
86
+ expect(crossingLine).toContain('B')
87
+ })
88
+
89
+ it('never splices one overlapping label onto another, whatever the outcome', () => {
90
+ // Real catalog sample ("ER: Blog Platform Schema"): USER-COMMENT's
91
+ // "authors" label, POST-COMMENT's "has" label, and POST-TAG's
92
+ // "tagged-with" label all naturally land on the same row. Before any
93
+ // fix, the whole row was overwritten down to just "tagged-with"
94
+ // (silently erasing "authors" and "has" entirely). A naive
95
+ // per-character occupancy guard produces a worse failure mode here:
96
+ // "tagged-with"'s free trailing characters ("ged-with") get written
97
+ // right after "has", producing "hasged-with" — a new, plausible-
98
+ // looking word that is neither original label. Whether a colliding
99
+ // label ends up relocated to a free row nearby or dropped outright
100
+ // (see the row-search test below for which one happens here), it must
101
+ // never partially splice into whatever's already at its natural row.
102
+ const ascii = renderMermaidASCII(
103
+ `erDiagram
104
+ USER ||--o{ POST : writes
105
+ USER ||--o{ COMMENT : authors
106
+ POST ||--o{ COMMENT : has
107
+ POST }|--o{ TAG : tagged-with`,
108
+ { colorMode: 'none' },
109
+ )
110
+
111
+ expect(ascii).not.toContain('hasged-with')
112
+ expect(ascii).not.toContain('gedauthors')
113
+ expect(ascii).toContain('authors')
114
+ expect(ascii).toContain('has')
115
+ })
116
+
117
+ it('relocates a colliding vertical-relationship label to a free row instead of dropping it', () => {
118
+ // Same real catalog sample as above. USER-COMMENT, POST-COMMENT, and
119
+ // POST-TAG all share the same startY/endY (they connect the same pair
120
+ // of entity "rows" in the grid layout), so their labels ("authors",
121
+ // "has", "tagged-with") all compute the identical natural midY. Simply
122
+ // dropping whichever one loses that collision — the earlier behavior —
123
+ // is a real, visible information loss: a rendered relationship with no
124
+ // label at all. There's free space a row away, clear of both text and
125
+ // markers, so "tagged-with" should be relocated there rather than
126
+ // vanish.
127
+ const ascii = renderMermaidASCII(
128
+ `erDiagram
129
+ USER ||--o{ POST : writes
130
+ USER ||--o{ COMMENT : authors
131
+ POST ||--o{ COMMENT : has
132
+ POST }|--o{ TAG : tagged-with`,
133
+ { colorMode: 'none' },
134
+ )
135
+
136
+ expect(ascii).toContain('tagged-with')
137
+ // And still not spliced together with "has" on its new row either.
138
+ expect(ascii).not.toContain('hastagged-with')
139
+ })
140
+
141
+ it('positions a jogged label next to the marker for the entity it actually describes', () => {
142
+ // Real catalog sample ("ER: Blog Platform Schema", used verbatim — see
143
+ // the row-search test above for why the attribute blocks matter here).
144
+ // A label's X position used to follow the relationship's column at its
145
+ // *upper* entity (POST, in this case) even when a jog moved the path
146
+ // over to the lower entity's column — so "tagged-with" rendered far
147
+ // from TAG's own marker, floating in the gap between it and COMMENT's
148
+ // markers with nothing visually tying it to what it describes. The
149
+ // label now follows the same target column the lower marker uses, so
150
+ // it sits immediately next to that marker instead.
151
+ const ascii = renderMermaidASCII(
152
+ `erDiagram
153
+ USER {
154
+ int id PK
155
+ string username UK
156
+ string email UK
157
+ date joined
158
+ }
159
+ POST {
160
+ int id PK
161
+ string title
162
+ text content
163
+ int author_id FK
164
+ date published
165
+ }
166
+ COMMENT {
167
+ int id PK
168
+ text body
169
+ int post_id FK
170
+ int user_id FK
171
+ date created
172
+ }
173
+ TAG {
174
+ int id PK
175
+ string name UK
176
+ }
177
+ USER ||--o{ POST : writes
178
+ USER ||--o{ COMMENT : authors
179
+ POST ||--o{ COMMENT : has
180
+ POST }|--o{ TAG : tagged-with`,
181
+ { colorMode: 'none' },
182
+ )
183
+
184
+ const lines = ascii.split('\n')
185
+ const labelRowIdx = lines.findIndex((l) => l.includes('tagged-with'))
186
+ expect(labelRowIdx).toBeGreaterThanOrEqual(0)
187
+ const row = lines[labelRowIdx]!
188
+ const idx = row.indexOf('tagged-with')
189
+ // Since issue #453, POST also has a second bottom attachment ("has")
190
+ // converging on COMMENT, so POST's own attachment columns for "has"
191
+ // and "tagged-with" are staggered instead of both centered on POST —
192
+ // which in turn changes where "tagged-with"'s jog (and so its label)
193
+ // lands. The label is preceded by the jog's own corner glyph ('┌ ')
194
+ // rather than sitting flush after the marker on the same row.
195
+ expect(row.slice(idx - 2, idx)).toBe('┌ ')
196
+ const cornerCol = idx - 2
197
+ // The marker sits at or adjacent to the corner's column, connected by
198
+ // the vertical line the corner turns into — but not necessarily on the
199
+ // very next row: issue #351's multi-row-obstruction routing can insert
200
+ // an extra connector row between the jog and the marker, so the exact
201
+ // row offset isn't fixed. Search a small vertical window below the
202
+ // corner instead of requiring literal same-row-plus-one adjacency.
203
+ const nearbyRows = [
204
+ labelRowIdx + 1,
205
+ labelRowIdx + 2,
206
+ labelRowIdx + 3,
207
+ ].filter((r) => r < lines.length)
208
+ const hasAlignedMarker = nearbyRows.some((r) =>
209
+ lines[r]!.slice(cornerCol - 1, cornerCol + 2).includes('○╟'),
210
+ )
211
+ expect(hasAlignedMarker).toBe(true)
212
+ })
213
+
214
+ it('drops a whole overlapping label in the same-row (horizontal) branch too', () => {
215
+ // A-C is a direct, non-adjacent same-row relationship skipping over B,
216
+ // so its label's clamped placement region spans the whole A..C gap and
217
+ // can land on top of A-B's own (already-drawn, adjacent) label. The
218
+ // vertical-branch case above exercises canPlaceLabelLine via the
219
+ // vertical label loop; this exercises the same guard in the horizontal
220
+ // label loop.
221
+ const ascii = renderMermaidASCII(
222
+ `erDiagram
223
+ A ||--o{ B : early
224
+ A ||--o{ C : longlonglabelhere
225
+ A ||--o{ D : y
226
+ B ||--o{ E : z`,
227
+ { colorMode: 'none' },
228
+ )
229
+
230
+ expect(ascii).toContain('early')
231
+ expect(ascii).not.toContain('longlonglabelhere')
232
+
233
+ // Not just the whole string: a per-character collision guard (the
234
+ // naive approach tried and rejected for canPlaceLabelLine, see above)
235
+ // could still leak part of the dropped label — e.g. "longlon" or
236
+ // "glabelhere" — into the gap right after "early" on the same row.
237
+ // Nothing alphabetic should appear there at all.
238
+ const labelRow = ascii.split('\n').find((l) => l.includes('early'))
239
+ expect(labelRow).toBeDefined()
240
+ const afterEarly = labelRow!.slice(
241
+ labelRow!.indexOf('early') + 'early'.length,
242
+ )
243
+ expect(afterEarly).not.toMatch(/[A-Za-z]/)
244
+ })
245
+
246
+ it('preserves an unrelated entity box border when a same-row line crosses through it', () => {
247
+ // Real catalog sample ("ER: Mixed Identifying & Non-Identifying"):
248
+ // ORDER-SHIPMENT is a direct, non-adjacent same-row relationship
249
+ // skipping over LINE_ITEM, so its dashed line crosses straight through
250
+ // LINE_ITEM's box (the routing itself — crossing through the box at all
251
+ // — is the separate, out-of-scope defect from #351/#390's "known
252
+ // limitation"). Text protection alone (the earlier tests above) keeps
253
+ // "LINE_ITEM" itself readable, but previously left the crossing line
254
+ // free to erase the box's border characters on either side of it,
255
+ // leaving an open-looking, broken box. Border cells are protected too.
256
+ const ascii = renderMermaidASCII(
257
+ `erDiagram
258
+ ORDER ||--|{ LINE_ITEM : contains
259
+ ORDER ||..o{ SHIPMENT : ships-via
260
+ PRODUCT ||--o{ LINE_ITEM : includes
261
+ PRODUCT ||..o{ REVIEW : receives`,
262
+ { colorMode: 'none' },
263
+ )
264
+
265
+ expect(ascii).toContain('┌───────────┐')
266
+ expect(ascii).toContain('└───────────┘')
267
+ // The crossing dashed line still runs right up to LINE_ITEM's box (that
268
+ // routing gap is out of scope), so its own '╌' sits just inside the
269
+ // border on both sides — but the border characters themselves must
270
+ // survive intact, not be replaced by more line/dash glyphs.
271
+ expect(ascii).toMatch(/│.LINE_ITEM.│/)
272
+ })
273
+
274
+ it('pads a label with a blank cell when a later relationship jog runs flush against it', () => {
275
+ // Real catalog sample ("ER: Blog Platform Schema", used verbatim —
276
+ // this needs the entities' attribute blocks to reproduce the exact
277
+ // geometry where it occurs): USER-COMMENT needs no horizontal jog of
278
+ // its own (its two entities are already column-aligned), so its label
279
+ // "authors" sits on a row that POST-COMMENT's much longer jog — a
280
+ // different, later-processed relationship — later fills with dashes
281
+ // clear across. isProtected alone keeps "authors" itself intact
282
+ // (that's what the earlier tests above cover), but without this
283
+ // padding the crossing dashes still ran flush against it on both sides
284
+ // ("────authors────") — readable, but visually indistinguishable from
285
+ // actual corruption at a glance.
286
+ const ascii = renderMermaidASCII(
287
+ `erDiagram
288
+ USER {
289
+ int id PK
290
+ string username UK
291
+ string email UK
292
+ date joined
293
+ }
294
+ POST {
295
+ int id PK
296
+ string title
297
+ text content
298
+ int author_id FK
299
+ date published
300
+ }
301
+ COMMENT {
302
+ int id PK
303
+ text body
304
+ int post_id FK
305
+ int user_id FK
306
+ date created
307
+ }
308
+ TAG {
309
+ int id PK
310
+ string name UK
311
+ }
312
+ USER ||--o{ POST : writes
313
+ USER ||--o{ COMMENT : authors
314
+ POST ||--o{ COMMENT : has
315
+ POST }|--o{ TAG : tagged-with`,
316
+ { colorMode: 'none' },
317
+ )
318
+
319
+ const row = ascii.split('\n').find((l) => l.includes('authors'))
320
+ expect(row).toBeDefined()
321
+ const idx = row!.indexOf('authors')
322
+ expect(row![idx - 1]).toBe(' ')
323
+ expect(row![idx + 'authors'.length]).toBe(' ')
324
+ })
325
+ })
@@ -0,0 +1,344 @@
1
+ // ============================================================================
2
+ // ASCII ER diagram regression tests for issue #351:
3
+ // 1. Stray/duplicated connector glyphs next to crow's-foot markers
4
+ // 2. Edges (and their labels) routed through/over other relationships or
5
+ // entity boxes
6
+ // 3. Oversized blank gutters between disconnected components
7
+ // ============================================================================
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
11
+ import { chooseFreeRow, isRowFree } from '../er-diagram.ts'
12
+ import { mkCanvas, write } from '../canvas.ts'
13
+ import type { Canvas } from '../types.ts'
14
+
15
+ /** Mark every cell in row `y` across [xStart, xEnd] as occupied. */
16
+ function occupyRow(canvas: Canvas, y: number, xStart: number, xEnd: number) {
17
+ for (let x = xStart; x <= xEnd; x++) write(canvas, x, y, '#')
18
+ }
19
+
20
+ describe('ASCII ER — no stray connector glyph next to a marker (issue #351)', () => {
21
+ it('insets only the colliding "one" marker on a horizontal connector, leaving the non-colliding "zero-many" marker flush (Unicode) — issue #413', () => {
22
+ const ascii = renderMermaidASCII(
23
+ `erDiagram
24
+ CUSTOMER ||--o{ ORDER : places`,
25
+ { colorMode: 'none' },
26
+ )
27
+ // #390 briefly made every horizontal marker sit flush against the
28
+ // border to avoid a "─│─" run (line-fill dash, lone "one" tick,
29
+ // line-fill dash) that the #351 fix worried would read as a stray,
30
+ // unexplained connector glyph. Review on #351/#413 found that concern
31
+ // doesn't hold in a real terminal (adjacent monospace glyphs don't
32
+ // visually fuse regardless of spacing), but a *blanket* revert back to
33
+ // inset for every horizontal marker was its own overcorrection: it
34
+ // adds an unrequested gap to markers that were never ambiguous in the
35
+ // first place. "zero-many" ('○╟') doesn't share a glyph with the
36
+ // border ('│') the way "one" ('│') does, so only "one" needs the
37
+ // inset — "zero-many" stays flush, matching #390's original intent for
38
+ // that glyph. This test locks in the per-side, collision-conditional
39
+ // result instead of a uniform one.
40
+ expect(ascii).toContain('CUSTOMER │─│')
41
+ expect(ascii).toContain('○╟│ ORDER')
42
+ })
43
+
44
+ it('insets only the colliding "one" marker on a horizontal connector, leaving the non-colliding "zero-many" marker flush (ASCII) — issue #413', () => {
45
+ const ascii = renderMermaidASCII(
46
+ `erDiagram
47
+ CUSTOMER ||--o{ ORDER : places`,
48
+ { colorMode: 'none', useAscii: true },
49
+ )
50
+ // Same per-side reasoning as the Unicode case above, in the ASCII glyph set.
51
+ expect(ascii).toContain('CUSTOMER |-|')
52
+ expect(ascii).toContain('o<| ORDER')
53
+ })
54
+
55
+ it('leaves both markers flush on a horizontal connector when neither collides with the border', () => {
56
+ const ascii = renderMermaidASCII(
57
+ `erDiagram
58
+ CUSTOMER }o--o{ ORDER : has`,
59
+ { colorMode: 'none' },
60
+ )
61
+ // Neither "zero-many" marker ('╢○'/'○╟') shares a glyph with the
62
+ // border ('│'), so a blanket inset (the reviewer's original diff,
63
+ // applied uniformly) would add a gap to both sides for no reason.
64
+ // Matches #390's original flush output exactly, since this case was
65
+ // never part of the #413 collision in the first place.
66
+ expect(ascii).toContain('CUSTOMER │╢○')
67
+ expect(ascii).toContain('○╟│ ORDER')
68
+ expect(ascii).not.toContain('CUSTOMER │─')
69
+ expect(ascii).not.toContain('─│ ORDER')
70
+ })
71
+
72
+ it('does not leave a stray fill row between a vertical marker and its entity border', () => {
73
+ // Forces a vertical (cross-row) connection: three entities with a
74
+ // sqrt-based row limit of 2 per row wrap C onto its own row below A/B.
75
+ const ascii = renderMermaidASCII(
76
+ `erDiagram
77
+ A ||--o{ B : ab
78
+ A ||--o{ C : ac`,
79
+ { colorMode: 'none' },
80
+ )
81
+ const lines = ascii.split('\n')
82
+
83
+ // The zero-many marker for the A→C relationship ("○╟") must sit on the
84
+ // row immediately before C's box starts — not with a leftover plain
85
+ // "│" line-fill row (visually indistinguishable from a second "one"
86
+ // tick) still separating it from the border. Before the fix, that
87
+ // leftover row rendered as an extra stray "│" directly under "○╟".
88
+ // Match only the *vertical* marker's own row — a bare "○╟" with nothing
89
+ // but leading whitespace before it — not the A→B horizontal
90
+ // relationship's identical-looking marker earlier in the output.
91
+ const markerLineIdx = lines.findIndex((l) => /^\s*○╟\s*$/.test(l))
92
+ expect(markerLineIdx).toBeGreaterThan(-1)
93
+ const nextLine = lines[markerLineIdx + 1]
94
+ expect(nextLine).toBeDefined()
95
+ expect(nextLine!.trimStart().startsWith('┌')).toBe(true)
96
+ })
97
+
98
+ // The tests above only exercise the "one"/"zero-many" glyphs (`│`, `○╟`).
99
+ // Review on #351 (https://github.com/dfadler/zombie-mermaid/issues/351#issuecomment-5497209031)
100
+ // re-tested with a different cardinality combination — "zero-or-one"
101
+ // (`│○`/`○│`) — confirming the vertical marker stays flush either way.
102
+ // "zero-or-one" also collides with the border on the horizontal axis
103
+ // (its leading/trailing glyph is the same `│` as the "one" marker), so
104
+ // — unlike "zero-many" above — *both* sides of a `C |o--|| D` connector
105
+ // get the inset, not just one. These two tests lock in that finding
106
+ // directly rather than leaving it as a PR-description claim with no
107
+ // regression coverage.
108
+
109
+ it('insets the "zero-or-one" marker from the entity border on a horizontal connector, since it collides on both sides — issue #413', () => {
110
+ const ascii = renderMermaidASCII(
111
+ `erDiagram
112
+ C |o--|| D : owns`,
113
+ { colorMode: 'none' },
114
+ )
115
+ // Matches the reviewer's own quoted "main" (pre-#390) output exactly:
116
+ // "C │─│○─│─│ D" — a fill dash separates each entity's border from its
117
+ // own marker on both sides of the connector.
118
+ expect(ascii).toContain('C │─│○')
119
+ expect(ascii).toContain('─│─│ D')
120
+ })
121
+
122
+ it('does not leave a stray fill row before a vertical "zero-or-one" marker', () => {
123
+ // Forces a vertical connection the same way as the test above (a
124
+ // second entity fills row 1, wrapping the third onto its own row), but
125
+ // with the reviewer's "zero-or-one" cardinality on the vertical
126
+ // relationship instead of "zero-many" — confirming flush placement
127
+ // stays clean on the vertical axis regardless of which marker glyph is
128
+ // involved, unlike the horizontal case immediately above.
129
+ const ascii = renderMermaidASCII(
130
+ `erDiagram
131
+ A ||--o{ B : ab
132
+ A |o--|| C : owns`,
133
+ { colorMode: 'none' },
134
+ )
135
+ const lines = ascii.split('\n')
136
+ // A's own end of the A→C relationship ("│○") must sit on the row
137
+ // immediately after A's box bottom border — not with a leftover blank
138
+ // row still separating it from the border.
139
+ const aBoxBottomIdx = lines.findIndex((l) => l.includes('└───┘ ab'))
140
+ expect(aBoxBottomIdx).toBeGreaterThan(-1)
141
+ const markerLine = lines[aBoxBottomIdx + 1]
142
+ expect(markerLine).toBeDefined()
143
+ expect(markerLine).toContain('│○')
144
+ })
145
+ })
146
+
147
+ describe('ASCII ER — relationships routed without colliding (issue #351)', () => {
148
+ it('gives independent relationships between the same two rows distinct jog rows', () => {
149
+ // B→C and A→C both terminate on C's row from A/B's row above; before
150
+ // the fix, every vertical relationship's horizontal jog used the exact
151
+ // same geometric midpoint, so B→C's jog and A→C's label landed on the
152
+ // identical row and overwrote each other.
153
+ const ascii = renderMermaidASCII(
154
+ `erDiagram
155
+ A ||--o{ B : rel1
156
+ B ||--o{ C : rel2
157
+ A ||--o{ C : rel3`,
158
+ { colorMode: 'none' },
159
+ )
160
+ const lines = ascii.split('\n')
161
+
162
+ const rel2Line = lines.find((l) => l.includes('rel2'))
163
+ const rel3Line = lines.find((l) => l.includes('rel3'))
164
+ expect(rel2Line).toBeDefined()
165
+ expect(rel3Line).toBeDefined()
166
+ // Both labels must render intact (not merged/garbled into the same row).
167
+ expect(rel2Line).not.toBe(rel3Line)
168
+ expect(ascii).toContain('rel1')
169
+ expect(ascii).toContain('rel2')
170
+ expect(ascii).toContain('rel3')
171
+ })
172
+
173
+ it('does not cut a relationship label with another relationship’s jog line', () => {
174
+ // Five entities across two component rows, closely mirroring the
175
+ // issue's own description: a later relationship's vertical/horizontal
176
+ // segment used to cut straight through an earlier relationship's label,
177
+ // e.g. "appears_in" rendered as "appear│_in".
178
+ const ascii = renderMermaidASCII(
179
+ `erDiagram
180
+ CUSTOMER ||--o{ ORDER : places
181
+ ORDER ||--|{ LINE_ITEM : contains
182
+ PRODUCT ||--o{ LINE_ITEM : appears_in
183
+ CUSTOMER ||--o{ ADDRESS : has`,
184
+ { colorMode: 'none' },
185
+ )
186
+ // Every label must render as one intact, uninterrupted word — not
187
+ // split by a stray line character routed through its middle.
188
+ expect(ascii).toContain('places')
189
+ expect(ascii).toContain('contains')
190
+ expect(ascii).toContain('appears_in')
191
+ expect(ascii).toContain('has')
192
+ expect(ascii).not.toMatch(/[a-z]+[─│┊╌][a-z]+/)
193
+ })
194
+
195
+ it('does not let a jog row collide with a label whose span falls outside the jog range (CodeRabbit review, PR #390)', () => {
196
+ // A relationship's label is always drawn to the right of the upper
197
+ // entity's own center column, regardless of which way its jog goes —
198
+ // for a leftward jog (or a short rightward one) that label span sits
199
+ // outside [lx, rx], the range renderErAscii originally checked for
200
+ // occupancy before choosing a jog row. Found by a differential search
201
+ // over random small diagrams comparing the old (narrow) and fixed
202
+ // (label-span-widened) occupancy ranges; this is the minimal case that
203
+ // reproduces it deterministically — DDDD--CCC's jog lands on a row
204
+ // that BB--A's own label already used, and without the widened check
205
+ // DDDD--CCC's own label ends up prefixed with a stray "│" bled through
206
+ // from BB--A's vertical stem.
207
+ const ascii = renderMermaidASCII(
208
+ `erDiagram
209
+ DDDD ||--o{ A : x0
210
+ BB ||--o{ A : l1
211
+ DDDD ||--o{ CCC : longerlabelhere2`,
212
+ { colorMode: 'none' },
213
+ )
214
+ const lines = ascii.split('\n')
215
+ const labelLine = lines.find((l) => l.includes('longerlabelhere2'))
216
+ expect(labelLine).toBeDefined()
217
+ const idx = labelLine!.indexOf('longerlabelhere2')
218
+ // The characters immediately before the label must be plain fill dashes,
219
+ // not a stray "│" bled through from an unrelated relationship's
220
+ // vertical stem sharing this row.
221
+ expect(labelLine!.slice(Math.max(0, idx - 3), idx)).not.toContain('│')
222
+ })
223
+ })
224
+
225
+ describe('ASCII ER — compact gutters between disconnected components (issue #351)', () => {
226
+ it('does not leave a large blank gutter between two small disconnected components', () => {
227
+ const ascii = renderMermaidASCII(
228
+ `erDiagram
229
+ CUSTOMER ||--o{ ORDER : places
230
+ PRODUCT ||--o{ LINE_ITEM : appears_in`,
231
+ { colorMode: 'none' },
232
+ )
233
+ const lines = ascii.split('\n')
234
+
235
+ // Count the run of fully-blank lines between the two components.
236
+ const firstBoxLine = lines.findIndex((l) => l.includes('└'))
237
+ const secondComponentLine = lines.findIndex(
238
+ (l, i) => i > firstBoxLine && l.includes('PRODUCT'),
239
+ )
240
+ expect(firstBoxLine).toBeGreaterThan(-1)
241
+ expect(secondComponentLine).toBeGreaterThan(firstBoxLine)
242
+
243
+ let blankRun = 0
244
+ for (let i = firstBoxLine + 1; i < secondComponentLine; i++) {
245
+ if (lines[i]!.trim() === '') blankRun++
246
+ }
247
+ // Before the fix, componentGap=6 produced a run of 6 fully blank rows
248
+ // for this exact diagram. A visual break is still expected, but not a
249
+ // gutter that dwarfs the content itself.
250
+ expect(blankRun).toBeLessThanOrEqual(3)
251
+
252
+ // Sanity: both components still render in full.
253
+ expect(ascii).toContain('CUSTOMER')
254
+ expect(ascii).toContain('ORDER')
255
+ expect(ascii).toContain('PRODUCT')
256
+ expect(ascii).toContain('LINE_ITEM')
257
+ })
258
+ })
259
+
260
+ describe('chooseFreeRow / isRowFree (unit)', () => {
261
+ // renderErAscii's fixed vGap=4 only ever produces a 2-row-wide candidate
262
+ // interval, so the geometric midpoint is always the interval's first
263
+ // (topmost) row — the "scan upward" half of chooseFreeRow's search can
264
+ // never find a free row through the full render pipeline alone. These
265
+ // tests exercise that logic directly against a wider, constructed range.
266
+
267
+ it('returns the geometric midpoint when it is free', () => {
268
+ const canvas = mkCanvas(20, 20)
269
+ expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3)
270
+ })
271
+
272
+ it('scans downward when the midpoint is occupied', () => {
273
+ const canvas = mkCanvas(20, 20)
274
+ occupyRow(canvas, 3, 0, 10) // midpoint occupied
275
+ expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(4)
276
+ })
277
+
278
+ it('scans upward when the midpoint and every row below it are occupied', () => {
279
+ const canvas = mkCanvas(20, 20)
280
+ occupyRow(canvas, 3, 0, 10) // midpoint
281
+ occupyRow(canvas, 4, 0, 10) // one below
282
+ // row 2 (one above the midpoint) stays free
283
+ expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(2)
284
+ })
285
+
286
+ it('falls back to the geometric midpoint when every candidate row is occupied', () => {
287
+ const canvas = mkCanvas(20, 20)
288
+ for (let y = 1; y <= 5; y++) occupyRow(canvas, y, 0, 10)
289
+ expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3)
290
+ })
291
+
292
+ it('ignores the skipped column when checking occupancy', () => {
293
+ const canvas = mkCanvas(20, 20)
294
+ write(canvas, 5, 3, '#') // only the skipped column is occupied
295
+ expect(isRowFree(canvas, 3, 0, 10, 5)).toBe(true)
296
+ expect(isRowFree(canvas, 3, 0, 10, -1)).toBe(false)
297
+ })
298
+
299
+ // CodeRabbit review on PR #390: a column past the canvas's current width
300
+ // was reported as `undefined` by a plain index and treated as occupied,
301
+ // even though nothing had actually been drawn there yet — a label routinely
302
+ // grows the canvas on write (see increaseSize at the label call sites),
303
+ // so a not-yet-allocated column is "not drawn", not "occupied".
304
+ it('treats a column past the canvas width as free, not occupied', () => {
305
+ const canvas = mkCanvas(5, 5) // columns 0..5 only
306
+ expect(isRowFree(canvas, 2, 0, 10, -1)).toBe(true)
307
+ expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3) // still the midpoint
308
+ })
309
+
310
+ // CodeRabbit review on PR #390: renderErAscii's jog-row selection checked
311
+ // occupancy only across [lx, rx] (the jog's own span), not the
312
+ // relationship's label — which is always drawn to the right of the upper
313
+ // entity's center column, regardless of which way the jog itself goes.
314
+ // For a leftward jog (rx sits at the upper entity's own column), the
315
+ // label span falls entirely outside that checked range.
316
+ it('rejects a midpoint row whose label span (right of the jog range) is occupied', () => {
317
+ const canvas = mkCanvas(30, 20)
318
+ const lineX = 10 // upper entity's center column
319
+ const lowerCX = 4 // lower entity's center column — left of lineX: a leftward jog
320
+ const lx = Math.min(lineX, lowerCX)
321
+ const rx = Math.max(lineX, lowerCX) // === lineX; the label starts past this
322
+ const labelEndX = lineX + 1 + 'some label'.length
323
+ // Nothing occupies [lx, rx] at the midpoint, but the label area
324
+ // (lineX + 2 .. labelEndX) is already occupied by earlier content.
325
+ occupyRow(canvas, 3, lineX + 2, labelEndX)
326
+ // Checking only the jog's own span (the pre-fix behavior) would accept
327
+ // row 3 even though the label that lands there next would collide.
328
+ expect(isRowFree(canvas, 3, lx, rx, lineX)).toBe(true)
329
+ // Widening the checked range to include the label span (the fix) finds
330
+ // the collision and moves to a different row instead.
331
+ const chosen = chooseFreeRow(
332
+ canvas,
333
+ 0,
334
+ 6,
335
+ lx,
336
+ Math.max(rx, labelEndX),
337
+ lineX,
338
+ )
339
+ expect(chosen).not.toBe(3)
340
+ expect(isRowFree(canvas, chosen, lx, Math.max(rx, labelEndX), lineX)).toBe(
341
+ true,
342
+ )
343
+ })
344
+ })