@zombie-mermaid/ascii-renderer 2.2.6 → 3.0.0

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 (32) hide show
  1. package/dist/index.cjs +7 -7
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.js +1245 -1203
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -3
  6. package/src/__tests__/ascii-decision-lr-box-start.test.ts +1 -1
  7. package/src/__tests__/ascii-er-attribute-column-order-1134.test.ts +69 -0
  8. package/src/__tests__/ascii-er-corner-glyphs.test.ts +27 -4
  9. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +13 -3
  10. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +107 -0
  11. package/src/__tests__/ascii-er-stray-connectors.test.ts +15 -9
  12. package/src/__tests__/ascii-sequence-activation.test.ts +171 -0
  13. package/src/__tests__/ascii-shape-diamond.test.ts +1 -1
  14. package/src/__tests__/ascii-shape-rounded.test.ts +1 -1
  15. package/src/__tests__/ascii-shapes-circle.test.ts +1 -1
  16. package/src/__tests__/ascii-shapes-hexagon.test.ts +1 -1
  17. package/src/__tests__/ascii-shapes-special.test.ts +35 -0
  18. package/src/__tests__/ascii-validate.test.ts +1 -42
  19. package/src/__tests__/ascii.test.ts +1 -1
  20. package/src/__tests__/testdata/ascii/er_attributes.txt +9 -9
  21. package/src/__tests__/testdata/ascii/er_identifying.txt +8 -8
  22. package/src/__tests__/testdata/unicode/er_attributes.txt +9 -9
  23. package/src/__tests__/testdata/unicode/er_identifying.txt +8 -8
  24. package/src/{validate.ts → __tests__/validate.ts} +3 -40
  25. package/src/draw-boxes.ts +14 -0
  26. package/src/er-diagram.ts +134 -31
  27. package/src/sequence.ts +87 -1
  28. package/src/shapes/index.ts +47 -5
  29. package/src/shapes/circle.ts +0 -31
  30. package/src/shapes/diamond.ts +0 -31
  31. package/src/shapes/hexagon.ts +0 -35
  32. package/src/shapes/rounded.ts +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/ascii-renderer",
3
- "version": "2.2.6",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "description": "ASCII and Unicode box-drawing renderer for zombie-mermaid diagrams.",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "LICENSE"
36
36
  ],
37
37
  "dependencies": {
38
- "@zombie-mermaid/core": "2.2.6",
39
- "@zombie-mermaid/mermaid-parser": "2.2.6"
38
+ "@zombie-mermaid/core": "3.0.0",
39
+ "@zombie-mermaid/mermaid-parser": "3.0.0"
40
40
  }
41
41
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { describe, it, expect } from 'vitest'
16
16
  import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
17
- import { findOrphanedJunctions } from '../validate.ts'
17
+ import { findOrphanedJunctions } from './validate.ts'
18
18
  import { readdirSync, readFileSync } from 'node:fs'
19
19
  import { join, dirname } from 'node:path'
20
20
  import { fileURLToPath } from 'node:url'
