@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,70 @@
1
+ /**
2
+ * Regression tests for issue #205 — ASCII renderer box borders misaligned by
3
+ * zero-width Unicode combining marks.
4
+ *
5
+ * `displayWidth()` used to add 1 column for every code point that isn't
6
+ * "wide" (CJK etc), including combining marks (general categories Mn/Me,
7
+ * e.g. U+0300–U+036F COMBINING ACUTE ACCENT). A real terminal gives such a
8
+ * mark 0 columns — it attaches to the preceding base character. A decomposed
9
+ * string like "café" (e + U+0301) used to measure as 5 columns; a real
10
+ * terminal renders it as 4.
11
+ *
12
+ * `terminalDisplayWidth` re-implements the "how many terminal columns does
13
+ * this string occupy" calculation independently of
14
+ * `src/ascii/display-width.ts`, so these tests verify the *rendered output*
15
+ * against an authority separate from the implementation under test.
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
19
+ import { displayWidth } from '../display-width.ts'
20
+ import {
21
+ terminalDisplayWidth,
22
+ assertUniformDisplayWidth,
23
+ } from './helpers/terminal-display-width.ts'
24
+
25
+ // "café", decomposed: c a f e + COMBINING ACUTE ACCENT (U+0301) — 5 code
26
+ // points, 4 grapheme clusters / terminal columns.
27
+ const DECOMPOSED_CAFE = 'caf' + 'e' + '́'
28
+
29
+ describe('ASCII renderer — combining mark box alignment (issue #205)', () => {
30
+ it('displayWidth("café") (decomposed) is 4, not 5', () => {
31
+ expect([...DECOMPOSED_CAFE].length).toBe(5)
32
+ expect(displayWidth(DECOMPOSED_CAFE)).toBe(4)
33
+ expect(terminalDisplayWidth(DECOMPOSED_CAFE)).toBe(4)
34
+ })
35
+
36
+ for (const useAscii of [false, true]) {
37
+ describe(useAscii ? 'ASCII charset' : 'Unicode charset', () => {
38
+ it('renders a flowchart node with a decomposed label at uniform row width', () => {
39
+ const mermaid = `flowchart TD\n A[${DECOMPOSED_CAFE}] --> B[ok]`
40
+ const ascii = renderMermaidASCII(mermaid, {
41
+ useAscii,
42
+ colorMode: 'none',
43
+ })
44
+ assertUniformDisplayWidth(ascii)
45
+ expect(ascii).toContain(DECOMPOSED_CAFE)
46
+ })
47
+ })
48
+ }
49
+
50
+ it('renders a class diagram compartment box with a decomposed member name at uniform width', () => {
51
+ const mermaid = `classDiagram\n class Person {\n +String ${DECOMPOSED_CAFE}\n }`
52
+ const ascii = renderMermaidASCII(mermaid, { colorMode: 'none' })
53
+ assertUniformDisplayWidth(ascii)
54
+ expect(ascii).toContain(DECOMPOSED_CAFE)
55
+ })
56
+
57
+ it('renders an ER diagram compartment box with a decomposed attribute name at uniform width', () => {
58
+ const mermaid = `erDiagram\n ITEM {\n string ${DECOMPOSED_CAFE}\n }`
59
+ const ascii = renderMermaidASCII(mermaid, { colorMode: 'none' })
60
+ assertUniformDisplayWidth(ascii)
61
+ expect(ascii).toContain(DECOMPOSED_CAFE)
62
+ })
63
+
64
+ it('does not perturb the all-ASCII case', () => {
65
+ const ascii = renderMermaidASCII('flowchart TD\n A[plain] --> B[ok]', {
66
+ colorMode: 'none',
67
+ })
68
+ assertUniformDisplayWidth(ascii)
69
+ })
70
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { addCoordsOverlay } from '../coords.ts'
3
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
4
+
5
+ describe('addCoordsOverlay', () => {
6
+ it('prepends a two-row column ruler and a row-index gutter', () => {
7
+ const rendered = 'AB\nCD'
8
+ const result = addCoordsOverlay(rendered)
9
+ const lines = result.split('\n')
10
+
11
+ // Two ruler rows on top, then one gutter-prefixed row per original line.
12
+ expect(lines).toHaveLength(4)
13
+ expect(lines[0]).toBe(' 00')
14
+ expect(lines[1]).toBe(' 01')
15
+ expect(lines[2]).toBe('0 AB')
16
+ expect(lines[3]).toBe('1 CD')
17
+ })
18
+
19
+ it('pads the row gutter to fit multi-digit row indices', () => {
20
+ const rendered = Array.from({ length: 11 }, (_, i) => `row${i}`).join('\n')
21
+ const result = addCoordsOverlay(rendered)
22
+ const lines = result.split('\n')
23
+
24
+ // Row gutter width is 2 (since max row index is 10), so row 0's label
25
+ // is right-aligned as " 0" and row 10's as "10".
26
+ expect(lines[2]?.startsWith(' 0 ')).toBe(true)
27
+ expect(lines[lines.length - 1]?.startsWith('10 ')).toBe(true)
28
+ })
29
+
30
+ it('handles an empty string without throwing', () => {
31
+ expect(() => addCoordsOverlay('')).not.toThrow()
32
+ })
33
+
34
+ it('measures width by visible characters, not raw length with ANSI codes', () => {
35
+ // A 2-char-wide line wrapped in a color escape sequence is still only
36
+ // 2 columns wide on screen — the ruler must match that, not the ~10
37
+ // raw characters the escape codes add.
38
+ const colored = '\x1b[37mAB\x1b[0m'
39
+ const result = addCoordsOverlay(colored)
40
+ const lines = result.split('\n')
41
+ expect(lines[0]).toBe(' 00')
42
+ expect(lines[1]).toBe(' 01')
43
+ })
44
+ })
45
+
46
+ describe('renderMermaidASCII – showCoords', () => {
47
+ const DIAGRAM = 'graph LR\n A --> B'
48
+
49
+ it('is off by default (no ruler in output)', () => {
50
+ const result = renderMermaidASCII(DIAGRAM)
51
+ // Without the overlay, the very first line is drawing content, not a
52
+ // row of digit-only ruler characters starting at the left margin.
53
+ expect(result.split('\n')[0]).not.toMatch(/^\s*0123456789/)
54
+ })
55
+
56
+ it('overlays coordinate rulers when showCoords is true', () => {
57
+ const plain = renderMermaidASCII(DIAGRAM)
58
+ const withCoords = renderMermaidASCII(DIAGRAM, { showCoords: true })
59
+
60
+ expect(withCoords).not.toBe(plain)
61
+ expect(withCoords.split('\n').length).toBe(plain.split('\n').length + 2)
62
+ expect(withCoords).toContain('0123456789')
63
+ })
64
+ })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Regression test for GitHub issue #86 — ASCII: stray ├ character on
3
+ * flowchart decision-node edge labels (LR, Unicode).
4
+ *
5
+ * Repro (from the issue): a decision node with two outgoing labeled edges,
6
+ * rendered LR with the default Unicode box-drawing charset. The box-start
7
+ * connector for the first ("Yes") edge landed on a blank grid cell — one
8
+ * that had no real vertical border line above or below it — and still
9
+ * unconditionally emitted a ├ tee character there, producing a stray,
10
+ * disconnected junction glyph instead of a plain horizontal line.
11
+ *
12
+ * Not reproducible in TD direction or with the ASCII charset (`useAscii:
13
+ * true`), since ASCII mode skips box-start connectors entirely.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
17
+ import { findOrphanedJunctions } from '../validate.ts'
18
+ import { readdirSync, readFileSync } from 'node:fs'
19
+ import { join, dirname } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const ISSUE_86_REPRO = `flowchart LR
23
+ A{Decision} -->|Yes| B[Do thing]
24
+ A -->|No| C[Other thing]`
25
+
26
+ describe('issue #86: stray ├ on decision-node edge labels (LR, Unicode)', () => {
27
+ it('does not emit an orphaned ├ where the "Yes" edge exits the decision box', () => {
28
+ const out = renderMermaidASCII(ISSUE_86_REPRO, { colorMode: 'none' })
29
+
30
+ const orphans = findOrphanedJunctions(out)
31
+ expect(orphans).toEqual([])
32
+ })
33
+
34
+ it('places the "Yes" edge box-start exactly on the decision box\'s real right border, not drifted onto a blank cell', () => {
35
+ // A sibling fix (issue #64 bug 5, box-start connector drift) re-anchors
36
+ // horizontal connectors to the node's *actual* rendered border column
37
+ // instead of a grid-centered position that a sibling label could widen.
38
+ // Combined with this fix's validation, the "Yes" connector now lands
39
+ // exactly on the decision box's real border — so the correct output
40
+ // *is* a genuine ├ junction there (replacing what's a plain │ on every
41
+ // other row of that same border column), not a stray one. The
42
+ // structural check above (findOrphanedJunctions) is what actually
43
+ // proves it's not orphaned; this test locks in that the junction's
44
+ // column lines up with the box's own border on adjacent rows, which is
45
+ // what "not drifted" means concretely.
46
+ const out = renderMermaidASCII(ISSUE_86_REPRO, { colorMode: 'none' })
47
+ const lines = out.split('\n')
48
+ const yesLineIdx = lines.findIndex((l) => l.includes('Yes'))
49
+ expect(yesLineIdx).toBeGreaterThanOrEqual(0)
50
+
51
+ const yesRow = lines[yesLineIdx]!
52
+ const junctionCol = yesRow.indexOf('├')
53
+ expect(junctionCol).toBeGreaterThanOrEqual(0)
54
+
55
+ // The same column on the rows immediately above/below must be the
56
+ // decision box's own plain vertical border — confirming the junction
57
+ // sits exactly on a real border line, not on a blank/drifted cell.
58
+ expect(lines[yesLineIdx - 1]?.[junctionCol]).toBe('│')
59
+ expect(lines[yesLineIdx + 1]?.[junctionCol]).toBe('│')
60
+ })
61
+
62
+ it('still renders a genuine ├ junction for a normal single-edge box-start', () => {
63
+ // Sanity check: the fix must not turn into a false negative that
64
+ // removes legitimate tee/junction characters where a real vertical
65
+ // border line does exist above and below.
66
+ const out = renderMermaidASCII(
67
+ `flowchart LR
68
+ A[Box A] --> B[Box B]`,
69
+ { colorMode: 'none' },
70
+ )
71
+ expect(out).toContain('├')
72
+ expect(findOrphanedJunctions(out)).toEqual([])
73
+ })
74
+
75
+ it('still renders a genuine ┬ junction for a TD fan-out box-start', () => {
76
+ // Labeled edges opt out of edge bundling (see analyzeEdgeBundles'
77
+ // canBundle check), so both edges go through the plain drawArrow /
78
+ // drawBoxStart path exercised by this fix rather than the separate
79
+ // fan-out bundle trunk/junction logic.
80
+ const out = renderMermaidASCII(
81
+ `flowchart TD
82
+ A[Box A] -->|one| B[Box B]
83
+ A -->|two| C[Box C]`,
84
+ { colorMode: 'none' },
85
+ )
86
+ expect(out).toContain('┬')
87
+ expect(findOrphanedJunctions(out)).toEqual([])
88
+ })
89
+
90
+ it('has no orphaned junctions across all Unicode golden fixtures', () => {
91
+ const dir = join(
92
+ dirname(fileURLToPath(import.meta.url)),
93
+ 'testdata',
94
+ 'unicode',
95
+ )
96
+ const files = readdirSync(dir).filter((f) => f.endsWith('.txt'))
97
+
98
+ for (const file of files) {
99
+ const content = readFileSync(join(dir, file), 'utf-8')
100
+ const expected = content.split('\n---\n')[1] ?? content.split('---')[1]
101
+ if (!expected) continue
102
+ const orphans = findOrphanedJunctions(expected)
103
+ expect(orphans, `orphaned junction(s) in fixture: ${file}`).toEqual([])
104
+ }
105
+ })
106
+ })
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Direct unit tests for `src/ascii/display-width.ts`'s grapheme-cluster-based
3
+ * width algorithm, covering both bugs it fixes:
4
+ *
5
+ * - #205: a decomposed combining mark (base char + Mn/Me code point) must
6
+ * contribute 0 columns of its own, not 1.
7
+ * - #214: a composed multi-code-point grapheme cluster (ZWJ emoji sequence,
8
+ * flag via regional indicators, skin-tone modifier) must be measured
9
+ * once per cluster, not once per code point.
10
+ *
11
+ * The render-level regression suites (`ascii-combining-mark-width.test.ts`,
12
+ * `ascii-emoji-cluster-width.test.ts`) verify these against a fully
13
+ * independent oracle; this file tests the module's own public API directly,
14
+ * including the degenerate lone-combining-mark case that a full diagram
15
+ * render wouldn't naturally exercise.
16
+ *
17
+ * Strings are built from explicit `\u` code point escapes rather than typed
18
+ * accented/emoji literals, so the decomposed/precomposed and
19
+ * single-cluster/multi-code-point distinctions under test can't be silently
20
+ * altered by source-file Unicode normalization.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ charDisplayWidth,
25
+ displayWidth,
26
+ toDisplayCells,
27
+ WIDE_CHAR_PLACEHOLDER,
28
+ } from '../display-width.ts'
29
+
30
+ const COMBINING_ACUTE = '́'
31
+ const COMBINING_GRAVE = '̀'
32
+ // "café", decomposed: c a f e + COMBINING ACUTE ACCENT (5 code points).
33
+ const DECOMPOSED_CAFE = 'caf' + 'e' + COMBINING_ACUTE
34
+ // "café", precomposed: c a f é, where é is the single code point U+00E9
35
+ // (4 code points).
36
+ const PRECOMPOSED_CAFE = 'caf' + 'é'
37
+
38
+ // 👨‍👩‍👧 — MAN, ZWJ, WOMAN, ZWJ, GIRL (5 code points, 1 grapheme cluster).
39
+ const ZWJ_FAMILY = '\u{1F468}' + '‍' + '\u{1F469}' + '‍' + '\u{1F467}'
40
+ // 🇺🇸 — REGIONAL INDICATOR U + REGIONAL INDICATOR S (2 code points, 1 cluster).
41
+ const FLAG_US = '\u{1F1FA}' + '\u{1F1F8}'
42
+ // 👍🏽 — THUMBS UP + MEDIUM SKIN TONE modifier (2 code points, 1 cluster).
43
+ const THUMB_MEDIUM = '\u{1F44D}' + '\u{1F3FD}'
44
+
45
+ describe('displayWidth — combining marks (issue #205)', () => {
46
+ it('measures a decomposed "café" (e + U+0301) as 4 columns', () => {
47
+ expect([...DECOMPOSED_CAFE].length).toBe(5) // 5 code points
48
+ expect(displayWidth(DECOMPOSED_CAFE)).toBe(4)
49
+ })
50
+
51
+ it('measures the precomposed form identically', () => {
52
+ expect([...PRECOMPOSED_CAFE].length).toBe(4) // 4 code points already
53
+ expect(displayWidth(PRECOMPOSED_CAFE)).toBe(4)
54
+ })
55
+
56
+ it('does not add width for multiple stacked combining marks on one base', () => {
57
+ // "e" + combining acute + combining grave — still one grapheme cluster.
58
+ const stacked = 'e' + COMBINING_ACUTE + COMBINING_GRAVE
59
+ expect(displayWidth(stacked)).toBe(1)
60
+ })
61
+
62
+ it('treats a lone combining mark with no base character as zero-width', () => {
63
+ expect(charDisplayWidth(COMBINING_ACUTE)).toBe(0)
64
+ expect(displayWidth(COMBINING_ACUTE)).toBe(0)
65
+ })
66
+
67
+ it('produces no grid cell for a lone combining mark', () => {
68
+ expect(toDisplayCells(COMBINING_ACUTE)).toEqual([])
69
+ })
70
+ })
71
+
72
+ describe('displayWidth — composed grapheme clusters (issue #214)', () => {
73
+ it('measures a ZWJ family emoji as 2 columns, not 8', () => {
74
+ expect([...ZWJ_FAMILY].length).toBe(5) // 5 code points
75
+ expect(displayWidth(ZWJ_FAMILY)).toBe(2)
76
+ })
77
+
78
+ it('measures a regional-indicator flag as 2 columns, not 4', () => {
79
+ expect([...FLAG_US].length).toBe(2)
80
+ expect(displayWidth(FLAG_US)).toBe(2)
81
+ })
82
+
83
+ it('measures a skin-tone-modified emoji as 2 columns, not 4', () => {
84
+ expect([...THUMB_MEDIUM].length).toBe(2)
85
+ expect(displayWidth(THUMB_MEDIUM)).toBe(2)
86
+ })
87
+
88
+ it('still measures plain CJK correctly (contrast case)', () => {
89
+ expect(displayWidth('日本')).toBe(4) // 日本
90
+ })
91
+
92
+ it('measures a mixed string combining marks and emoji clusters', () => {
93
+ const text = DECOMPOSED_CAFE + ' ' + ZWJ_FAMILY
94
+ expect(displayWidth(text)).toBe(4 + 1 + 2)
95
+ })
96
+ })
97
+
98
+ describe('displayWidth — VS16 emoji-presentation override', () => {
99
+ // ▶️ / ◀️ — the arrowhead glyphs excluded from `isWideChar` as narrow
100
+ // Geometric Shapes characters (see text-metrics.ts), explicitly forced to
101
+ // emoji presentation via a trailing U+FE0F VARIATION SELECTOR-16.
102
+ const RIGHT_ARROW_VS16 = '▶' + '\u{FE0F}'
103
+ const LEFT_ARROW_VS16 = '◀' + '\u{FE0F}'
104
+
105
+ it('measures VS16-suffixed arrowheads as 2 columns despite the base character being narrow', () => {
106
+ expect(charDisplayWidth(RIGHT_ARROW_VS16)).toBe(2)
107
+ expect(charDisplayWidth(LEFT_ARROW_VS16)).toBe(2)
108
+ })
109
+
110
+ it('still measures the bare (VS16-less) arrowheads as narrow', () => {
111
+ expect(charDisplayWidth('▶')).toBe(1)
112
+ expect(charDisplayWidth('◀')).toBe(1)
113
+ })
114
+
115
+ it('keeps cell count in sync with displayWidth for a VS16 arrowhead', () => {
116
+ expect(toDisplayCells(RIGHT_ARROW_VS16)).toEqual([
117
+ RIGHT_ARROW_VS16,
118
+ WIDE_CHAR_PLACEHOLDER,
119
+ ])
120
+ })
121
+ })
122
+
123
+ describe('toDisplayCells — grid-cell/column-count invariant', () => {
124
+ it('keeps cell count in sync with displayWidth for a decomposed mark', () => {
125
+ expect(toDisplayCells(DECOMPOSED_CAFE).length).toBe(
126
+ displayWidth(DECOMPOSED_CAFE),
127
+ )
128
+ })
129
+
130
+ it('keeps cell count in sync with displayWidth for a ZWJ emoji', () => {
131
+ expect(toDisplayCells(ZWJ_FAMILY).length).toBe(displayWidth(ZWJ_FAMILY))
132
+ })
133
+
134
+ it('writes the whole composed cluster into a single cell, followed by a placeholder', () => {
135
+ const cells = toDisplayCells(ZWJ_FAMILY)
136
+ expect(cells).toEqual([ZWJ_FAMILY, WIDE_CHAR_PLACEHOLDER])
137
+ })
138
+
139
+ it('writes a base character + combining mark into a single cell with no placeholder', () => {
140
+ const decomposedE = 'e' + COMBINING_ACUTE
141
+ const cells = toDisplayCells(decomposedE)
142
+ expect(cells).toEqual([decomposedE])
143
+ })
144
+
145
+ it('leaves plain ASCII behavior unchanged', () => {
146
+ expect(toDisplayCells('ab')).toEqual(['a', 'b'])
147
+ expect(displayWidth('ab')).toBe(2)
148
+ })
149
+
150
+ it('leaves plain CJK behavior unchanged', () => {
151
+ const cells = toDisplayCells('日') // 日
152
+ expect(cells).toEqual(['日', WIDE_CHAR_PLACEHOLDER])
153
+ })
154
+ })
@@ -0,0 +1,224 @@
1
+ // ============================================================================
2
+ // ASCII arrow-drawing coverage — no-arrow edges, bidirectional arrowheads,
3
+ // and upward labeled edges (src/ascii/draw-arrows.ts)
4
+ // ============================================================================
5
+
6
+ import { describe, it, expect } from 'vitest'
7
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
8
+ import { unicodeArrowChar, asciiArrowChar } from '../draw-arrows.ts'
9
+ import {
10
+ Up,
11
+ Down,
12
+ Left,
13
+ Right,
14
+ UpperRight,
15
+ UpperLeft,
16
+ LowerRight,
17
+ LowerLeft,
18
+ Middle,
19
+ } from '../types.ts'
20
+
21
+ describe('ASCII arrow drawing: no-arrow edges', () => {
22
+ it('renders a solid no-arrow edge without an arrowhead in unicode mode', () => {
23
+ const result = renderMermaidASCII(`
24
+ graph LR
25
+ A --- B
26
+ `)
27
+ expect(result).toContain('─')
28
+ expect(result).not.toMatch(/[►◄▲▼]/)
29
+ })
30
+
31
+ it('renders a solid no-arrow edge without an arrowhead in ascii mode', () => {
32
+ const result = renderMermaidASCII(
33
+ `
34
+ graph LR
35
+ A --- B
36
+ `,
37
+ { useAscii: true },
38
+ )
39
+ expect(result).not.toMatch(/[<>^v]/)
40
+ })
41
+
42
+ it('renders a dotted no-arrow edge without an arrowhead', () => {
43
+ const result = renderMermaidASCII(`
44
+ graph LR
45
+ A -.- B
46
+ `)
47
+ expect(result).toContain('┄')
48
+ expect(result).not.toMatch(/[►◄▲▼]/)
49
+ })
50
+
51
+ it('renders a thick no-arrow edge without an arrowhead', () => {
52
+ const result = renderMermaidASCII(`
53
+ graph LR
54
+ A === B
55
+ `)
56
+ expect(result).toContain('━')
57
+ expect(result).not.toMatch(/[►◄▲▼]/)
58
+ })
59
+
60
+ it('renders a vertical no-arrow edge without an arrowhead', () => {
61
+ const result = renderMermaidASCII(`
62
+ graph TD
63
+ A --- B
64
+ `)
65
+ expect(result).not.toMatch(/[►◄▲▼]/)
66
+ })
67
+
68
+ it('renders a labeled no-arrow edge', () => {
69
+ const result = renderMermaidASCII(`
70
+ graph LR
71
+ A ---|connects| B
72
+ `)
73
+ expect(result).toContain('connects')
74
+ expect(result).not.toMatch(/[►◄▲▼]/)
75
+ })
76
+ })
77
+
78
+ describe('ASCII arrow drawing: bidirectional edges', () => {
79
+ it('draws arrowheads at both ends of a solid bidirectional edge (LR, unicode)', () => {
80
+ const result = renderMermaidASCII(`
81
+ graph LR
82
+ A <--> B
83
+ `)
84
+ expect(result).toContain('◄')
85
+ expect(result).toContain('►')
86
+ })
87
+
88
+ it('draws arrowheads at both ends of a solid bidirectional edge (LR, ascii)', () => {
89
+ const result = renderMermaidASCII(
90
+ `
91
+ graph LR
92
+ A <--> B
93
+ `,
94
+ { useAscii: true },
95
+ )
96
+ expect(result).toContain('<')
97
+ expect(result).toContain('>')
98
+ })
99
+
100
+ it('draws arrowheads at both ends of a vertical bidirectional edge (TD, unicode)', () => {
101
+ const result = renderMermaidASCII(`
102
+ graph TD
103
+ A <--> B
104
+ `)
105
+ expect(result).toContain('▲')
106
+ expect(result).toContain('▼')
107
+ })
108
+
109
+ it('draws arrowheads at both ends of a vertical bidirectional edge (TD, ascii)', () => {
110
+ const result = renderMermaidASCII(
111
+ `
112
+ graph TD
113
+ A <--> B
114
+ `,
115
+ { useAscii: true },
116
+ )
117
+ expect(result).toContain('^')
118
+ expect(result).toContain('v')
119
+ })
120
+
121
+ it('draws arrowheads at both ends of a bidirectional edge reversed by layout (BT)', () => {
122
+ const result = renderMermaidASCII(`
123
+ graph BT
124
+ A <--> B
125
+ `)
126
+ expect(result).toContain('▲')
127
+ expect(result).toContain('▼')
128
+ })
129
+
130
+ it('draws arrowheads on a dotted bidirectional edge', () => {
131
+ const result = renderMermaidASCII(`
132
+ graph LR
133
+ A <-.-> B
134
+ `)
135
+ expect(result).toContain('┄')
136
+ expect(result).toContain('◄')
137
+ expect(result).toContain('►')
138
+ })
139
+
140
+ it('draws arrowheads on a thick bidirectional edge', () => {
141
+ const result = renderMermaidASCII(`
142
+ graph LR
143
+ A <==> B
144
+ `)
145
+ expect(result).toContain('━')
146
+ expect(result).toContain('◄')
147
+ expect(result).toContain('►')
148
+ })
149
+
150
+ it('draws both arrowheads on a bidirectional back-edge routed around other nodes', () => {
151
+ const result = renderMermaidASCII(`
152
+ graph LR
153
+ A --> B
154
+ B --> C
155
+ C <--> A
156
+ `)
157
+ expect(result).toContain('►')
158
+ expect(result).toContain('▲')
159
+ })
160
+
161
+ it('draws a labeled bidirectional edge', () => {
162
+ const result = renderMermaidASCII(`
163
+ graph LR
164
+ A <-->|sync| B
165
+ `)
166
+ expect(result).toContain('sync')
167
+ expect(result).toContain('◄')
168
+ expect(result).toContain('►')
169
+ })
170
+ })
171
+
172
+ describe('unicodeArrowChar / asciiArrowChar: direction-to-glyph mapping', () => {
173
+ // Direct unit coverage for every Direction, including LowerLeft — the
174
+ // ELK/pathfinder layout this module draws from only produces a diagonal
175
+ // direction via determinePath's rare Case-4 fallback, and every
176
+ // hand-written or gallery diagram that triggers it happens to need the
177
+ // target down-and-*right* of its source, never down-and-left. See
178
+ // issue #1062's drawArrowHead refactor.
179
+ it('maps all four orthogonal directions to filled triangles (unicode)', () => {
180
+ expect(unicodeArrowChar(Up)).toBe('▲')
181
+ expect(unicodeArrowChar(Down)).toBe('▼')
182
+ expect(unicodeArrowChar(Left)).toBe('◄')
183
+ expect(unicodeArrowChar(Right)).toBe('►')
184
+ })
185
+
186
+ it('maps all four diagonal directions to Arrows-block glyphs (unicode)', () => {
187
+ expect(unicodeArrowChar(UpperRight)).toBe('↗')
188
+ expect(unicodeArrowChar(UpperLeft)).toBe('↖')
189
+ expect(unicodeArrowChar(LowerRight)).toBe('↘')
190
+ expect(unicodeArrowChar(LowerLeft)).toBe('↙')
191
+ })
192
+
193
+ it('returns undefined for Middle (no arrowhead glyph)', () => {
194
+ expect(unicodeArrowChar(Middle)).toBeUndefined()
195
+ expect(asciiArrowChar(Middle)).toBeUndefined()
196
+ })
197
+
198
+ it('maps all four orthogonal directions to ASCII carets (ascii mode)', () => {
199
+ expect(asciiArrowChar(Up)).toBe('^')
200
+ expect(asciiArrowChar(Down)).toBe('v')
201
+ expect(asciiArrowChar(Left)).toBe('<')
202
+ expect(asciiArrowChar(Right)).toBe('>')
203
+ })
204
+
205
+ it('has no diagonal glyphs in ASCII mode', () => {
206
+ expect(asciiArrowChar(UpperRight)).toBeUndefined()
207
+ expect(asciiArrowChar(UpperLeft)).toBeUndefined()
208
+ expect(asciiArrowChar(LowerRight)).toBeUndefined()
209
+ expect(asciiArrowChar(LowerLeft)).toBeUndefined()
210
+ })
211
+ })
212
+
213
+ describe('ASCII arrow drawing: upward edge labels', () => {
214
+ it('offsets the label on an edge that routes upward', () => {
215
+ const result = renderMermaidASCII(`
216
+ graph TD
217
+ A --> B
218
+ B -->|back| A
219
+ `)
220
+ expect(result).toContain('back')
221
+ expect(result).toContain('▲')
222
+ expect(result).toContain('▼')
223
+ })
224
+ })