@zombie-mermaid/ascii-renderer 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/LICENSE +22 -0
  2. package/dist/index.cjs +8 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +146 -0
  5. package/dist/index.d.ts +146 -0
  6. package/dist/index.js +5392 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +36 -0
  9. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  10. package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
  11. package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
  12. package/src/__tests__/ascii-canvas-write.test.ts +132 -0
  13. package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
  14. package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
  15. package/src/__tests__/ascii-cjk-width.test.ts +150 -0
  16. package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
  17. package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
  18. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  19. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
  20. package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
  21. package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
  22. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
  23. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
  24. package/src/__tests__/ascii-class-padding.test.ts +104 -0
  25. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  26. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  27. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
  28. package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
  29. package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
  30. package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
  31. package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
  32. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
  33. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
  34. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
  35. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
  36. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
  37. package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
  38. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
  39. package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
  40. package/src/__tests__/ascii-edge-styles.test.ts +149 -0
  41. package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
  42. package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
  43. package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
  44. package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
  45. package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
  46. package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
  47. package/src/__tests__/ascii-er-padding.test.ts +114 -0
  48. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
  49. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
  50. package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
  51. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
  52. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
  53. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
  54. package/src/__tests__/ascii-lane-search.test.ts +207 -0
  55. package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
  56. package/src/__tests__/ascii-multiline.test.ts +288 -0
  57. package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
  58. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
  59. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
  60. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
  61. package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
  62. package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
  63. package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
  64. package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
  65. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
  66. package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
  67. package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
  68. package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
  69. package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
  70. package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
  71. package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
  72. package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
  73. package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
  74. package/src/__tests__/ascii-shapes-special.test.ts +344 -0
  75. package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
  76. package/src/__tests__/ascii-shapes-state.test.ts +224 -0
  77. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
  78. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
  79. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
  80. package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
  81. package/src/__tests__/ascii-territory-unit.test.ts +219 -0
  82. package/src/__tests__/ascii-validate.test.ts +220 -0
  83. package/src/__tests__/ascii.test.ts +325 -0
  84. package/src/__tests__/class-arrow-directions.test.ts +505 -0
  85. package/src/__tests__/draw-lines.test.ts +93 -0
  86. package/src/__tests__/edge-cell-styles.test.ts +278 -0
  87. package/src/__tests__/grid-occupancy.test.ts +240 -0
  88. package/src/__tests__/helpers/ascii-form.ts +142 -0
  89. package/src/__tests__/helpers/terminal-display-width.ts +74 -0
  90. package/src/__tests__/pathfinder.test.ts +239 -0
  91. package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
  92. package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
  93. package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
  94. package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
  95. package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
  96. package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
  97. package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
  98. package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
  99. package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
  100. package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
  101. package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
  102. package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
  103. package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
  104. package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
  105. package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
  106. package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
  107. package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
  108. package/src/__tests__/testdata/ascii/comments.txt +23 -0
  109. package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
  110. package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
  111. package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
  112. package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
  113. package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
  114. package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
  115. package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
  116. package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
  117. package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
  118. package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
  119. package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
  120. package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
  121. package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
  122. package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
  123. package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
  124. package/src/__tests__/testdata/ascii/single_node.txt +8 -0
  125. package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
  126. package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
  127. package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
  128. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
  129. package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
  130. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
  131. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
  132. package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
  133. package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
  134. package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
  135. package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
  136. package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
  137. package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
  138. package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
  139. package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
  140. package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
  141. package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
  142. package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
  143. package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
  144. package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
  145. package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
  146. package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
  147. package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
  148. package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
  149. package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
  150. package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
  151. package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
  152. package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
  153. package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
  154. package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
  155. package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
  156. package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
  157. package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
  158. package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
  159. package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
  160. package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
  161. package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
  162. package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
  163. package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
  164. package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
  165. package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
  166. package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
  167. package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
  168. package/src/__tests__/testdata/unicode/comments.txt +23 -0
  169. package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
  170. package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
  171. package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
  172. package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
  173. package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
  174. package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
  175. package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
  176. package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
  177. package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
  178. package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
  179. package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
  180. package/src/__tests__/testdata/unicode/single_node.txt +8 -0
  181. package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
  182. package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
  183. package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
  184. package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
  185. package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
  186. package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
  187. package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
  188. package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
  189. package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
  190. package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
  191. package/src/__tests__/xychart-ascii.test.ts +376 -0
  192. package/src/ansi.ts +490 -0
  193. package/src/canvas.ts +757 -0
  194. package/src/class-diagram.ts +2001 -0
  195. package/src/converter.ts +446 -0
  196. package/src/coords.ts +58 -0
  197. package/src/display-width.ts +151 -0
  198. package/src/draw-arrows.ts +593 -0
  199. package/src/draw-boxes.ts +267 -0
  200. package/src/draw-bundles.ts +611 -0
  201. package/src/draw-lines.ts +174 -0
  202. package/src/draw-subgraphs.ts +108 -0
  203. package/src/draw.ts +350 -0
  204. package/src/edge-bundling.ts +435 -0
  205. package/src/edge-cell-styles.ts +209 -0
  206. package/src/edge-routing.ts +1070 -0
  207. package/src/er-diagram.ts +1488 -0
  208. package/src/flowchart.ts +94 -0
  209. package/src/grid-occupancy.ts +234 -0
  210. package/src/grid.ts +1309 -0
  211. package/src/hyperlinks.ts +248 -0
  212. package/src/index.ts +163 -0
  213. package/src/lane-search.ts +68 -0
  214. package/src/multiline-utils.ts +82 -0
  215. package/src/pathfinder.ts +448 -0
  216. package/src/registry.ts +88 -0
  217. package/src/sequence.ts +1318 -0
  218. package/src/shapes/circle.ts +31 -0
  219. package/src/shapes/corners.ts +273 -0
  220. package/src/shapes/diamond.ts +31 -0
  221. package/src/shapes/hexagon.ts +35 -0
  222. package/src/shapes/index.ts +123 -0
  223. package/src/shapes/rectangle.ts +199 -0
  224. package/src/shapes/rounded.ts +31 -0
  225. package/src/shapes/special.ts +360 -0
  226. package/src/shapes/stadium.ts +122 -0
  227. package/src/shapes/state.ts +204 -0
  228. package/src/shapes/types.ts +78 -0
  229. package/src/territory.ts +136 -0
  230. package/src/types.ts +454 -0
  231. package/src/validate.ts +189 -0
  232. package/src/xychart.ts +1085 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Regression tests for issue #182 — `drawMultiBox` (the multi-compartment