@@ -0,0 +1,69 @@
1
+ // ============================================================================
2
+ // ASCII ER diagram: attribute columns render as type/name/keys (issue #1134)
3
+ //
4
+ // formatAttribute (src/ascii/er-diagram.ts) used to build "PK type name" —
5
+ // keys, then type, then name — but real mermaid's SVG lays attribute
6
+ // columns out left-to-right as type/name/keys (confirmed against the SVG's
7
+ // attribute-type/attribute-name/attribute-keys x-coordinates in the
8
+ // form-judge report on issue #1119). This file pins the corrected order.
9
+ // ============================================================================
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
13
+
14
+ describe('ASCII ER attribute columns render as type/name/keys (issue #1134)', () => {
15
+ it('renders a single-key attribute as "type name KEY", not "KEY type name"', () => {
16
+ const ascii = renderMermaidASCII(
17
+ `erDiagram
18
+ ORDER {
19
+ int id PK
20
+ }`,
21
+ { colorMode: 'none' },
22
+ )
23
+ expect(ascii).toContain('int id PK')
24
+ expect(ascii).not.toContain('PK int id')
25
+ })
26
+
27
+ it('renders a multi-key attribute\'s keys joined and trailing, e.g. "type name PK,FK"', () => {
28
+ const ascii = renderMermaidASCII(
29
+ `erDiagram
30
+ LINE_ITEM {
31
+ int product_id PK FK
32
+ }`,
33
+ { colorMode: 'none' },
34
+ )
35
+ expect(ascii).toContain('int product_id PK,FK')
36
+ expect(ascii).not.toContain('PK,FK int product_id')
37
+ })
38
+
39
+ it('renders a keyless attribute with no reserved key-column padding', () => {
40
+ const ascii = renderMermaidASCII(
41
+ `erDiagram
42
+ CUSTOMER {
43
+ string name
44
+ }`,
45
+ { colorMode: 'none' },
46
+ )
47
+ // The pre-#1134 format always reserved a fixed-width 3-space key
48
+ // prefix (' ') even when an attribute had no key at all — a keyless
49
+ // attribute now renders with nothing but its own type/name text, so
50
+ // the entity's box no longer wastes 3 columns it never needed.
51
+ expect(ascii).toContain('string name')
52
+ expect(ascii).not.toContain(' string name')
53
+ })
54
+
55
+ it('keeps a mix of keyed and keyless attributes readable in the same entity', () => {
56
+ const ascii = renderMermaidASCII(
57
+ `erDiagram
58
+ CUSTOMER {
59
+ string name PK
60
+ string email UK
61
+ int age
62
+ }`,
63
+ { colorMode: 'none' },
64
+ )
65
+ expect(ascii).toContain('string name PK')
66
+ expect(ascii).toContain('string email UK')
67
+ expect(ascii).toContain('int age')
68
+ })
69
+ })
@@ -57,17 +57,29 @@ describe('ASCII ER relationship routing draws a corner glyph at each turn (issue
57
57
  // the detour line, rather than inset into the detour's own row the
58
58
  // way the old inset positioning did. Checked separately below instead
59
59
  // of assuming they share a row.
60
- expect(ascii).toContain('└─────────────── tracks ────────────┘')
60
+ //
61
+ // The detour line used to have "tracks" (MIDDLE_ENTITY--FIFTH_ENTITY's
62
+ // own label, a *different*, vertical relationship) embedded directly in
63
+ // its dashes, with no separator — the same label-lands-on-a-foreign-line
64
+ // defect issue #1119 fixes. "tracks" now relocates to its own row next
65
+ // to its own marker instead.
66
+ expect(ascii).toContain('└─────────────────│─────────────────┘')
61
67
  const lines = ascii.split('\n')
62
68
  const detourLineIndex = lines.findIndex((l) =>
63
- l.includes('└─────────────── tracks ────────────┘'),
69
+ l.includes('└─────────────────│─────────────────┘'),
64
70
  )
65
71
  expect(detourLineIndex).toBeGreaterThan(0)
66
72
  // MIDDLE_ENTITY's "tracks" marker ('┼', flush against its own border)
67
73
  // sits on the row immediately above the detour line.
68
74
  expect(lines[detourLineIndex - 1]).toMatch(/┼/)
75
+ // "relates_to" (this detour's own label) sits on the row right below it.
76
+ expect(lines[detourLineIndex + 1]).toContain('relates_to')
69
77
  expect(ascii).toContain('relates_to')
70
78
  expect(ascii).toContain('tracks')
79
+ // "tracks" renders next to its own bottom marker ('○╟'), not spliced
80
+ // onto the unrelated detour line above.
81
+ const tracksLine = lines.find((l) => l.includes('tracks'))
82
+ expect(tracksLine).toContain('○╟')
71
83
  expect(ascii).not.toMatch(/[a-z]+[─│┊╌][a-z]+/)
72
84
  })
73
85
 
