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/dist/ascii.cjs +7 -7
- package/dist/ascii.cjs.map +1 -1
- package/dist/ascii.js +943 -913
- package/dist/ascii.js.map +1 -1
- package/dist/cli.js +3265 -3229
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +191 -161
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +84 -13
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.js +84 -13
- package/dist/mcp.js.map +1 -1
- package/package.json +3 -3
- package/packages/ascii-renderer/src/class-diagram.ts +192 -12
- package/packages/ascii-renderer/src/sequence.ts +42 -8
- package/src/__tests__/ascii-class-box-occupancy.test.ts +82 -43
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +8 -8
- package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
- package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zombie-mermaid",
|
|
3
|
-
"version": "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/
|
|
133
|
-
"@zombie-mermaid/
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
584
|
-
x:
|
|
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
|
-
|
|
590
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// `findBoxRect`). If the different-row filter worked, both
|
|
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
|
|
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).
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 -->
|
|
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
|
-
//
|
|
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
|
|
207
|
+
// filter is missing); in the baseline, it doesn't.
|
|
183
208
|
const overlappingAlphaRect = findBoxRect(overlapping, 'Alpha')
|
|
184
|
-
const overlappingKidRect = findBoxRect(overlapping, '
|
|
209
|
+
const overlappingKidRect = findBoxRect(overlapping, 'VeryLongKidName')
|
|
185
210
|
expect(overlappingKidRect.y0).toBeGreaterThan(overlappingAlphaRect.y1)
|
|
186
|
-
expect(overlappingKidRect.x0).
|
|
211
|
+
expect(overlappingKidRect.x0).toBeLessThanOrEqual(overlappingAlphaRect.x1)
|
|
187
212
|
const nonOverlappingAlphaRect = findBoxRect(nonOverlapping, 'Alpha')
|
|
188
213
|
const nonOverlappingKidRect = findBoxRect(nonOverlapping, 'Kid')
|
|
189
|
-
expect(
|
|
190
|
-
nonOverlappingAlphaRect.x0
|
|
191
|
-
|
|
214
|
+
expect(
|
|
215
|
+
nonOverlappingKidRect.x1 < nonOverlappingAlphaRect.x0 ||
|
|
216
|
+
nonOverlappingKidRect.x0 > nonOverlappingAlphaRect.x1,
|
|
217
|
+
).toBe(true)
|
|
192
218
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
89
|
-
//
|
|
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
|
|
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
|
|
95
|
-
|
|
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
|
+
})
|