@zombie-mermaid/ascii-renderer 2.2.6 → 3.1.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 (34) 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-fan-in-then-fan-out-upstream-63.test.ts +78 -0
  13. package/src/__tests__/ascii-fan-out-upstream-113.test.ts +101 -0
  14. package/src/__tests__/ascii-sequence-activation.test.ts +171 -0
  15. package/src/__tests__/ascii-shape-diamond.test.ts +1 -1
  16. package/src/__tests__/ascii-shape-rounded.test.ts +1 -1
  17. package/src/__tests__/ascii-shapes-circle.test.ts +1 -1
  18. package/src/__tests__/ascii-shapes-hexagon.test.ts +1 -1
  19. package/src/__tests__/ascii-shapes-special.test.ts +35 -0
  20. package/src/__tests__/ascii-validate.test.ts +1 -42
  21. package/src/__tests__/ascii.test.ts +1 -1
  22. package/src/__tests__/testdata/ascii/er_attributes.txt +9 -9
  23. package/src/__tests__/testdata/ascii/er_identifying.txt +8 -8
  24. package/src/__tests__/testdata/unicode/er_attributes.txt +9 -9
  25. package/src/__tests__/testdata/unicode/er_identifying.txt +8 -8
  26. package/src/{validate.ts → __tests__/validate.ts} +3 -40
  27. package/src/draw-boxes.ts +14 -0
  28. package/src/er-diagram.ts +134 -31
  29. package/src/sequence.ts +87 -1
  30. package/src/shapes/index.ts +47 -5
  31. package/src/shapes/circle.ts +0 -31
  32. package/src/shapes/diamond.ts +0 -31
  33. package/src/shapes/hexagon.ts +0 -35
  34. 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.1.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/mermaid-parser": "3.1.0",
