zombie-mermaid 2.2.2 → 2.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zombie-mermaid",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "license": "MIT",
5
5
  "description": "Render Mermaid diagrams as beautiful SVGs or ASCII art. Ultra-fast, fully themeable, zero DOM dependencies. Maintained fork of beautiful-mermaid.",
6
6
  "type": "module",
@@ -129,8 +129,8 @@
129
129
  "@zombie-mermaid/ascii-renderer": "2.2.0",
130
130
  "@zombie-mermaid/core": "2.2.0",
131
131
  "@zombie-mermaid/mcp": "2.2.0",
132
- "@zombie-mermaid/mermaid-parser": "2.2.0",
133
- "@zombie-mermaid/svg-renderer": "2.2.0"
132
+ "@zombie-mermaid/svg-renderer": "2.2.0",
133
+ "@zombie-mermaid/mermaid-parser": "2.2.0"
134
134
  },
135
135
  "optionalDependencies": {
136
136
  "@resvg/resvg-js": "^2.6.2"
@@ -563,35 +563,215 @@ export function renderClassAscii(
563
563
  const group = levelGroups[lv]!
564
564
  if (group.length === 0) continue
565
565
 
566
+ // --- #970's block-based x-assignment (issues #971, #972) ---
567
+ // docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md.
568
+ // Alignment blocks group classes at this level sharing the exact same
569
+ // parent-id set, processed as a unit. A block's *qualifying* parents
570
+ // are the subset strictly shallower than this level — a parent at the
571
+ // same level (a rootless relationship cycle, whose members still
572
+ // record each other as "parents" in `parents` even though nothing
573
+ // above them ever resolves) never qualifies. A block with zero
574
+ // qualifying parents has "no resolvable parents" and every member
575
+ // keeps its plain left-to-right baseline position, computed
576
+ // individually (not as a joint unit — #971's exact per-class
577
+ // behavior, preserved verbatim for this shape, including when a
578
+ // no-resolvable-parents block's members happen not to be declaration-
579
+ // adjacent). A block with at least one qualifying parent computes a
580
+ // real desired center: the mean of its qualifying parents' own,
581
+ // already-placed box centers (a single qualifying parent's mean is
582
+ // just that parent's center — the #964 repro exactly, #971's case; a
583
+ // block of more than one class sharing that parent set spreads evenly
584
+ // by centering the block's *total* width on that shared center, #972's
585
+ // "multiple children of one parent" case; more than one qualifying
586
+ // parent averages their centers, #972's "converging edges" case).
587
+ // Either way, the block's desired *left* edge (its slot-space left,
588
+ // matching `currentX`'s role below) is `desiredCenter -
589
+ // floor(totalWidth / 2)` — floor chosen only for determinism. A single
590
+ // left-to-right compaction pass then resolves any collision between
591
+ // *any* two blocks' desired positions — universal across every block
592
+ // shape, not just multi-parent ones, since two independently-aligned
593
+ // singleton blocks can still want overlapping positions (#971) — by
594
+ // only ever pushing a block right of its desired position, never left,
595
+ // and never reordering blocks relative to each other.
596
+ //
597
+ // A class with no parents at all is always its own singleton block —
598
+ // two unrelated roots must never be merged into one block just because
599
+ // both happen to have an empty parent set; there is nothing shared to
600
+ // align them around.
601
+ const blockKeyOf = (id: string): string => {
602
+ const pset = parents.get(id)
603
+ return pset && pset.size > 0 ? [...pset].sort().join(',') : `root:${id}`
604
+ }
605
+ // Every member sharing a given block key, regardless of adjacency in
606
+ // `group` — looked up once a block is actually confirmed to have a
607
+ // shared center to align around (see the main loop below). Building
608
+ // this eagerly for every id up front, and processing every block's
609
+ // *entire* membership together the moment its first (in declaration
610
+ // order) member is reached — even for a block with no resolvable
611
+ // parents — used to silently reorder an unrelated class that was
612
+ // declared between two same-parent-set siblings ahead of it, moving it
613
+ // visually later than its own declaration position (caught in review
614
+ // on #972). The main loop below only merges members into one placement
615
+ // unit for a block that actually resolves to a shared center; a
616
+ // no-resolvable-parents "block" is never merged, so it can't reorder
617
+ // anything.
618
+ const blockMembers = new Map<string, string[]>()
619
+ for (const id of group) {
620
+ const key = blockKeyOf(id)
621
+ let members = blockMembers.get(key)
622
+ if (!members) {
623
+ members = []
624
+ blockMembers.set(key, members)
625
+ }
626
+ members.push(id)
627
+ }
628
+
629
+ // Baseline: today's plain left-to-right slot position for every class
630
+ // in this level, computed exactly as the pre-#971 algorithm did — the
631
+ // fallback position for a no-resolvable-parents block's members, and
632
+ // the starting point the compaction step below can only ever push
633
+ // right of, never left.
634
+ const baselineSlotLeft = new Map<string, number>()
635
+ const slotWidthOf = new Map<string, number>()
636
+ const leftPadOf = new Map<string, number>()
637
+ {
638
+ let x = 0
639
+ for (const id of group) {
640
+ const w = classBoxW.get(id)!
641
+ // The box's center column sits `floor(w / 2)` cells from its left
642
+ // edge (the same arithmetic `connectionColumns` uses), so anything
643
+ // reaching further than that past the center overhangs the box.
644
+ const reach = columnReach.get(id)!
645
+ const leftPad = Math.max(0, reach.left - Math.floor(w / 2))
646
+ const rightPad = Math.max(0, reach.right - (w - 1 - Math.floor(w / 2)))
647
+ baselineSlotLeft.set(id, x)
648
+ slotWidthOf.set(id, leftPad + w + rightPad)
649
+ leftPadOf.set(id, leftPad)
650
+ x += leftPad + w + rightPad + hGap
651
+ }
652
+ }
653
+
566
654
  let currentX = 0
567
655
  let maxH = 0
568
656
 
569
- for (const id of group) {
657
+ /** Places one class's box at slot-left `slotLeft`, advancing `currentX`/`maxH`/`maxSlotEnd`. */
658
+ const placeAt = (id: string, slotLeft: number): void => {
570
659
  const cls = classById.get(id)!
571
660
  const w = classBoxW.get(id)!
572
661
  const h = classBoxH.get(id)!
573
- // The box's center column sits `floor(w / 2)` cells from its left
574
- // edge (the same arithmetic `connectionColumns` uses), so anything
575
- // reaching further than that past the center overhangs the box.
576
- const reach = columnReach.get(id)!
577
- const leftPad = Math.max(0, reach.left - Math.floor(w / 2))
578
- const rightPad = Math.max(0, reach.right - (w - 1 - Math.floor(w / 2)))
662
+ const leftPad = leftPadOf.get(id)!
663
+ const slotWidth = slotWidthOf.get(id)!
579
664
  placed.set(id, {
580
665
  cls,
581
666
  sections: classSections.get(id)!,
582
667
  // Shifted right only by the left overhang, so a box with nothing
583
- // reaching past its left edge lands exactly where it always has.
584
- x: currentX + leftPad,
668
+ // reaching past its left edge lands exactly where its slot does.
669
+ x: slotLeft + leftPad,
585
670
  y: currentY,
586
671
  width: w,
587
672
  height: h,
588
673
  })
589
- const slotWidth = leftPad + w + rightPad
590
- maxSlotEnd = Math.max(maxSlotEnd, currentX + slotWidth)
591
- currentX += slotWidth + hGap
674
+ maxSlotEnd = Math.max(maxSlotEnd, slotLeft + slotWidth)
675
+ currentX = slotLeft + slotWidth + hGap
592
676
  maxH = Math.max(maxH, h)
593
677
  }
594
678
 
679
+ const placedThisLevel = new Set<string>()
680
+ for (const id of group) {
681
+ // Already placed as part of an earlier qualifying block's joint
682
+ // placement below (never true for a no-resolvable-parents id, which
683
+ // is always placed the moment the loop reaches it, in its own true
684
+ // declaration-order turn).
685
+ if (placedThisLevel.has(id)) continue
686
+
687
+ const key = blockKeyOf(id)
688
+ const pset = parents.get(id)
689
+ // `level.get(pid)!`: every relationship endpoint is guaranteed a
690
+ // `classById`/`level` entry (the parser's `ensureClass` auto-creates
691
+ // an implicit class for any id used only as a relationship endpoint,
692
+ // never just a dangling reference — see `classById.has(...)`'s own
693
+ // guards elsewhere in this file, which protect a *different* case),
694
+ // so this is never actually missing.
695
+ const qualifying = pset
696
+ ? [...pset].filter((pid) => level.get(pid)! < lv)
697
+ : []
698
+
699
+ if (qualifying.length === 0) {
700
+ // No resolvable parents: this id has no shared center to align
701
+ // around, so it is never merged into a joint block with the other
702
+ // members `blockKeyOf` groups it with — only *this* id is placed
703
+ // here, in its own true position in `group`'s declaration order,
704
+ // exactly matching #971's per-class behavior for this shape. Any
705
+ // other same-key member is placed independently, on its own later
706
+ // turn through this same loop — merging them together here would
707
+ // reorder whatever was declared between them ahead of its own
708
+ // declaration position (issue caught in #972's review).
709
+ const slotLeft = Math.max(baselineSlotLeft.get(id)!, currentX, 0)
710
+ placeAt(id, slotLeft)
711
+ placedThisLevel.add(id)
712
+ continue
713
+ }
714
+
715
+ // A block with a real shared center to align around *is* placed as
716
+ // a joint unit, gathering every member `blockKeyOf` groups with it
717
+ // regardless of adjacency in `group` — the whole reason to group
718
+ // same-parent-set siblings together in the first place.
719
+ const members = blockMembers.get(key)!
720
+
721
+ // `placed.get(...)!`: every id in `qualifying` has a strictly-
722
+ // shallower level than `lv`, and this loop places every class of a
723
+ // level before moving to the next, so a strictly-shallower class is
724
+ // always already placed by the time this level is processed.
725
+ // "Center column" is always a box's own rendered center, never the
726
+ // reach-padded slot's — aligning to the slot center instead would
727
+ // visually misalign the boxes whenever a relationship's label
728
+ // overhang is asymmetric.
729
+ const parentCenters = qualifying.map((pid) => {
730
+ const p = placed.get(pid)!
731
+ return p.x + Math.floor(p.width / 2)
732
+ })
733
+ const desiredCenter = Math.round(
734
+ parentCenters.reduce((sum, c) => sum + c, 0) / parentCenters.length,
735
+ )
736
+
737
+ let totalWidth = 0
738
+ for (let i = 0; i < members.length; i++) {
739
+ totalWidth += slotWidthOf.get(members[i]!)!
740
+ if (i < members.length - 1) totalWidth += hGap
741
+ }
742
+ // `totalWidth` is the *slot* span (every member's own reach padding
743
+ // included at both ends), but the thing that must visually center on
744
+ // `desiredCenter` is the *rendered box* span — from the first
745
+ // member's own box left edge to the last member's own box right
746
+ // edge, which excludes the outermost reach padding at each end (that
747
+ // padding only ever separates a box from a *neighbor*; at the
748
+ // block's own two ends there is no neighbor to separate from, so it
749
+ // just becomes asymmetric dead space that would otherwise skew the
750
+ // visible boxes off `desiredCenter` whenever the first member's
751
+ // leftPad differs from the last member's rightPad — e.g. one child
752
+ // has a long incoming label reserving room on its own left, the
753
+ // other has none). Subtracting them back out here, once, is cheaper
754
+ // than threading a separate "visible width" accumulator through the
755
+ // loop above (caught in #972's review).
756
+ const firstLeftPad = leftPadOf.get(members[0]!)!
757
+ const lastMember = members[members.length - 1]!
758
+ const lastRightPad =
759
+ slotWidthOf.get(lastMember)! -
760
+ leftPadOf.get(lastMember)! -
761
+ classBoxW.get(lastMember)!
762
+ const visibleWidth = totalWidth - firstLeftPad - lastRightPad
763
+ const blockDesiredLeft =
764
+ desiredCenter - firstLeftPad - Math.floor(visibleWidth / 2)
765
+ const blockActualLeft = Math.max(blockDesiredLeft, currentX, 0)
766
+
767
+ let memberX = blockActualLeft
768
+ for (const memberId of members) {
769
+ placeAt(memberId, memberX)
770
+ placedThisLevel.add(memberId)
771
+ memberX = currentX
772
+ }
773
+ }
774
+
595
775
  currentY += maxH + vGap
596
776
  }
597
777
 
@@ -276,14 +276,33 @@ export function renderSequenceAscii(
276
276
  // message-label sizing happened to leave).
277
277
  const leftNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
278
278
  const rightNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
279
+ // A single-actor `over` note draws centered on that actor's own lifeline
280
+ // (see the note-x calculation below): `Math.floor(w / 2)` columns land to
281
+ // its left, the rest to its right. Neither half was reserved during gap
282
+ // sizing before this fix, so a note wider than the actor's own column
283
+ // silently spilled into whichever neighbour was closer (issue #992) — the
284
+ // same problem left/rightNoteWidth above already solved for `left`/
285
+ // `right` notes, just for the two halves of a centered one instead. A
286
+ // multi-actor `over` note (`Note over Alice,Bob`) is excluded on purpose:
287
+ // spanning both actors' columns is its intended shape, not a collision.
288
+ const overNoteHalfLeft: number[] = new Array(diagram.actors.length).fill(0)
289
+ const overNoteHalfRight: number[] = new Array(diagram.actors.length).fill(0)
279
290
  for (const note of diagram.notes) {
280
- if (note.position !== 'left' && note.position !== 'right') continue
281
- const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
282
- const w = noteBoxWidth(note)
283
- if (note.position === 'left') {
284
- leftNoteWidth[aIdx] = Math.max(leftNoteWidth[aIdx]!, w)
285
- } else {
286
- rightNoteWidth[aIdx] = Math.max(rightNoteWidth[aIdx]!, w)
291
+ if (note.position === 'left' || note.position === 'right') {
292
+ const aIdx = actorIndexOf(note.actorIds[0]!)
293
+ const w = noteBoxWidth(note)
294
+ if (note.position === 'left') {
295
+ leftNoteWidth[aIdx] = Math.max(leftNoteWidth[aIdx]!, w)
296
+ } else {
297
+ rightNoteWidth[aIdx] = Math.max(rightNoteWidth[aIdx]!, w)
298
+ }
299
+ } else if (note.position === 'over' && note.actorIds.length === 1) {
300
+ const aIdx = actorIndexOf(note.actorIds[0]!)
301
+ const w = noteBoxWidth(note)
302
+ const halfLeft = Math.floor(w / 2)
303
+ const halfRight = w - halfLeft
304
+ overNoteHalfLeft[aIdx] = Math.max(overNoteHalfLeft[aIdx]!, halfLeft)
305
+ overNoteHalfRight[aIdx] = Math.max(overNoteHalfRight[aIdx]!, halfRight)
287
306
  }
288
307
  }
289
308
 
@@ -312,6 +331,11 @@ export function renderSequenceAscii(
312
331
  Math.max(
313
332
  halfBox[0]! + (boxSpanOf[0] === -1 ? 0 : BRACKET_GAP + 1),
314
333
  leftNoteWidth[0]! + 1,
334
+ // A single-actor `over` note on the leftmost actor has no left
335
+ // neighbour either — same reasoning as the `left`-note case just
336
+ // above, but for the note's own left half (`nx = llX[0] -
337
+ // overNoteHalfLeft[0]` must stay >= 0; issue #992).
338
+ overNoteHalfLeft[0]!,
315
339
  ),
316
340
  ]
317
341
  for (let i = 1; i < diagram.actors.length; i++) {
@@ -329,7 +353,17 @@ export function renderSequenceAscii(
329
353
  // so a wide note doesn't overflow into the neighbouring lifeline. The
330
354
  // `+2` mirrors adjMaxWidth's own margin below and matches the 1-column
331
355
  // clearance the note-x calculation already leaves on the lifeline side.
332
- const noteGapNeed = Math.max(rightNoteWidth[i - 1]!, leftNoteWidth[i]!) + 2
356
+ // A single-actor `over` note on either actor reaches into this same gap
357
+ // from its own side (its right half from i-1, its left half from i) —
358
+ // issue #992, folded into the same max() rather than summed, matching
359
+ // how the left/right pair above is already handled.
360
+ const noteGapNeed =
361
+ Math.max(
362
+ rightNoteWidth[i - 1]!,
363
+ leftNoteWidth[i]!,
364
+ overNoteHalfRight[i - 1]!,
365
+ overNoteHalfLeft[i]!,
366
+ ) + 2
333
367
  const gap = Math.max(
334
368
  halfBox[i - 1]! + halfBox[i]! + 2 + wallExtra,
335
369
  adjMaxWidth[i - 1]! + 2,
@@ -137,70 +137,109 @@ ${manyAttrs('b', 6)}
137
137
  // pair via the level-BFS (Root has no parent, Kid is its level-1 child),
138
138
  // placed on a different row from the Alpha/Mid/Charlie cycle.
139
139
  //
140
- // Two orderings of the same Root/Kid pair are compared: declaring Root
141
- // first places Kid's column off to the side, outside the Alpha-to-Charlie
142
- // detour span; declaring Root between Alpha and Mid places Kid's column
143
- // so it overlaps Alpha's own range instead (confirmed below via
144
- // `findBoxRect`). If the different-row filter worked, both orderings
140
+ // Two variants of the same Root/Kid pair are compared: a narrow `Kid`
141
+ // never reaches the cycle's column span (baseline); a `Kid` wide enough
142
+ // to need real space, with `Root` declared right after `Alpha`, overlaps
143
+ // Alpha's (and Mid's) column range instead (confirmed below via
144
+ // `findBoxRect`). If the different-row filter worked, both variants
145
145
  // render the cycle identically — Root/Kid's actual row is irrelevant to
146
- // it either way. A test that only used the non-overlapping ordering would
146
+ // it either way. A test that only used the non-overlapping variant would
147
147
  // pass even with the filter deleted (verified: an earlier version of this
148
148
  // test did exactly that, and CodeRabbit correctly flagged it as not
149
- // actually exercising the filter — see the PR discussion). Sabotage-
150
- // verified: deleting the filter leaves both orderings' `+ bN: String`
151
- // lines intact but makes the overlapping ordering one row taller than the
152
- // non-overlapping one (19 vs. 18), which the equality assertion below
149
+ // actually exercising the filter — see the PR discussion).
150
+ //
151
+ // Declaration order alone no longer controls Kid's column (issue #971:
152
+ // `Kid`, as the sole occupant of its level with exactly one qualifying
153
+ // parent, now aligns under Root's own — already-placed — column instead
154
+ // of always landing at the leftmost slot). This test used to force the
155
+ // overlapping/non-overlapping split by moving `Root`/`Kid`'s declaration
156
+ // point around the cycle alone; that no longer produces reliably
157
+ // different geometry, since Kid's position now tracks wherever Root
158
+ // ends up rather than its own declaration slot. Widening `Kid` (so its
159
+ // box needs more room than the gap next to Root provides) is what
160
+ // reliably produces a real overlap now, confirmed empirically against
161
+ // the current renderer rather than assumed.
162
+ //
163
+ // Sabotage-verified: deleting the filter leaves both variants' `+ bN:
164
+ // String` lines intact but makes the overlapping variant one row taller
165
+ // than the baseline (19 vs. 18), which the equality assertion below
153
166
  // catches.
154
167
  it('3-node cycle, unrelated Root->Kid pair on a different level: obstruction search ignores it regardless of whether it overlaps the detour span', () => {
155
- function withRootKid(rootDeclaredBetweenAlphaAndMid: boolean): string {
156
- const rootAndKid = ` class Root
168
+ // Baseline: Root/Kid declared after the whole cycle, Kid narrow enough
169
+ // to never need more room than the gap next to Root provides.
170
+ const nonOverlapping = renderMermaidASCII(
171
+ `classDiagram
172
+ class Alpha
173
+ class Mid {
174
+ ${manyAttrs('b', 6)}
175
+ }
176
+ class Charlie
177
+ class Root
157
178
  class Kid
158
- `
159
- return `classDiagram
160
- ${rootDeclaredBetweenAlphaAndMid ? '' : rootAndKid} class Alpha
161
- ${rootDeclaredBetweenAlphaAndMid ? rootAndKid : ''} class Mid {
179
+ Alpha --> Mid
180
+ Mid --> Charlie
181
+ Charlie --> Alpha
182
+ Root --> Kid`,
183
+ { useAscii: true },
184
+ )
185
+ // Overlapping: Root declared immediately after Alpha, Kid wide enough
186
+ // that centering it under Root's (narrow) column reaches into Alpha's.
187
+ const overlapping = renderMermaidASCII(
188
+ `classDiagram
189
+ class Alpha
190
+ class Root
191
+ class VeryLongKidName
192
+ class Mid {
162
193
  ${manyAttrs('b', 6)}
163
194
  }
164
195
  class Charlie
165
196
  Alpha --> Mid
166
197
  Mid --> Charlie
167
198
  Charlie --> Alpha
168
- Root --> Kid`
169
- }
170
-
171
- const nonOverlapping = renderMermaidASCII(withRootKid(false), {
172
- useAscii: true,
173
- })
174
- const overlapping = renderMermaidASCII(withRootKid(true), {
175
- useAscii: true,
176
- })
199
+ Root --> VeryLongKidName`,
200
+ { useAscii: true },
201
+ )
177
202
 
178
203
  // Confirms the geometry this test relies on: in the "overlapping"
179
- // ordering, Kid really does sit on a different row from the cycle, and
204
+ // variant, Kid really does sit on a different row from the cycle, and
180
205
  // really does overlap Alpha's own column range (i.e. the obstruction
181
206
  // search has something to incorrectly notice if the different-row
182
- // filter is missing); in the "non-overlapping" ordering, it doesn't.
207
+ // filter is missing); in the baseline, it doesn't.
183
208
  const overlappingAlphaRect = findBoxRect(overlapping, 'Alpha')
184
- const overlappingKidRect = findBoxRect(overlapping, 'Kid')
209
+ const overlappingKidRect = findBoxRect(overlapping, 'VeryLongKidName')
185
210
  expect(overlappingKidRect.y0).toBeGreaterThan(overlappingAlphaRect.y1)
186
- expect(overlappingKidRect.x0).toBeLessThan(overlappingAlphaRect.x1)
211
+ expect(overlappingKidRect.x0).toBeLessThanOrEqual(overlappingAlphaRect.x1)
187
212
  const nonOverlappingAlphaRect = findBoxRect(nonOverlapping, 'Alpha')
188
213
  const nonOverlappingKidRect = findBoxRect(nonOverlapping, 'Kid')
189
- expect(nonOverlappingKidRect.x1).toBeLessThanOrEqual(
190
- nonOverlappingAlphaRect.x0,
191
- )
214
+ expect(
215
+ nonOverlappingKidRect.x1 < nonOverlappingAlphaRect.x0 ||
216
+ nonOverlappingKidRect.x0 > nonOverlappingAlphaRect.x1,
217
+ ).toBe(true)
192
218
 
193
- for (const ascii of [nonOverlapping, overlapping]) {
194
- expectAllPresentOnce(ascii, [
195
- '+ b0: String',
196
- '+ b1: String',
197
- '+ b2: String',
198
- '+ b3: String',
199
- '+ b4: String',
200
- '+ b5: String',
201
- ])
202
- expectNoBoxOverlap(ascii, ['Alpha', 'Mid', 'Charlie', 'Root', 'Kid'])
203
- }
219
+ expectAllPresentOnce(nonOverlapping, [
220
+ '+ b0: String',
221
+ '+ b1: String',
222
+ '+ b2: String',
223
+ '+ b3: String',
224
+ '+ b4: String',
225
+ '+ b5: String',
226
+ ])
227
+ expectNoBoxOverlap(nonOverlapping, [
228
+ 'Alpha',
229
+ 'Mid',
230
+ 'Charlie',
231
+ 'Root',
232
+ 'Kid',
233
+ ])
234
+ expectAllPresentOnce(overlapping, [
235
+ '+ b0: String',
236
+ '+ b1: String',
237
+ '+ b2: String',
238
+ '+ b3: String',
239
+ '+ b4: String',
240
+ '+ b5: String',
241
+ ])
242
+ expectNoBoxOverlap(overlapping, ['Alpha', 'Mid', 'Charlie', 'Root'])
204
243
 
205
244
  // The real check: Kid overlapping the detour span must not change the
206
245
  // cycle's own rendered height compared to Kid sitting clear of it.
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { describe, it, expect } from 'vitest'
28
28
  import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
29
+ import { findBoxRect } from './helpers/ascii-form.ts'
29
30
 
30
31
  const MVC_SOURCE = `classDiagram
31
32
  class Model {
@@ -85,17 +86,16 @@ describe('ASCII class diagram — detour-aware relationship label placement (iss
85
86
  const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
86
87
  const lines = ascii.split('\n')
87
88
 
88
- // Model's top border row (e.g. "┌───────────────────────────┐│") gives
89
- // its right-edge column directly. `refreshes` — the label for the
89
+ // Model's box right edge (column-based, not a literal-column-0
90
+ // assumption — issue #971 can legitimately shift Model's own column
91
+ // when it has a real single qualifying parent to align under, same as
92
+ // it does here against Controller). `refreshes` — the label for the
90
93
  // relationship that detours around Model to reach View — must land
91
- // strictly to the right of that border on every row it occupies, never
94
+ // strictly to the right of that edge on every row it occupies, never
92
95
  // overlapping Model's own footprint the way the straight-line midpoint
93
96
  // used to (issue #487).
94
- const modelTopRow = lines.findIndex(
95
- (l, i) => /^┌─+┐/.test(l) && (lines[i + 1]?.includes('Model') ?? false),
96
- )
97
- expect(modelTopRow).toBeGreaterThanOrEqual(0)
98
- const modelRightBorder = lines[modelTopRow]!.indexOf('┐')
97
+ const modelRect = findBoxRect(ascii, 'Model')
98
+ const modelRightBorder = modelRect.x1
99
99
  expect(modelRightBorder).toBeGreaterThan(0)
100
100
 
101
101
  const refreshesRow = lines.findIndex((l) => l.includes('refreshes'))
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Regression tests for issue #971 — "ASCII class-diagram layout: single-
3
+ * parent x-alignment," the first implementation stage of #964's fix
4
+ * (docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md).
5
+ *
6
+ * Before this fix, `class-diagram.ts` packed each level's classes strictly
7
+ * left-to-right in declaration order, with no reference to any parent's
8
+ * column — a level with exactly one occupant always landed at column 0,
9
+ * even when its real parent sat far to the right. This fix aligns a
10
+ * "singleton block" (no sibling at its level shares its exact parent-id
11
+ * set) with exactly one qualifying (strictly-shallower) parent under that
12
+ * parent's own box center instead. Every other shape (no parents, an
13
+ * all-same-level/rootless-cycle parent set, or more than one class sharing
14
+ * a parent set) is out of scope here — it keeps its pre-fix left-to-right
15
+ * position, tracked separately by issue #972.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
20
+ import { findBoxRect } from './helpers/ascii-form.ts'
21
+
22
+ describe('ASCII class diagram — single-parent x-alignment (issue #971)', () => {
23
+ it("#964's exact repro: a lone child aligns under its real parent's center, not the leftmost column", () => {
24
+ const src = `classDiagram
25
+ class Left
26
+ class TallMid {
27
+ +String b0
28
+ +String b1
29
+ +String b2
30
+ +String b3
31
+ +String b4
32
+ +String b5
33
+ }
34
+ class RightSrc
35
+ class Kid
36
+ RightSrc --> Kid`
37
+ const ascii = renderMermaidASCII(src, { useAscii: true })
38
+
39
+ const rightSrc = findBoxRect(ascii, 'RightSrc')
40
+ const kid = findBoxRect(ascii, 'Kid')
41
+ const rightSrcCenter =
42
+ rightSrc.x0 + Math.floor((rightSrc.x1 - rightSrc.x0) / 2)
43
+ const kidCenter = kid.x0 + Math.floor((kid.x1 - kid.x0) / 2)
44
+
45
+ // Kid's box center lands on (or within 1 cell of, for odd-width
46
+ // rounding) RightSrc's box center -- not at column 0, where every
47
+ // sole-occupant level used to land regardless of its real parent.
48
+ expect(Math.abs(kidCenter - rightSrcCenter)).toBeLessThanOrEqual(1)
49
+ expect(kid.x0).toBeGreaterThan(0)
50
+
51
+ // Left is the leftmost root and must stay exactly where declaration
52
+ // order has always put it -- this fix only ever moves a class that has
53
+ // a real single parent to align to.
54
+ const left = findBoxRect(ascii, 'Left')
55
+ expect(left.x0).toBe(0)
56
+ })
57
+
58
+ it('a root class with no parent at all is unaffected', () => {
59
+ const src = `classDiagram
60
+ class Lonely`
61
+ const ascii = renderMermaidASCII(src, { useAscii: true })
62
+ const lonely = findBoxRect(ascii, 'Lonely')
63
+ expect(lonely.x0).toBe(0)
64
+ })
65
+
66
+ it('a class whose only parent edges are same-level (a rootless relationship cycle) is unaffected', () => {
67
+ // A -> B -> C -> A has no external root: the level-BFS puts every
68
+ // member at level 0 together, but the `parents` map still records
69
+ // each one's in-cycle predecessor. That predecessor is same-level, not
70
+ // strictly shallower, so it doesn't qualify -- every member keeps its
71
+ // plain declaration-order position, same as before this fix.
72
+ const src = `classDiagram
73
+ class A
74
+ class B
75
+ class C
76
+ A --> B
77
+ B --> C
78
+ C --> A`
79
+ const ascii = renderMermaidASCII(src, { useAscii: true })
80
+ const a = findBoxRect(ascii, 'A')
81
+ const b = findBoxRect(ascii, 'B')
82
+ const c = findBoxRect(ascii, 'C')
83
+ expect(a.x0).toBe(0)
84
+ expect(a.x0).toBeLessThan(b.x0)
85
+ expect(b.x0).toBeLessThan(c.x0)
86
+ })
87
+
88
+ // Two children sharing the exact same single parent are a *multi-member*
89
+ // block -- explicitly out of #971's scope (see #972) -- so this sample
90
+ // must render byte-for-byte the way it always has. Chosen because
91
+ // docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md
92
+ // names this exact samples-data.ts sample ("Class: Inheritance (<|--)")
93
+ // as one it verified stays unchanged.
94
+ it('an existing catalog sample with a multi-child parent (Class: Inheritance) keeps its exact current positions', () => {
95
+ const src = `classDiagram
96
+ class Animal {
97
+ +String name
98
+ +eat() void
99
+ }
100
+ class Dog {
101
+ +String breed
102
+ +bark() void
103
+ }
104
+ class Cat {
105
+ +bool isIndoor
106
+ +meow() void
107
+ }
108
+ Animal <|-- Dog
109
+ Animal <|-- Cat`
110
+ const ascii = renderMermaidASCII(src, { useAscii: true })
111
+ const animal = findBoxRect(ascii, 'Animal')
112
+ const dog = findBoxRect(ascii, 'Dog')
113
+ const cat = findBoxRect(ascii, 'Cat')
114
+ expect(animal).toEqual({ x0: 0, x1: 17, y0: 0, y1: 6 })
115
+ expect(dog).toEqual({ x0: 0, x1: 18, y0: 10, y1: 16 })
116
+ expect(cat).toEqual({ x0: 23, x1: 42, y0: 10, y1: 16 })
117
+ })
118
+
119
+ it('two independent single-parent children at the same level both align, and compaction keeps them from overlapping', () => {
120
+ // ParentA and ParentB are both roots; KidA is ParentA's only child,
121
+ // KidB is ParentB's only child. Each is its own singleton block with
122
+ // exactly one qualifying parent, so both get a real desired center --
123
+ // this exercises the compaction pass itself (#971 must implement it
124
+ // for every block shape, not just multi-parent ones): two different
125
+ // singleton blocks can still want colliding desired positions.
126
+ const src = `classDiagram
127
+ class ParentA
128
+ class ParentB
129
+ class KidA
130
+ class KidB
131
+ ParentA --> KidA
132
+ ParentB --> KidB`
133
+ const ascii = renderMermaidASCII(src, { useAscii: true })
134
+ const kidA = findBoxRect(ascii, 'KidA')
135
+ const kidB = findBoxRect(ascii, 'KidB')
136
+ // Declaration order is preserved -- KidA never lands right of KidB.
137
+ expect(kidA.x1).toBeLessThan(kidB.x0)
138
+ })
139
+ })