@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,432 @@
1
+ // ============================================================================
2
+ // ASCII sequence diagram structural "form" invariants
3
+ //
4
+ // svg-samples.visual.test.ts / ascii-samples.visual.test.ts only self-diff
5
+ // against a committed baseline screenshot — a defect present when that
6
+ // baseline was captured passes forever. This file asserts structural
7
+ // properties the renderer should hold regardless of any specific fixture:
8
+ // a block's frame must fit its own label (generalizes PR #387's fix beyond
9
+ // its single fixed repro), a note must not visually collide with a lifeline
10
+ // it isn't attached to, and an `actor` participant must render distinctly
11
+ // from a plain `participant` box.
12
+ //
13
+ // Cases that assert already-correct, already-fixed behavior are plain
14
+ // `it()`s — regression locks. Cases that assert behavior confirmed broken
15
+ // today use `it.fails(...)`: the assertion body is real and genuinely
16
+ // exercises the bug, so it fails loudly (in the good direction, since
17
+ // `it.fails` inverts pass/fail) the moment someone fixes it elsewhere —
18
+ // signaling that case should be promoted to a plain `it()`. These bugs are
19
+ // intentionally not fixed in this change; fixing them is separate work.
20
+ // ============================================================================
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
24
+ import { findBoxRect, findTextRect } from './helpers/ascii-form.ts'
25
+
26
+ const LONG_LABEL = 'a rather long condition label that used to get clipped'
27
+ const VERY_LONG_LABEL =
28
+ 'an extremely long condition label that spans far more columns than any single lifeline gap would ever naturally provide'
29
+
30
+ function participants(names: string[]): string {
31
+ return names.map((n) => ` participant ${n}`).join('\n')
32
+ }
33
+
34
+ const BORDER_ASCII = new Set(['+', '-', '|'])
35
+
36
+ /**
37
+ * A block's left/right wall columns, found via the row containing
38
+ * `anchorText` (e.g. the block type keyword, which — unlike a header/divider
39
+ * label — is never itself truncated, so it's a stable anchor even when the
40
+ * label content next to it is clipped). The walls are constant across the
41
+ * whole block (header, every divider, and the body all share the same two
42
+ * wall columns), so one anchor row is enough regardless of which row a
43
+ * later containment check is against.
44
+ */
45
+ function blockFrameSpan(
46
+ ascii: string,
47
+ anchorText: string,
48
+ ): { x0: number; x1: number } {
49
+ const lines = ascii.split('\n')
50
+ const y = lines.findIndex((l) => l.includes(anchorText))
51
+ if (y === -1)
52
+ throw new Error(`blockFrameSpan: no row containing "${anchorText}"`)
53
+ const line = lines[y]!
54
+ let x0 = line.length
55
+ let x1 = -1
56
+ for (let x = 0; x < line.length; x++) {
57
+ if (BORDER_ASCII.has(line[x]!)) {
58
+ x0 = Math.min(x0, x)
59
+ x1 = Math.max(x1, x)
60
+ }
61
+ }
62
+ if (x1 === -1) throw new Error(`blockFrameSpan: no border chars on row ${y}`)
63
+ return { x0, x1 }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Block-wall-fits-label matrix (generalizes PR #387 / issue #352)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe('ASCII sequence — block frame fits its own label (issue #352 family)', () => {
71
+ // PR #387 (issue #352's fix) closes this gap generically — for every
72
+ // block type, not just its own alt/else repro — by measuring the
73
+ // longest header/divider label up front and widening the wall (see
74
+ // `maxBlockLabelWidth` in src/ascii/sequence.ts) rather than deriving it
75
+ // purely from `minLX`/`maxLX`. The long-label matrix below was written
76
+ // as `it.fails` against pre-#387 `main`, documenting that the gap wasn't
77
+ // limited to `alt`/`else` or to one label length; now that the fix has
78
+ // landed, every case is a plain `it()` regression lock, same as the
79
+ // short-label cases above.
80
+ const shortLabelCases: Array<{
81
+ name: string
82
+ type: 'alt' | 'loop' | 'opt' | 'par' | 'critical'
83
+ actors: string[]
84
+ }> = [
85
+ { name: 'alt, short label, 2 actors', type: 'alt', actors: ['A', 'B'] },
86
+ { name: 'loop, short label, 2 actors', type: 'loop', actors: ['A', 'B'] },
87
+ {
88
+ name: 'opt, short label, 3 actors',
89
+ type: 'opt',
90
+ actors: ['A', 'B', 'C'],
91
+ },
92
+ {
93
+ name: 'par, short label, 3 actors',
94
+ type: 'par',
95
+ actors: ['A', 'B', 'C'],
96
+ },
97
+ {
98
+ name: 'critical, short label, 2 actors',
99
+ type: 'critical',
100
+ actors: ['A', 'B'],
101
+ },
102
+ ]
103
+
104
+ for (const c of shortLabelCases) {
105
+ it(c.name, () => {
106
+ const src = `sequenceDiagram
107
+ ${participants(c.actors)}
108
+ ${c.type} ok
109
+ ${c.actors[0]}->>${c.actors[1]}: x
110
+ end`
111
+ const ascii = renderMermaidASCII(src, { useAscii: true })
112
+ expect(ascii).toContain('ok')
113
+ })
114
+ }
115
+
116
+ const longLabelCases: Array<{
117
+ name: string
118
+ type: 'alt' | 'loop' | 'opt' | 'par' | 'critical'
119
+ label: string
120
+ actors: string[]
121
+ }> = [
122
+ {
123
+ name: 'alt, long label, 2 actors',
124
+ type: 'alt',
125
+ label: LONG_LABEL,
126
+ actors: ['A', 'B'],
127
+ },
128
+ {
129
+ name: 'alt, very long label, 2 actors',
130
+ type: 'alt',
131
+ label: VERY_LONG_LABEL,
132
+ actors: ['A', 'B'],
133
+ },
134
+ {
135
+ name: 'alt, long label, 5 actors',
136
+ type: 'alt',
137
+ label: LONG_LABEL,
138
+ actors: ['A', 'B', 'C', 'D', 'E'],
139
+ },
140
+ {
141
+ name: 'loop, long label, 2 actors',
142
+ type: 'loop',
143
+ label: LONG_LABEL,
144
+ actors: ['A', 'B'],
145
+ },
146
+ {
147
+ name: 'opt, long label, 3 actors',
148
+ type: 'opt',
149
+ label: LONG_LABEL,
150
+ actors: ['A', 'B', 'C'],
151
+ },
152
+ {
153
+ name: 'par, long label, 3 actors',
154
+ type: 'par',
155
+ label: LONG_LABEL,
156
+ actors: ['A', 'B', 'C'],
157
+ },
158
+ {
159
+ name: 'critical, very long label, 2 actors',
160
+ type: 'critical',
161
+ label: VERY_LONG_LABEL,
162
+ actors: ['A', 'B'],
163
+ },
164
+ ]
165
+
166
+ for (const c of longLabelCases) {
167
+ it(c.name, () => {
168
+ const src = `sequenceDiagram
169
+ ${participants(c.actors)}
170
+ ${c.type} ${c.label}
171
+ ${c.actors[0]}->>${c.actors[1]}: x
172
+ end`
173
+ const ascii = renderMermaidASCII(src, { useAscii: true })
174
+ // Not just "does the full label appear somewhere" — it must also sit
175
+ // inside the block's own walls, not spill past them.
176
+ expect(ascii).toContain(c.label)
177
+ const frame = blockFrameSpan(ascii, c.type)
178
+ const labelRect = findTextRect(ascii, c.label)
179
+ expect(labelRect.x0).toBeGreaterThanOrEqual(frame.x0)
180
+ expect(labelRect.x1).toBeLessThanOrEqual(frame.x1)
181
+ })
182
+ }
183
+
184
+ it('alt/else with multiple branches: every divider label survives unclipped and stays inside the frame', () => {
185
+ const src = `sequenceDiagram
186
+ participant A
187
+ participant B
188
+ alt ${LONG_LABEL}
189
+ A->>B: x
190
+ else ${VERY_LONG_LABEL}
191
+ A->>B: y
192
+ end`
193
+ const ascii = renderMermaidASCII(src, { useAscii: true })
194
+ expect(ascii).toContain(LONG_LABEL)
195
+ expect(ascii).toContain(VERY_LONG_LABEL)
196
+ // Walls are constant across the whole block, so the header row alone
197
+ // (anchored on "alt", never itself truncated) gives the frame span
198
+ // both labels — header and divider — must stay inside.
199
+ const frame = blockFrameSpan(ascii, 'alt')
200
+ for (const label of [LONG_LABEL, VERY_LONG_LABEL]) {
201
+ const rect = findTextRect(ascii, label)
202
+ expect(rect.x0).toBeGreaterThanOrEqual(frame.x0)
203
+ expect(rect.x1).toBeLessThanOrEqual(frame.x1)
204
+ }
205
+ })
206
+ })
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Note cross-contamination matrix
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** Column of an actor's lifeline: the horizontal midpoint of its header box. */
213
+ function lifelineColumn(ascii: string, actorName: string): number {
214
+ const rect = findBoxRect(ascii, actorName)
215
+ return Math.round((rect.x0 + rect.x1) / 2)
216
+ }
217
+
218
+ describe('ASCII sequence — notes do not collide with an unrelated lifeline', () => {
219
+ // Fixed by issue #953 case A: `sequence.ts`'s note-x calculation
220
+ // (`nx = llX[aIdx] - nWidth - 1`) used to clamp straight to
221
+ // `Math.max(0, nx)`, so a note wider than the leftmost actor's (small)
222
+ // left margin clamped to column 0 and swallowed its own actor's lifeline
223
+ // column instead of sitting entirely to its left. The fix reserves room
224
+ // in the leftmost lifeline's own initial position (`llX[0]`) for its
225
+ // widest `left` note up front, so `nx` never needs clamping in the first
226
+ // place. Per this file's header convention, a case confirmed fixed is
227
+ // promoted from `it.fails` to a plain `it()` regression lock.
228
+ it("note left of the leftmost actor does not overlap that actor's own lifeline", () => {
229
+ const src = `sequenceDiagram
230
+ participant A as Alice
231
+ participant B as Bob
232
+ Note left of A: ${LONG_LABEL}
233
+ A->>B: hi`
234
+ const ascii = renderMermaidASCII(src, { useAscii: true })
235
+ // findBoxRect, not findTextRect — the note's own border/padding must
236
+ // also stay clear of the lifeline, not just its bare text span.
237
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
238
+ const aliceCol = lifelineColumn(ascii, 'Alice')
239
+ expect(aliceCol < noteRect.x0 || aliceCol > noteRect.x1).toBe(true)
240
+ })
241
+
242
+ // Fixed by issue #953 case B: same clamp as above, but the victim is a
243
+ // *different* actor's lifeline — the exact shape visible in
244
+ // samples-data.ts's "Sequence: Notes (Right/Left/Over)" sample ("Alice
245
+ // prepares" reaching into Bob's lifeline). The fix reserves a
246
+ // note-width-aware gap between adjacent lifelines (see `leftNoteWidth`/
247
+ // `rightNoteWidth` in sequence.ts), so a `left` note's own gap is sized to
248
+ // fit it before `nx` is ever computed.
249
+ it("note left of a non-leftmost actor does not overlap the left-neighbor's lifeline", () => {
250
+ const src = `sequenceDiagram
251
+ participant A as Alice
252
+ participant B as Bob
253
+ participant C as Carol
254
+ Note left of B: ${LONG_LABEL}
255
+ B->>C: hi`
256
+ const ascii = renderMermaidASCII(src, { useAscii: true })
257
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
258
+ const aliceCol = lifelineColumn(ascii, 'Alice')
259
+ expect(aliceCol < noteRect.x0 || aliceCol > noteRect.x1).toBe(true)
260
+ })
261
+
262
+ // Fixed by issue #953 case C: the lifeline gap used to be sized only from
263
+ // message-label widths — note width never entered that calculation for
264
+ // either side. Notes draw last (after lifelines/boxes/messages/blocks),
265
+ // so a right-note wider than the gap to the *next* actor silently
266
+ // overwrote that actor's lifeline. No `Math.max(0, …)` clamp was involved
267
+ // — a genuinely different bug from the two above, sharing only the
268
+ // visible symptom. The fix folds note width into the same gap-sizing pass
269
+ // that already accounts for message labels.
270
+ it("note right of an actor does not overlap the next actor's lifeline", () => {
271
+ const src = `sequenceDiagram
272
+ participant A as Alice
273
+ participant B as Bob
274
+ participant C as Carol
275
+ A->>B: hi
276
+ Note right of B: ${LONG_LABEL}`
277
+ const ascii = renderMermaidASCII(src, { useAscii: true })
278
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
279
+ const carolCol = lifelineColumn(ascii, 'Carol')
280
+ expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
281
+ })
282
+
283
+ // Fixed by issue #992: a single-actor `Note over` centers on that actor's
284
+ // own lifeline (`nx = llX[aIdx] - Math.floor(nWidth / 2)`), but neither
285
+ // half of that width was reserved during layout — so a note wider than
286
+ // the actor's own column spilled into whichever neighbor was closer,
287
+ // exactly the shape cases A-C above already fixed for `left`/`right`
288
+ // notes. This is the exact repro from the issue (two actors, a note over
289
+ // the first one). A *multi*-actor `Note over A,B` is unaffected —
290
+ // spanning both actors is its intended shape (see the "note over two
291
+ // actors" case below), not this bug.
292
+ it("single-actor 'over' note on the first actor does not overlap the next actor's lifeline (issue #992 repro)", () => {
293
+ const src = `sequenceDiagram
294
+ participant A as Alice
295
+ participant B as Bob
296
+ Note over A: ${LONG_LABEL}
297
+ A->>B: hi`
298
+ const ascii = renderMermaidASCII(src, { useAscii: true })
299
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
300
+ const bobCol = lifelineColumn(ascii, 'Bob')
301
+ expect(bobCol < noteRect.x0 || bobCol > noteRect.x1).toBe(true)
302
+ })
303
+
304
+ // Same fix, but a middle actor: both halves of the note (left half toward
305
+ // Alice, right half toward Carol) need to be reserved, not just the
306
+ // leftmost-actor case above.
307
+ it("single-actor 'over' note on a middle actor does not overlap either neighbor's lifeline", () => {
308
+ const src = `sequenceDiagram
309
+ participant A as Alice
310
+ participant B as Bob
311
+ participant C as Carol
312
+ A->>B: hi
313
+ Note over B: ${LONG_LABEL}`
314
+ const ascii = renderMermaidASCII(src, { useAscii: true })
315
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
316
+ const aliceCol = lifelineColumn(ascii, 'Alice')
317
+ const carolCol = lifelineColumn(ascii, 'Carol')
318
+ expect(aliceCol < noteRect.x0 || aliceCol > noteRect.x1).toBe(true)
319
+ expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
320
+ })
321
+
322
+ it("short single-actor 'over' note does not overlap the next actor", () => {
323
+ const src = `sequenceDiagram
324
+ participant A as Alice
325
+ participant B as Bob
326
+ Note over A: ok
327
+ A->>B: hi`
328
+ const ascii = renderMermaidASCII(src, { useAscii: true })
329
+ const noteRect = findBoxRect(ascii, 'ok')
330
+ const bobCol = lifelineColumn(ascii, 'Bob')
331
+ expect(bobCol < noteRect.x0 || bobCol > noteRect.x1).toBe(true)
332
+ })
333
+
334
+ // Regression locks: short notes, which don't hit any of the three bugs
335
+ // above, must keep rendering without collision.
336
+ it('short note left of a middle actor does not overlap its left neighbor', () => {
337
+ const src = `sequenceDiagram
338
+ participant A as Alice
339
+ participant B as Bob
340
+ participant C as Carol
341
+ Note left of B: hi
342
+ B->>C: hi`
343
+ const ascii = renderMermaidASCII(src, { useAscii: true })
344
+ const noteRect = findBoxRect(ascii, 'hi')
345
+ const aliceCol = lifelineColumn(ascii, 'Alice')
346
+ expect(aliceCol < noteRect.x0 || aliceCol > noteRect.x1).toBe(true)
347
+ })
348
+
349
+ it('short note right of an actor does not overlap the next actor', () => {
350
+ const src = `sequenceDiagram
351
+ participant A as Alice
352
+ participant B as Bob
353
+ participant C as Carol
354
+ A->>B: hi
355
+ Note right of B: ok`
356
+ const ascii = renderMermaidASCII(src, { useAscii: true })
357
+ const noteRect = findBoxRect(ascii, 'ok')
358
+ const carolCol = lifelineColumn(ascii, 'Carol')
359
+ expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
360
+ })
361
+
362
+ it('note over two actors does not overlap a third, uninvolved actor', () => {
363
+ const src = `sequenceDiagram
364
+ participant A as Alice
365
+ participant B as Bob
366
+ participant C as Carol
367
+ A->>B: hi
368
+ Note over A,B: conversation complete`
369
+ const ascii = renderMermaidASCII(src, { useAscii: true })
370
+ const noteRect = findBoxRect(ascii, 'conversation complete')
371
+ const carolCol = lifelineColumn(ascii, 'Carol')
372
+ expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
373
+ })
374
+ })
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Actor-kind fidelity: `actor` must render distinctly from `participant`
378
+ // ---------------------------------------------------------------------------
379
+
380
+ type ActorPosition = 'first' | 'middle' | 'last'
381
+
382
+ /**
383
+ * Declares `id` (as either `actor` or `participant`) at `position` among two
384
+ * other fixed participants — first/middle/last are only meaningful with at
385
+ * least one neighbor on the relevant side, so this always yields 3 declared
386
+ * actors, not just `id` alone.
387
+ */
388
+ function declareActorAt(
389
+ position: ActorPosition,
390
+ kind: 'actor' | 'participant',
391
+ id: string,
392
+ ): string {
393
+ const target = `${kind} ${id} as User`
394
+ const lines =
395
+ position === 'first'
396
+ ? [target, 'participant S as System', 'participant DB as Database']
397
+ : position === 'last'
398
+ ? ['participant S as System', 'participant DB as Database', target]
399
+ : ['participant S as System', target, 'participant DB as Database']
400
+ return lines.map((l) => ` ${l}`).join('\n')
401
+ }
402
+
403
+ describe('ASCII sequence — `actor` renders distinctly from `participant`', () => {
404
+ // Fixed by issue #449: `src/ascii/sequence.ts`'s `drawActorBox` now draws
405
+ // a small stick-figure glyph (`ACTOR_GLYPH_LINES`) above the label for
406
+ // `actor.type === 'actor'`, so an `actor` and a `participant` with the
407
+ // same label no longer render byte-for-byte identically. Per this file's
408
+ // header convention, a case confirmed fixed is promoted from `it.fails`
409
+ // to a plain `it()` regression lock.
410
+ it.each<[ActorPosition, string]>([
411
+ ['first', 'U'],
412
+ ['middle', 'U'],
413
+ ['last', 'U'],
414
+ ])(
415
+ 'an %s-position `actor` looks different from a `participant` (%s)',
416
+ (position, id) => {
417
+ const asActor = renderMermaidASCII(
418
+ `sequenceDiagram
419
+ ${declareActorAt(position, 'actor', id)}
420
+ ${id}->>S: hi`,
421
+ { useAscii: true },
422
+ )
423
+ const asParticipant = renderMermaidASCII(
424
+ `sequenceDiagram
425
+ ${declareActorAt(position, 'participant', id)}
426
+ ${id}->>S: hi`,
427
+ { useAscii: true },
428
+ )
429
+ expect(asActor).not.toBe(asParticipant)
430
+ },
431
+ )
432
+ })
@@ -0,0 +1,219 @@
1
+ // ============================================================================
2
+ // ASCII sequence diagram tests — parity gaps found comparing against
3
+ // AlexanderGrooff/mermaid-ascii: autonumber, bidirectional arrows, and
4
+ // undeclared multi-word actor names.
5
+ // ============================================================================
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
9
+
10
+ describe('ASCII sequence diagrams – autonumber', () => {
11
+ it('renders sequence numbers near each numbered arrow, in order', () => {
12
+ const result = renderMermaidASCII(
13
+ `sequenceDiagram
14
+ autonumber
15
+ A->>B: First
16
+ A->>B: Second`,
17
+ { useAscii: false },
18
+ )
19
+ const lines = result.split('\n')
20
+ const firstArrowLine = lines.find((l) => l.includes('▶') && l.includes('1'))
21
+ const secondArrowLine = lines
22
+ .slice(lines.indexOf(firstArrowLine ?? ''))
23
+ .find((l) => l.includes('▶') && l.includes('2'))
24
+ expect(firstArrowLine).toBeDefined()
25
+ expect(secondArrowLine).toBeDefined()
26
+ })
27
+
28
+ it('does not number messages before autonumber', () => {
29
+ const result = renderMermaidASCII(
30
+ `sequenceDiagram
31
+ A->>B: Unnumbered
32
+ autonumber
33
+ A->>B: Numbered`,
34
+ { useAscii: false },
35
+ )
36
+ const lines = result.split('\n')
37
+ const unnumberedArrowLine = lines.find(
38
+ (l) => l.includes('▶') && !/[0-9]/.test(l),
39
+ )
40
+ const numberedArrowLine = lines.find(
41
+ (l) => l.includes('▶') && /[0-9]/.test(l),
42
+ )
43
+ expect(unnumberedArrowLine).toBeDefined()
44
+ expect(numberedArrowLine).toBeDefined()
45
+ })
46
+
47
+ it('places the badge at the departure end for a right-to-left numbered message', () => {
48
+ // B->>A is right-to-left (B's lane is to the right of A's) — exercises
49
+ // the `!leftToRight` badge-placement branch, distinct from the
50
+ // left-to-right case in the first test above.
51
+ const result = renderMermaidASCII(
52
+ `sequenceDiagram
53
+ autonumber
54
+ A->>B: Hello
55
+ B->>A: Reply`,
56
+ { useAscii: false },
57
+ )
58
+ const replyArrowLine = result
59
+ .split('\n')
60
+ .find((l) => l.includes('◀') && l.includes('2'))
61
+ expect(replyArrowLine).toBeDefined()
62
+ // The badge belongs at the DEPARTURE end (B, on the right), not the
63
+ // arrival end (A, where the arrowhead points) — assert the digit's
64
+ // column comes after the arrowhead's, not just that both appear
65
+ // somewhere on the same line (a badge misplaced at the arrival end
66
+ // would still satisfy a same-line-only check).
67
+ const arrowCol = replyArrowLine!.indexOf('◀')
68
+ const badgeCol = replyArrowLine!.indexOf('2')
69
+ expect(badgeCol).toBeGreaterThan(arrowCol)
70
+ })
71
+
72
+ it('omits the badge (without corrupting the arrow) when it would not fit, left-to-right', () => {
73
+ const result = renderMermaidASCII(
74
+ `sequenceDiagram
75
+ autonumber 999999999999 1
76
+ A->>B: Yo`,
77
+ { useAscii: false },
78
+ )
79
+ const arrowLine = result.split('\n').find((l) => l.includes('▶'))
80
+ expect(arrowLine).toBeDefined()
81
+ expect(arrowLine).not.toMatch(/[0-9]/)
82
+ })
83
+
84
+ it('omits the badge (without corrupting the arrow) when it would not fit, right-to-left', () => {
85
+ const result = renderMermaidASCII(
86
+ `sequenceDiagram
87
+ participant A
88
+ participant B
89
+ autonumber 999999999999 1
90
+ B->>A: Yo`,
91
+ { useAscii: false },
92
+ )
93
+ const arrowLine = result.split('\n').find((l) => l.includes('◀'))
94
+ expect(arrowLine).toBeDefined()
95
+ expect(arrowLine).not.toMatch(/[0-9]/)
96
+ })
97
+
98
+ it('renders without autonumber unchanged (no stray digits near arrows)', () => {
99
+ const result = renderMermaidASCII(
100
+ `sequenceDiagram
101
+ A->>B: Hello`,
102
+ { useAscii: false },
103
+ )
104
+ expect(result).toContain('Hello')
105
+ expect(result).not.toMatch(/[0-9]/)
106
+ })
107
+ })
108
+
109
+ describe('ASCII sequence diagrams – bidirectional arrows', () => {
110
+ it('draws arrowheads on both ends for a solid bidirectional arrow', () => {
111
+ const result = renderMermaidASCII(
112
+ `sequenceDiagram
113
+ A<<->>B: Sync call`,
114
+ { useAscii: false },
115
+ )
116
+ expect(result).toContain('◀')
117
+ expect(result).toContain('▶')
118
+ })
119
+
120
+ it('draws a dashed bidirectional arrow with arrowheads on both ends', () => {
121
+ const result = renderMermaidASCII(
122
+ `sequenceDiagram
123
+ A<<-->>B: Async call`,
124
+ { useAscii: false },
125
+ )
126
+ expect(result).toContain('◀')
127
+ expect(result).toContain('▶')
128
+ expect(result).toContain('╌')
129
+ })
130
+
131
+ it('a regular one-way arrow only gets one arrowhead', () => {
132
+ const result = renderMermaidASCII(
133
+ `sequenceDiagram
134
+ A->>B: Hello`,
135
+ { useAscii: false },
136
+ )
137
+ const arrowLine = result.split('\n').find((l) => l.includes('▶'))
138
+ expect(arrowLine).toBeDefined()
139
+ expect(arrowLine).not.toContain('◀')
140
+ })
141
+
142
+ it('renders bidirectional arrows in ASCII-only mode without special glyphs', () => {
143
+ const result = renderMermaidASCII(
144
+ `sequenceDiagram
145
+ A<<->>B: Sync call`,
146
+ { useAscii: true },
147
+ )
148
+ expect(result).toContain('<')
149
+ expect(result).toContain('>')
150
+ })
151
+
152
+ it('draws arrowheads on both ends when the bidirectional message runs right-to-left', () => {
153
+ // B is declared second (lane to the right of A), so B<<-->>A draws
154
+ // right-to-left — exercises the mirrored-arrowhead branch for
155
+ // `!leftToRight`, distinct from the left-to-right case above.
156
+ const result = renderMermaidASCII(
157
+ `sequenceDiagram
158
+ A->>B: Hello
159
+ B<<-->>A: Reply sync`,
160
+ { useAscii: false },
161
+ )
162
+ const arrowLines = result
163
+ .split('\n')
164
+ .filter((l) => l.includes('◀') || l.includes('▶'))
165
+ expect(arrowLines).toHaveLength(2)
166
+ // First message (A->>B) is one-way, only gets the destination arrowhead.
167
+ expect(arrowLines[0]).not.toContain('◀')
168
+ expect(arrowLines[0]).toContain('▶')
169
+ // Second message (B<<-->>A) is bidirectional and right-to-left: the
170
+ // primary arrowhead lands on the left (A) end, the mirrored one on the
171
+ // right (B) end.
172
+ expect(arrowLines[1]).toContain('◀')
173
+ expect(arrowLines[1]).toContain('▶')
174
+ })
175
+ })
176
+
177
+ describe('ASCII sequence diagrams – multi-word inline actor names', () => {
178
+ it('renders an undeclared actor name with a space and a hyphenated name', () => {
179
+ const result = renderMermaidASCII(
180
+ `sequenceDiagram
181
+ cron job->>customer-notifier: hi`,
182
+ { useAscii: false },
183
+ )
184
+ expect(result).toContain('cron job')
185
+ expect(result).toContain('customer-notifier')
186
+ expect(result).toContain('hi')
187
+ })
188
+
189
+ it('still renders plain single-word actor names correctly', () => {
190
+ const result = renderMermaidASCII(
191
+ `sequenceDiagram
192
+ Alice->>Bob: Hello`,
193
+ { useAscii: false },
194
+ )
195
+ expect(result).toContain('Alice')
196
+ expect(result).toContain('Bob')
197
+ })
198
+ })
199
+
200
+ describe('ASCII sequence diagrams – standalone activate/deactivate (#419)', () => {
201
+ it('renders identically to the +/- shorthand, and does not leak the keyword as text', () => {
202
+ const standalone = renderMermaidASCII(
203
+ `sequenceDiagram
204
+ Alice->>John: Hello John, how are you?
205
+ activate John
206
+ John-->>Alice: Great!
207
+ deactivate John`,
208
+ { useAscii: false },
209
+ )
210
+ const shorthand = renderMermaidASCII(
211
+ `sequenceDiagram
212
+ Alice->>+John: Hello John, how are you?
213
+ John-->>-Alice: Great!`,
214
+ { useAscii: false },
215
+ )
216
+ expect(standalone).toBe(shorthand)
217
+ expect(standalone).not.toContain('activate')
218
+ })
219
+ })