@@ -155,8 +167,19 @@ describe('ASCII ER relationship routing draws a corner glyph at each turn (issue
155
167
  // from a single shared-center bypass to two side-by-side ones; the
156
168
  // corner glyphs at the bypass's own turns are still there, just at the
157
169
  // updated column.
158
- expect(ascii).toContain('┌─│──────────────────┘')
159
- expect(ascii).toContain('○╟─────────────────┘')
170
+ //
171
+ // These two exact substrings shifted again with issue #1134's ER
172
+ // attribute-column reorder (type/name/keys instead of keys/type/name):
173
+ // A/D/G's boxes carry no keyed attributes here, so the old format's
174
+ // fixed-width key prefix (' ' when absent) padded every attribute
175
+ // line by 3 unused columns; the reordered format only emits a key
176
+ // suffix when a key actually exists, so these now-narrower boxes shift
177
+ // the bypass's free-column search enough to change both jog rows'
178
+ // exact shape — each jog's two corners now land on the very same row
179
+ // (self-contained '┌'...'┘' runs) rather than sharing a row with a
180
+ // marker glyph the way the pre-#1134 geometry did.
181
+ expect(ascii).toContain('┌─────────────────────────┘')
182
+ expect(ascii).toContain('┌────────────────────┘')
160
183
  expect(ascii).toContain('ag')
161
184
  })
162
185
 
@@ -451,8 +451,18 @@ describe('ASCII ER relationship routing — multi-row obstruction (CodeRabbit fo
451
451
  (l, i) => l.includes('┌') && lines[i + 1]?.includes('│ D'),
452
452
  )
453
453
  expect(dTopIdx, 'expected to find D’s top-border row').toBeGreaterThan(-1)
454
- const dTopLine = lines[dTopIdx]!
455
- const afterDBox = dTopLine.slice(dTopLine.indexOf('┐') + 1)
456
- expect(afterDBox).toMatch(/[│╢╟○]/)
454
+ // Issue #1134 reordered ER attribute columns to type/name/keys, dropping
455
+ // the old format's fixed-width key prefix (' ' when an attribute had
456
+ // no key) that every one of A/D/G's keyless attribute lines used to pay
457
+ // for unconditionally. That narrows all three boxes here enough that the
458
+ // "ag" bypass no longer needs to jog laterally past D on D's own
459
+ // top-border row — it now clears D vertically, with its downward-turn
460
+ // marker landing in the gap row directly above D's box instead. That gap
461
+ // row is still unambiguously outside D's box (which starts at dTopIdx),
462
+ // so a connector/marker character there is the same proof as before:
463
+ // the line was actually routed around D, not silently guarded away
464
+ // inside it (setCGuarded would leave no trace at all in that case).
465
+ const gapLine = lines[dTopIdx - 1]!
466
+ expect(gapLine).toMatch(/[│╢╟○┌└]/)
457
467
  })
458
468
  })
@@ -322,4 +322,111 @@ describe('ASCII ER relationship draws do not overwrite existing text (issue #392
322
322
  expect(row![idx - 1]).toBe(' ')
323
323
  expect(row![idx + 'authors'.length]).toBe(' ')
324
324
  })