3
+ * boxes used by class and ER diagrams) measured and wrote text by UTF-16 code
4
+ * unit rather than display width, the same bug class #66 fixed for the
5
+ * single-box path.
6
+ *
7
+ * The fix has to cover three sites at once, which is why these tests exercise
8
+ * whole rendered diagrams rather than `drawMultiBox` alone:
9
+ *
10
+ * 1. `drawMultiBox`'s own sizing and per-character write loop, and
11
+ * 2/3. the box dimensions `class-diagram.ts` and `er-diagram.ts` each
12
+ * precompute to reserve grid space *before* drawing.
13
+ *
14
+ * Fixing only (1) would desync the drawn box from the space layout reserved
15
+ * for it, so the multi-box grid cases below (wide-character and ASCII
16
+ * siblings placed side by side) are the ones that would catch a partial fix.
17
+ *
18
+ * Width is measured with an oracle independent of `src/ascii/display-width.ts`
19
+ * — see the shared helper.
20
+ */
21
+ import { describe, it, expect } from 'vitest'
22
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
23
+ import {
24
+ terminalDisplayWidth,
25
+ assertUniformDisplayWidth,
26
+ } from './helpers/terminal-display-width.ts'
27
+
28
+ /**
29
+ * Assert that every border row and content row of a multi-compartment box
30
+ * puts its right-hand vertical border in the same terminal column — the
31
+ * direct symptom from the issue, content overflowing its own border.
32
+ *
33
+ * Only valid for single-box output: relationship connector lines also contain
34
+ * the vertical border character without being a box edge.
35
+ */
36
+ function assertBordersAligned(ascii: string, useAscii: boolean): void {
37
+ const vChar = useAscii ? '|' : '│'
38
+ const rows = ascii.split('\n').filter((l) => l.includes(vChar))
39
+ expect(rows.length).toBeGreaterThan(0)
40
+
41
+ const columns = rows.map((row) =>
42
+ terminalDisplayWidth(row.slice(0, row.lastIndexOf(vChar))),
43
+ )
44
+ const expected = columns[0]
45
+ for (let i = 0; i < rows.length; i++) {
46
+ expect(
47
+ columns[i],
48
+ `row ${i} (${JSON.stringify(rows[i])}) has its right border at column ${columns[i]}, expected ${expected}`,
49
+ ).toBe(expected)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Terminal-column gap between two boxes drawn side by side on the same row.
55
+ *
56
+ * This is the assertion that catches a *partial* fix. If `drawMultiBox` is
57
+ * corrected but a caller still reserves grid space using code-unit widths,
58
+ * the box drawn for wide-character content is wider than the space reserved
59
+ * for it, and the surplus eats into the gap separating it from its neighbour
60
+ * (and, for a wide enough box, into the neighbour itself). The rendered box
61
+ * still looks well-formed, so only the spacing reveals the desync.
62
+ */
63
+ function siblingGap(ascii: string, useAscii: boolean): number {
64
+ // Matched on the top-border row rather than by corner character: in the
65
+ // ASCII charset all four corners are '+', so there is no distinct
66
+ // right-corner glyph to search for. Two runs of border characters
67
+ // separated by spaces is unambiguous in both charsets.
68
+ const border = useAscii ? '[+\\-]' : '[┌┐─]'
69
+ const twoBoxes = new RegExp(`^\\s*(${border}+)(\\s+)(${border}+)`)
70
+
71
+ for (const line of ascii.split('\n')) {
72
+ const match = twoBoxes.exec(line)
73
+ if (match) return terminalDisplayWidth(match[2]!)
74
+ }
75
+
76
+ expect.fail('expected a row containing two side-by-side box borders')
77
+ }
78
+
79
+ describe('ASCII renderer — wide characters in multi-compartment boxes (issue #182)', () => {
80
+ for (const useAscii of [false, true]) {
81
+ const charset = useAscii ? 'ASCII charset' : 'Unicode charset'
82
+
83
+ describe(`class diagrams — ${charset}`, () => {
84
+ it('keeps a CJK class box within its own border', () => {
85
+ const ascii = renderMermaidASCII(
86
+ 'classDiagram\n class Aクラス {\n +名前 name\n +取得する() void\n }',
87
+ { useAscii, colorMode: 'none' },
88
+ )
89
+ assertUniformDisplayWidth(ascii)
90
+ assertBordersAligned(ascii, useAscii)
91
+ expect(ascii).toContain('名前')
92
+ })
93
+
94
+ it('does not desync layout from drawing for wide/ASCII sibling boxes', () => {
95
+ // Two boxes side by side on the same level: one wide-character, one
96
+ // ASCII. A fix applied to drawMultiBox but not to class-diagram.ts's
97
+ // own sizing pass would overlap or misalign these.
98
+ const ascii = renderMermaidASCII(
99
+ 'classDiagram\n class Base\n class 子クラスA {\n +属性値 v\n }\n class ChildB {\n +value v\n }\n Base <|-- 子クラスA\n Base <|-- ChildB',
100
+ { useAscii, colorMode: 'none' },
101
+ )
102
+ assertUniformDisplayWidth(ascii)
103
+ expect(ascii).toContain('子クラスA')
104
+ expect(ascii).toContain('ChildB')
105
+
106
+ // The wide-character box must be separated from its ASCII sibling by
107
+ // the class renderer's full horizontal gap (hGap = 4). A caller still
108
+ // reserving code-unit width narrows this instead.
109
+ expect(siblingGap(ascii, useAscii)).toBe(4)
110
+ })
111
+
112
+ it('centers a CJK relationship label without overrunning', () => {
113
+ const ascii = renderMermaidASCII(
114
+ 'classDiagram\n class 動物 {\n +名前 name\n }\n class 犬 {\n +吠える() void\n }\n 動物 <|-- 犬 : 継承する',
115
+ { useAscii, colorMode: 'none' },
116
+ )
117
+ assertUniformDisplayWidth(ascii)
118
+ expect(ascii).toContain('継承する')
119
+ })
120
+
121
+ it('handles a non-BMP (surrogate-pair) member without splitting it', () => {
122
+ const ascii = renderMermaidASCII(
123
+ 'classDiagram\n class Party {\n +🎉🎉 confetti\n }',
124
+ { useAscii, colorMode: 'none' },
125
+ )
126
+ assertUniformDisplayWidth(ascii)
127
+ // A code-unit write loop would emit lone surrogate halves into
128
+ // adjacent cells; the pair must survive intact.
129
+ expect(ascii).toContain('🎉🎉')
130
+ expect(ascii).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/)
131
+ expect(ascii).not.toMatch(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/)
132
+ })
133
+ })
134
+
135
+ describe(`ER diagrams — ${charset}`, () => {
136
+ it('keeps a CJK entity box within its own border', () => {
137
+ const ascii = renderMermaidASCII(
138
+ 'erDiagram\n 顧客 {\n string 名前\n int 年齢\n }',
139
+ { useAscii, colorMode: 'none' },
140
+ )
141
+ assertUniformDisplayWidth(ascii)
142
+ assertBordersAligned(ascii, useAscii)
143
+ expect(ascii).toContain('顧客')
144
+ })
145
+
146
+ it('does not desync layout from drawing for wide/ASCII sibling entities', () => {
147
+ const ascii = renderMermaidASCII(
148
+ 'erDiagram\n 顧客 ||--o{ ORDER : places\n 顧客 {\n string 名前\n }\n ORDER {\n int number\n }',
149
+ { useAscii, colorMode: 'none' },
150
+ )
151
+ assertUniformDisplayWidth(ascii)
152
+ expect(ascii).toContain('顧客')
153
+ expect(ascii).toContain('ORDER')
154
+ })
155
+
156
+ it('places a CJK relationship label without breaking alignment', () => {
157
+ const ascii = renderMermaidASCII(
158
+ 'erDiagram\n 顧客 ||--o{ 注文 : "発注する"\n 顧客 {\n string 名前\n }\n 注文 {\n int 番号\n }',
159
+ { useAscii, colorMode: 'none' },
160
+ )
161
+ assertUniformDisplayWidth(ascii)
162
+ expect(ascii).toContain('発注する')
163
+ })
164
+ })
165
+ }
166
+
167
+ it('leaves the common all-ASCII case intact', () => {
168
+ // Guard against the fix perturbing diagrams with no wide characters.
169
+ const single = renderMermaidASCII(
170
+ 'classDiagram\n class Animal {\n +String name\n +eat() void\n }',
171
+ { colorMode: 'none' },
172
+ )
173
+ assertUniformDisplayWidth(single)
174
+ assertBordersAligned(single, false)
175
+ expect(single).toContain('+ name: String')
176
+
177
+ const related = renderMermaidASCII(
178
+ 'classDiagram\n class Animal {\n +String name\n }\n class Dog\n Animal <|-- Dog',
179
+ { colorMode: 'none' },
180
+ )
181
+ assertUniformDisplayWidth(related)
182
+ expect(related).toContain('Dog')
183
+ })
184
+ })
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
3
+ import { mkCanvas, canvasToString } from '../canvas.ts'
4
+ import {
5
+ drawMultilineTextCentered,
6
+ drawMultilineTextLeft,
7
+ } from '../multiline-utils.ts'
8
+
9
+ describe('ASCII multi-line labels', () => {
10
+ describe('flowchart nodes', () => {
11
+ it('renders multi-line node labels', () => {
12
+ const ascii = renderMermaidASCII('graph TD\n A[Line1<br>Line2]', {
13
+ useAscii: false,
14
+ })
15
+ expect(ascii).toContain('Line1')
16
+ expect(ascii).toContain('Line2')
17
+ // Lines should be on different rows
18
+ const lines = ascii.split('\n')
19
+ const line1Row = lines.findIndex((l) => l.includes('Line1'))
20
+ const line2Row = lines.findIndex((l) => l.includes('Line2'))
21
+ expect(line2Row).toBeGreaterThan(line1Row)
22
+ })
23
+
24
+ it('handles 3+ line labels', () => {
25
+ const ascii = renderMermaidASCII('graph TD\n A[A<br>B<br>C]', {
26
+ useAscii: false,
27
+ })
28
+ expect(ascii).toContain('A')
29
+ expect(ascii).toContain('B')
30
+ expect(ascii).toContain('C')
31
+ // Verify vertical ordering
32
+ const lines = ascii.split('\n')
33
+ const aRow = lines.findIndex(
34
+ (l) => l.includes('A') && !l.includes('─') && !l.includes('-'),
35
+ )
36
+ const bRow = lines.findIndex((l) => l.includes('B'))
37
+ const cRow = lines.findIndex((l) => l.includes('C'))
38
+ expect(bRow).toBeGreaterThan(aRow)
39
+ expect(cRow).toBeGreaterThan(bRow)
40
+ })
41
+
42
+ it('renders in ASCII mode (not Unicode)', () => {
43
+ const ascii = renderMermaidASCII('graph TD\n A[Line1<br>Line2]', {
44
+ useAscii: true,
45
+ })
46
+ expect(ascii).toContain('Line1')
47
+ expect(ascii).toContain('Line2')
48
+ // Should use ASCII box characters
49
+ expect(ascii).toContain('+')
50
+ expect(ascii).toContain('-')
51
+ })
52
+ })
53
+
54
+ describe('flowchart edge labels', () => {
55
+ it('renders multi-line edge labels', () => {
56
+ const ascii = renderMermaidASCII(
57
+ 'graph TD\n A --> B\n A -->|Line1<br>Line2| C',
58
+ { useAscii: false },
59
+ )
60
+ expect(ascii).toContain('Line1')
61
+ expect(ascii).toContain('Line2')
62
+ })
63
+ })
64
+
65
+ describe('flowchart subgraph labels', () => {
66
+ it('renders multi-line subgraph labels', () => {
67
+ const ascii = renderMermaidASCII(
68
+ `graph TD
69
+ subgraph sg [Group<br>Header]
70
+ A[Node]
71
+ end
72
+ `,
73
+ { useAscii: false },
74
+ )
75
+ expect(ascii).toContain('Group')
76
+ expect(ascii).toContain('Header')
77
+ })
78
+ })
79
+
80
+ describe('sequence diagram', () => {
81
+ it('renders multi-line actor labels', () => {
82
+ const ascii = renderMermaidASCII(
83
+ `sequenceDiagram
84
+ participant A as Actor<br>One
85
+ A->>A: msg
86
+ `,
87
+ { useAscii: false },
88
+ )
89
+ expect(ascii).toContain('Actor')
90
+ expect(ascii).toContain('One')
91
+ })
92
+
93
+ it('renders multi-line message labels', () => {
94
+ const ascii = renderMermaidASCII(
95
+ `sequenceDiagram
96
+ participant A
97
+ participant B
98
+ A->>B: Line1<br>Line2
99
+ `,
100
+ { useAscii: false },
101
+ )
102
+ expect(ascii).toContain('Line1')
103
+ expect(ascii).toContain('Line2')
104
+ })
105
+
106
+ it('preserves existing note multi-line support', () => {
107
+ const ascii = renderMermaidASCII(
108
+ `sequenceDiagram
109
+ participant A
110
+ A->>A: self
111
+ Note over A: Note line 1<br>Note line 2
112
+ `,
113
+ { useAscii: false },
114
+ )
115
+ expect(ascii).toContain('Note line 1')
116
+ expect(ascii).toContain('Note line 2')
117
+ })
118
+ })
119
+
120
+ describe('class diagram', () => {
121
+ it('renders multi-line class names', () => {
122
+ const ascii = renderMermaidASCII(
123
+ `classDiagram
124
+ class MyClass["Long<br>Name"]
125
+ `,
126
+ { useAscii: false },
127
+ )
128
+ expect(ascii).toContain('Long')
129
+ expect(ascii).toContain('Name')
130
+ })
131
+
132
+ it('renders multi-line relationship labels', () => {
133
+ const ascii = renderMermaidASCII(
134
+ `classDiagram
135
+ A --> B : uses<br>implements
136
+ `,
137
+ { useAscii: false },
138
+ )
139
+ expect(ascii).toContain('uses')
140
+ expect(ascii).toContain('implements')
141
+ })
142
+ })
143
+
144
+ describe('ER diagram', () => {
145
+ it('renders multi-line entity names', () => {
146
+ const ascii = renderMermaidASCII(
147
+ `erDiagram
148
+ "Entity<br>Name" {
149
+ string id
150
+ }
151
+ `,
152
+ { useAscii: false },
153
+ )
154
+ expect(ascii).toContain('Entity')
155
+ expect(ascii).toContain('Name')
156
+ })
157
+
158
+ it('renders multi-line relationship labels', () => {
159
+ const ascii = renderMermaidASCII(
160
+ `erDiagram
161
+ A ||--o{ B : "has<br>many"
162
+ `,
163
+ { useAscii: false },
164
+ )
165
+ expect(ascii).toContain('has')
166
+ expect(ascii).toContain('many')
167
+ })
168
+ })
169
+
170
+ describe('edge cases', () => {
171
+ it('handles empty lines from consecutive <br>', () => {
172
+ const ascii = renderMermaidASCII('graph TD\n A[Line1<br><br>Line3]', {
173
+ useAscii: false,
174
+ })
175
+ expect(ascii).toContain('Line1')
176
+ expect(ascii).toContain('Line3')
177
+ })
178
+
179
+ it('handles single-line labels (no <br>)', () => {
180
+ const ascii = renderMermaidASCII('graph TD\n A[SingleLine]', {
181
+ useAscii: false,
182
+ })
183
+ expect(ascii).toContain('SingleLine')
184
+ })
185
+
186
+ it('handles very long lines', () => {
187
+ const long = 'A'.repeat(30)
188
+ const ascii = renderMermaidASCII(`graph TD\n A[${long}<br>Short]`, {
189
+ useAscii: false,
190
+ })
191
+ expect(ascii).toContain(long)
192
+ expect(ascii).toContain('Short')
193
+ })
194
+
195
+ it('handles mixed short and long lines', () => {
196
+ const ascii = renderMermaidASCII(
197
+ 'graph TD\n A[Short<br>VeryLongSecondLine<br>Med]',
198
+ { useAscii: false },
199
+ )
200
+ expect(ascii).toContain('Short')
201
+ expect(ascii).toContain('VeryLongSecondLine')
202
+ expect(ascii).toContain('Med')
203
+ })
204
+ })
205
+
206
+ describe('multiline-utils functions', () => {
207
+ it('splitLines splits on newlines', () => {
208
+ // Test through the rendering pipeline
209
+ const ascii = renderMermaidASCII('graph TD\n A[One<br>Two<br>Three]', {
210
+ useAscii: false,
211
+ })
212
+ const lines = ascii.split('\n')
213
+ // All three words should appear on separate lines
214
+ expect(lines.some((l) => l.includes('One'))).toBe(true)
215
+ expect(lines.some((l) => l.includes('Two'))).toBe(true)
216
+ expect(lines.some((l) => l.includes('Three'))).toBe(true)
217
+ })
218
+
219
+ it('maxLineWidth uses longest line for box sizing', () => {
220
+ // Box should be wide enough for the longest line
221
+ const ascii = renderMermaidASCII('graph TD\n A[X<br>LongLine<br>Y]', {
222
+ useAscii: false,
223
+ })
224
+ // The box should contain LongLine without truncation
225
+ expect(ascii).toContain('LongLine')
226
+ })
227
+ })
228
+
229
+ describe('drawMultilineTextCentered', () => {
230
+ it('draws a single line centered at the given point', () => {
231
+ const canvas = mkCanvas(10, 4)
232
+ drawMultilineTextCentered(canvas, 'Hi', 5, 2)
233
+ const lines = canvasToString(canvas).split('\n')
234
+ expect(lines[2]).toContain('Hi')
235
+ })
236
+
237
+ it('centers multiple lines vertically around the point', () => {
238
+ const canvas = mkCanvas(12, 6)
239
+ drawMultilineTextCentered(canvas, 'One\nTwo\nThree', 6, 3)
240
+ const lines = canvasToString(canvas).split('\n')
241
+ const oneRow = lines.findIndex((l) => l.includes('One'))
242
+ const twoRow = lines.findIndex((l) => l.includes('Two'))
243
+ const threeRow = lines.findIndex((l) => l.includes('Three'))
244
+ expect(oneRow).toBeGreaterThanOrEqual(0)
245
+ expect(twoRow).toBe(oneRow + 1)
246
+ expect(threeRow).toBe(twoRow + 1)
247
+ })
248
+
249
+ it('centers each line horizontally around the given x', () => {
250
+ const canvas = mkCanvas(20, 2)
251
+ drawMultilineTextCentered(canvas, 'Wide\nX', 10, 1)
252
+ const lines = canvasToString(canvas).split('\n')
253
+ const wideRow = lines.findIndex((l) => l.includes('Wide'))
254
+ const xRow = lines.findIndex((l) => l.includes('X'))
255
+ // 'Wide' (4 chars) centered on x=10 starts 2 columns left of center
256
+ expect(lines[wideRow]!.indexOf('Wide')).toBe(8)
257
+ // 'X' (1 char) centered on x=10 starts exactly at center
258
+ expect(lines[xRow]!.indexOf('X')).toBe(10)
259
+ })
260
+
261
+ it('handles an empty label without throwing', () => {
262
+ const canvas = mkCanvas(6, 3)
263
+ expect(() => drawMultilineTextCentered(canvas, '', 3, 1)).not.toThrow()
264
+ })
265
+ })
266
+
267
+ describe('drawMultilineTextLeft', () => {
268
+ it('draws a single line starting at the given coordinate', () => {
269
+ const canvas = mkCanvas(10, 4)
270
+ drawMultilineTextLeft(canvas, 'Hello', 1, 1)
271
+ const lines = canvasToString(canvas).split('\n')
272
+ expect(lines[1]!.indexOf('Hello')).toBe(1)
273
+ })
274
+
275
+ it('places each subsequent line one row below the previous', () => {
276
+ const canvas = mkCanvas(10, 5)
277
+ drawMultilineTextLeft(canvas, 'Alpha\nBeta', 0, 0)
278
+ const lines = canvasToString(canvas).split('\n')
279
+ expect(lines[0]!.indexOf('Alpha')).toBe(0)
280
+ expect(lines[1]!.indexOf('Beta')).toBe(0)
281
+ })
282
+
283
+ it('handles an empty label without throwing', () => {
284
+ const canvas = mkCanvas(6, 3)
285
+ expect(() => drawMultilineTextLeft(canvas, '', 0, 0)).not.toThrow()
286
+ })
287
+ })
288
+ })
@@ -0,0 +1,154 @@
1
+ // ============================================================================
2
+ // ASCII padding edge-case tests (issue #343 — CodeRabbit follow-up)
3
+ //
4
+ // #343 made paddingX/paddingY/boxBorderPadding functional for sequence,
5
+ // class, and ER diagrams. CodeRabbit's review of that PR found three ways
6
+ // an out-of-range padding value could corrupt output rather than just
7
+ // tighten/loosen spacing:
8
+ //
9
+ // 1. A negative boxBorderPadding could make measureMultiBox/drawMultiBox
10
+ // compute a negative box width for a short label (class/ER boxes, and
11
+ // sequence actor/note boxes, which derive their own width the same way).
12
+ // 2. A very small paddingX/paddingY on an ER diagram could shrink hGap/vGap
13
+ // below what a 2-cell crow's-foot marker needs, making same-row markers
14
+ // overlap or vertical markers land on the same row.
15
+ // 3. A very small paddingY on a sequence diagram could collapse rowGap to
16
+ // 0, so a block divider and the following message (or a block's closing
17
+ // border and the footer) would land on the same row and overwrite each
18
+ // other, rather than just rendering tightly.
19
+ //
20
+ // This file locks in the fixes for all three.
21
+ // ============================================================================
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
25
+
26
+ describe('ASCII padding edge cases (issue #343 follow-up)', () => {
27
+ it('a negative boxBorderPadding does not produce a negative-width class box', () => {
28
+ const cls = `classDiagram
29
+ class Foo`
30
+ // Should not throw, and every border run should still be a real box
31
+ // (at least 3 chars: two corners + at least one border char).
32
+ const ascii = renderMermaidASCII(cls, {
33
+ useAscii: true,
34
+ boxBorderPadding: -10,
35
+ })
36
+ const borderRuns = ascii.match(/\+-+\+/g) ?? []
37
+ expect(borderRuns.length).toBeGreaterThan(0)
38
+ for (const run of borderRuns) expect(run.length).toBeGreaterThanOrEqual(3)
39
+ expect(ascii).toContain('Foo')
40
+ })
41
+
42
+ it('a negative boxBorderPadding does not produce a negative-width ER box', () => {
43
+ const er = `erDiagram
44
+ CUSTOMER {
45
+ string name
46
+ }`
47
+ const ascii = renderMermaidASCII(er, {
48
+ useAscii: true,
49
+ boxBorderPadding: -10,
50
+ })
51
+ const borderRuns = ascii.match(/\+-+\+/g) ?? []
52
+ expect(borderRuns.length).toBeGreaterThan(0)
53
+ for (const run of borderRuns) expect(run.length).toBeGreaterThanOrEqual(3)
54
+ expect(ascii).toContain('CUSTOMER')
55
+ })
56
+
57
+ it('a negative boxBorderPadding does not produce a negative-width sequence actor box', () => {
58
+ const seq = `sequenceDiagram
59
+ participant A
60
+ participant B
61
+ A->>B: Hi`
62
+ const ascii = renderMermaidASCII(seq, {
63
+ useAscii: true,
64
+ boxBorderPadding: -10,
65
+ })
66
+ const borderRuns = ascii.match(/\+-+\+/g) ?? []
67
+ expect(borderRuns.length).toBeGreaterThan(0)
68
+ for (const run of borderRuns) expect(run.length).toBeGreaterThanOrEqual(3)
69
+ expect(ascii).toContain('A')
70
+ expect(ascii).toContain('B')
71
+ })
72
+
73
+ it("a very negative paddingX keeps ER crow's-foot markers from overlapping", () => {
74
+ // Both endpoint markers ('|' for "one", '<' + 'o' for "zero-many") must
75
+ // still appear as distinct, non-overwriting characters in the connector
76
+ // segment between the two entity boxes. Extract that segment precisely
77
+ // (the text between "CUSTOMER |" and "| ORDER") rather than slicing from
78
+ // the line's first/last '|' — the "one" marker is itself a '|', so a
79
+ // naive first/last-pipe slice would silently include it as a false
80
+ // positive even when it has been overwritten by the other marker.
81
+ const er = `erDiagram
82
+ CUSTOMER ||--o{ ORDER : x`
83
+ const ascii = renderMermaidASCII(er, { useAscii: true, paddingX: -100 })
84
+ const line = ascii.split('\n').find((l) => l.includes('CUSTOMER'))
85
+ expect(line).toBeDefined()
86
+ const match = /CUSTOMER\s*\|(.*)\|\s*ORDER/.exec(line!)
87
+ expect(match).not.toBeNull()
88
+ const connector = match![1]!
89
+ expect(connector).toContain('|')
90
+ expect(connector).toContain('o')
91
+ expect(connector).toContain('<')
92
+ })
93
+
94
+ it('a very negative paddingY keeps ER vertical relationship markers on separate rows', () => {
95
+ // A wraps to a second row relative to C (via B forcing a 2-column grid),
96
+ // producing a vertical connector whose upper/lower crow's-foot markers
97
+ // must land on different rows even when vGap is squeezed.
98
+ const er = `erDiagram
99
+ A ||--o{ B : r1
100
+ A ||--o{ C : r2`
101
+ const ascii = renderMermaidASCII(er, { useAscii: true, paddingY: -100 })
102
+ const lines = ascii.split('\n')
103
+ // Find the two marker rows for the A-C vertical relationship: one
104
+ // containing the label "r2" (upper marker) and, below it, one
105
+ // containing the zero-many marker glyphs.
106
+ const r2Idx = lines.findIndex((l) => l.includes('r2'))
107
+ expect(r2Idx).toBeGreaterThanOrEqual(0)
108
+ const markerIdx = lines.findIndex(
109
+ (l, i) => i > r2Idx && /[o<]/.test(l) && !l.includes('CUSTOMER'),
110
+ )
111
+ expect(markerIdx).toBeGreaterThan(r2Idx)
112
+ })
113
+
114
+ it('very tight paddingY keeps a block divider from overwriting the next message', () => {
115
+ const seq = `sequenceDiagram
116
+ participant A
117
+ participant B
118
+ alt yes
119
+ A->>B: One
120
+ else no
121
+ A->>B: Two
122
+ end`
123
+ const ascii = renderMermaidASCII(seq, { useAscii: true, paddingY: -100 })
124
+ // Both branch labels must survive — a collision would silently drop
125
+ // "Two" by overwriting its row with the divider row.
126
+ expect(ascii).toContain('One')
127
+ expect(ascii).toContain('Two')
128
+ expect(ascii).toContain('[no]')
129
+ })
130
+
131
+ it('very tight paddingY keeps a block-closing border from overwriting the footer', () => {
132
+ const seq = `sequenceDiagram
133
+ participant A
134
+ participant B
135
+ alt yes
136
+ A->>B: One
137
+ end`
138
+ const ascii = renderMermaidASCII(seq, { useAscii: true, paddingY: -100 })
139
+ const lines = ascii.split('\n')
140
+ // The two-actor header and footer boxes each draw a "+---+ +---+"-
141
+ // style top border and an identical-looking bottom border — 4 such rows
142
+ // total. A collision between the block's closing border and the
143
+ // footer's top border overwrites the footer's top border entirely
144
+ // (verified by sabotaging the fix: the count drops to 3, and the
145
+ // footer's content row — "| A | | B |" — appears directly under the
146
+ // block's closing border with no border row above it), rather than
147
+ // merely rendering close together, so counting these rows is a precise
148
+ // way to detect it instead of checking for adjacency.
149
+ const twoBoxBorderRows = lines.filter((l) =>
150
+ /^\s*\+-+\+\s+\+-+\+\s*$/.test(l),
151
+ )
152
+ expect(twoBoxBorderRows.length).toBe(4)
153
+ })
154
+ })