@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,98 @@
1
+ // ============================================================================
2
+ // ASCII ER diagram regression test for issue #411:
3
+ //
4
+ // Two *unrelated* relationships' vertical stems can land on adjacent (not
5
+ // identical) columns purely by coincidence — when that happens, nothing in
6
+ // the rendered output distinguishes them, so a reader sees what looks like
7
+ // one connector that inexplicably bends sideways partway down, rather than
8
+ // two independent lines. This is different from #350 (content overwrite,
9
+ // already fixed) and #351 (routing collisions/gutters, already fixed): no
10
+ // character is ever corrupted here, the two stems just sit close enough to
11
+ // be mistaken for each other.
12
+ // ============================================================================
13
+
14
+ import { describe, it, expect } from 'vitest'
15
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
16
+
17
+ describe('ASCII ER — unrelated relationship stems stay visually separated (issue #411)', () => {
18
+ it('keeps CUSTOMER→ADDRESS and PRODUCT→LINE_ITEM stems at least one column apart', () => {
19
+ // The exact repro from issue #411: CUSTOMER's own stem (toward ADDRESS)
20
+ // and PRODUCT/LINE_ITEM's own stem land on entity-center columns that
21
+ // are only 1 apart by coincidence (12-wide CUSTOMER vs. 11-wide
22
+ // PRODUCT, both starting at column 0 in their own row) — before the
23
+ // fix, that put a bare '│' from one relationship immediately beside a
24
+ // '┌'/'┼' from the other, with no blank column between them:
25
+ //
26
+ // ┼ ╢○
27
+ // ┌│appears_in ───── has ─────────────────┘
28
+ // │└───────────────┐
29
+ // ┼ ○╟
30
+ //
31
+ // ("appears_in" is PRODUCT→LINE_ITEM's own label; "has" is
32
+ // CUSTOMER→ADDRESS's own label — the two relationships have nothing to
33
+ // do with each other, so their stems must read as visually distinct.)
34
+ const ascii = renderMermaidASCII(
35
+ `erDiagram
36
+ CUSTOMER ||--o{ ORDER : places
37
+ ORDER ||--|{ LINE_ITEM : contains
38
+ PRODUCT ||--o{ LINE_ITEM : appears_in
39
+ CUSTOMER ||--o{ ADDRESS : has`,
40
+ { colorMode: 'none' },
41
+ )
42
+
43
+ // The pre-fix signature: PRODUCT/LINE_ITEM's corner immediately
44
+ // followed, with no gap, by CUSTOMER/ADDRESS's vertical stem.
45
+ expect(ascii).not.toContain('┌│appears_in')
46
+ expect(ascii).not.toContain('│└───────────────┐')
47
+
48
+ // A blank column now separates the two relationships' stems on every
49
+ // row where both are present.
50
+ expect(ascii).toContain('┌ appears_in')
51
+ expect(ascii).toContain('│ │ has')
52
+ expect(ascii).toContain('┼ └')
53
+ })
54
+
55
+ it('does not corrupt a corner glyph when escalating a jog to avoid an unrelated stem (issue #411 fix side effect)', () => {
56
+ // A denser diagram where two different vertical relationships'
57
+ // natural columns land close enough to trigger the #411 separation
58
+ // logic on both sides. Before a follow-up fix, the escalation could
59
+ // pick a "via" column identical to another relationship's own column,
60
+ // silently overwriting its corner glyph with a plain vertical bar.
61
+ const ascii = renderMermaidASCII(
62
+ `erDiagram
63
+ STUDENT {
64
+ int id PK
65
+ string name
66
+ date dob
67
+ string grade
68
+ }
69
+ TEACHER {
70
+ int id PK
71
+ string name
72
+ string department
73
+ }
74
+ COURSE {
75
+ int id PK
76
+ string title
77
+ int teacher_id FK
78
+ int credits
79
+ }
80
+ ENROLLMENT {
81
+ int id PK
82
+ int student_id FK
83
+ int course_id FK
84
+ string semester
85
+ float grade
86
+ }
87
+ TEACHER ||--o{ COURSE : teaches
88
+ STUDENT ||--o{ ENROLLMENT : enrolled
89
+ COURSE ||--o{ ENROLLMENT : has`,
90
+ { colorMode: 'none' },
91
+ )
92
+
93
+ // TEACHER→COURSE's own corner, where its jog turns down into COURSE,
94
+ // must survive intact rather than being overwritten by
95
+ // STUDENT→ENROLLMENT's bypass column landing on the same cell.
96
+ expect(ascii).toContain('┌ teaches')
97
+ })
98
+ })
@@ -0,0 +1,110 @@
1
+ // ============================================================================
2
+ // ASCII ER diagram: a vertical relationship's "one" (`||`) cardinality
3
+ // marker was completely invisible when it sat on the upper or lower entity.
4
+ //
5
+ // getCrowsFootChars() was written for horizontal use, where a '│'/'|' tick
6
+ // is perpendicular to the horizontal line ('─'/'-') it crosses — that reads
7
+ // correctly. The same function was reused verbatim for vertical markers,
8
+ // where a '│'/'|' tick is instead *parallel* to the vertical connecting
9
+ // line ('│'/'|'), so for "one" cardinality specifically the marker glyph
10
+ // and the line-fill glyph were identical: drawing the marker produced no
11
+ // visible change at all, not just a subtle one.
12
+ //
13
+ // The fix adds a `vertical` parameter to getCrowsFootChars, used only for
14
+ // the 'one' case, so a vertical "one" marker renders as '┼'/'+' (crossing
15
+ // the vertical line) instead of '│'/'|' (blending into it). 'zero-one',
16
+ // 'many', and 'zero-many' are untouched — they already render distinctly
17
+ // on the vertical axis.
18
+ //
19
+ // An earlier version of this fix used a bare '─'/'-' for the marker. That
20
+ // made it visible, but a bare dash has no vertical extent, so it read as a
21
+ // gap severing the line rather than a tick crossing it (the vertical line
22
+ // stops at the row above the marker and resumes at the row below, with
23
+ // nothing connecting through the marker's own cell). '┼'/'+' carry both
24
+ // strokes, so the line reads as continuous with a crossing tick — the same
25
+ // way '│'/'|' crosses the horizontal line in the non-vertical case.
26
+ //
27
+ // Found while reviewing PR #439's vertical-relationship example.
28
+ // ============================================================================
29
+
30
+ import { describe, it, expect } from 'vitest'
31
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
32
+
33
+ describe('ASCII ER vertical "one" cardinality marker (invisible-marker fix)', () => {
34
+ it('renders a distinct tick for the upper entity\'s "one" marker, not the invisible line glyph', () => {
35
+ // A connects to both B (horizontal) and C (vertical, forced by the
36
+ // 3-entity sqrt-based row layout) — the exact repro that surfaced the
37
+ // bug. Before the fix, A's vertical "one" marker toward C rendered as
38
+ // '│', identical to the plain connecting-line fill character, so
39
+ // nothing distinguished "here is A's cardinality marker" from "just
40
+ // more line".
41
+ const ascii = renderMermaidASCII(
42
+ `erDiagram
43
+ A ||--o{ B : ab
44
+ A ||--o{ C : ac`,
45
+ { colorMode: 'none' },
46
+ )
47
+ const lines = ascii.split('\n')
48
+ // The marker sits flush against A's own border (#390's flush-marker
49
+ // change — merged after this test was first written, which shifted
50
+ // the vertical label off the marker's own row; the label's position
51
+ // is a separate geometric-midpoint calculation, not anchored to the
52
+ // marker — see the row-by-row check below instead of assuming they
53
+ // share a line).
54
+ const markerLineIndex = lines.findIndex((l) => /^\s*┼\s*$/.test(l))
55
+ expect(markerLineIndex).toBeGreaterThanOrEqual(0)
56
+ expect(lines[markerLineIndex - 1]).toContain('└───┘')
57
+
58
+ // The marker's own line must not be a lone '│' (the pre-fix, invisible
59
+ // rendering) or a bare '─' (an earlier fix attempt, which visually
60
+ // severs the line — see the header comment above).
61
+ expect(lines[markerLineIndex]).not.toMatch(/^\s*[│─]\s*$/)
62
+
63
+ // The line continues unbroken below the marker, through the label row.
64
+ expect(lines[markerLineIndex + 1]).toContain('│ ac')
65
+ })
66
+
67
+ it('renders distinct ticks at both ends when both entities have "one" cardinality', () => {
68
+ const ascii = renderMermaidASCII(
69
+ `erDiagram
70
+ A ||--o{ B : ab
71
+ A ||--|| C : ac`,
72
+ { colorMode: 'none' },
73
+ )
74
+ // Two separate '┼' marker ticks: one at A's lower edge, one at C's
75
+ // upper edge, both distinct from the '│' line around them.
76
+ const lines = ascii.split('\n')
77
+ const tickLineIndexes = lines
78
+ .map((l, i) => (/^\s*┼\s*$/.test(l) ? i : -1))
79
+ .filter((i) => i >= 0)
80
+ expect(tickLineIndexes.length).toBe(2)
81
+ const [upperTick, lowerTick] = tickLineIndexes as [number, number]
82
+ // Upper tick flush against A's border, lower tick flush against C's
83
+ // (#390's flush-marker change), with the connecting line still
84
+ // reading as continuous — every row between the two ticks starts with
85
+ // the plain '│' line-fill character.
86
+ expect(lines[upperTick - 1]).toContain('└───┘')
87
+ expect(lines[lowerTick + 1]).toContain('┌───┐')
88
+ for (let i = upperTick + 1; i < lowerTick; i++) {
89
+ expect(lines[i]).toMatch(/^\s*│/)
90
+ }
91
+ })
92
+
93
+ it('renders the ASCII-mode equivalent ("+" instead of "|") for the vertical "one" marker', () => {
94
+ const ascii = renderMermaidASCII(
95
+ `erDiagram
96
+ A ||--o{ B : ab
97
+ A ||--o{ C : ac`,
98
+ { colorMode: 'none', useAscii: true },
99
+ )
100
+ const lines = ascii.split('\n')
101
+ const markerLineIndex = lines.findIndex((l) => /^\s*\+\s*$/.test(l))
102
+ expect(markerLineIndex).toBeGreaterThanOrEqual(0)
103
+ expect(lines[markerLineIndex - 1]).toContain('+---+')
104
+ // The pre-fix invisible rendering used a lone '|' on the marker line;
105
+ // an earlier fix attempt used a bare '-', which visually severs the
106
+ // line (no vertical extent) — see the header comment above.
107
+ expect(lines[markerLineIndex]).not.toMatch(/^\s*[|-]\s*$/)
108
+ expect(lines[markerLineIndex + 1]).toContain('| ac')
109
+ })
110
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Regression/coverage test for the deepest fallback tier of
3
+ * `determineLabelLine` (src/ascii/edge-routing.ts) — the one that runs when
4
+ * *no* segment is both wide enough for the label and clear of nodes, so it
5
+ * falls back to `clearLaterSegments` (non-terminal segments clear of
6
+ * nodes), then `clearSegments` (any segment clear of nodes, terminal or
7
+ * not), then finally every segment regardless.
8
+ *
9
+ * Exercises two branches #450's fix introduced that the rest of the suite
10
+ * never reaches on their own:
11
+ *
12
+ * - A non-terminal segment (touching neither the source's nor the
13
+ * target's own border) that still fails `clearOfNodes` because its
14
+ * interior runs through an unrelated node's block — so
15
+ * `!isTerminalSegment(s) && clearOfNodes(s.line)` evaluates the right
16
+ * operand and gets `false` from it, rather than short-circuiting on a
17
+ * terminal segment (the only case the rest of the suite happens to hit).
18
+ * - `clearLaterSegments` ending up non-empty, so the ternary at
19
+ * `determineLabelLine` uses it directly instead of falling through to
20
+ * `clearSegments`.
21
+ *
22
+ * The two terminal segments are deliberately given a *larger* combined
23
+ * column width than the middle (non-terminal) one, all still under the
24
+ * label's length. This matters for the second test below: with the
25
+ * terminal exclusion removed (the mutation a sabotage check should catch),
26
+ * `clearLaterSegments` would include the terminal segments too, and being
27
+ * wider they'd win the width-descending sort — silently picking a
28
+ * box-border-adjacent segment instead of the safe middle one, the exact
29
+ * class of bug #450 was filed against. Equal widths would let the middle
30
+ * segment win the tie-break by filter order alone, masking that mutation.
31
+ *
32
+ * Constructed directly against `determineLabelLine` (bypassing the parser
33
+ * and full grid layout, following the same pattern as
34
+ * ascii-edge-routing-single-point-path.test.ts) so the exact segment/label
35
+ * widths and the blocked cell can be pinned precisely, rather than hoping
36
+ * some real diagram happens to hit this deep a fallback tier.
37
+ */
38
+ import { describe, it, expect } from 'vitest'
39
+ import { determineLabelLine } from '../edge-routing.ts'
40
+ import { Down, gridKey } from '../types.ts'
41
+ import type { AsciiEdge, AsciiGraph, AsciiNode } from '../types.ts'
42
+ import { createGrid } from '../grid-occupancy.ts'
43
+
44
+ function makeNode(name: string, x: number, y: number): AsciiNode {
45
+ return {
46
+ name,
47
+ displayLabel: name,
48
+ shape: 'rectangle',
49
+ index: 0,
50
+ gridCoord: { x, y },
51
+ drawingCoord: null,
52
+ drawing: null,
53
+ drawn: false,
54
+ styleClassName: '',
55
+ styleClass: { name: '', styles: {} },
56
+ }
57
+ }
58
+
59
+ function makeGraph(nodes: AsciiNode[]): AsciiGraph {
60
+ return {
61
+ nodes,
62
+ edges: [],
63
+ canvas: [],
64
+ roleCanvas: [],
65
+ grid: createGrid(),
66
+ columnWidth: new Map(),
67
+ rowHeight: new Map(),
68
+ subgraphs: [],
69
+ config: {
70
+ useAscii: false,
71
+ paddingX: 5,
72
+ paddingY: 5,
73
+ boxBorderPadding: 1,
74
+ graphDirection: 'TD',
75
+ },
76
+ offsetX: 0,
77
+ offsetY: 0,
78
+ bundles: [],
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Shared path/width setup for both tests: a 3-segment path (0,0)-(2,0)-
84
+ * (5,0)-(8,0), where segment 1 (index 1, x=0..2, touching the source) and
85
+ * segment 3 (index 3, x=5..8, touching the target) are terminal, and
86
+ * segment 2 (index 2, x=2..5) is the only non-terminal one.
87
+ *
88
+ * Columns 0,1,2,5,6,7,8 are set to width 10 (terminal segments 1 and 3
89
+ * total 30 and 40 respectively); columns 3,4 stay at width 1 (segment 2
90
+ * totals 22, via its shared boundary columns 2 and 5). All three stay
91
+ * under the 60-character label below, so the primary and "any suitable
92
+ * segment" tiers both fail on width alone regardless of clearOfNodes.
93
+ */
94
+ function makeAsymmetricWidthGraph(): { graph: AsciiGraph; edge: AsciiEdge } {
95
+ const from = makeNode('A', 0, 0)
96
+ const to = makeNode('B', 8, 0)
97
+ const graph = makeGraph([from, to])
98
+
99
+ for (const x of [0, 1, 2, 5, 6, 7, 8]) graph.columnWidth.set(x, 10)
100
+ for (const x of [3, 4]) graph.columnWidth.set(x, 1)
101
+
102
+ const edge: AsciiEdge = {
103
+ from,
104
+ to,
105
+ // 60 characters: wider than every segment's combined width above
106
+ // (the widest, segment 3, totals 40).
107
+ text: 'this label is deliberately far too long to ever possibly fit',
108
+ path: [
109
+ { x: 0, y: 0 },
110
+ { x: 2, y: 0 },
111
+ { x: 5, y: 0 },
112
+ { x: 8, y: 0 },
113
+ ],
114
+ labelLine: [],
115
+ startDir: Down,
116
+ endDir: Down,
117
+ style: 'solid',
118
+ hasArrowStart: false,
119
+ hasArrowEnd: true,
120
+ }
121
+
122
+ return { graph, edge }
123
+ }
124
+
125
+ describe('determineLabelLine terminal-segment-exclusion fallback tiers', () => {
126
+ it('falls back to clearSegments when the only non-terminal segment is blocked by an unrelated node', () => {
127
+ const { graph, edge } = makeAsymmetricWidthGraph()
128
+
129
+ // Block the middle (non-terminal) segment's sole interior cell with an
130
+ // unrelated node's occupied cell, so clearOfNodes reports it blocked —
131
+ // the only non-terminal segment available is then unusable, leaving
132
+ // clearLaterSegments empty and forcing the fallback to clearSegments
133
+ // (any segment clear of nodes, terminal or not).
134
+ graph.grid.add(gridKey({ x: 3, y: 0 }))
135
+ // A cell wedged between the two edge nodes stands in for that
136
+ // unrelated node's own footprint (not registered as a real AsciiNode —
137
+ // clearOfNodes only consults grid occupancy, not node identity).
138
+
139
+ expect(() => determineLabelLine(graph, edge)).not.toThrow()
140
+
141
+ // Falls back to a terminal segment (clearSegments), not the blocked
142
+ // middle one — confirms the cascade actually walked past the empty
143
+ // clearLaterSegments tier instead of using the blocked segment anyway.
144
+ const [p1, p2] = edge.labelLine
145
+ expect(p1).toBeDefined()
146
+ expect(p2).toBeDefined()
147
+ const usedMiddleSegment =
148
+ (p1!.x === 2 && p2!.x === 5) || (p1!.x === 5 && p2!.x === 2)
149
+ expect(usedMiddleSegment).toBe(false)
150
+ })
151
+
152
+ it('uses the non-terminal segment via clearLaterSegments even though the terminal segments are wider', () => {
153
+ const { graph, edge } = makeAsymmetricWidthGraph()
154
+
155
+ // Unlike the previous test, nothing blocks the middle (non-terminal)
156
+ // segment's interior this time, so clearLaterSegments ends up with
157
+ // exactly that one segment. The terminal segments are wider (30/40 vs
158
+ // 22) and would win a width-descending sort if they weren't excluded —
159
+ // this is what actually catches a mutation that drops the terminal
160
+ // exclusion from clearLaterSegments's filter (see this file's header).
161
+ expect(() => determineLabelLine(graph, edge)).not.toThrow()
162
+
163
+ const [p1, p2] = edge.labelLine
164
+ const usedMiddleSegment =
165
+ (p1!.x === 2 && p2!.x === 5) || (p1!.x === 5 && p2!.x === 2)
166
+ expect(usedMiddleSegment).toBe(true)
167
+ })
168
+ })
@@ -0,0 +1,207 @@
1
+ // `findFreeLane` (src/ascii/lane-search.ts) is the search algorithm that
2
+ // `er-diagram.ts`'s `chooseFreeRow` and `class-diagram.ts`'s
3
+ // `findClearColumn` independently grew, extracted in issue #617.
4
+ //
5
+ // Two kinds of test here:
6
+ //
7
+ // 1. Direct unit tests pinning the contract — search order, inclusive
8
+ // bounds, the deliberately unbounded `preferred` probe, and the
9
+ // `undefined` return that each caller turns into its own fallback.
10
+ // 2. Differential tests against verbatim copies of *both* pre-extraction
11
+ // loops, over randomized occupancy. The extraction's acceptance bar is
12
+ // byte-identical rendered output, and the rendered sample suite alone
13
+ // doesn't reach every branch of either original search — so the
14
+ // originals are kept here as reference implementations and the shared
15
+ // function is required to agree with them on every generated input.
16
+ import { describe, it, expect } from 'vitest'
17
+ import { findFreeLane } from '../lane-search.ts'
18
+
19
+ describe('findFreeLane', () => {
20
+ it('returns the preferred lane when it is free', () => {
21
+ expect(findFreeLane(5, 0, 10, () => true)).toBe(5)
22
+ })
23
+
24
+ it('probes the preferred lane exactly once, before any bounds apply', () => {
25
+ const probed: number[] = []
26
+ // `preferred` sits outside [min, max] and is still tested — the ER
27
+ // caller depends on this for a gap too narrow to hold a candidate.
28
+ const result = findFreeLane(3, 10, 20, (c) => {
29
+ probed.push(c)
30
+ return c === 3
31
+ })
32
+ expect(result).toBe(3)
33
+ expect(probed).toEqual([3])
34
+ })
35
+
36
+ it('scans forward before backward at each distance', () => {
37
+ const probed: number[] = []
38
+ findFreeLane(5, 0, 10, (c) => {
39
+ probed.push(c)
40
+ return false
41
+ })
42
+ expect(probed.slice(0, 7)).toEqual([5, 6, 4, 7, 3, 8, 2])
43
+ })
44
+
45
+ it('prefers the nearer free lane, forward winning a tie', () => {
46
+ // Both 4 and 6 are free and equidistant from 5.
47
+ expect(findFreeLane(5, 0, 10, (c) => c === 4 || c === 6)).toBe(6)
48
+ // Backward-only: 4 is free, 6 is not.
49
+ expect(findFreeLane(5, 0, 10, (c) => c === 4)).toBe(4)
50
+ // A nearer backward lane beats a further forward one.
51
+ expect(findFreeLane(5, 0, 10, (c) => c === 3 || c === 9)).toBe(3)
52
+ })
53
+
54
+ it('treats min and max as inclusive bounds on outward candidates', () => {
55
+ expect(findFreeLane(5, 0, 7, (c) => c === 7)).toBe(7)
56
+ expect(findFreeLane(5, 0, 6, (c) => c === 7)).toBeUndefined()
57
+ expect(findFreeLane(5, 2, 10, (c) => c === 2)).toBe(2)
58
+ expect(findFreeLane(5, 3, 10, (c) => c === 2)).toBeUndefined()
59
+ })
60
+
61
+ it('never calls the occupancy check on an out-of-range candidate', () => {
62
+ const probed: number[] = []
63
+ findFreeLane(5, 4, 6, (c) => {
64
+ probed.push(c)
65
+ return false
66
+ })
67
+ expect(probed).toEqual([5, 6, 4])
68
+ })
69
+
70
+ it('returns undefined when nothing in range is free', () => {
71
+ expect(findFreeLane(5, 0, 10, () => false)).toBeUndefined()
72
+ })
73
+
74
+ it('returns undefined when the bounds leave no candidates at all', () => {
75
+ // min > max — ER's narrow-gap case, where only `preferred` is probed.
76
+ const probed: number[] = []
77
+ const result = findFreeLane(5, 6, 4, (c) => {
78
+ probed.push(c)
79
+ return false
80
+ })
81
+ expect(result).toBeUndefined()
82
+ expect(probed).toEqual([5])
83
+ })
84
+
85
+ it('searches far enough to reach whichever bound is further away', () => {
86
+ expect(findFreeLane(5, 0, 100, (c) => c === 100)).toBe(100)
87
+ expect(findFreeLane(95, 0, 100, (c) => c === 0)).toBe(0)
88
+ })
89
+ })
90
+
91
+ // --- Differential tests against the pre-extraction implementations -------
92
+
93
+ /**
94
+ * Deterministic 32-bit LCG. A fixed seed keeps a failure reproducible;
95
+ * `Math.random` would make one impossible to re-run.
96
+ */
97
+ function makeRng(seed: number): () => number {
98
+ let state = seed >>> 0
99
+ return () => {
100
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0
101
+ return state / 0x100000000
102
+ }
103
+ }
104
+
105
+ /** `chooseFreeRow`'s search loop, copied verbatim from before #617. */
106
+ function legacyChooseRow(
107
+ startY: number,
108
+ endY: number,
109
+ isRowFree: (y: number) => boolean,
110
+ ): number {
111
+ const preferred = Math.floor((startY + endY) / 2)
112
+ if (isRowFree(preferred)) return preferred
113
+
114
+ const maxOffset = endY - startY
115
+ for (let d = 1; d <= maxOffset; d++) {
116
+ const below = preferred + d
117
+ if (below < endY && isRowFree(below)) return below
118
+ const above = preferred - d
119
+ if (above > startY && isRowFree(above)) return above
120
+ }
121
+ return preferred
122
+ }
123
+
124
+ /** `findClearColumn`'s search loop, copied verbatim from before #617. */
125
+ function legacyClearColumn(
126
+ startX: number,
127
+ totalW: number,
128
+ isColumnClear: (x: number) => boolean,
129
+ ): number {
130
+ if (isColumnClear(startX)) return startX
131
+
132
+ for (let offset = 1; offset < totalW + 10; offset++) {
133
+ const rightX = startX + offset
134
+ if (isColumnClear(rightX)) return rightX
135
+ const leftX = startX - offset
136
+ if (leftX >= 0 && isColumnClear(leftX)) return leftX
137
+ }
138
+
139
+ return totalW + 2
140
+ }
141
+
142
+ describe('findFreeLane matches the implementations it replaced', () => {
143
+ it("reproduces chooseFreeRow's row search on randomized occupancy", () => {
144
+ const rng = makeRng(0x5eed_617)
145
+ for (let trial = 0; trial < 2000; trial++) {
146
+ const startY = Math.floor(rng() * 20)
147
+ const endY = startY + Math.floor(rng() * 20)
148
+ // Density spans "almost everything free" to "almost nothing free" so
149
+ // both the hit and the exhausted-fallback paths get exercised.
150
+ const density = rng()
151
+ const occupied = new Set<number>()
152
+ for (let y = startY - 5; y <= endY + 5; y++) {
153
+ if (rng() < density) occupied.add(y)
154
+ }
155
+ const isRowFree = (y: number): boolean => !occupied.has(y)
156
+
157
+ const preferred = Math.floor((startY + endY) / 2)
158
+ const extracted =
159
+ findFreeLane(preferred, startY + 1, endY - 1, isRowFree) ?? preferred
160
+
161
+ expect({ startY, endY, chosen: extracted }).toEqual({
162
+ startY,
163
+ endY,
164
+ chosen: legacyChooseRow(startY, endY, isRowFree),
165
+ })
166
+ }
167
+ })
168
+
169
+ it("reproduces findClearColumn's column search on randomized occupancy", () => {
170
+ const rng = makeRng(0xc01_5e5)
171
+ for (let trial = 0; trial < 2000; trial++) {
172
+ const totalW = 4 + Math.floor(rng() * 40)
173
+ const startX = Math.floor(rng() * totalW)
174
+ const density = rng()
175
+ // Only columns within the canvas can be blocked by a box — past the
176
+ // widest box every column is clear, which is why the original's
177
+ // `totalW + 2` fallback was unreachable in practice.
178
+ const blocked = new Set<number>()
179
+ for (let x = 0; x < totalW; x++) {
180
+ if (rng() < density) blocked.add(x)
181
+ }
182
+ const isColumnClear = (x: number): boolean => !blocked.has(x)
183
+
184
+ const extracted =
185
+ findFreeLane(startX, 0, startX + totalW + 9, isColumnClear) ??
186
+ totalW + 2
187
+
188
+ expect({ totalW, startX, chosen: extracted }).toEqual({
189
+ totalW,
190
+ startX,
191
+ chosen: legacyClearColumn(startX, totalW, isColumnClear),
192
+ })
193
+ }
194
+ })
195
+
196
+ it("reproduces findClearColumn's fallback when every column is blocked", () => {
197
+ // Not reachable through the real renderer (a column past the widest box
198
+ // is always clear), but the fallback is still wired up — so pin the
199
+ // agreement rather than leaving the branch untested.
200
+ const totalW = 12
201
+ const startX = 3
202
+ const neverClear = (): boolean => false
203
+ expect(
204
+ findFreeLane(startX, 0, startX + totalW + 9, neverClear) ?? totalW + 2,
205
+ ).toBe(legacyClearColumn(startX, totalW, neverClear))
206
+ })
207
+ })