325
+
326
+ it('renders the "includes" line solid at its crossing with "ships-via", not dashed (issue #1145)', () => {
327
+ // Real catalog sample ("ER: Mixed Identifying & Non-Identifying").
328
+ // PRODUCT--LINE_ITEM ("includes", solid — identifying) and
329
+ // ORDER--SHIPMENT ("ships-via", dashed — non-identifying, detoured
330
+ // beneath LINE_ITEM which sits between ORDER and SHIPMENT in the same
331
+ // row) route through the same cell: ships-via's horizontal detour fill
332
+ // crosses directly through includes' vertical stem just below
333
+ // LINE_ITEM. setCGuarded now refuses to let a dashed write land on an
334
+ // already-drawn solid glyph there, so "includes" renders solid at the
335
+ // crossing rather than picking up ships-via's dash.
336
+ const ascii = renderMermaidASCII(
337
+ `erDiagram
338
+ ORDER ||--|{ LINE_ITEM : contains
339
+ ORDER ||..o{ SHIPMENT : ships-via
340
+ PRODUCT ||--o{ LINE_ITEM : includes
341
+ PRODUCT ||..o{ REVIEW : receives`,
342
+ { colorMode: 'none' },
343
+ )
344
+
345
+ const lines = ascii.split('\n')
346
+ // ships-via's detour fill sits one row below LINE_ITEM's own
347
+ // zero-many marker ('╢○', for "includes") — anchoring on that marker
348
+ // (unambiguous: it's only ever drawn once, at LINE_ITEM's own bottom
349
+ // edge) rather than searching for a '└...┘' corner pair directly,
350
+ // since every entity box's own border also has solid corners and a
351
+ // naive substring search can match those instead (they're a different
352
+ // row, but a row can contain more than one corner pair).
353
+ const markerRowIdx = lines.findIndex((l) => l.includes('╢○'))
354
+ expect(markerRowIdx).toBeGreaterThanOrEqual(0)
355
+ const detourRow = lines[markerRowIdx + 1]!
356
+ const detourStart = detourRow.indexOf('└╌')
357
+ const detourEnd = detourRow.indexOf('╌┘') + 1
358
+ expect(detourStart).toBeGreaterThanOrEqual(0)
359
+
360
+ // includes' own vertical stem (solid '│') crosses this same row exactly
361
+ // once, between the detour's two corners — that crossing cell must
362
+ // read as solid, not dashed.
363
+ const crossingIdx = detourRow.indexOf('│', detourStart)
364
+ expect(crossingIdx).toBeGreaterThan(detourStart)
365
+ expect(crossingIdx).toBeLessThan(detourEnd)
366
+ expect(detourRow[crossingIdx]).toBe('│')
367
+
368
+ // And the fill on either side of that crossing is still ships-via's own
369
+ // dashed glyph — the fix only protects the one style-conflicting cell,
370
+ // it doesn't let the solid write bleed into its neighbors.
371
+ expect(detourRow[crossingIdx - 1]).toBe('╌')
372
+ expect(detourRow[crossingIdx + 1]).toBe('╌')
373
+ })
374
+
375
+ it('never lets a later-drawn dashed relationship line overwrite an earlier solid one at their crossing (issue #1145)', () => {
376
+ // Same geometry as the sample above (same entities, same connections,
377
+ // same declaration/draw order — contains, ships-via, includes,
378
+ // receives), but with which relationship is solid vs. dashed flipped:
379
+ // "ships-via" is now identifying (solid, "--") and "includes" is now
380
+ // non-identifying (dashed, ".."). `rel.identifying` only selects which
381
+ // glyph set a relationship draws with — it doesn't affect any routing
382
+ // decision (obstruction detection, jog-row search, detour placement) —
383
+ // so this renders with the exact same cell layout as the original
384
+ // sample, just with the two relationships' styles swapped. That makes
385
+ // it a clean, order-preserving way to flip *which* style the
386
+ // later-drawn relationship carries, without perturbing the routing
387
+ // itself the way reordering the relationships outright would.
388
+ //
389
+ // ships-via (now solid) is still drawn before includes (now dashed) —
390
+ // same relationship order as above — so before the fix, includes'
391
+ // later dashed write unconditionally overwrote ships-via's
392
+ // already-drawn solid detour fill at their crossing cell, opening a
393
+ // single-character dashed gap in an otherwise solid line: exactly the
394
+ // "solid stem ends up showing a dashed glyph, purely from draw order"
395
+ // defect the issue describes. The fix refuses that overwrite, so the
396
+ // detour fill must now be solid all the way across, with no dashed
397
+ // character breaking it.
398
+ const ascii = renderMermaidASCII(
399
+ `erDiagram
400
+ ORDER ||--|{ LINE_ITEM : contains
401
+ ORDER ||--o{ SHIPMENT : ships-via
402
+ PRODUCT ||..o{ LINE_ITEM : includes
403
+ PRODUCT ||..o{ REVIEW : receives`,
404
+ { colorMode: 'none' },
405
+ )
406
+
407
+ const lines = ascii.split('\n')
408
+ // Same anchor as the test above: ships-via's detour fill sits one row
409
+ // below LINE_ITEM's zero-many marker. Anchoring there (rather than
410
+ // searching for a '└...┘' pair directly) avoids a false match against
411
+ // an entity box's own solid border elsewhere in the render — with the
412
+ // fill itself now solid too (styles swapped for this test), a naive
413
+ // substring search matches ORDER's own box border instead of the
414
+ // detour row.
415
+ const markerRowIdx = lines.findIndex((l) => l.includes('╢○'))
416
+ expect(markerRowIdx).toBeGreaterThanOrEqual(0)
417
+ const detourRow = lines[markerRowIdx + 1]!
418
+ const detourStart = detourRow.indexOf('└─') // index of the '└' corner
419
+ const detourEnd = detourRow.indexOf('─┘') + 1 // index of the '┘' corner
420
+ expect(detourStart).toBeGreaterThanOrEqual(0)
421
+ // Everything strictly between the two corners.
422
+ const fill = detourRow.slice(detourStart + 1, detourEnd)
423
+
424
+ // No dashed glyph ('┊', includes' own now-dashed vertical stem's
425
+ // style) may appear anywhere inside the solid detour fill.
426
+ expect(fill).not.toContain('┊')
427
+ // And the fill is unbroken solid line across its whole span (not, say,
428
+ // silently dropped to a blank gap instead of dashed — the crossing
429
+ // cell must still carry a line glyph, just the solid one).
430
+ expect(fill).toBe('─'.repeat(fill.length))
431
+ })
325
432
  })