39
+ "@zombie-mermaid/core": "3.1.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,78 @@
1
+ /**
2
+ * Regression tests for upstream lukilabs/beautiful-mermaid#63: an LR fan-in
3
+ * followed by a fan-out (`A --> C`, `B --> C`, `C --> D`, `C --> E`).
4
+ *
5
+ * Upstream's bug: C->E was routed along B->C's corridor, so B's row rendered a
6
+ * misleading `├───┴──►E`, as if B pointed at E. The fixed shape has C->E on its
7
+ * own trunk (`┬` off C's right side, then `└────┐` down to E) while B->C ends
8
+ * at `┘` under C.
9
+ *
10
+ * This fork fixes it through findUnrelatedOverlap / createEdgeCellOwners
11
+ * (edge-cell-styles.ts) via rerouteAroundStyleConflicts (grid.ts), not by
12
+ * porting upstream's routing change; these tests pin the rendered outcome, not
13
+ * the mechanism.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
17
+
18
+ const lines = (s: string): string[] => s.split('\n').map((l) => l.trimEnd())
19
+
20
+ const SOURCE = `graph LR
21
+ A --> C
22
+ B --> C
23
+ C --> D
24
+ C --> E`
25
+
26
+ describe('upstream #63: LR fan-in then fan-out keeps C->E off B->C corridor', () => {
27
+ it('renders C->E on its own trunk (unicode)', () => {
28
+ expect(lines(renderMermaidASCII(SOURCE, { colorMode: 'none' }))).toEqual([
29
+ '┌───┐ ┌───┐ ┌───┐',
30
+ '│ │ │ │ │ │',
31
+ '│ A ├────►│ C ├──┬─►│ D │',
32
+ '│ │ │ │ │ │ │',
33
+ '└───┘ └───┘ │ └───┘',
34
+ ' ▲ │',
35
+ ' │ │',
36
+ ' │ └────┐',
37
+ ' │ │',
38
+ ' │ ▼',
39
+ '┌───┐ │ ┌───┐',
40
+ '│ │ │ │ │',
41
+ '│ B ├───────┘ │ E │',
42
+ '│ │ │ │',
43
+ '└───┘ └───┘',
44
+ ])
45
+ })
46
+
47
+ it('B row ends at the corner under C and never reaches E (unicode)', () => {
48
+ const out = lines(renderMermaidASCII(SOURCE, { colorMode: 'none' }))
49
+ const bRow = out.find((l) => l.includes('│ B ├'))!
50
+ // B's edge terminates at exactly one `┘`; nothing horizontal after it.
51
+ expect(bRow.match(/┘/g)).toHaveLength(1)
52
+ expect(bRow.slice(bRow.indexOf('┘') + 1)).not.toMatch(/[─├┴┬►]/)
53
+ // The misleading tee joining B's corridor to E must not appear anywhere.
54
+ expect(out.join('\n')).not.toContain('┴')
55
+ })
56
+
57
+ it('renders the same shape with the ASCII charset', () => {
58
+ expect(
59
+ lines(renderMermaidASCII(SOURCE, { colorMode: 'none', useAscii: true })),
60
+ ).toEqual([
61
+ '+---+ +---+ +---+',
62
+ '| | | | | |',
63
+ '| A +---->| C +--+->| D |',
64
+ '| | | | | | |',
65
+ '+---+ +---+ | +---+',
66
+ ' ^ |',
67
+ ' | |',
68
+ ' | +----+',
69
+ ' | |',
70
+ ' | v',
71
+ '+---+ | +---+',
72
+ '| | | | |',
73
+ '| B +-------+ | E |',
74
+ '| | | |',
75
+ '+---+ +---+',
76
+ ])
77
+ })
78
+ })
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Regression tests for upstream lukilabs/beautiful-mermaid#113 (fixing
3
+ * upstream issues #111 and #112): ASCII fan-out routing.
4
+ *
5
+ * - #111: sibling edges from one source (TB, labelled) must share a single
6
+ * horizontal trunk leaving the source's right border, each label sitting on
7
+ * its own vertical branch, instead of later edges detouring to a lower row.
8
+ * - #112: in an LR fan-out, a sibling edge's label widens the source's border
9
+ * column; the first edge's box-start connector must stay flush on the
10
+ * source box's real border rather than drifting right with the grid cell.
11
+ *
12
+ * This fork solves these through edge-bundling / draw-bundles / draw-arrows
13
+ * rather than porting upstream's pathfinder changes; these tests pin the
14
+ * rendered outcome, not the mechanism.
15
+ */
16
+ import { describe, it, expect } from 'vitest'
17
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
18
+
19
+ const lines = (s: string): string[] => s.split('\n').map((l) => l.trimEnd())
20
+
21
+ const TD_FANOUT = `flowchart TB
22
+ Src["Source"]
23
+ Left["Left Target"]
24
+ Center["Center Target"]
25
+ Right["Right Target"]
26
+ Src -->|left*| Left
27
+ Src -->|center*| Center
28
+ Src -->|right*| Right`
29
+
30
+ const LR_FANOUT = `flowchart LR
31
+ Src["Source"]
32
+ Top["Top Target"]
33
+ Mid["Middle Target"]
34
+ Bot["Bottom Target"]
35
+ Src -->|top*| Top
36
+ Src -->|mid*| Mid
37
+ Src -->|bot*| Bot`
38
+
39
+ describe('upstream #111: TB fan-out siblings share one trunk', () => {
40
+ it('renders the shared trunk with labels on their own vertical branches (unicode)', () => {
41
+ const out = renderMermaidASCII(TD_FANOUT, { colorMode: 'none' })
42
+ expect(lines(out)).toEqual([
43
+ '┌─────────────┐',
44
+ '│ │',
45
+ '│ Source ├─────────────┬─────────────────────┐',
46
+ '│ │ │ │',
47
+ '└──────┬──────┘ center* right*',
48
+ ' │ │ │',
49
+ ' left* │ │',
50
+ ' │ │ │',
51
+ ' │ │ │',
52
+ ' ▼ ▼ ▼',
53
+ '┌─────────────┐ ┌───────────────┐ ┌──────────────┐',
54
+ '│ │ │ │ │ │',
55
+ '│ Left Target │ │ Center Target │ │ Right Target │',
56
+ '│ │ │ │ │ │',
57
+ '└─────────────┘ └───────────────┘ └──────────────┘',
58
+ ])
59
+ })
60
+
61
+ it('has exactly one horizontal trunk row off the source border (ASCII)', () => {
62
+ const out = lines(
63
+ renderMermaidASCII(TD_FANOUT, { colorMode: 'none', useAscii: true }),
64
+ )
65
+ // Box border rows start with '+'; only the source-border row carries a long horizontal run; the buggy
66
+ // upstream output put a second run (the "right*" detour) on a lower row.
67
+ const rowsWithRun = out.filter((l) => !l.startsWith('+') && /-{5,}/.test(l))
68
+ expect(rowsWithRun).toHaveLength(1)
69
+ expect(rowsWithRun[0]).toMatch(/^\|\s+Source\s+\+-+\+-+\+$/)
70
+ // No label is stranded on a horizontal segment.
71
+ expect(out.join('\n')).not.toMatch(/[-─]\/?(center|right)\*[-─]/)
72
+ })
73
+ })
74
+
75
+ describe('upstream #112: LR fan-out box-start connector stays on the border', () => {
76
+ it('places ├ directly under the source box right border column (unicode)', () => {
77
+ const out = lines(renderMermaidASCII(LR_FANOUT, { colorMode: 'none' }))
78
+ const sourceRow = out.find((l) => l.includes('Source'))!
79
+ const borderCol = sourceRow.indexOf('├')
80
+ expect(borderCol).toBe(out[0]!.indexOf('┐'))
81
+ // Every row of the source box (top border down to bottom border) agrees
82
+ // on the border column.
83
+ expect(out[0]![borderCol]).toBe('┐')
84
+ expect(out[4]![borderCol]).toBe('┘')
85
+ // The connector is the border glyph itself, not preceded by a gap.
86
+ expect(sourceRow[borderCol - 1]).toBe(' ')
87
+ expect(sourceRow.slice(0, borderCol)).toBe('│ Source ')
88
+ })
89
+
90
+ it('renders the full fan-out with ├ flush on the border (unicode)', () => {
91
+ const out = renderMermaidASCII(LR_FANOUT, { colorMode: 'none' })
92
+ expect(lines(out).slice(0, 6)).toEqual([
93
+ '┌────────┐ ┌───────────────┐',
94
+ '│ │ │ │',
95
+ '│ Source ├top*─►│ Top Target │',
96
+ '│ │ │ │',
97
+ '└────┬───┘ └───────────────┘',
98
+ ' │',
99
+ ])
100
+ })
101
+ })