@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
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@zombie-mermaid/ascii-renderer",
3
+ "version": "2.2.1",
4
+ "license": "MIT",
5
+ "description": "ASCII and Unicode box-drawing renderer for zombie-mermaid diagrams.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "dist/index.cjs",
9
+ "module": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.cts",
19
+ "default": "./dist/index.cjs"
20
+ }
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "files": [
28
+ "src/",
29
+ "dist/",
30
+ "LICENSE"
31
+ ],
32
+ "dependencies": {
33
+ "@zombie-mermaid/core": "workspace:*",
34
+ "@zombie-mermaid/mermaid-parser": "workspace:*"
35
+ }
36
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Regression tests for #1083: the last edge in a mixed-style fan-out got a
3
+ * bogus diagonal arrowhead (↘) instead of a straight one (▼), even though
4
+ * its line was perfectly vertical with zero horizontal drift.
5
+ *
6
+ * Root cause: that edge's route fell through to determinePath's Case-4
7
+ * direct fallback (see #418), producing a single non-axis-aligned grid
8
+ * segment. draw-lines.ts's drawLine draws that as an L (horizontal run,
9
+ * then vertical run) folded into one `linesDrawn` entry. drawArrowHead used
10
+ * to derive direction from that entry's *first* and *last* point, which
11
+ * spans both legs of the L and reads as diagonal — even when the actual
12
+ * final approach into the arrowhead is a plain vertical step. Fixed by
13
+ * deriving direction from the last two points (the final step) instead.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
17
+
18
+ const ARROWHEAD_CHARS = '▼▲◄►↖↗↘↙'
19
+
20
+ function arrowCharsInLastRowBeforeTargets(source: string): string[] {
21
+ const lines = renderMermaidASCII(source, { colorMode: 'none' }).split('\n')
22
+ const arrowRow = lines.find((l) =>
23
+ [...l].some((ch) => ARROWHEAD_CHARS.includes(ch)),
24
+ )!
25
+ return [...arrowRow].filter((ch) => ARROWHEAD_CHARS.includes(ch))
26
+ }
27
+
28
+ describe('arrowhead direction on a Case-4 direct-fallback (diagonal) path', () => {
29
+ it('renders every arrowhead as ▼ when thick is the last mixed-style edge', () => {
30
+ const source = `graph TD
31
+ A[Source] -->|solid| B[Target 1]
32
+ A -.->|dotted| C[Target 2]
33
+ A ==>|thick| D[Target 3]`
34
+
35
+ const arrows = arrowCharsInLastRowBeforeTargets(source)
36
+ expect(arrows).toEqual(['▼', '▼', '▼'])
37
+ })
38
+
39
+ it('renders every arrowhead as ▼ regardless of which style is last', () => {
40
+ // Same three styles, reordered so `solid` (not `thick`) is the one that
41
+ // falls through to the Case-4 fallback — the bug followed *position*,
42
+ // not the specific style.
43
+ const source = `graph TD
44
+ A[Source] ==>|thick| B[Target 1]
45
+ A -.->|dotted| C[Target 2]
46
+ A -->|solid| D[Target 3]`
47
+
48
+ const arrows = arrowCharsInLastRowBeforeTargets(source)
49
+ expect(arrows).toEqual(['▼', '▼', '▼'])
50
+ })
51
+ })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Direct unit tests for `firstClaimWins` (packages/ascii-renderer/src/canvas.ts),
3
+ * added in response to a CodeRabbit finding on PR #1093 (the #1067 fix):
4
+ * https://github.com/dfadler/zombie-mermaid/pull/1093#discussion_r… (canvas.ts:446).
5
+ *
6
+ * `firstClaimWins` runs before `mergeCanvases` on the line-canvas layer to
7
+ * fix #1067's "one edge's style bleeds into another's" bug — but its
8
+ * original implementation suppressed a later canvas's character at any
9
+ * already-claimed cell purely by coordinate, with no regard for what the
10
+ * two characters actually were. That silently broke a genuine perpendicular
11
+ * crossing between two plain box-drawing edges (one edge's own `─`, another
12
+ * unrelated edge's own `│`): `mergeCanvases`'s existing junction-merge logic
13
+ * (`isJunctionChar` + `mergeJunctions`) would normally combine them into
14
+ * `┼`, but firstClaimWins now deleted the second character before
15
+ * mergeCanvases ever saw it, so the crossing silently became whichever edge
16
+ * drew first instead. The fix: only suppress a later cell when the two
17
+ * characters *wouldn't* form a meaningful junction merge (i.e. when at
18
+ * least one of them isn't a junction char at all — the actual #1067
19
+ * scenario, dashed vs. heavy characters, neither of which is in
20
+ * `JUNCTION_CHARS`).
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import { mkCanvas, firstClaimWins, mergeCanvases } from '../canvas.ts'
24
+ import type { Canvas } from '../types.ts'
25
+
26
+ function canvasWithChar(
27
+ width: number,
28
+ height: number,
29
+ x: number,
30
+ y: number,
31
+ c: string,
32
+ ): Canvas {
33
+ const canvas = mkCanvas(width, height)
34
+ canvas[x]![y] = c
35
+ return canvas
36
+ }
37
+
38
+ describe('firstClaimWins', () => {
39
+ it('preserves a genuine perpendicular crossing between two plain junction characters', () => {
40
+ // Edge A's own line draws '─' at (2,2); edge B's own (unrelated) line
41
+ // independently draws '│' at the very same cell — a real crossing, not
42
+ // a style conflict.
43
+ const horizontal = canvasWithChar(4, 4, 2, 2, '─')
44
+ const vertical = canvasWithChar(4, 4, 2, 2, '│')
45
+
46
+ const resolved = firstClaimWins([horizontal, vertical])
47
+ // Both canvases must still carry their own character at the shared
48
+ // cell — dropping either one would make it impossible for
49
+ // mergeCanvases's junction-merge logic below to ever see both.
50
+ expect(resolved[0]![2]![2]).toBe('─')
51
+ expect(resolved[1]![2]![2]).toBe('│')
52
+
53
+ const merged = mergeCanvases(
54
+ mkCanvas(4, 4),
55
+ { x: 0, y: 0 },
56
+ false,
57
+ ...resolved,
58
+ )
59
+ expect(merged[2]![2]).toBe('┼')
60
+ })
61
+
62
+ it('still suppresses a later same-orientation duplicate (collinear overlap)', () => {
63
+ const first = canvasWithChar(4, 4, 1, 1, '─')
64
+ const second = canvasWithChar(4, 4, 1, 1, '─')
65
+ const resolved = firstClaimWins([first, second])
66
+ expect(resolved[0]![1]![1]).toBe('─')
67
+ expect(resolved[1]![1]![1]).toBe(' ')
68
+ })
69
+
70
+ it('still suppresses a later non-junction character at an already-claimed cell (#1067)', () => {
71
+ // The original #1067 scenario: a dashed edge's own leg ('┄', not a
72
+ // junction char) sharing a cell with a heavy edge's own leg ('━', also
73
+ // not a junction char) — neither can form a meaningful merge, so first
74
+ // claim must still win.
75
+ const dashed = canvasWithChar(4, 4, 1, 1, '┄')
76
+ const heavy = canvasWithChar(4, 4, 1, 1, '━')
77
+ const resolved = firstClaimWins([dashed, heavy])
78
+ expect(resolved[0]![1]![1]).toBe('┄')
79
+ expect(resolved[1]![1]![1]).toBe(' ')
80
+ })
81
+
82
+ it('suppresses a later cell when only one side is a junction character', () => {
83
+ const junction = canvasWithChar(4, 4, 1, 1, '│')
84
+ const heavy = canvasWithChar(4, 4, 1, 1, '━')
85
+ const resolved = firstClaimWins([junction, heavy])
86
+ expect(resolved[0]![1]![1]).toBe('│')
87
+ expect(resolved[1]![1]![1]).toBe(' ')
88
+ })
89
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Regression for a bug found while investigating PR #1093 (the #1067 fix):
3
+ * `setCanvasSizeToGrid`/`setRoleCanvasSizeToGrid` sized the canvas from
4
+ * `columnWidth`/`rowHeight` alone, with no way to account for
5
+ * `graph.offsetX`/`offsetY` — the shift `offsetDrawingForSubgraphs` applies
6
+ * whenever a subgraph's own border padding would otherwise push its content
7
+ * negative. Every `gridToDrawingCoord` call (including edge line-drawing)
8
+ * adds that offset to its result, but `createMapping` sized the canvas
9
+ * *before* the offset was known, leaving it exactly `offsetX`/`offsetY` too
10
+ * small. Node boxes were retroactively shifted into the correct position,
11
+ * but nothing widened the canvas array to match — so any edge line whose
12
+ * drawing coordinate landed in that unreserved margin was silently dropped
13
+ * by `write()`'s out-of-bounds clip. This surfaced as a fully disconnected
14
+ * edge (a dangling corner glyph with no line connecting it to its node) in
15
+ * the "State: Composite States" sample's `Error --> Idle : retry` edge,
16
+ * once the #1067 chain-overlap fix rerouted that edge through a column
17
+ * further right than anything else in the diagram — see that sample's
18
+ * regenerated baseline in this same PR.
19
+ */
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ mkCanvas,
23
+ mkRoleCanvas,
24
+ setCanvasSizeToGrid,
25
+ setRoleCanvasSizeToGrid,
26
+ getCanvasSize,
27
+ } from '../canvas.ts'
28
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
29
+
30
+ describe('setCanvasSizeToGrid / setRoleCanvasSizeToGrid — offset', () => {
31
+ it('reserves extra width/height for a nonzero offset', () => {
32
+ const canvas = mkCanvas(0, 0)
33
+ const columnWidth = new Map([
34
+ [0, 3],
35
+ [1, 4],
36
+ ])
37
+ const rowHeight = new Map([
38
+ [0, 2],
39
+ [1, 5],
40
+ ])
41
+ setCanvasSizeToGrid(canvas, columnWidth, rowHeight, 6, 3)
42
+ const [maxX, maxY] = getCanvasSize(canvas)
43
+ // columnWidth sums to 7, plus an offset of 6 => max valid index 12.
44
+ expect(maxX).toBe(12)
45
+ // rowHeight sums to 7, plus an offset of 3 => max valid index 9.
46
+ expect(maxY).toBe(9)
47
+ })
48
+
49
+ it('matches the un-offset canvas size when offset is omitted (default 0)', () => {
50
+ const canvas = mkCanvas(0, 0)
51
+ const columnWidth = new Map([[0, 5]])
52
+ const rowHeight = new Map([[0, 4]])
53
+ setCanvasSizeToGrid(canvas, columnWidth, rowHeight)
54
+ const [maxX, maxY] = getCanvasSize(canvas)
55
+ expect(maxX).toBe(4)
56
+ expect(maxY).toBe(3)
57
+ })
58
+
59
+ it('role canvas sizing accepts the same offset', () => {
60
+ const roleCanvas = mkRoleCanvas(0, 0)
61
+ const columnWidth = new Map([[0, 3]])
62
+ const rowHeight = new Map([[0, 2]])
63
+ setRoleCanvasSizeToGrid(roleCanvas, columnWidth, rowHeight, 5, 1)
64
+ expect(roleCanvas.length - 1).toBe(7)
65
+ expect((roleCanvas[0]?.length ?? 1) - 1).toBe(2)
66
+ })
67
+ })
68
+
69
+ describe('subgraph-offset edges reach their node border (#1093 regression)', () => {
70
+ it("draws the retry edge's connector all the way into Error's box, with no gap", () => {
71
+ const src = `stateDiagram-v2
72
+ [*] --> Idle
73
+ Idle --> Processing : submit
74
+ state Processing {
75
+ parse --> validate
76
+ validate --> execute
77
+ }
78
+ Processing --> Complete : done
79
+ Processing --> Error : fail
80
+ Error --> Idle : retry
81
+ Complete --> [*]`
82
+
83
+ const lines = renderMermaidASCII(src, { colorMode: 'none' }).split('\n')
84
+ const errorRow = lines.find((l) => l.includes('Error'))
85
+ expect(errorRow).toBeDefined()
86
+
87
+ // Error's own right border ('├', where the outgoing retry edge exits)
88
+ // must be immediately followed by a drawn connector character, not a
89
+ // gap — a disconnected edge leaves that cell (and the next) blank.
90
+ const portIndex = errorRow!.indexOf('├')
91
+ expect(portIndex).toBeGreaterThan(-1)
92
+ const afterPort = errorRow!.slice(portIndex + 1)
93
+ expect(afterPort).toMatch(/^[─┘└┐┌│]/)
94
+ })
95
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Direct unit tests for `write()` (src/ascii/canvas.ts) — specifically its
3
+ * clip boundary in all four directions.
4
+ *
5
+ * These previously didn't exist: an adversarial review of PR #180
6
+ * instrumented the clip condition and ran the full suite (1318 passing
7
+ * tests at the time) and found the clip branch was never exercised — 0
8
+ * clip events fired. That gap is exactly how a real border-corruption
9
+ * regression in `drawSubgraphLabel` (src/ascii/draw-subgraphs.ts, see
10
+ * ascii-subgraph-label-border-clip.test.ts) got through green CI: no test
11
+ * distinguished `write()`'s clip semantics from the per-call-site guards it
12
+ * replaced.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import { mkCanvas, mkRoleCanvas, write, getCanvasSize } from '../canvas.ts'
16
+ import type { CharRole } from '../types.ts'
17
+
18
+ describe('canvas.ts write()', () => {
19
+ it('writes within bounds, including exactly on the inclusive max edge', () => {
20
+ const canvas = mkCanvas(2, 2) // valid indices 0..2 in both axes
21
+ const [maxX, maxY] = getCanvasSize(canvas)
22
+ expect([maxX, maxY]).toEqual([2, 2])
23
+
24
+ write(canvas, 0, 0, 'A')
25
+ write(canvas, maxX, maxY, 'B')
26
+ write(canvas, maxX, 0, 'C')
27
+ write(canvas, 0, maxY, 'D')
28
+
29
+ expect(canvas[0]![0]).toBe('A')
30
+ expect(canvas[maxX]![maxY]).toBe('B')
31
+ expect(canvas[maxX]![0]).toBe('C')
32
+ expect(canvas[0]![maxY]).toBe('D')
33
+ })
34
+
35
+ it('is a no-op at x = -1 (just past the low edge)', () => {
36
+ const canvas = mkCanvas(2, 2)
37
+ // No cell to inspect at x = -1; assert the call doesn't throw and
38
+ // leaves the rest of the canvas untouched.
39
+ expect(() => write(canvas, -1, 1, 'X')).not.toThrow()
40
+ for (const col of canvas) {
41
+ expect(col.every((c) => c === ' ')).toBe(true)
42
+ }
43
+ })
44
+
45
+ it('is a no-op at x = maxX + 1 (just past the high edge)', () => {
46
+ const canvas = mkCanvas(2, 2)
47
+ const [maxX] = getCanvasSize(canvas)
48
+ expect(() => write(canvas, maxX + 1, 1, 'X')).not.toThrow()
49
+ for (const col of canvas) {
50
+ expect(col.every((c) => c === ' ')).toBe(true)
51
+ }
52
+ })
53
+
54
+ it('writes at x = maxX (on the inclusive edge, not clipped)', () => {
55
+ const canvas = mkCanvas(2, 2)
56
+ const [maxX] = getCanvasSize(canvas)
57
+ write(canvas, maxX, 1, 'X')
58
+ expect(canvas[maxX]![1]).toBe('X')
59
+ })
60
+
61
+ it('is a no-op at y = -1 (just past the low edge)', () => {
62
+ const canvas = mkCanvas(2, 2)
63
+ expect(() => write(canvas, 1, -1, 'X')).not.toThrow()
64
+ for (const col of canvas) {
65
+ expect(col.every((c) => c === ' ')).toBe(true)
66
+ }
67
+ })
68
+
69
+ it('is a no-op at y = maxY + 1 (just past the high edge)', () => {
70
+ const canvas = mkCanvas(2, 2)
71
+ const [, maxY] = getCanvasSize(canvas)
72
+ expect(() => write(canvas, 1, maxY + 1, 'X')).not.toThrow()
73
+ for (const col of canvas) {
74
+ expect(col.every((c) => c === ' ')).toBe(true)
75
+ }
76
+ })
77
+
78
+ it('writes at y = maxY (on the inclusive edge, not clipped)', () => {
79
+ const canvas = mkCanvas(2, 2)
80
+ const [, maxY] = getCanvasSize(canvas)
81
+ write(canvas, 1, maxY, 'X')
82
+ expect(canvas[1]![maxY]).toBe('X')
83
+ })
84
+
85
+ it('never grows the canvas on an out-of-range write in any direction', () => {
86
+ const canvas = mkCanvas(2, 2)
87
+ const before = getCanvasSize(canvas)
88
+ write(canvas, -1, 1, 'X')
89
+ write(canvas, 5, 1, 'X')
90
+ write(canvas, 1, -1, 'X')
91
+ write(canvas, 1, 5, 'X')
92
+ expect(getCanvasSize(canvas)).toEqual(before)
93
+ })
94
+
95
+ it('out-of-range writes are a no-op on the role canvas too, when roleTracking is provided', () => {
96
+ const canvas = mkCanvas(2, 2)
97
+ const roleCanvas = mkRoleCanvas(2, 2)
98
+ const role: CharRole = 'border'
99
+
100
+ write(canvas, -1, 1, 'X', { role, roleCanvas })
101
+ write(canvas, 5, 1, 'X', { role, roleCanvas })
102
+ write(canvas, 1, -1, 'X', { role, roleCanvas })
103
+ write(canvas, 1, 5, 'X', { role, roleCanvas })
104
+
105
+ for (const col of roleCanvas) {
106
+ expect(col.every((r) => r === null)).toBe(true)
107
+ }
108
+ })
109
+
110
+ it('records the role on the role canvas for an in-range write', () => {
111
+ const canvas = mkCanvas(2, 2)
112
+ const roleCanvas = mkRoleCanvas(2, 2)
113
+ const role: CharRole = 'border'
114
+
115
+ write(canvas, 1, 1, 'X', { role, roleCanvas })
116
+
117
+ expect(canvas[1]![1]).toBe('X')
118
+ expect(roleCanvas[1]![1]).toBe('border')
119
+ })
120
+
121
+ it('does not touch the role canvas when roleTracking is omitted', () => {
122
+ const canvas = mkCanvas(2, 2)
123
+ const roleCanvas = mkRoleCanvas(2, 2)
124
+
125
+ write(canvas, 1, 1, 'X')
126
+
127
+ expect(canvas[1]![1]).toBe('X')
128
+ for (const col of roleCanvas) {
129
+ expect(col.every((r) => r === null)).toBe(true)
130
+ }
131
+ })
132
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Regression test for #1067 ("System Architecture" form-judge finding): an
3
+ * edge chained through a shared intermediate node (`C(Mobile App) -->
4
+ * B(API Gateway)` then `B --> E(User Service)`) could render as one
5
+ * unbroken line straight from Mobile App to User Service, even though the
6
+ * source has no direct `Mobile App --> User Service` edge.
7
+ *
8
+ * Root cause: both edges are independently routed (LR direction, so
9
+ * `analyzeEdgeBundles` never groups anything — see its own "LR routing
10
+ * handles merging naturally at corners" comment), and their corners
11
+ * happened to land on the exact same grid cell: `Mobile App --> API
12
+ * Gateway`'s incoming leg travels up API Gateway's own column to the same
13
+ * row `API Gateway --> User Service`'s outgoing leg travels down, before
14
+ * turning right toward User Service. The two legs are collinear, so the
15
+ * drawn result reads as one continuous connector across a shared row.
16
+ *
17
+ * Fix: `edge-cell-styles.ts`'s `findUnrelatedOverlap` (scoped to true
18
+ * *chain* pairs — one edge's `to` equal to the other's `from`, not a
19
+ * fan-in/fan-out sibling pair, which legitimately shares trunk cells; see
20
+ * that module's doc for why the broader "shares neither endpoint" version
21
+ * broke the `ampersand_lhs_and_rhs` golden file) flags a chain pair that
22
+ * shares 2+ open cells beyond their common node's own border, and
23
+ * `rerouteAroundStyleConflicts` (grid.ts) reroutes one of them around the
24
+ * conflicting cell — the same mechanism already used for cross-style
25
+ * conflicts, extended to this same-style chain case.
26
+ */
27
+ import { describe, it, expect } from 'vitest'
28
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
29
+
30
+ const SYSTEM_ARCHITECTURE = `graph LR
31
+ subgraph clients [Client Layer]
32
+ A([Web App]) --> B[API Gateway]
33
+ C([Mobile App]) --> B
34
+ end
35
+ subgraph services [Service Layer]
36
+ B --> D[Auth Service]
37
+ B --> E[User Service]
38
+ B --> F[Order Service]
39
+ end
40
+ subgraph data [Data Layer]
41
+ D --> G[(Auth DB)]
42
+ E --> H[(User DB)]
43
+ F --> I[(Order DB)]
44
+ F --> J([Message Queue])
45
+ end`
46
+
47
+ describe('chained edges through a shared intermediate node stay visually distinct (#1067)', () => {
48
+ const lines = renderMermaidASCII(SYSTEM_ARCHITECTURE, { colorMode: 'none' })
49
+ .split('\n')
50
+ .map((l) => l.replace(/\s+$/, ''))
51
+
52
+ it('renders every label and does not throw', () => {
53
+ const out = lines.join('\n')
54
+ for (const label of [
55
+ 'Web App',
56
+ 'Mobile App',
57
+ 'API Gateway',
58
+ 'Auth Service',
59
+ 'User Service',
60
+ 'Order Service',
61
+ ]) {
62
+ expect(out).toContain(label)
63
+ }
64
+ })
65
+
66
+ it("leaves a visual break between Mobile App's own row and User Service — they must not read as one connected line", () => {
67
+ const mobileRow = lines.find((l) => l.includes('Mobile App'))
68
+ expect(mobileRow).toBeDefined()
69
+ // Start just past Mobile App's own right border connector ('├' or
70
+ // '┤'), and end at User Service's own left border ('│ User Service').
71
+ // Both boxes' own padding ("Mobile App │" / "│ User Service")
72
+ // contributes a harmless blank cell or two right next to the label
73
+ // text itself — excluding both box borders from the scanned range
74
+ // means only the *connector* in between is checked, not incidental box
75
+ // padding (see the sabotage check on this test, which caught exactly
76
+ // this false pass on an earlier, looser version of this assertion).
77
+ const afterLabel = mobileRow!.indexOf('Mobile App') + 'Mobile App'.length
78
+ const start = mobileRow!.slice(afterLabel).search(/[├┤]/) + afterLabel + 1
79
+ const end = mobileRow!.indexOf('│ User Service')
80
+ expect(end).toBeGreaterThan(start)
81
+ const between = mobileRow!.slice(start, end)
82
+
83
+ // The bug produced a fully packed run of box-drawing characters (no
84
+ // blank cell at all) all the way from Mobile App's own border to User
85
+ // Service's — i.e. one unbroken connector. A genuine visual break
86
+ // requires at least one blank cell somewhere in between.
87
+ expect(between).toMatch(/ /)
88
+ })
89
+
90
+ it("does not draw an arrowhead into User Service on Mobile App's own row", () => {
91
+ // User Service's real incoming edge (from API Gateway) is routed on a
92
+ // different row; if Mobile App's row also shows an arrowhead landing
93
+ // directly on User Service's border, the two are still effectively
94
+ // fused into what reads as one edge.
95
+ const mobileRow = lines.find((l) => l.includes('Mobile App'))!
96
+ const userServiceBoxStart = mobileRow.indexOf('│ User Service')
97
+ expect(userServiceBoxStart).toBeGreaterThan(-1)
98
+ expect(mobileRow[userServiceBoxStart - 1]).not.toBe('►')
99
+ })
100
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Regression test: ASCII-charset rendering (`{ useAscii: true }`) never drew
3
+ * a junction character where an edge exits a node's border — the border
4
+ * stayed a plain run of dashes even at the column where a connector
5
+ * dropped/branched from it, while Unicode mode correctly drew a T-junction
6
+ * ('┬'/'┴'/'├'/'┤') there. Root cause: `drawBoxStart` (draw-arrows.ts) and
7
+ * the fan-in box-start connector (draw-bundles.ts) early-returned/guarded
8
+ * on `useAscii`, skipping junction placement entirely instead of writing
9
+ * the ASCII equivalent ('+').
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
13
+
14
+ describe('ASCII vs Unicode charset: box-start border junctions', () => {
15
+ it('draws a "+" junction on a plain top-down edge, matching the Unicode T-junction', () => {
16
+ const src = `flowchart TD
17
+ A[Hello World] --> B[Done]`
18
+
19
+ const unicode = renderMermaidASCII(src, { useAscii: false })
20
+ const ascii = renderMermaidASCII(src, { useAscii: true })
21
+
22
+ expect(unicode).toContain('└──────┬──────┘')
23
+ // Before the fix this rendered as a plain, uninterrupted
24
+ // '+-------------+' with no junction at all.
25
+ expect(ascii).toContain('+------+------+')
26
+ })
27
+
28
+ it('draws a "+" junction on a left-right (LR) edge exit, matching Unicode', () => {
29
+ const src = `flowchart LR
30
+ A[Hello] --> B[World]`
31
+
32
+ const unicode = renderMermaidASCII(src, { useAscii: false })
33
+ const ascii = renderMermaidASCII(src, { useAscii: true })
34
+
35
+ expect(unicode).toContain('├')
36
+
37
+ // The row with the arrow must have a '+' where Unicode has '├', not a
38
+ // plain '|' vertical border with no junction.
39
+ const unicodeRow = unicode.split('\n').find((l) => l.includes('├'))!
40
+ const asciiRow = ascii.split('\n').find((l) => l.includes('+---->'))
41
+ expect(asciiRow).toBeDefined()
42
+ const junctionCol = unicodeRow.indexOf('├')
43
+ expect(asciiRow![junctionCol]).toBe('+')
44
+ })
45
+
46
+ it('draws "+" junctions on a fan-in bundle box-start connector in ASCII mode', () => {
47
+ const src = `flowchart TD
48
+ A[One] --> C[Target]
49
+ B[Two] --> C`
50
+
51
+ const unicode = renderMermaidASCII(src, { useAscii: false })
52
+ const ascii = renderMermaidASCII(src, { useAscii: true })
53
+
54
+ // Unicode shows T-junctions where each source box's bottom border
55
+ // meets the fan-in connector line.
56
+ expect(unicode).toMatch(/[┬┴├┤]/)
57
+
58
+ // Each source box's bottom border must show an interior '+' junction
59
+ // where the fan-in connector drops from it, not a plain dash run.
60
+ expect(ascii).toContain('+----+---+')
61
+ expect(ascii).toContain('+--+--+')
62
+ })
63
+ })