@@ -200,10 +200,15 @@ describe('ASCII ER — relationships routed without colliding (issue #351)', ()
200
200
  // occupancy before choosing a jog row. Found by a differential search
201
201
  // over random small diagrams comparing the old (narrow) and fixed
202
202
  // (label-span-widened) occupancy ranges; this is the minimal case that
203
- // reproduces it deterministically — DDDD--CCC's jog lands on a row
204
- // that BB--A's own label already used, and without the widened check
205
- // DDDD--CCC's own label ends up prefixed with a stray "│" bled through
206
- // from BB--A's vertical stem.
203
+ // reproduces it deterministically — DDDD--CCC's jog used to land on the
204
+ // exact row BB--A's own label ("l1") already used, splicing the two
205
+ // together into one unbroken run ("l1longerlabelhere2") with no
206
+ // separator at all.
207
+ //
208
+ // Since #1119's fix (a label search also avoids a *different*
209
+ // relationship's own line, not just its own), DDDD--CCC's label
210
+ // relocates to its own row instead of sharing BB--A's — so the two
211
+ // no longer read as a single spliced word.
207
212
  const ascii = renderMermaidASCII(
208
213
  `erDiagram
209
214
  DDDD ||--o{ A : x0
@@ -211,14 +216,15 @@ describe('ASCII ER — relationships routed without colliding (issue #351)', ()
211
216
  DDDD ||--o{ CCC : longerlabelhere2`,
212
217
  { colorMode: 'none' },
213
218
  )
219
+ expect(ascii).toContain('longerlabelhere2')
220
+ expect(ascii).toContain('l1')
221
+ expect(ascii).not.toContain('l1longerlabelhere2')
214
222
  const lines = ascii.split('\n')
215
223
  const labelLine = lines.find((l) => l.includes('longerlabelhere2'))
216
224
  expect(labelLine).toBeDefined()
217
- const idx = labelLine!.indexOf('longerlabelhere2')
218
- // The characters immediately before the label must be plain fill dashes,
219
- // not a stray "│" bled through from an unrelated relationship's
220
- // vertical stem sharing this row.
221
- expect(labelLine!.slice(Math.max(0, idx - 3), idx)).not.toContain('│')
225
+ // The label itself must render as one intact, uninterrupted word — not
226
+ // split by a stray line character routed through its middle.
227
+ expect(labelLine).not.toMatch(/longer[─│┊╌]/)
222
228
  })
223
229
  })
224
230
 
