@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,187 @@
1
+ // ============================================================================
2
+ // ASCII class diagram — cross-level connector jog corrupting an intervening
3
+ // box's text
4
+ //
5
+ // Discovered incidentally while writing ascii-class-box-occupancy.test.ts's
6
+ // coverage for issue #953/PR #957 (the *same-level* relationship cycle's
7
+ // detour line lacking an occupancy guard). This is a distinct bug in a
8
+ // different branch of src/ascii/class-diagram.ts's relationship-drawing
9
+ // pass — the "target is below source" (and, by the same missing guard,
10
+ // "target is above source") branch, used whenever a relationship's two
11
+ // classes land on different levels.
12
+ //
13
+ // That branch's "no collision" routing draws a straight horizontal jog
14
+ // connecting the source's lane column to the target's lane column at some
15
+ // row between them. The *only* check that decided this was safe to draw
16
+ // without a detour (`findClearColumn`) tests whether the source's own lane
17
+ // column is itself clear of boxes across the full row range — it says
18
+ // nothing about whether the horizontal jog's row is clear all the way over
19
+ // to the target's column. When a class ends up positioned far from its
20
+ // actual parent's column — e.g. a level's only child is always placed at
21
+ // the leftmost slot regardless of which sibling at the level above is its
22
+ // parent — and a *different*, taller same-level sibling's box sits between
23
+ // those two columns, the jog cuts straight through that sibling's box,
24
+ // silently replacing whichever attribute/method row it crosses.
25
+ //
26
+ // Fixed by giving src/ascii/class-diagram.ts the same kind of box-occupancy
27
+ // guard (`boxCells`/`setCGuarded`) src/ascii/er-diagram.ts already has for
28
+ // issue #350, scoped to the cross-level branches only — the same-level
29
+ // branch's own detour (PR #957's scope) is untouched here.
30
+ // ============================================================================
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
34
+ import { findBoxRect, rectsOverlap } from './helpers/ascii-form.ts'
35
+
36
+ function expectNoBoxOverlap(ascii: string, names: string[]): void {
37
+ const rects = names.map((n) => ({ n, r: findBoxRect(ascii, n) }))
38
+ for (let i = 0; i < rects.length; i++) {
39
+ for (let j = i + 1; j < rects.length; j++) {
40
+ expect(
41
+ rectsOverlap(rects[i]!.r, rects[j]!.r),
42
+ `${rects[i]!.n} and ${rects[j]!.n} boxes overlap`,
43
+ ).toBe(false)
44
+ }
45
+ }
46
+ }
47
+
48
+ function manyAttrs(prefix: string, count: number): string {
49
+ return Array.from(
50
+ { length: count },
51
+ (_, i) => ` +String ${prefix}${i}`,
52
+ ).join('\n')
53
+ }
54
+
55
+ /** Assert every one of `needles` appears verbatim, exactly once, in `text`. */
56
+ function expectAllPresentOnce(text: string, needles: string[]): void {
57
+ for (const needle of needles) {
58
+ const count = text.split(needle).length - 1
59
+ expect(count, `expected "${needle}" to appear exactly once`).toBe(1)
60
+ }
61
+ }
62
+
63
+ describe('ASCII class diagrams — cross-level connector jog never corrupts a box', () => {
64
+ it('root-level sibling sits between a source and its far-off, sole child (minimal repro)', () => {
65
+ // Left/TallMid/RightSrc are three unrelated level-0 roots (no cycle
66
+ // involved, unlike the original report — isolates this bug from the
67
+ // separately-tracked same-level detour issue). Kid is RightSrc's only
68
+ // child, so it lands at the leftmost slot of level 1 regardless of
69
+ // RightSrc's own (rightmost) column — the "no collision" routing then
70
+ // has to jog the whole width of the canvas, straight through TallMid.
71
+ const src = `classDiagram
72
+ class Left
73
+ class TallMid {
74
+ ${manyAttrs('b', 6)}
75
+ }
76
+ class RightSrc
77
+ class Kid
78
+ RightSrc --> Kid`
79
+ const ascii = renderMermaidASCII(src, { useAscii: true })
80
+ expectAllPresentOnce(ascii, [
81
+ '+ b0: String',
82
+ '+ b1: String',
83
+ '+ b2: String',
84
+ '+ b3: String',
85
+ '+ b4: String',
86
+ '+ b5: String',
87
+ ])
88
+ expectNoBoxOverlap(ascii, ['Left', 'TallMid', 'RightSrc', 'Kid'])
89
+ })
90
+
91
+ it('originally-reported repro: same-level cycle plus an unrelated root/child pair', () => {
92
+ // The exact topology this bug was first reported against. Alpha/Mid/
93
+ // Charlie form a same-level relationship cycle (falls to level 0 via
94
+ // the level-BFS's "no root" fallback); Root is a genuine level-0 root
95
+ // (declared after the cycle) with its own child Kid at level 1. Only
96
+ // "+ b4: String" is asserted here — the cross-level bug this file
97
+ // covers. "+ b1: String" is a *separate*, already-tracked corruption
98
+ // (the same-level cycle's own detour, issue #953/PR #957) and is
99
+ // deliberately not asserted on so this suite stays independent of
100
+ // that fix landing.
101
+ const src = `classDiagram
102
+ class Alpha
103
+ class Mid {
104
+ ${manyAttrs('b', 6)}
105
+ }
106
+ class Charlie
107
+ class Root
108
+ class Kid
109
+ Alpha --> Mid
110
+ Mid --> Charlie
111
+ Charlie --> Alpha
112
+ Root --> Kid`
113
+ const ascii = renderMermaidASCII(src, { useAscii: true })
114
+ expect(
115
+ ascii,
116
+ '"+ b4: String" was overwritten by Root->Kid\'s jog',
117
+ ).toContain('+ b4: String')
118
+ })
119
+
120
+ it('deeper tree: a tall level-0 sibling between a level-1 source and its level-2 grandchild', () => {
121
+ const src = `classDiagram
122
+ class A
123
+ class TallMid {
124
+ ${manyAttrs('b', 6)}
125
+ }
126
+ class B
127
+ class Kid1
128
+ class GrandKid
129
+ B --> Kid1
130
+ Kid1 --> GrandKid`
131
+ const ascii = renderMermaidASCII(src, { useAscii: true })
132
+ expectAllPresentOnce(ascii, [
133
+ '+ b0: String',
134
+ '+ b1: String',
135
+ '+ b2: String',
136
+ '+ b3: String',
137
+ '+ b4: String',
138
+ '+ b5: String',
139
+ ])
140
+ expectNoBoxOverlap(ascii, ['A', 'TallMid', 'B', 'Kid1', 'GrandKid'])
141
+ })
142
+
143
+ it('more siblings: several unrelated roots plus a separate parent/child pair, inheritance marker', () => {
144
+ const src = `classDiagram
145
+ class Left
146
+ class TallMid {
147
+ ${manyAttrs('d', 6)}
148
+ }
149
+ class RightSrc
150
+ class Kid
151
+ RightSrc <|-- Kid`
152
+ const ascii = renderMermaidASCII(src, { useAscii: true })
153
+ expectAllPresentOnce(ascii, [
154
+ '+ d0: String',
155
+ '+ d1: String',
156
+ '+ d2: String',
157
+ '+ d3: String',
158
+ '+ d4: String',
159
+ '+ d5: String',
160
+ ])
161
+ expectNoBoxOverlap(ascii, ['Left', 'TallMid', 'RightSrc', 'Kid'])
162
+ })
163
+
164
+ it('"target above source" branch with a from-side marker renders without corrupting a box', () => {
165
+ // The "above" branch (this file's second `else if`) has no
166
+ // findClearColumn collision-avoidance at all — it always draws the
167
+ // straight jog/vertical/horizontal/vertical/jog path, so it's just as
168
+ // exposed to this bug as the "below" branch, and is guarded the same
169
+ // way here. A `Root --> A` / `A <|-- B` / `B --> A` back-edge forces
170
+ // A's level above B's (the level-assignment BFS keeps the *longest*
171
+ // path to a node, so B->A's back edge pushes A past B) — the
172
+ // `A <|-- B` edge then runs from the now-deeper A up to the
173
+ // shallower B, taking the "above" branch with its hollow-triangle
174
+ // marker at the 'from' end (A), a combination no other test in this
175
+ // file exercises.
176
+ const src = `classDiagram
177
+ class Root
178
+ class A
179
+ class B
180
+ class Extra
181
+ Root --> A
182
+ A <|-- B
183
+ B --> A`
184
+ const ascii = renderMermaidASCII(src, { useAscii: true })
185
+ expectNoBoxOverlap(ascii, ['Root', 'A', 'B', 'Extra'])
186
+ })
187
+ })
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Regression test for #487 — "ASCII class diagram: relationship labels
3
+ * ignore detour routing, appear attached to wrong box."
4
+ *
5
+ * Root cause: `renderClassAscii` anchored every relationship's label at the
6
+ * *idealized straight-line* midpoint between its source and target
7
+ * connection columns (`idealMidX = Math.floor((fromCX + toCX) / 2)`), even
8
+ * when the relationship's line actually detours around an intermediate box
9
+ * (the `needsDetour` / `findClearColumn` branch). On the "Class: MVC
10
+ * Architecture" sample, `Controller --> View : refreshes` detours around
11
+ * `Model` to reach `View`, but its label still anchored on the straight
12
+ * Controller/View midpoint — which happened to coincide with
13
+ * `Controller --> Model : updates`'s own (non-detoured, correct) label one
14
+ * row below it, making both labels read as though they terminated at
15
+ * `Model`.
16
+ *
17
+ * The fix (`computeLabelAnchor` in `class-diagram.ts`) anchors a detoured
18
+ * relationship's label on its *actual routed path* instead: the midpoint of
19
+ * the detour's vertical trunk (falling back to the longer horizontal jog
20
+ * when the trunk has no vertical room), flush against the trunk's clear
21
+ * side rather than centered on it — centering a label on the trunk's single
22
+ * clear column would re-overlap the very box the trunk was routed around,
23
+ * since `findClearColumn` only guarantees that one column is clear, not a
24
+ * whole label-width window.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
29
+ import { findBoxRect } from './helpers/ascii-form.ts'
30
+
31
+ const MVC_SOURCE = `classDiagram
32
+ class Model {
33
+ -data Map
34
+ +getData() Map
35
+ +setData(key, val) void
36
+ +notify() void
37
+ }
38
+ class View {
39
+ -model Model
40
+ +render() void
41
+ +update() void
42
+ }
43
+ class Controller {
44
+ -model Model
45
+ -view View
46
+ +handleInput(event) void
47
+ +updateModel(data) void
48
+ }
49
+ Controller --> Model : updates
50
+ Controller --> View : refreshes
51
+ View --> Model : reads
52
+ Model ..> View : notifies`
53
+
54
+ describe('ASCII class diagram — detour-aware relationship label placement (issue #487)', () => {
55
+ it("places `refreshes` on its own row, off `updates`'s arrowhead column", () => {
56
+ const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
57
+ const lines = ascii.split('\n')
58
+
59
+ const updatesRow = lines.findIndex((l) => l.includes('updates'))
60
+ const refreshesRow = lines.findIndex((l) => l.includes('refreshes'))
61
+ expect(updatesRow).toBeGreaterThanOrEqual(0)
62
+ expect(refreshesRow).toBeGreaterThanOrEqual(0)
63
+
64
+ // Before the fix, both labels sat on adjacent rows directly above the
65
+ // single arrowhead entering Model, reading as though both relationships
66
+ // terminated there. `refreshes` must not share `updates`'s column range
67
+ // (the sole visual cue, pre-fix, that made them look like the same
68
+ // termination) on any row, and in particular not on `updates`'s own row.
69
+ expect(refreshesRow).not.toBe(updatesRow)
70
+
71
+ const updatesLine = lines[updatesRow]!
72
+ const updatesStart = updatesLine.indexOf('updates')
73
+ const updatesEnd = updatesStart + 'updates'.length - 1
74
+
75
+ const refreshesLine = lines[refreshesRow]!
76
+ const refreshesStart = refreshesLine.indexOf('refreshes')
77
+ const refreshesEnd = refreshesStart + 'refreshes'.length - 1
78
+
79
+ // No column overlap between the two labels anywhere they might appear.
80
+ const overlapsColumns =
81
+ refreshesStart <= updatesEnd && updatesStart <= refreshesEnd
82
+ expect(overlapsColumns).toBe(false)
83
+ })
84
+
85
+ it("anchors `refreshes` past Model's right border, not centered inside its footprint", () => {
86
+ const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
87
+ const lines = ascii.split('\n')
88
+
89
+ // Model's box right edge (column-based, not a literal-column-0
90
+ // assumption — issue #971 can legitimately shift Model's own column
91
+ // when it has a real single qualifying parent to align under, same as
92
+ // it does here against Controller). `refreshes` — the label for the
93
+ // relationship that detours around Model to reach View — must land
94
+ // strictly to the right of that edge on every row it occupies, never
95
+ // overlapping Model's own footprint the way the straight-line midpoint
96
+ // used to (issue #487).
97
+ const modelRect = findBoxRect(ascii, 'Model')
98
+ const modelRightBorder = modelRect.x1
99
+ expect(modelRightBorder).toBeGreaterThan(0)
100
+
101
+ const refreshesRow = lines.findIndex((l) => l.includes('refreshes'))
102
+ expect(refreshesRow).toBeGreaterThanOrEqual(0)
103
+ const refreshesStart = lines[refreshesRow]!.indexOf('refreshes')
104
+ expect(refreshesStart).toBeGreaterThan(modelRightBorder)
105
+ })
106
+
107
+ it('does not move any non-detoured relationship label (reads, notifies, updates stay put)', () => {
108
+ const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
109
+ for (const label of ['updates', 'reads', 'notifies', 'refreshes']) {
110
+ expect(ascii).toContain(label)
111
+ }
112
+ expect(ascii).not.toContain('…')
113
+ })
114
+ })
@@ -0,0 +1,70 @@
1
+ // ============================================================================
2
+ // ASCII class diagram compartment tests (issue #87)
3
+ // ============================================================================
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
7
+
8
+ describe('ASCII class diagram box compartments', () => {
9
+ it('omits the attrs compartment for a class with methods but no attributes (regression)', () => {
10
+ // Before the fix, buildClassSections() always returned a 3-section box
11
+ // once methods were non-empty, even with zero attrs — drawMultiBox then
12
+ // rendered that empty section as a visible blank compartment.
13
+ const ascii = renderMermaidASCII(
14
+ `classDiagram
15
+ class Animal {
16
+ +makeSound()
17
+ }`,
18
+ { useAscii: true },
19
+ )
20
+ const lines = ascii.split('\n').filter((l) => l.trim().length > 0)
21
+ // A box with N sections has N+1 horizontal border lines (top, N-1
22
+ // internal dividers, bottom). 2 sections (header + methods) -> 3.
23
+ const dividerCount = lines.filter((l) => /^\+-+\+$/.test(l.trim())).length
24
+ expect(dividerCount).toBe(3)
25
+ expect(ascii).toContain('makeSound')
26
+ expect(ascii).not.toMatch(/\|\s*\|\n\+-+\+\n\|\s*\+makeSound/)
27
+ })
28
+
29
+ it('omits the methods compartment for a class with attrs but no methods (no regression)', () => {
30
+ const ascii = renderMermaidASCII(
31
+ `classDiagram
32
+ class Point {
33
+ +int x
34
+ }`,
35
+ { useAscii: true },
36
+ )
37
+ expect(ascii).toContain('x')
38
+ const lines = ascii.split('\n').filter((l) => l.trim().length > 0)
39
+ const dividerCount = lines.filter((l) => /^\+-+\+$/.test(l.trim())).length
40
+ expect(dividerCount).toBe(3)
41
+ })
42
+
43
+ it('renders both compartments for a class with attrs and methods', () => {
44
+ const ascii = renderMermaidASCII(
45
+ `classDiagram
46
+ class Circle {
47
+ +radius int
48
+ +draw() void
49
+ }`,
50
+ { useAscii: true },
51
+ )
52
+ expect(ascii).toContain('radius')
53
+ expect(ascii).toContain('draw')
54
+ const lines = ascii.split('\n').filter((l) => l.trim().length > 0)
55
+ const dividerCount = lines.filter((l) => /^\+-+\+$/.test(l.trim())).length
56
+ expect(dividerCount).toBe(4)
57
+ })
58
+
59
+ it('renders header only for a class with neither attrs nor methods', () => {
60
+ const ascii = renderMermaidASCII(
61
+ `classDiagram
62
+ class Empty`,
63
+ { useAscii: true },
64
+ )
65
+ expect(ascii).toContain('Empty')
66
+ const lines = ascii.split('\n').filter((l) => l.trim().length > 0)
67
+ const dividerCount = lines.filter((l) => /^\+-+\+$/.test(l.trim())).length
68
+ expect(dividerCount).toBe(2)
69
+ })
70
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Regression test for a pre-existing, previously-untested branch in
3
+ * `renderClassAscii`'s label placement: when a relationship's label would
4
+ * land inside another class's box (e.g. a "skip" edge whose two endpoints
5
+ * straddle a third class positioned between them), the renderer must find
6
+ * a clear gap instead of overwriting that box's content.
7
+ *
8
+ * `A --> C : skips`, with `B` (from the unrelated `A --> B --> C` chain)
9
+ * positioned directly between them, puts `skips`'s ideal label position
10
+ * inside B's box — this covers `renderClassAscii`'s `labelInBox` /
11
+ * gap-search fallback.
12
+ *
13
+ * Since #487's detour-aware label placement, `skips` (which detours around
14
+ * B) anchors on its own detour trunk — a column past B's right border —
15
+ * rather than searching for an entirely box-free *row*. That can legitimately
16
+ * put the label on the same terminal row as one of B's content lines (the
17
+ * same pattern the MVC sample's `refreshes` label uses, sharing a row with
18
+ * Model's compartment separator), as long as it never overlaps B's own
19
+ * *columns* — this test now checks that non-overlap directly instead of
20
+ * requiring a different row entirely.
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
25
+
26
+ describe('ASCII class diagram — label collision with an intermediate box', () => {
27
+ it('moves a skip-edge label out of an intermediate class box instead of overwriting it', () => {
28
+ const ascii = renderMermaidASCII(
29
+ `classDiagram
30
+ class A
31
+ class B
32
+ class C
33
+ A --> B
34
+ B --> C
35
+ A --> C : skips`,
36
+ { colorMode: 'none' },
37
+ )
38
+
39
+ const lines = ascii.split('\n')
40
+ const labelRow = lines.findIndex((l) => l.includes('skips'))
41
+ expect(labelRow).toBeGreaterThanOrEqual(0)
42
+
43
+ // B's box must still render intact — "B" itself, and both of its box
44
+ // borders — nowhere overwritten by the "skips" label text: wherever the
45
+ // label lands (even on the same row as one of B's own lines), its
46
+ // columns must fall entirely outside B's box columns.
47
+ const bRow = lines.findIndex((l) => l.includes('│ B │'))
48
+ expect(bRow).toBeGreaterThanOrEqual(0)
49
+ const bLine = lines[bRow]!
50
+ const bBoxStart = bLine.indexOf('│ B │')
51
+ const bBoxEnd = bBoxStart + '│ B │'.length - 1
52
+
53
+ const labelLine = lines[labelRow]!
54
+ const labelStart = labelLine.indexOf('skips')
55
+ const labelEnd = labelStart + 'skips'.length - 1
56
+ const overlapsOnSharedRow =
57
+ labelRow === bRow && labelStart <= bBoxEnd && bBoxStart <= labelEnd
58
+ expect(overlapsOnSharedRow).toBe(false)
59
+ })
60
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Regression test for issue #531: at a small enough `paddingY`, a
3
+ * relationship label could vanish from ASCII class-diagram output with no
4
+ * error, no ellipsis, and no trace.
5
+ *
6
+ * Root cause: `renderClassAscii` resolves each relationship label's row in
7
+ * two independent places. `computeLabelAnchor` gives a label a "raw" ideal
8
+ * row (`baseMidY`). If that row lands inside a box, a runtime fallback (the
9
+ * "box-gap scan") searches the from/to gap for the nearest row that is at
10
+ * least clear of boxes, and the draw pass uses whatever row the fallback
11
+ * picked. Separately, a horizontal-territory precompute (added for issue
12
+ * #447, to stop two same-row labels from overwriting each other) decides
13
+ * which labels need X-territory splitting by comparing each pair's row
14
+ * bands — but it computed those bands from the *raw* `baseMidY`, before the
15
+ * box-gap-scan fallback could move a label to a completely different row.
16
+ * Two relationships whose *ideal* rows never overlapped could still both
17
+ * resolve, via the fallback, to the *same actual* row — invisibly to the
18
+ * territory precompute, which never split their columns because it never
19
+ * saw them as colliding. The later relationship's unconditional `setC`
20
+ * write then silently overwrote the earlier one's already-drawn label.
21
+ *
22
+ * A second, related gap: even once territory rowStart/rowEnd values reflect
23
+ * the real (post-fallback) row, the split algorithm only ever compared a
24
+ * label against its immediate neighbor by `idealMidX` order. When a third
25
+ * label — on a different, non-colliding row — happened to sit between two
26
+ * same-row colliding labels in `idealMidX` order, the pair that actually
27
+ * needed splitting was never adjacent in that order and so was never
28
+ * compared at all.
29
+ *
30
+ * The fix (`resolveLabelFinalY` in class-diagram.ts) resolves each label's
31
+ * final row once, up front, and both the territory precompute and the draw
32
+ * pass reuse that single resolved value — so they can never disagree.
33
+ * Territory splitting also now scans outward for the nearest row-*actually*-
34
+ * overlapping neighbor instead of stopping at the immediate one. Neither
35
+ * fix is specific to detour routing (issue #487/#489's "route around an
36
+ * intermediate box" mechanism): the second test below reproduces the same
37
+ * silent-drop failure with a diagram where no relationship detours at all,
38
+ * confirming the bug (and the fix) live in the general label-placement
39
+ * mechanism, not the detour-trunk logic.
40
+ */
41
+
42
+ import { describe, it, expect } from 'vitest'
43
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
44
+
45
+ /** paddingY values below the default (5) that the issue found all reproduce
46
+ * the same corruption — the vertical gap floors at 1 row, so nothing about
47
+ * it changes at more extreme negative values. */
48
+ const tightPaddingYValues = [4, 3, 2, 1, 0, -1, -5, -10]
49
+
50
+ describe('ASCII class diagram — relationship label row collision (issue #531)', () => {
51
+ describe("detour-involving repro (the issue's own minimal repro)", () => {
52
+ const src = `classDiagram
53
+ class Model
54
+ class View
55
+ class Controller
56
+ Controller --> Model : updates
57
+ Controller --> View : refreshes
58
+ View --> Model : reads
59
+ Model ..> View : notifies`
60
+
61
+ it('renders every label in full at the default paddingY', () => {
62
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
63
+ expect(out).toContain('updates')
64
+ expect(out).toContain('refreshes')
65
+ expect(out).toContain('reads')
66
+ expect(out).toContain('notifies')
67
+ })
68
+
69
+ it.each(tightPaddingYValues)(
70
+ 'never silently drops a label at paddingY=%d',
71
+ (paddingY) => {
72
+ const out = renderMermaidASCII(src, { colorMode: 'none', paddingY })
73
+
74
+ // Three of the four labels have room to render in full regardless.
75
+ expect(out).toContain('updates')
76
+ expect(out).toContain('reads')
77
+ expect(out).toContain('notifies')
78
+
79
+ // `refreshes` shares a row with `updates` at this tight spacing and
80
+ // must yield some of its width via territory-splitting/truncation
81
+ // (issue #447's established, accepted behavior for genuinely
82
+ // insufficient space) — but it must never disappear *entirely* the
83
+ // way it (or `updates`, depending on exact geometry) did before the
84
+ // fix. Assert the real invariant rather than pinning the exact
85
+ // truncated glyphs: the row that carries `updates` must carry some
86
+ // non-blank content besides it, where the old bug left nothing.
87
+ const line = out.split('\n').find((l) => l.includes('updates'))
88
+ expect(line).toBeDefined()
89
+ const withoutUpdates = line!.replace('updates', '').trim()
90
+ expect(withoutUpdates.length).toBeGreaterThan(0)
91
+ },
92
+ )
93
+ })
94
+
95
+ describe('non-detour repro (general mechanism, no detour routing involved)', () => {
96
+ // A -> B, B -> C (adjacent, direct) plus A -> C (a "skip" relationship
97
+ // whose direct straight-line midpoint lands inside B's box, triggering
98
+ // the same box-gap-scan fallback purely from vertical box geometry —
99
+ // no relationship here ever detours horizontally). Confirms the bug (and
100
+ // the fix) are independent of the #487/#489 detour-trunk logic, per the
101
+ // issue's own "Scope note".
102
+ const src = `classDiagram
103
+ class A
104
+ class B
105
+ class C
106
+ A --> B : sib1
107
+ B --> C : sib2
108
+ A --> C : longlabelhere`
109
+
110
+ it('renders every label in full at the default paddingY', () => {
111
+ const out = renderMermaidASCII(src, { colorMode: 'none' })
112
+ expect(out).toContain('sib1')
113
+ expect(out).toContain('sib2')
114
+ expect(out).toContain('longlabelhere')
115
+ })
116
+
117
+ it.each(tightPaddingYValues)(
118
+ 'never silently drops a label at paddingY=%d',
119
+ (paddingY) => {
120
+ const out = renderMermaidASCII(src, { colorMode: 'none', paddingY })
121
+
122
+ expect(out).toContain('sib1')
123
+ expect(out).toContain('sib2')
124
+
125
+ // `longlabelhere` shares a row with `sib1` at this tight spacing and
126
+ // gets truncated rather than disappearing outright — see the note
127
+ // in the sibling describe block above.
128
+ const line = out.split('\n').find((l) => l.includes('sib1'))
129
+ expect(line).toBeDefined()
130
+ const withoutSib1 = line!.replace('sib1', '').trim()
131
+ expect(withoutSib1.length).toBeGreaterThan(0)
132
+ },
133
+ )
134
+ })
135
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Regression test for a bug discovered while integrating issue #447 with
3
+ * issue #448: `renderClassAscii`'s label-territory precomputation
4
+ * (added for #447) split/truncated two labels whenever their *horizontal*
5
+ * positions were close, even when the two relationships render on
6
+ * completely different rows and never actually visually collide — e.g.
7
+ * one class's two separate outgoing edges to two different targets at
8
+ * different heights (`Controller --> Model : updates` and
9
+ * `Controller --> View : refreshes` in the "Class: MVC Architecture"
10
+ * sample, which — before this fix — rendered as `refre…`/`…ates` even
11
+ * though nothing else was drawn anywhere near those cells).
12
+ *
13
+ * The fix tracks each label's row span (rowStart/rowEnd, mirroring the
14
+ * draw pass's own baseMidY calculation) and only treats two labels as
15
+ * needing territory-splitting when their row spans actually overlap.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
20
+
21
+ describe('ASCII class diagram — label territory is row-aware (issue #447 follow-up)', () => {
22
+ it('does not truncate two labels whose relationships share a source but target different, differently-positioned classes', () => {
23
+ // Uses the full "Class: MVC Architecture" sample source (samples-data.ts)
24
+ // — reproducing this needs its exact box layout; a hand-trimmed subset
25
+ // (e.g. dropping the `reads`/`notifies` pair) shifts row positions
26
+ // enough that `updates`/`refreshes` no longer collide, defeating the
27
+ // regression check.
28
+ const ascii = renderMermaidASCII(
29
+ `classDiagram
30
+ class Model {
31
+ -data Map
32
+ +getData() Map
33
+ +setData(key, val) void
34
+ +notify() void
35
+ }
36
+ class View {
37
+ -model Model
38
+ +render() void
39
+ +update() void
40
+ }
41
+ class Controller {
42
+ -model Model
43
+ -view View
44
+ +handleInput(event) void
45
+ +updateModel(data) void
46
+ }
47
+ Controller --> Model : updates
48
+ Controller --> View : refreshes
49
+ View --> Model : reads
50
+ Model ..> View : notifies`,
51
+ { colorMode: 'none' },
52
+ )
53
+
54
+ expect(ascii).toContain('updates')
55
+ expect(ascii).toContain('refreshes')
56
+ })
57
+ })