@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,150 @@
1
+ /**
2
+ * Regression tests for issue #66 — ASCII renderer box borders misaligned by
3
+ * CJK/fullwidth-form characters.
4
+ *
5
+ * The ASCII grid is column-major with one grid cell per JS code point, but
6
+ * CJK/kana/hangul/fullwidth-form/emoji characters occupy one grid cell while
7
+ * rendering as TWO columns in a real monospace terminal. Left unhandled,
8
+ * this makes box borders too narrow for wide-character labels.
9
+ *
10
+ * `terminalDisplayWidth` re-implements the "how many terminal columns does
11
+ * this string occupy" calculation independently of
12
+ * `src/ascii/display-width.ts`, so these tests verify the *rendered output*
13
+ * against an authority separate from the implementation under test — not
14
+ * just that the fix's own helper agrees with itself.
15
+ */
16
+ import { describe, it, expect } from 'vitest'
17
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
18
+ import {
19
+ terminalDisplayWidth,
20
+ assertUniformDisplayWidth,
21
+ } from './helpers/terminal-display-width.ts'
22
+
23
+ describe('ASCII renderer — CJK/fullwidth character box alignment (issue #66)', () => {
24
+ const mermaid = 'flowchart TD\n A[日本語テスト] --> B[終了]'
25
+
26
+ for (const useAscii of [false, true]) {
27
+ describe(useAscii ? 'ASCII charset' : 'Unicode charset', () => {
28
+ it('renders the issue repro with all rows sharing one terminal display width', () => {
29
+ const ascii = renderMermaidASCII(mermaid, {
30
+ useAscii,
31
+ colorMode: 'none',
32
+ })
33
+ assertUniformDisplayWidth(ascii)
34
+ })
35
+
36
+ it('sizes the label box wide enough for the CJK label content', () => {
37
+ const ascii = renderMermaidASCII(mermaid, {
38
+ useAscii,
39
+ colorMode: 'none',
40
+ })
41
+ const lines = ascii.split('\n')
42
+
43
+ const labelLineIndex = lines.findIndex((l) =>
44
+ l.includes('日本語テスト'),
45
+ )
46
+ expect(labelLineIndex).toBeGreaterThanOrEqual(0)
47
+ const labelLine = lines[labelLineIndex]!
48
+
49
+ // The border directly above the label line delimits the box width.
50
+ const borderLine = lines[labelLineIndex - 1]!
51
+ expect(borderLine.length).toBeGreaterThan(0)
52
+
53
+ // The label row and its border must occupy the same terminal width —
54
+ // that is the concrete manifestation of "borders line up".
55
+ expect(terminalDisplayWidth(labelLine)).toBe(
56
+ terminalDisplayWidth(borderLine),
57
+ )
58
+
59
+ // The label's own display width (6 fullwidth glyphs = 12 columns)
60
+ // must fit inside the box's interior (border width minus the two
61
+ // vertical border characters).
62
+ const boxDisplayWidth = terminalDisplayWidth(borderLine)
63
+ const labelDisplayWidth = terminalDisplayWidth('日本語テスト')
64
+ expect(labelDisplayWidth).toBe(12)
65
+ expect(boxDisplayWidth).toBeGreaterThanOrEqual(labelDisplayWidth + 2)
66
+ })
67
+
68
+ it('keeps the box vertical borders in the same column on every row', () => {
69
+ const ascii = renderMermaidASCII(mermaid, {
70
+ useAscii,
71
+ colorMode: 'none',
72
+ })
73
+ const lines = ascii.split('\n')
74
+ const vChar = useAscii ? '|' : '│'
75
+
76
+ const labelLineIndex = lines.findIndex((l) =>
77
+ l.includes('日本語テスト'),
78
+ )
79
+ const labelLine = lines[labelLineIndex]!
80
+ const blankLineAbove = lines[labelLineIndex - 2]! // top padding row
81
+
82
+ // The right border character's *string* index differs between the
83
+ // CJK row and an all-ASCII row in the same box (because the CJK row
84
+ // has fewer code units for the same display width) — but the
85
+ // terminal-column index of the right border must match.
86
+ const cjkRightBorderCol = terminalDisplayWidth(
87
+ labelLine.slice(0, labelLine.lastIndexOf(vChar)),
88
+ )
89
+ const blankRightBorderCol = terminalDisplayWidth(
90
+ blankLineAbove.slice(0, blankLineAbove.lastIndexOf(vChar)),
91
+ )
92
+ expect(cjkRightBorderCol).toBe(blankRightBorderCol)
93
+ })
94
+
95
+ it('does not introduce diagonal or otherwise-broken box characters', () => {
96
+ const ascii = renderMermaidASCII(mermaid, {
97
+ useAscii,
98
+ colorMode: 'none',
99
+ })
100
+ expect(ascii).not.toContain('/')
101
+ expect(ascii).not.toContain('\\')
102
+ })
103
+ })
104
+ }
105
+
106
+ it('handles mixed ASCII + CJK content in the same label', () => {
107
+ const ascii = renderMermaidASCII(
108
+ 'flowchart TD\n A[Hello 世界] --> B[OK]',
109
+ { colorMode: 'none' },
110
+ )
111
+ assertUniformDisplayWidth(ascii)
112
+ expect(ascii).toContain('Hello 世界')
113
+ })
114
+
115
+ it('handles emoji labels', () => {
116
+ const ascii = renderMermaidASCII(
117
+ 'flowchart TD\n A[🎉 Party] --> B[Done]',
118
+ {
119
+ colorMode: 'none',
120
+ },
121
+ )
122
+ assertUniformDisplayWidth(ascii)
123
+ })
124
+
125
+ it('handles CJK edge labels without breaking column alignment', () => {
126
+ const ascii = renderMermaidASCII(
127
+ 'flowchart TD\n A[Start] -->|日本語ラベル| B[End]',
128
+ { colorMode: 'none' },
129
+ )
130
+ assertUniformDisplayWidth(ascii)
131
+ expect(ascii).toContain('日本語ラベル')
132
+ })
133
+
134
+ it('handles CJK labels on non-rectangle shapes (diamond, stadium)', () => {
135
+ const ascii = renderMermaidASCII(
136
+ 'flowchart TD\n A{決定} --> B(("結果"))\n A --> C([スタジアム])',
137
+ { colorMode: 'none' },
138
+ )
139
+ assertUniformDisplayWidth(ascii)
140
+ })
141
+
142
+ it('handles a CJK subgraph title', () => {
143
+ const ascii = renderMermaidASCII(
144
+ 'flowchart TD\n subgraph サブグラフ\n A[Node] --> B[Node2]\n end',
145
+ { colorMode: 'none' },
146
+ )
147
+ assertUniformDisplayWidth(ascii)
148
+ expect(ascii).toContain('サブグラフ')
149
+ })
150
+ })
@@ -0,0 +1,462 @@
1
+ // ============================================================================
2
+ // ASCII class diagram box-occupancy invariants
3
+ //
4
+ // packages/ascii-renderer/src/er-diagram.ts has an occupancy guard —
5
+ // `setCGuarded`/`boxCells`/`regionClear` — fixing issue #350.
6
+ // packages/ascii-renderer/src/class-diagram.ts now generalizes that same
7
+ // guard (see its own `boxCells`/`setCGuarded`, plus an obstruction-aware
8
+ // same-level detour row) to close issue #953's class-diagram cases. This
9
+ // suite sweeps the same "does a relationship line/label ever land on top of
10
+ // an unrelated class's box" question issue #350 asked for ER diagrams,
11
+ // generalized to class diagrams. Two cases below (the same-level detour
12
+ // crossing a taller, in-between class's box) used to be confirmed-broken and
13
+ // marked `it.fails`; they're now plain `it()` cases, verifying the fix.
14
+ // ============================================================================
15
+
16
+ import { describe, it, expect } from 'vitest'
17
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
18
+ import { findBoxRect, rectsOverlap } from './helpers/ascii-form.ts'
19
+
20
+ function expectNoBoxOverlap(ascii: string, names: string[]): void {
21
+ const rects = names.map((n) => ({ n, r: findBoxRect(ascii, n) }))
22
+ for (let i = 0; i < rects.length; i++) {
23
+ for (let j = i + 1; j < rects.length; j++) {
24
+ expect(
25
+ rectsOverlap(rects[i]!.r, rects[j]!.r),
26
+ `${rects[i]!.n} and ${rects[j]!.n} boxes overlap`,
27
+ ).toBe(false)
28
+ }
29
+ }
30
+ }
31
+
32
+ function manyAttrs(prefix: string, count: number): string {
33
+ return Array.from(
34
+ { length: count },
35
+ (_, i) => ` +String ${prefix}${i}`,
36
+ ).join('\n')
37
+ }
38
+
39
+ /** Assert every one of `needles` appears verbatim, exactly once, in `text`. */
40
+ function expectAllPresentOnce(text: string, needles: string[]): void {
41
+ for (const needle of needles) {
42
+ const count = text.split(needle).length - 1
43
+ expect(count, `expected "${needle}" to appear exactly once`).toBe(1)
44
+ }
45
+ }
46
+
47
+ describe('ASCII class diagrams — class boxes never overlap', () => {
48
+ it('2 classes, inheritance, target below (baseline)', () => {
49
+ const src = `classDiagram
50
+ class A
51
+ class B
52
+ A <|-- B`
53
+ const ascii = renderMermaidASCII(src, { useAscii: true })
54
+ expectNoBoxOverlap(ascii, ['A', 'B'])
55
+ })
56
+
57
+ it('2 classes, association, long label', () => {
58
+ const src = `classDiagram
59
+ class A
60
+ class B
61
+ A --> B : a very long descriptive association label`
62
+ const ascii = renderMermaidASCII(src, { useAscii: true })
63
+ expectNoBoxOverlap(ascii, ['A', 'B'])
64
+ })
65
+
66
+ it('root + 3 same-level siblings, one inheritance edge', () => {
67
+ const src = `classDiagram
68
+ class Root
69
+ class Sib1
70
+ class Sib2
71
+ class Sib3
72
+ Root <|-- Sib1
73
+ Root <|-- Sib2
74
+ Root <|-- Sib3`
75
+ const ascii = renderMermaidASCII(src, { useAscii: true })
76
+ expectNoBoxOverlap(ascii, ['Root', 'Sib1', 'Sib2', 'Sib3'])
77
+ })
78
+
79
+ // ---------------------------------------------------------------------
80
+ // "Same level" routing (src/ascii/class-diagram.ts's final `else` branch,
81
+ // the class-diagram analog of issue #350's ER bug) turns out to be hard
82
+ // to reach on purpose: empirically (verified by rendering and inspecting
83
+ // raw output, not just reading the code), a direct relationship edge
84
+ // between two classes pushes the target to a deeper level than the
85
+ // source even when both share a parent — so two siblings connected by
86
+ // their own edge route through the "below" branch, not "same level".
87
+ // The same-level branch only fires when the layout can't linearly level
88
+ // the graph at all, i.e. a relationship cycle forces its `levelCap`
89
+ // fallback. A 3-node cycle (A->B->C->A) reliably triggers it.
90
+ // ---------------------------------------------------------------------
91
+
92
+ it('3-node cycle (same-level fallback), no obstruction (baseline)', () => {
93
+ const src = `classDiagram
94
+ class Alpha
95
+ class Mid
96
+ class Charlie
97
+ Alpha --> Mid
98
+ Mid --> Charlie
99
+ Charlie --> Alpha`
100
+ const ascii = renderMermaidASCII(src, { useAscii: true })
101
+ expectNoBoxOverlap(ascii, ['Alpha', 'Mid', 'Charlie'])
102
+ })
103
+
104
+ // The same-level branch's detour row used to be computed only from the
105
+ // relationship's own two endpoints (Alpha, Charlie), never checking
106
+ // whether Mid — sitting between them in the same row — was taller and
107
+ // extended below that row. class-diagram.ts now searches for such a
108
+ // same-row obstruction and routes the detour below its bottom edge too
109
+ // (generalizing er-diagram.ts's obstructionBottom guard, issue #350),
110
+ // so the detour's horizontal segment no longer cuts through Mid's box.
111
+ it('3-node cycle, Mid (middle) has 6 attributes: detour line corrupts an attribute row', () => {
112
+ const src = `classDiagram
113
+ class Alpha
114
+ class Mid {
115
+ ${manyAttrs('b', 6)}
116
+ }
117
+ class Charlie
118
+ Alpha --> Mid
119
+ Mid --> Charlie
120
+ Charlie --> Alpha`
121
+ const ascii = renderMermaidASCII(src, { useAscii: true })
122
+ expectAllPresentOnce(ascii, [
123
+ '+ b0: String',
124
+ '+ b1: String',
125
+ '+ b2: String',
126
+ '+ b3: String',
127
+ '+ b4: String',
128
+ '+ b5: String',
129
+ ])
130
+ })
131
+
132
+ // The same-row obstruction search (see the test above) must skip a class
133
+ // on a *different* level/row, even when that class's box geometrically
134
+ // overlaps the detour's horizontal span — not just when it happens to sit
135
+ // off to the side where the search would ignore it anyway regardless of
136
+ // the `other.y !== fromP.y` filter. `Root`/`Kid` are a real root/child
137
+ // pair via the level-BFS (Root has no parent, Kid is its level-1 child),
138
+ // placed on a different row from the Alpha/Mid/Charlie cycle.
139
+ //
140
+ // Two variants of the same Root/Kid pair are compared: a narrow `Kid`
141
+ // never reaches the cycle's column span (baseline); a `Kid` wide enough
142
+ // to need real space, with `Root` declared right after `Alpha`, overlaps
143
+ // Alpha's (and Mid's) column range instead (confirmed below via
144
+ // `findBoxRect`). If the different-row filter worked, both variants
145
+ // render the cycle identically — Root/Kid's actual row is irrelevant to
146
+ // it either way. A test that only used the non-overlapping variant would
147
+ // pass even with the filter deleted (verified: an earlier version of this
148
+ // test did exactly that, and CodeRabbit correctly flagged it as not
149
+ // actually exercising the filter — see the PR discussion).
150
+ //
151
+ // Declaration order alone no longer controls Kid's column (issue #971:
152
+ // `Kid`, as the sole occupant of its level with exactly one qualifying
153
+ // parent, now aligns under Root's own — already-placed — column instead
154
+ // of always landing at the leftmost slot). This test used to force the
155
+ // overlapping/non-overlapping split by moving `Root`/`Kid`'s declaration
156
+ // point around the cycle alone; that no longer produces reliably
157
+ // different geometry, since Kid's position now tracks wherever Root
158
+ // ends up rather than its own declaration slot. Widening `Kid` (so its
159
+ // box needs more room than the gap next to Root provides) is what
160
+ // reliably produces a real overlap now, confirmed empirically against
161
+ // the current renderer rather than assumed.
162
+ //
163
+ // Sabotage-verified: deleting the filter leaves both variants' `+ bN:
164
+ // String` lines intact but makes the overlapping variant one row taller
165
+ // than the baseline (19 vs. 18), which the equality assertion below
166
+ // catches.
167
+ it('3-node cycle, unrelated Root->Kid pair on a different level: obstruction search ignores it regardless of whether it overlaps the detour span', () => {
168
+ // Baseline: Root/Kid declared after the whole cycle, Kid narrow enough
169
+ // to never need more room than the gap next to Root provides.
170
+ const nonOverlapping = renderMermaidASCII(
171
+ `classDiagram
172
+ class Alpha
173
+ class Mid {
174
+ ${manyAttrs('b', 6)}
175
+ }
176
+ class Charlie
177
+ class Root
178
+ class Kid
179
+ Alpha --> Mid
180
+ Mid --> Charlie
181
+ Charlie --> Alpha
182
+ Root --> Kid`,
183
+ { useAscii: true },
184
+ )
185
+ // Overlapping: Root declared immediately after Alpha, Kid wide enough
186
+ // that centering it under Root's (narrow) column reaches into Alpha's.
187
+ const overlapping = renderMermaidASCII(
188
+ `classDiagram
189
+ class Alpha
190
+ class Root
191
+ class VeryLongKidName
192
+ class Mid {
193
+ ${manyAttrs('b', 6)}
194
+ }
195
+ class Charlie
196
+ Alpha --> Mid
197
+ Mid --> Charlie
198
+ Charlie --> Alpha
199
+ Root --> VeryLongKidName`,
200
+ { useAscii: true },
201
+ )
202
+
203
+ // Confirms the geometry this test relies on: in the "overlapping"
204
+ // variant, Kid really does sit on a different row from the cycle, and
205
+ // really does overlap Alpha's own column range (i.e. the obstruction
206
+ // search has something to incorrectly notice if the different-row
207
+ // filter is missing); in the baseline, it doesn't.
208
+ const overlappingAlphaRect = findBoxRect(overlapping, 'Alpha')
209
+ const overlappingKidRect = findBoxRect(overlapping, 'VeryLongKidName')
210
+ expect(overlappingKidRect.y0).toBeGreaterThan(overlappingAlphaRect.y1)
211
+ expect(overlappingKidRect.x0).toBeLessThanOrEqual(overlappingAlphaRect.x1)
212
+ const nonOverlappingAlphaRect = findBoxRect(nonOverlapping, 'Alpha')
213
+ const nonOverlappingKidRect = findBoxRect(nonOverlapping, 'Kid')
214
+ expect(
215
+ nonOverlappingKidRect.x1 < nonOverlappingAlphaRect.x0 ||
216
+ nonOverlappingKidRect.x0 > nonOverlappingAlphaRect.x1,
217
+ ).toBe(true)
218
+
219
+ expectAllPresentOnce(nonOverlapping, [
220
+ '+ b0: String',
221
+ '+ b1: String',
222
+ '+ b2: String',
223
+ '+ b3: String',
224
+ '+ b4: String',
225
+ '+ b5: String',
226
+ ])
227
+ expectNoBoxOverlap(nonOverlapping, [
228
+ 'Alpha',
229
+ 'Mid',
230
+ 'Charlie',
231
+ 'Root',
232
+ 'Kid',
233
+ ])
234
+ expectAllPresentOnce(overlapping, [
235
+ '+ b0: String',
236
+ '+ b1: String',
237
+ '+ b2: String',
238
+ '+ b3: String',
239
+ '+ b4: String',
240
+ '+ b5: String',
241
+ ])
242
+ expectNoBoxOverlap(overlapping, ['Alpha', 'Mid', 'Charlie', 'Root'])
243
+
244
+ // The real check: Kid overlapping the detour span must not change the
245
+ // cycle's own rendered height compared to Kid sitting clear of it.
246
+ expect(overlapping.split('\n').length).toBe(
247
+ nonOverlapping.split('\n').length,
248
+ )
249
+ })
250
+
251
+ // `computeLabelAnchor`'s same-level branch used to recompute the label's
252
+ // row from scratch (`Math.max(fromBY, toBY) + 2`) instead of reusing the
253
+ // connector's own obstruction-aware `detourY` (`sameLevelDetourY` above)
254
+ // — so a *labeled* same-level relationship still landed its label inside
255
+ // Mid's box even after the unlabeled connector-line fix above, the same
256
+ // corruption in a sibling code path (caught in review on PR #957).
257
+ it('3-node cycle, Mid (middle) has 6 attributes, labeled wrap-around relationship: label no longer corrupts an attribute row', () => {
258
+ const src = `classDiagram
259
+ class Alpha
260
+ class Mid {
261
+ ${manyAttrs('b', 6)}
262
+ }
263
+ class Charlie
264
+ Alpha --> Mid
265
+ Mid --> Charlie
266
+ Charlie --> Alpha : a rather long descriptive label`
267
+ const ascii = renderMermaidASCII(src, { useAscii: true })
268
+ expectAllPresentOnce(ascii, [
269
+ '+ b0: String',
270
+ '+ b1: String',
271
+ '+ b2: String',
272
+ '+ b3: String',
273
+ '+ b4: String',
274
+ '+ b5: String',
275
+ ])
276
+ expect(ascii).toContain('a rather long descriptive label')
277
+ })
278
+
279
+ it('3-node cycle, long relationship label, no tall obstruction', () => {
280
+ const src = `classDiagram
281
+ class Alpha
282
+ class Mid
283
+ class Charlie
284
+ Alpha --> Mid : a rather long descriptive label
285
+ Mid --> Charlie
286
+ Charlie --> Alpha`
287
+ const ascii = renderMermaidASCII(src, { useAscii: true })
288
+ expectNoBoxOverlap(ascii, ['Alpha', 'Mid', 'Charlie'])
289
+ })
290
+
291
+ it('3-node cycle, long class names, no tall obstruction', () => {
292
+ const src = `classDiagram
293
+ class AlphaService
294
+ class MidRepository
295
+ class CharlieController
296
+ AlphaService --> MidRepository
297
+ MidRepository --> CharlieController
298
+ CharlieController --> AlphaService`
299
+ const ascii = renderMermaidASCII(src, { useAscii: true })
300
+ expectNoBoxOverlap(ascii, [
301
+ 'AlphaService',
302
+ 'MidRepository',
303
+ 'CharlieController',
304
+ ])
305
+ })
306
+
307
+ it('2 levels (A,B / C,D), C tall, A->D association (cross-level jog)', () => {
308
+ const src = `classDiagram
309
+ class A
310
+ class B
311
+ class C {
312
+ ${manyAttrs('c', 6)}
313
+ }
314
+ class D
315
+ A <|-- C
316
+ B <|-- D
317
+ A --> D`
318
+ const ascii = renderMermaidASCII(src, { useAscii: true })
319
+ expectNoBoxOverlap(ascii, ['A', 'B', 'C', 'D'])
320
+ })
321
+
322
+ // A 4-node cycle (A->B->C->D->A) plus a skip edge (A->C) also fell into
323
+ // the same unguarded same-level detour as the 3-node case above — a
324
+ // second, independently-verified topology hitting the same code gap,
325
+ // this time skipping over B's box. Fixed by the same obstruction-aware
326
+ // detour routing (see the 3-node cycle test above).
327
+ it('4-node cycle with a skip edge, tall middle class: detour corrupts an attribute row', () => {
328
+ const src = `classDiagram
329
+ class A
330
+ class B {
331
+ ${manyAttrs('b', 6)}
332
+ }
333
+ class C
334
+ class D
335
+ A --> B
336
+ B --> C
337
+ C --> D
338
+ D --> A
339
+ A --> C`
340
+ const ascii = renderMermaidASCII(src, { useAscii: true })
341
+ expectAllPresentOnce(ascii, [
342
+ '+ b0: String',
343
+ '+ b1: String',
344
+ '+ b2: String',
345
+ '+ b3: String',
346
+ '+ b4: String',
347
+ '+ b5: String',
348
+ ])
349
+ })
350
+
351
+ it('3 siblings + 2 children, crossing inheritance edges', () => {
352
+ const src = `classDiagram
353
+ class Sib1
354
+ class Sib2
355
+ class Sib3
356
+ class Child1
357
+ class Child2
358
+ Sib1 <|-- Child1
359
+ Sib3 <|-- Child2
360
+ Sib2 <|-- Child1
361
+ Sib2 <|-- Child2`
362
+ const ascii = renderMermaidASCII(src, { useAscii: true })
363
+ expectNoBoxOverlap(ascii, ['Sib1', 'Sib2', 'Sib3', 'Child1', 'Child2'])
364
+ })
365
+
366
+ it('strict 3-level vertical chain (negative control, no detour logic)', () => {
367
+ const src = `classDiagram
368
+ class A
369
+ class B
370
+ class C
371
+ A <|-- B
372
+ B <|-- C`
373
+ const ascii = renderMermaidASCII(src, { useAscii: true })
374
+ expectNoBoxOverlap(ascii, ['A', 'B', 'C'])
375
+ })
376
+
377
+ it('4 siblings under a root, plus a cross-relationship A->D', () => {
378
+ const src = `classDiagram
379
+ class Root
380
+ class A
381
+ class B
382
+ class C
383
+ class D
384
+ Root <|-- A
385
+ Root <|-- B
386
+ Root <|-- C
387
+ Root <|-- D
388
+ A --> D`
389
+ const ascii = renderMermaidASCII(src, { useAscii: true })
390
+ expectNoBoxOverlap(ascii, ['Root', 'A', 'B', 'C', 'D'])
391
+ })
392
+
393
+ it('2 classes, realization, target above source', () => {
394
+ const src = `classDiagram
395
+ class Impl
396
+ class Iface
397
+ Impl ..|> Iface`
398
+ const ascii = renderMermaidASCII(src, { useAscii: true })
399
+ expectNoBoxOverlap(ascii, ['Impl', 'Iface'])
400
+ })
401
+
402
+ it('3 siblings under a root, chained by two dependency edges (A->B, B->C)', () => {
403
+ const src = `classDiagram
404
+ class Root
405
+ class A
406
+ class B
407
+ class C
408
+ Root <|-- A
409
+ Root <|-- B
410
+ Root <|-- C
411
+ A ..> B
412
+ B ..> C`
413
+ const ascii = renderMermaidASCII(src, { useAscii: true })
414
+ expectNoBoxOverlap(ascii, ['Root', 'A', 'B', 'C'])
415
+ })
416
+
417
+ it('2 classes, association, one has interface annotation + long attrs/methods', () => {
418
+ const src = `classDiagram
419
+ class Serializable {
420
+ <<interface>>
421
+ +serialize() String
422
+ +deserialize(data) void
423
+ }
424
+ class Document
425
+ Document ..|> Serializable`
426
+ const ascii = renderMermaidASCII(src, { useAscii: true })
427
+ expectNoBoxOverlap(ascii, ['Serializable', 'Document'])
428
+ })
429
+
430
+ it('3 siblings under a root plus a relationship, unrelated D one level below', () => {
431
+ const src = `classDiagram
432
+ class Alpha
433
+ class Bravo
434
+ class Charlie
435
+ class Delta
436
+ Alpha <|-- Bravo
437
+ Alpha <|-- Charlie
438
+ Bravo *-- Charlie
439
+ Bravo <|-- Delta`
440
+ const ascii = renderMermaidASCII(src, { useAscii: true })
441
+ expectNoBoxOverlap(ascii, ['Alpha', 'Bravo', 'Charlie', 'Delta'])
442
+ })
443
+
444
+ it('cyclic dependency edges (cycle-breaking still non-overlapping)', () => {
445
+ const src = `classDiagram
446
+ class View
447
+ class Model
448
+ View --> Model
449
+ Model ..> View`
450
+ const ascii = renderMermaidASCII(src, { useAscii: true })
451
+ expectNoBoxOverlap(ascii, ['View', 'Model'])
452
+ })
453
+
454
+ it('2 classes, near-duplicate names (substring-collision guard)', () => {
455
+ const src = `classDiagram
456
+ class ServiceA
457
+ class ServiceA2
458
+ ServiceA <|-- ServiceA2`
459
+ const ascii = renderMermaidASCII(src, { useAscii: true })
460
+ expectNoBoxOverlap(ascii, ['ServiceA', 'ServiceA2'])
461
+ })
462
+ })