@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,104 @@
1
+ // ============================================================================
2
+ // ASCII class diagram padding tests (issue #343)
3
+ //
4
+ // `-x`/`-y`/`-p` (AsciiRenderOptions.paddingX/paddingY/boxBorderPadding)
5
+ // previously only affected the flowchart/state grid layout — class diagrams
6
+ // ignored them entirely (hGap/vGap were fixed constants, and drawMultiBox
7
+ // was always called with its implicit default padding). This file verifies
8
+ // the fix, and that a render with no explicit padding option still renders
9
+ // byte-for-byte identical to before the fix (see the DEFAULT_PADDING_*
10
+ // comment in src/ascii/types.ts for why that matters).
11
+ // ============================================================================
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
15
+
16
+ // Two independent, same-level classes — exercises the horizontal gap.
17
+ const SIDE_BY_SIDE = `classDiagram
18
+ class Foo
19
+ class Bar`
20
+
21
+ // A parent/child pair — exercises the vertical gap between levels.
22
+ const STACKED = `classDiagram
23
+ class Animal
24
+ class Dog
25
+ Animal <|-- Dog`
26
+
27
+ function colOf(ascii: string, lineIndex: number, needle: string): number {
28
+ const line = ascii.split('\n')[lineIndex]
29
+ if (line === undefined) throw new Error(`no line ${lineIndex}`)
30
+ const idx = line.indexOf(needle)
31
+ if (idx === -1) throw new Error(`"${needle}" not found on line ${lineIndex}`)
32
+ return idx
33
+ }
34
+
35
+ function lineCount(ascii: string): number {
36
+ return ascii.split('\n').filter((l) => l.length > 0).length
37
+ }
38
+
39
+ function borderWidth(ascii: string, lineIndex: number): number {
40
+ const line = ascii.split('\n')[lineIndex]
41
+ const match = line ? /\+-+\+/.exec(line) : null
42
+ if (!match) throw new Error(`no border run on line ${lineIndex}: ${line}`)
43
+ return match[0].length
44
+ }
45
+
46
+ describe('ASCII class diagrams — padding (issue #343)', () => {
47
+ it('renders identically with no padding options and with explicit defaults', () => {
48
+ const withoutOptions = renderMermaidASCII(SIDE_BY_SIDE, {
49
+ useAscii: true,
50
+ })
51
+ const withDefaults = renderMermaidASCII(SIDE_BY_SIDE, {
52
+ useAscii: true,
53
+ paddingX: 5,
54
+ paddingY: 5,
55
+ boxBorderPadding: 1,
56
+ })
57
+ expect(withDefaults).toBe(withoutOptions)
58
+ })
59
+
60
+ it('paddingX widens the gap between same-level class boxes', () => {
61
+ const base = renderMermaidASCII(SIDE_BY_SIDE, { useAscii: true })
62
+ const wide = renderMermaidASCII(SIDE_BY_SIDE, {
63
+ useAscii: true,
64
+ paddingX: 25,
65
+ })
66
+ const baseCol = colOf(base, 1, '| Bar |')
67
+ const wideCol = colOf(wide, 1, '| Bar |')
68
+ expect(wideCol).toBeGreaterThan(baseCol)
69
+ const mid = renderMermaidASCII(SIDE_BY_SIDE, {
70
+ useAscii: true,
71
+ paddingX: 15,
72
+ })
73
+ const midCol = colOf(mid, 1, '| Bar |')
74
+ expect(midCol - baseCol).toBe(wideCol - midCol)
75
+ })
76
+
77
+ it('a very small paddingX tightens the gap below the default', () => {
78
+ const base = renderMermaidASCII(SIDE_BY_SIDE, { useAscii: true })
79
+ const tight = renderMermaidASCII(SIDE_BY_SIDE, {
80
+ useAscii: true,
81
+ paddingX: -10,
82
+ })
83
+ expect(colOf(tight, 1, '| Bar |')).toBeLessThan(colOf(base, 1, '| Bar |'))
84
+ })
85
+
86
+ it('paddingY widens the vertical gap between inheritance levels', () => {
87
+ const base = renderMermaidASCII(STACKED, { useAscii: true })
88
+ const tall = renderMermaidASCII(STACKED, { useAscii: true, paddingY: 25 })
89
+ expect(lineCount(tall)).toBeGreaterThan(lineCount(base))
90
+ const mid = renderMermaidASCII(STACKED, { useAscii: true, paddingY: 15 })
91
+ expect(lineCount(mid) - lineCount(base)).toBe(
92
+ lineCount(tall) - lineCount(mid),
93
+ )
94
+ })
95
+
96
+ it('boxBorderPadding widens class boxes by exactly 2 columns per unit', () => {
97
+ const base = renderMermaidASCII(SIDE_BY_SIDE, { useAscii: true })
98
+ const padded = renderMermaidASCII(SIDE_BY_SIDE, {
99
+ useAscii: true,
100
+ boxBorderPadding: 4,
101
+ })
102
+ expect(borderWidth(padded, 0) - borderWidth(base, 0)).toBe(6)
103
+ })
104
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Regression tests for issue #971 — "ASCII class-diagram layout: single-
3
+ * parent x-alignment," the first implementation stage of #964's fix
4
+ * (docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md).
5
+ *
6
+ * Before this fix, `class-diagram.ts` packed each level's classes strictly
7
+ * left-to-right in declaration order, with no reference to any parent's
8
+ * column — a level with exactly one occupant always landed at column 0,
9
+ * even when its real parent sat far to the right. This fix aligns a
10
+ * "singleton block" (no sibling at its level shares its exact parent-id
11
+ * set) with exactly one qualifying (strictly-shallower) parent under that
12
+ * parent's own box center instead. Every other shape (no parents, an
13
+ * all-same-level/rootless-cycle parent set, or more than one class sharing
14
+ * a parent set) is out of scope here — it keeps its pre-fix left-to-right
15
+ * position, tracked separately by issue #972.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
20
+ import { findBoxRect } from './helpers/ascii-form.ts'
21
+
22
+ describe('ASCII class diagram — single-parent x-alignment (issue #971)', () => {
23
+ it("#964's exact repro: a lone child aligns under its real parent's center, not the leftmost column", () => {
24
+ const src = `classDiagram
25
+ class Left
26
+ class TallMid {
27
+ +String b0
28
+ +String b1
29
+ +String b2
30
+ +String b3
31
+ +String b4
32
+ +String b5
33
+ }
34
+ class RightSrc
35
+ class Kid
36
+ RightSrc --> Kid`
37
+ const ascii = renderMermaidASCII(src, { useAscii: true })
38
+
39
+ const rightSrc = findBoxRect(ascii, 'RightSrc')
40
+ const kid = findBoxRect(ascii, 'Kid')
41
+ const rightSrcCenter =
42
+ rightSrc.x0 + Math.floor((rightSrc.x1 - rightSrc.x0) / 2)
43
+ const kidCenter = kid.x0 + Math.floor((kid.x1 - kid.x0) / 2)
44
+
45
+ // Kid's box center lands on (or within 1 cell of, for odd-width
46
+ // rounding) RightSrc's box center -- not at column 0, where every
47
+ // sole-occupant level used to land regardless of its real parent.
48
+ expect(Math.abs(kidCenter - rightSrcCenter)).toBeLessThanOrEqual(1)
49
+ expect(kid.x0).toBeGreaterThan(0)
50
+
51
+ // Left is the leftmost root and must stay exactly where declaration
52
+ // order has always put it -- this fix only ever moves a class that has
53
+ // a real single parent to align to.
54
+ const left = findBoxRect(ascii, 'Left')
55
+ expect(left.x0).toBe(0)
56
+ })
57
+
58
+ it('a root class with no parent at all is unaffected', () => {
59
+ const src = `classDiagram
60
+ class Lonely`
61
+ const ascii = renderMermaidASCII(src, { useAscii: true })
62
+ const lonely = findBoxRect(ascii, 'Lonely')
63
+ expect(lonely.x0).toBe(0)
64
+ })
65
+
66
+ it('a class whose only parent edges are same-level (a rootless relationship cycle) is unaffected', () => {
67
+ // A -> B -> C -> A has no external root: the level-BFS puts every
68
+ // member at level 0 together, but the `parents` map still records
69
+ // each one's in-cycle predecessor. That predecessor is same-level, not
70
+ // strictly shallower, so it doesn't qualify -- every member keeps its
71
+ // plain declaration-order position, same as before this fix.
72
+ const src = `classDiagram
73
+ class A
74
+ class B
75
+ class C
76
+ A --> B
77
+ B --> C
78
+ C --> A`
79
+ const ascii = renderMermaidASCII(src, { useAscii: true })
80
+ const a = findBoxRect(ascii, 'A')
81
+ const b = findBoxRect(ascii, 'B')
82
+ const c = findBoxRect(ascii, 'C')
83
+ expect(a.x0).toBe(0)
84
+ expect(a.x0).toBeLessThan(b.x0)
85
+ expect(b.x0).toBeLessThan(c.x0)
86
+ })
87
+
88
+ // Two children sharing the exact same single parent are a *multi-member*
89
+ // block -- explicitly out of #971's scope (see #972) -- so this sample
90
+ // must render byte-for-byte the way it always has. Chosen because
91
+ // docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md
92
+ // names this exact samples-data.ts sample ("Class: Inheritance (<|--)")
93
+ // as one it verified stays unchanged.
94
+ it('an existing catalog sample with a multi-child parent (Class: Inheritance) keeps its exact current positions', () => {
95
+ const src = `classDiagram
96
+ class Animal {
97
+ +String name
98
+ +eat() void
99
+ }
100
+ class Dog {
101
+ +String breed
102
+ +bark() void
103
+ }
104
+ class Cat {
105
+ +bool isIndoor
106
+ +meow() void
107
+ }
108
+ Animal <|-- Dog
109
+ Animal <|-- Cat`
110
+ const ascii = renderMermaidASCII(src, { useAscii: true })
111
+ const animal = findBoxRect(ascii, 'Animal')
112
+ const dog = findBoxRect(ascii, 'Dog')
113
+ const cat = findBoxRect(ascii, 'Cat')
114
+ expect(animal).toEqual({ x0: 0, x1: 17, y0: 0, y1: 6 })
115
+ expect(dog).toEqual({ x0: 0, x1: 18, y0: 10, y1: 16 })
116
+ expect(cat).toEqual({ x0: 23, x1: 42, y0: 10, y1: 16 })
117
+ })
118
+
119
+ it('two independent single-parent children at the same level both align, and compaction keeps them from overlapping', () => {
120
+ // ParentA and ParentB are both roots; KidA is ParentA's only child,
121
+ // KidB is ParentB's only child. Each is its own singleton block with
122
+ // exactly one qualifying parent, so both get a real desired center --
123
+ // this exercises the compaction pass itself (#971 must implement it
124
+ // for every block shape, not just multi-parent ones): two different
125
+ // singleton blocks can still want colliding desired positions.
126
+ const src = `classDiagram
127
+ class ParentA
128
+ class ParentB
129
+ class KidA
130
+ class KidB
131
+ ParentA --> KidA
132
+ ParentB --> KidB`
133
+ const ascii = renderMermaidASCII(src, { useAscii: true })
134
+ const kidA = findBoxRect(ascii, 'KidA')
135
+ const kidB = findBoxRect(ascii, 'KidB')
136
+ // Declaration order is preserved -- KidA never lands right of KidB.
137
+ expect(kidA.x1).toBeLessThan(kidB.x0)
138
+ })
139
+ })
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Regression tests for issue #972 — "ASCII class-diagram layout:
3
+ * multi-parent / overlap resolution," the second implementation stage of
4
+ * #964's fix (docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md),
5
+ * building on #971's single-parent alignment + universal compaction pass.
6
+ *
7
+ * #971 only computed a real desired position for a *singleton* block (no
8
+ * sibling at its level shares its exact parent-id set) with exactly one
9
+ * qualifying (strictly-shallower) parent. This fix adds the remaining
10
+ * block shapes #970's decision named: a block of more than one class
11
+ * sharing one qualifying-parent-set spreads evenly around that shared
12
+ * center; a block with more than one qualifying parent centers on the
13
+ * mean of their centers. Both reuse #971's exact compaction pass
14
+ * unchanged, which already handles "an aligned position collides with a
15
+ * sibling" — #972 does not add new nudging logic, only new
16
+ * desired-position computations for `if (qualifying.length === 0)` this
17
+ * pass now escapes into.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
22
+ import { findBoxRect, rectsOverlap } from './helpers/ascii-form.ts'
23
+
24
+ function boxCenter(rect: { x0: number; x1: number }): number {
25
+ return rect.x0 + Math.floor((rect.x1 - rect.x0 + 1) / 2)
26
+ }
27
+
28
+ describe('ASCII class diagram — multi-parent / overlap resolution (issue #972)', () => {
29
+ it('two children of the same parent spread evenly around its center, not packed from column 0', () => {
30
+ // WidePadding gives Animal real room on both sides -- proves genuine
31
+ // symmetric spreading, not just an edge-of-canvas clamp coincidence.
32
+ const src = `classDiagram
33
+ class WidePadding {
34
+ +String p0
35
+ +String p1
36
+ +String p2
37
+ }
38
+ class Animal
39
+ class Dog
40
+ class Cat
41
+ Animal <|-- Dog
42
+ Animal <|-- Cat`
43
+ const ascii = renderMermaidASCII(src, { useAscii: true })
44
+ const animal = findBoxRect(ascii, 'Animal')
45
+ const dog = findBoxRect(ascii, 'Dog')
46
+ const cat = findBoxRect(ascii, 'Cat')
47
+
48
+ // Declaration order preserved: Dog left of Cat, neither overlapping.
49
+ expect(dog.x1).toBeLessThan(cat.x0)
50
+ // The pair's combined footprint centers on Animal's own center.
51
+ const blockCenter = dog.x0 + Math.floor((cat.x1 - dog.x0 + 1) / 2)
52
+ expect(Math.abs(blockCenter - boxCenter(animal))).toBeLessThanOrEqual(1)
53
+ // Neither child sits at column 0 (the pre-#972 leftover artifact).
54
+ expect(dog.x0).toBeGreaterThan(0)
55
+ })
56
+
57
+ it('a child with two parents at different levels centers on the mean of their centers', () => {
58
+ // Controller (level 0) -> Model (level 1) -> View (level 2); View also
59
+ // has a direct edge from Controller. View's qualifying parents are
60
+ // both Controller and Model (both strictly shallower than View), so
61
+ // its desired center is their mean -- not just Controller's alone.
62
+ const src = `classDiagram
63
+ class Controller {
64
+ +handleInput() void
65
+ }
66
+ class Model {
67
+ +getData() void
68
+ }
69
+ class View {
70
+ +render() void
71
+ }
72
+ Controller --> Model
73
+ Controller --> View
74
+ Model ..> View`
75
+ const ascii = renderMermaidASCII(src, { useAscii: true })
76
+ const controller = findBoxRect(ascii, 'Controller')
77
+ const model = findBoxRect(ascii, 'Model')
78
+ const view = findBoxRect(ascii, 'View')
79
+
80
+ const expectedCenter = Math.round(
81
+ (boxCenter(controller) + boxCenter(model)) / 2,
82
+ )
83
+ expect(Math.abs(boxCenter(view) - expectedCenter)).toBeLessThanOrEqual(1)
84
+ // Not simply parked back at column 0 the way an unresolved block used to be.
85
+ expect(view.x0).toBeGreaterThan(0)
86
+ })
87
+
88
+ it('a multi-child block whose desired position collides with an earlier sibling is pushed right without overlapping it', () => {
89
+ // Two parents both near the canvas's left edge: EarlyParent's own
90
+ // child (EarlySibling) is placed first; WidePair's two children want
91
+ // to center on WidePair, which sits close enough that their desired
92
+ // block would otherwise land on top of EarlySibling. The universal
93
+ // compaction pass (#971, reused unchanged by #972) must push the
94
+ // whole block right of EarlySibling instead of overlapping it.
95
+ const src = `classDiagram
96
+ class EarlyParent
97
+ class WidePair
98
+ class EarlySibling
99
+ class ChildA
100
+ class ChildB
101
+ EarlyParent --> EarlySibling
102
+ WidePair --> ChildA
103
+ WidePair --> ChildB`
104
+ const ascii = renderMermaidASCII(src, { useAscii: true })
105
+ const names = [
106
+ 'EarlyParent',
107
+ 'WidePair',
108
+ 'EarlySibling',
109
+ 'ChildA',
110
+ 'ChildB',
111
+ ]
112
+ const rects = names.map((n) => ({ n, r: findBoxRect(ascii, n) }))
113
+ for (let i = 0; i < rects.length; i++) {
114
+ for (let j = i + 1; j < rects.length; j++) {
115
+ expect(
116
+ rectsOverlap(rects[i]!.r, rects[j]!.r),
117
+ `${rects[i]!.n} and ${rects[j]!.n} boxes overlap`,
118
+ ).toBe(false)
119
+ }
120
+ }
121
+ // Declaration order preserved even after compaction pushes the block right.
122
+ const earlySibling = findBoxRect(ascii, 'EarlySibling')
123
+ const childA = findBoxRect(ascii, 'ChildA')
124
+ expect(earlySibling.x1).toBeLessThan(childA.x0)
125
+ })
126
+
127
+ it('a block with no qualifying parents (a rootless cycle with two same-level "parent" edges) keeps its plain baseline position', () => {
128
+ // A and B mutually reference each other with no external root: both
129
+ // land at level 0, and neither's same-level "parent" edge qualifies --
130
+ // #972 must not spread or converge this shape, only #971's untouched
131
+ // no-resolvable-parents fallback applies.
132
+ const src = `classDiagram
133
+ class A
134
+ class B
135
+ A --> B
136
+ B --> A`
137
+ const ascii = renderMermaidASCII(src, { useAscii: true })
138
+ const a = findBoxRect(ascii, 'A')
139
+ const b = findBoxRect(ascii, 'B')
140
+ expect(a.x0).toBe(0)
141
+ expect(a.x1).toBeLessThan(b.x0)
142
+ })
143
+
144
+ // Regression for a CodeRabbit finding on #972's own PR (thread
145
+ // PRRT_kwDOT5ndxc6hpu-C): two classes with no resolvable parents but the
146
+ // exact same (non-qualifying) parent-id set used to be merged into one
147
+ // joint placement unit the moment the *first* of them was reached in
148
+ // declaration order -- silently pulling an unrelated class declared
149
+ // *between* them ahead of its own position, since it hadn't been placed
150
+ // yet when the merged pair's combined placement consumed the columns it
151
+ // would have occupied.
152
+ it('an unrelated class declared between two same-parent-set siblings with no qualifying parent keeps its own declaration-order position', () => {
153
+ // Q and R share the exact same parent-id set ({P}), but P is at the
154
+ // same level as Q/R (a 3-way mutual-reference cycle), so neither
155
+ // qualifies -- both are "no resolvable parents" blocks on their own.
156
+ // Interloper is a plain, unrelated root declared between Q and R.
157
+ const src = `classDiagram
158
+ class P
159
+ class Q
160
+ class Interloper
161
+ class R
162
+ P --> Q
163
+ P --> R
164
+ Q --> P
165
+ R --> P`
166
+ const ascii = renderMermaidASCII(src, { useAscii: true })
167
+ const q = findBoxRect(ascii, 'Q')
168
+ const interloper = findBoxRect(ascii, 'Interloper')
169
+ const r = findBoxRect(ascii, 'R')
170
+ // Declared order Q, Interloper, R must survive as rendered left-to-right order.
171
+ expect(q.x1).toBeLessThan(interloper.x0)
172
+ expect(interloper.x1).toBeLessThan(r.x0)
173
+ })
174
+
175
+ // Regression for a CodeRabbit finding on #972's own PR (thread
176
+ // PRRT_kwDOT5ndxc6hpu-M): a multi-member block's desired left edge used
177
+ // to be derived from centering the *padded slot* span (every member's
178
+ // own reach padding, including the outermost edges with no neighbor to
179
+ // separate from) on the shared parent center, rather than the *visible
180
+ // box* span -- so a block whose first member has real reach padding
181
+ // (e.g. its own outgoing relationship carries a long label) and whose
182
+ // last member has none rendered visibly off-center from the parent it
183
+ // was supposed to align under.
184
+ it('a multi-child block with asymmetric reach padding on one child still visually centers on the shared parent', () => {
185
+ const src = `classDiagram
186
+ class Padding {
187
+ +String p0
188
+ +String p1
189
+ +String p2
190
+ +String p3
191
+ }
192
+ class Animal
193
+ class Dog
194
+ class Cat
195
+ class SomewhereElse
196
+ Animal <|-- Dog
197
+ Animal <|-- Cat
198
+ Dog --> SomewhereElse : a moderately long label`
199
+ const ascii = renderMermaidASCII(src, { useAscii: true })
200
+ const animal = findBoxRect(ascii, 'Animal')
201
+ const dog = findBoxRect(ascii, 'Dog')
202
+ const cat = findBoxRect(ascii, 'Cat')
203
+ const visibleBlockCenter = dog.x0 + Math.floor((cat.x1 - dog.x0 + 1) / 2)
204
+ expect(
205
+ Math.abs(visibleBlockCenter - boxCenter(animal)),
206
+ ).toBeLessThanOrEqual(1)
207
+ })
208
+ })
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Regression test for #448 — "Class: MVC Architecture sample drops the
3
+ * `View --> Model : reads` edge."
4
+ *
5
+ * Root cause: `renderClassAscii` connected every relationship's line/arrow/
6
+ * label to its source and target boxes at a fixed box-center column, with
7
+ * no awareness of other relationships. When two relationships connect the
8
+ * same pair of classes in opposite directions (`View --> Model : reads`
9
+ * alongside `Model ..> View : notifies`), both computed the identical
10
+ * center column, so the later-drawn one silently overwrote the earlier
11
+ * one's line, arrowhead, and label.
12
+ *
13
+ * The fix groups relationships by unordered class-pair and gives each
14
+ * member of a multi-relationship group its own connection column. This
15
+ * test exercises that grouping/column-offset logic directly — no prior
16
+ * test in this repo did (verification for #448 was ad hoc/visual only via
17
+ * a real-terminal capture, never committed as a vitest case), which is
18
+ * exactly why this code path had gone completely uncovered.
19
+ *
20
+ * Uses the same source as the "Class: MVC Architecture" sample
21
+ * (samples-data.ts) that the original issue was reported against — a
22
+ * narrower repro with only short single-word labels and no other class
23
+ * members hits an unrelated, tighter label/border collision (also
24
+ * present, separately, in the pre-#448 code and out of scope here), so
25
+ * this keeps the box widths the real sample has.
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest'
29
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
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 — reciprocal relationships (issue #448)', () => {
55
+ it('renders all four relationships, including both directions of the Model/View reciprocal pair', () => {
56
+ const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
57
+
58
+ for (const label of ['updates', 'refreshes', 'reads', 'notifies']) {
59
+ expect(ascii).toContain(label)
60
+ }
61
+ })
62
+
63
+ it('gives the reciprocal pair distinct connection columns instead of one overwriting the other', () => {
64
+ const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
65
+ const lines = ascii.split('\n')
66
+
67
+ const readsCol = lines.find((l) => l.includes('reads'))!.indexOf('reads')
68
+ const notifiesCol = lines
69
+ .find((l) => l.includes('notifies'))!
70
+ .indexOf('notifies')
71
+ expect(readsCol).not.toBe(notifiesCol)
72
+ })
73
+
74
+ it('leaves a single (non-reciprocal) relationship at the plain center column', () => {
75
+ const ascii = renderMermaidASCII(
76
+ `classDiagram
77
+ class A
78
+ class B
79
+ A --> B : uses`,
80
+ { colorMode: 'none' },
81
+ )
82
+ expect(ascii).toContain('uses')
83
+ })
84
+ })
85
+
86
+ /**
87
+ * Regression test for issue #632 — a weekly form-judge audit comparing this
88
+ * repo's ASCII output against real mermaid.js SVG flagged the "Class:
89
+ * Relationship Labels" sample (samples-data.ts): `Teacher --> Course` and
90
+ * `Student --> Course` are two *distinct* pairs (not a reciprocal pair
91
+ * between the same two classes, which #448 above already covers) that both
92
+ * terminate at the same target, `Course`.
93
+ *
94
+ * Root cause: `pairGroups` (the #448 fix, keyed by the unordered
95
+ * `[from, to]` pair) only fans relationships between the exact same two
96
+ * classes — it never grouped these, so both anchored at Course's identical
97
+ * center column, collapsing their arrowheads onto the same cell. Since
98
+ * both relationships' single horizontal jog then also landed on the exact
99
+ * same row, the later-drawn label (in the separate label-drawing pass)
100
+ * blotted out most of the visible line, so `Student --> Course` looked
101
+ * like it terminated in blank space short of Course instead of reaching it.
102
+ *
103
+ * The fix (`relFromOffset`/`relToOffset` in `class-diagram.ts`) fans a
104
+ * relationship at whichever single end it shares with another
105
+ * relationship, even when the two relationships are between otherwise
106
+ * unrelated classes — scoped to a direct, adjacent-level convergence (see
107
+ * that code's comment for why a level-skipping relationship, which already
108
+ * needs the separate box-collision detour routing, is excluded).
109
+ */
110
+ describe('ASCII class diagram — relationships converging on a shared target from different sources (issue #632)', () => {
111
+ const STUDENT_COURSE_SOURCE = `classDiagram
112
+ class Teacher {
113
+ +String name
114
+ }
115
+ class Student {
116
+ +String name
117
+ }
118
+ class Course {
119
+ +String title
120
+ }
121
+ Teacher --> Course : teaches
122
+ Student --> Course : enrolled in`
123
+
124
+ it('renders both relationship labels', () => {
125
+ const ascii = renderMermaidASCII(STUDENT_COURSE_SOURCE, {
126
+ colorMode: 'none',
127
+ })
128
+ expect(ascii).toContain('teaches')
129
+ expect(ascii).toContain('enrolled in')
130
+ })
131
+
132
+ it('draws two distinct arrowheads into Course instead of collapsing both onto one shared cell', () => {
133
+ const ascii = renderMermaidASCII(STUDENT_COURSE_SOURCE, {
134
+ colorMode: 'none',
135
+ })
136
+ // Before the fix, both relationships anchored at the exact same
137
+ // column, so only a single '▼' was ever drawn for both of them
138
+ // combined — the `Student --> Course` connector had no arrowhead of
139
+ // its own at all.
140
+ const arrowCount = (ascii.match(/▼/g) ?? []).length
141
+ expect(arrowCount).toBe(2)
142
+ })
143
+
144
+ it('gives the two relationships distinct target columns, both landing on Course', () => {
145
+ const ascii = renderMermaidASCII(STUDENT_COURSE_SOURCE, {
146
+ colorMode: 'none',
147
+ })
148
+ const lines = ascii.split('\n')
149
+ const arrowCols = lines.flatMap((line) =>
150
+ [...line].map((ch, i) => (ch === '▼' ? i : -1)).filter((i) => i >= 0),
151
+ )
152
+ expect(arrowCols).toHaveLength(2)
153
+ expect(arrowCols[0]).not.toBe(arrowCols[1])
154
+
155
+ // Course's box must actually span both arrow columns — otherwise
156
+ // they've merely moved apart without both still landing on the target.
157
+ const courseRow = lines.findIndex((line) => line.includes('Course'))
158
+ expect(courseRow).toBeGreaterThan(0)
159
+ const boxRow = lines[courseRow - 1]!
160
+ const boxLeft = boxRow.indexOf('┌')
161
+ const boxRight = boxRow.indexOf('┐')
162
+ expect(boxLeft).toBeGreaterThanOrEqual(0)
163
+ expect(boxRight).toBeGreaterThan(boxLeft)
164
+ for (const col of arrowCols) {
165
+ expect(col).toBeGreaterThanOrEqual(boxLeft)
166
+ expect(col).toBeLessThanOrEqual(boxRight)
167
+ }
168
+ })
169
+ })