@@ -0,0 +1,171 @@
1
+ // ============================================================================
2
+ // ASCII sequence diagrams — activation bars on lifelines (issue #1133)
3
+ //
4
+ // Mermaid overlays a highlighted rectangle on a lifeline while a participant
5
+ // is actively processing a call, driven by standalone `activate`/`deactivate`
6
+ // statements or the `+`/`-` arrow shorthand (`A->>+B` / `A-->>-B`). The
7
+ // parser already models both forms (`SequenceDiagram.activations` and
8
+ // `Message.activate`/`Message.deactivate` — see mermaid-parser's
9
+ // sequence/types.ts) and the SVG renderer already draws the bars
10
+ // (svg-renderer's sequence/layout.ts), but the ASCII renderer drew every
11
+ // lifeline as a uniform `│` regardless of activation state.
12
+ // ============================================================================
13
+
14
+ import { describe, it, expect } from 'vitest'
15
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
16
+
17
+ describe('ASCII sequence diagrams – activation bars (issue #1133)', () => {
18
+ it('draws a double-line glyph on the recipient lifeline for +/- shorthand activation', () => {
19
+ const result = renderMermaidASCII(
20
+ `
21
+ sequenceDiagram
22
+ participant Client
23
+ participant Server
24
+ Client->>+Server: request
25
+ Server-->>-Client: response`,
26
+ { useAscii: false },
27
+ )
28
+
29
+ expect(result).toBe(
30
+ [
31
+ '┌────────┐ ┌────────┐ ',
32
+ '│ Client │ │ Server │ ',
33
+ '└────┬───┘ └────┬───┘ ',
34
+ ' │ │ ',
35
+ ' │ request │ ',
36
+ ' │─────────────▶ ',
37
+ ' │ ║ ',
38
+ ' │ response ║ ',
39
+ ' ◀╌╌╌╌╌╌╌╌╌╌╌╌╌║ ',
40
+ ' │ │ ',
41
+ '┌────┴───┐ ┌────┴───┐ ',
42
+ '│ Client │ │ Server │ ',
43
+ '└────────┘ └────────┘ ',
44
+ ].join('\n'),
45
+ )
46
+ })
47
+
48
+ it('uses the ASCII-fallback glyph (‖) in useAscii mode', () => {
49
+ const result = renderMermaidASCII(
50
+ `
51
+ sequenceDiagram
52
+ participant Client
53
+ participant Server
54
+ Client->>+Server: request
55
+ Server-->>-Client: response`,
56
+ { useAscii: true },
57
+ )
58
+
59
+ const lines = result.split('\n')
60
+ expect(lines.some((l) => l.includes('‖'))).toBe(true)
61
+ expect(result).not.toContain('║')
62
+ })
63
+
64
+ it('draws the same bar for standalone activate/deactivate statements', () => {
65
+ const result = renderMermaidASCII(
66
+ `
67
+ sequenceDiagram
68
+ participant A
69
+ participant B
70
+ A->>B: hello
71
+ activate B
72
+ B->>A: world
73
+ deactivate B`,
74
+ { useAscii: false },
75
+ )
76
+
77
+ expect(result).toBe(
78
+ [
79
+ ' ┌───┐ ┌───┐ ',
80
+ ' │ A │ │ B │ ',
81
+ ' └─┬─┘ └─┬─┘ ',
82
+ ' │ │ ',
83
+ ' │ hello │ ',
84
+ ' │──────────▶ ',
85
+ ' │ ║ ',
86
+ ' │ world ║ ',
87
+ ' ◀──────────║ ',
88
+ ' │ │ ',
89
+ ' ┌─┴─┐ ┌─┴─┐ ',
90
+ ' │ A │ │ B │ ',
91
+ ' └───┘ └───┘ ',
92
+ ].join('\n'),
93
+ )
94
+ })
95
+
96
+ it('keeps a nested self-activation active across the outer activation without a distinct nesting glyph', () => {
97
+ // B activates itself while already active (A->>+B, then B->>+B on the
98
+ // same actor): the lifeline stays marked active continuously from the
99
+ // outer activate through the outer deactivate, including the nested
100
+ // span — this renderer intentionally doesn't offset nested activations
101
+ // sideways the way the SVG renderer does (no spare column per lifeline
102
+ // without reflowing spacing), so nesting depth isn't visually distinct.
103
+ const result = renderMermaidASCII(
104
+ `
105
+ sequenceDiagram
106
+ participant A
107
+ participant B
108
+ A->>+B: outer
109
+ B->>+B: inner
110
+ B-->>-B: inner done
111
+ B-->>-A: outer done`,
112
+ { useAscii: false },
113
+ )
114
+
115
+ expect(result).toBe(
116
+ [
117
+ ' ┌───┐ ┌───┐ ',
118
+ ' │ A │ │ B │ ',
119
+ ' └─┬─┘ └─┬─┘ ',
120
+ ' │ │ ',
121
+ ' │ outer │ ',
122
+ ' │───────────────▶ ',
123
+ ' │ ║ ',
124
+ ' │ ├───┐ ',
125
+ ' │ ║ │ inner ',
126
+ ' │ ◀───┘ ',
127
+ ' │ ║ ',
128
+ ' │ ├╌╌╌┐ ',
129
+ ' │ ║ │ inner done ',
130
+ ' │ ◀╌╌╌┘ ',
131
+ ' │ ║ ',
132
+ ' │ outer done ║ ',
133
+ ' ◀╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌║ ',
134
+ ' │ │ ',
135
+ ' ┌─┴─┐ ┌─┴─┐ ',
136
+ ' │ A │ │ B │ ',
137
+ ' └───┘ └───┘ ',
138
+ ].join('\n'),
139
+ )
140
+ })
141
+
142
+ it('keeps an unbalanced activate (no matching deactivate) active through to the lifeline end', () => {
143
+ const result = renderMermaidASCII(
144
+ `
145
+ sequenceDiagram
146
+ participant A
147
+ participant B
148
+ A->>+B: call
149
+ B->>A: reply`,
150
+ { useAscii: false },
151
+ )
152
+
153
+ expect(result).toBe(
154
+ [
155
+ ' ┌───┐ ┌───┐ ',
156
+ ' │ A │ │ B │ ',
157
+ ' └─┬─┘ └─┬─┘ ',
158
+ ' │ │ ',
159
+ ' │ call │ ',
160
+ ' │──────────▶ ',
161
+ ' │ ║ ',
162
+ ' │ reply ║ ',
163
+ ' ◀──────────║ ',
164
+ ' │ ║ ',
165
+ ' ┌─┴─┐ ┌─┴─┐ ',
166
+ ' │ A │ │ B │ ',
167
+ ' └───┘ └───┘ ',
168
+ ].join('\n'),
169
+ )
170
+ })
171
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { diamondRenderer } from '../shapes/diamond.ts'
2
+ import { diamondRenderer } from '../shapes/index.ts'
3
3
  import { canvasToString } from '../canvas.ts'
4
4
  import type { ShapeRenderOptions } from '../shapes/types.ts'
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { roundedRenderer } from '../shapes/rounded.ts'
2
+ import { roundedRenderer } from '../shapes/index.ts'
3
3
  import { canvasToString } from '../canvas.ts'
4
4
  import type { ShapeRenderOptions } from '../shapes/types.ts'
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { circleRenderer } from '../shapes/circle.ts'
2
+ import { circleRenderer } from '../shapes/index.ts'
3
3
  import { canvasToString } from '../canvas.ts'
4
4
  import type { ShapeRenderOptions } from '../shapes/types.ts'
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { hexagonRenderer } from '../shapes/hexagon.ts'
2
+ import { hexagonRenderer } from '../shapes/index.ts'
3
3
  import { canvasToString } from '../canvas.ts'
4
4
  import type { ShapeRenderOptions } from '../shapes/types.ts'
5
5
 
@@ -150,6 +150,41 @@ describe('cylinder shape [(text)]', () => {
150
150
  expect(out).toMatch(/'-+'/)
151
151
  })
152
152
 
153
+ it('draws a rim line distinguishing it from a plain rounded node (issue #1119)', () => {
154
+ // The actual flowchart drawing path (drawNode -> drawBoxWithGridDimensions
155
+ // in draw-boxes.ts) draws every node shape as a plain bordered rectangle
156
+ // with shape-specific corner glyphs — it never calls cylinderRenderer
157
+ // (exercised only in isolation, above), and cylinder's corners are
158
+ // identical to rounded's own (see corners.ts's SHAPE_CORNERS), so a
159
+ // cylinder rendered in a real flowchart used to be indistinguishable
160
+ // from a rounded node — a weekly form-judge audit flagged this exact
161
+ // regression (#1119). drawBoxWithGridDimensions now draws a rim line
162
+ // just inside the top and bottom border for 'cylinder' specifically,
163
+ // using the extra height getShapeDimensions already reserves for it.
164
+ const src = `flowchart TD
165
+ A(Rounded)
166
+ B[(Database)]`
167
+ const out = renderMermaidASCII(src, { useAscii: false })
168
+ const lines = out.split('\n')
169
+ const topBorderLineIdx = lines.findIndex((l) => l.includes('╭'))
170
+ const topBorderLine = lines[topBorderLineIdx]!
171
+ const roundedX = topBorderLine.indexOf('╭')
172
+ const roundedEndX = topBorderLine.indexOf('╮', roundedX)
173
+ const databaseX = topBorderLine.indexOf('╭', roundedX + 1)
174
+ const databaseEndX = topBorderLine.indexOf('╮', databaseX)
175
+ expect(roundedX).toBeGreaterThanOrEqual(0)
176
+ expect(databaseX).toBeGreaterThan(roundedEndX)
177
+
178
+ // Row just inside the top border: Rounded's own interior span is blank
179
+ // (no rim); Database's is a solid rule between its vertical borders.
180
+ const rimRow = lines[topBorderLineIdx + 1]!
181
+ const roundedRimSpan = rimRow.slice(roundedX + 1, roundedEndX)
182
+ expect(roundedRimSpan.trim()).toBe('')
183
+ const databaseRimSpan = rimRow.slice(databaseX + 1, databaseEndX)
184
+ expect(databaseRimSpan.length).toBeGreaterThan(0)
185
+ expect(databaseRimSpan).toMatch(/^─+$/)
186
+ })
187
+
153
188
  it('computes extra height for the curved top/bottom rows', () => {
154
189
  const dims = cylinderRenderer.getDimensions('DB', unicodeOpts)
155
190
  expect(dims.gridRows[0]).toBe(2)
@@ -8,10 +8,8 @@ import {
8
8
  DIAGONAL_CHARS,
9
9
  hasDiagonalLines,
10
10
  findDiagonalLines,
11
- assertNoDiagonals,
12
11
  findOrphanedJunctions,
13
- hasOrphanedJunctions,
14
- } from '../validate.ts'
12
+ } from './validate.ts'
15
13
 
16
14
  describe('DIAGONAL_CHARS', () => {
17
15
  it('exposes ascii, unicode, and combined character sets', () => {
@@ -90,35 +88,6 @@ describe('findDiagonalLines', () => {
90
88
  })
91
89
  })
92
90
 
93
- describe('assertNoDiagonals', () => {
94
- it('does not throw when there are no diagonals', () => {
95
- expect(() => assertNoDiagonals('┌───┐\n│ A │\n└───┘')).not.toThrow()
96
- })
97
-
98
- it('throws with position details when diagonals are found', () => {
99
- expect(() => assertNoDiagonals('a / b')).toThrow(
100
- /Diagonal lines detected\. .*Found 1 diagonal character\(s\):\n\s*Line 1, Col 3: '\/'/s,
101
- )
102
- })
103
-
104
- it('includes the context string in the error message when provided', () => {
105
- expect(() => assertNoDiagonals('a / b', 'my-diagram')).toThrow(
106
- /Diagonal lines detected in "my-diagram"\./,
107
- )
108
- })
109
-
110
- it('omits the context clause when no context is provided', () => {
111
- try {
112
- assertNoDiagonals('a / b')
113
- throw new Error('expected assertNoDiagonals to throw')
114
- } catch (err) {
115
- const message = err instanceof Error ? err.message : String(err)
116
- expect(message).toContain('Diagonal lines detected. ')
117
- expect(message).not.toContain(' in "')
118
- }
119
- })
120
- })
121
-
122
91
  describe('findOrphanedJunctions', () => {
123
92
  it('returns an empty array when there are no junction characters', () => {
124
93
  expect(findOrphanedJunctions('┌───┐\n│ A │\n└───┘')).toEqual([])
@@ -208,13 +177,3 @@ describe('findOrphanedJunctions', () => {
208
177
  ])
209
178
  })
210
179
  })
211
-
212
- describe('hasOrphanedJunctions', () => {
213
- it('returns false when there are no orphaned junctions', () => {
214
- expect(hasOrphanedJunctions('┌───┐\n│ A │\n└───┘')).toBe(false)
215
- })
216
-
217
- it('returns true when at least one orphaned junction is found', () => {
218
- expect(hasOrphanedJunctions(' ┬ ')).toBe(true)
219
- })
220
- })
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { describe, it, expect } from 'vitest'
11
11
  import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
12
- import { hasDiagonalLines, DIAGONAL_CHARS } from '../validate.ts'
12
+ import { hasDiagonalLines, DIAGONAL_CHARS } from './validate.ts'
13
13
  import { readdirSync, readFileSync } from 'node:fs'
14
14
  import { join, dirname } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'