@zombie-mermaid/ascii-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +5392 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
- package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
- package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
- package/src/__tests__/ascii-canvas-write.test.ts +132 -0
- package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
- package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
- package/src/__tests__/ascii-cjk-width.test.ts +150 -0
- package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
- package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
- package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
- package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
- package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
- package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
- package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
- package/src/__tests__/ascii-class-padding.test.ts +104 -0
- 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-class-reciprocal-relationships-448.test.ts +169 -0
- package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
- package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
- package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
- package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
- package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
- package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
- package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
- package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
- package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
- package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
- package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
- package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
- package/src/__tests__/ascii-edge-styles.test.ts +149 -0
- package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
- package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
- package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
- package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
- package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
- package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
- package/src/__tests__/ascii-er-padding.test.ts +114 -0
- package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
- package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
- package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
- package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
- package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
- package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
- package/src/__tests__/ascii-lane-search.test.ts +207 -0
- package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
- package/src/__tests__/ascii-multiline.test.ts +288 -0
- package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
- package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
- package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
- package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
- package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
- package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
- package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
- package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
- package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
- package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
- package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
- package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
- package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
- package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
- package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
- package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
- package/src/__tests__/ascii-shapes-special.test.ts +344 -0
- package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
- package/src/__tests__/ascii-shapes-state.test.ts +224 -0
- package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
- package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
- package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
- package/src/__tests__/ascii-territory-unit.test.ts +219 -0
- package/src/__tests__/ascii-validate.test.ts +220 -0
- package/src/__tests__/ascii.test.ts +325 -0
- package/src/__tests__/class-arrow-directions.test.ts +505 -0
- package/src/__tests__/draw-lines.test.ts +93 -0
- package/src/__tests__/edge-cell-styles.test.ts +278 -0
- package/src/__tests__/grid-occupancy.test.ts +240 -0
- package/src/__tests__/helpers/ascii-form.ts +142 -0
- package/src/__tests__/helpers/terminal-display-width.ts +74 -0
- package/src/__tests__/pathfinder.test.ts +239 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
- package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
- package/src/__tests__/testdata/ascii/comments.txt +23 -0
- package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
- package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
- package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
- package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
- package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
- package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
- package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
- package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
- package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/ascii/single_node.txt +8 -0
- package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
- package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
- package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
- package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
- package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
- package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
- package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
- package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
- package/src/__tests__/testdata/unicode/comments.txt +23 -0
- package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
- package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
- package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
- package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
- package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
- package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/unicode/single_node.txt +8 -0
- package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
- package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
- package/src/__tests__/xychart-ascii.test.ts +376 -0
- package/src/ansi.ts +490 -0
- package/src/canvas.ts +757 -0
- package/src/class-diagram.ts +2001 -0
- package/src/converter.ts +446 -0
- package/src/coords.ts +58 -0
- package/src/display-width.ts +151 -0
- package/src/draw-arrows.ts +593 -0
- package/src/draw-boxes.ts +267 -0
- package/src/draw-bundles.ts +611 -0
- package/src/draw-lines.ts +174 -0
- package/src/draw-subgraphs.ts +108 -0
- package/src/draw.ts +350 -0
- package/src/edge-bundling.ts +435 -0
- package/src/edge-cell-styles.ts +209 -0
- package/src/edge-routing.ts +1070 -0
- package/src/er-diagram.ts +1488 -0
- package/src/flowchart.ts +94 -0
- package/src/grid-occupancy.ts +234 -0
- package/src/grid.ts +1309 -0
- package/src/hyperlinks.ts +248 -0
- package/src/index.ts +163 -0
- package/src/lane-search.ts +68 -0
- package/src/multiline-utils.ts +82 -0
- package/src/pathfinder.ts +448 -0
- package/src/registry.ts +88 -0
- package/src/sequence.ts +1318 -0
- package/src/shapes/circle.ts +31 -0
- package/src/shapes/corners.ts +273 -0
- package/src/shapes/diamond.ts +31 -0
- package/src/shapes/hexagon.ts +35 -0
- package/src/shapes/index.ts +123 -0
- package/src/shapes/rectangle.ts +199 -0
- package/src/shapes/rounded.ts +31 -0
- package/src/shapes/special.ts +360 -0
- package/src/shapes/stadium.ts +122 -0
- package/src/shapes/state.ts +204 -0
- package/src/shapes/types.ts +78 -0
- package/src/territory.ts +136 -0
- package/src/types.ts +454 -0
- package/src/validate.ts +189 -0
- package/src/xychart.ts +1085 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII ER diagram: relationship draws must not overwrite already-placed
|
|
3
|
+
// text (GitHub issue #392)
|
|
4
|
+
//
|
|
5
|
+
// Relationships are drawn in declaration order. A later relationship's
|
|
6
|
+
// straight connecting line, crow's-foot marker, or label can land on the
|
|
7
|
+
// exact canvas cell an earlier relationship's label (or an entity's own
|
|
8
|
+
// header/attribute text) already wrote, silently corrupting it — a stray
|
|
9
|
+
// line glyph mid-word, or one label's characters spliced into another's.
|
|
10
|
+
// This is a distinct mechanism from #351's jog-row collisions (fixed by
|
|
11
|
+
// chooseFreeRow in #390): here nothing routes around anything, a later
|
|
12
|
+
// write simply stomps an earlier one with no occupancy check at all.
|
|
13
|
+
//
|
|
14
|
+
// A vertical relationship's label goes one step further than plain
|
|
15
|
+
// overwrite-avoidance: when multiple vertical relationships share the same
|
|
16
|
+
// upper/lower entity "rows" (and so the same natural label row), simply
|
|
17
|
+
// refusing to overwrite would silently drop every label but the first —
|
|
18
|
+
// a real information loss, not just a corruption risk. Those labels
|
|
19
|
+
// instead search nearby rows within the relationship's own vertical run
|
|
20
|
+
// for a free spot before giving up.
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest'
|
|
24
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
25
|
+
|
|
26
|
+
describe('ASCII ER relationship draws do not overwrite existing text (issue #392)', () => {
|
|
27
|
+
it('does not let a later vertical relationship line cut through an earlier label', () => {
|
|
28
|
+
// A-D's label "helloworld" and B-E's vertical connector line land on
|
|
29
|
+
// the same canvas cell (both components span the same two rows), with
|
|
30
|
+
// B-E's own label positioned clear of A-D's label span so this isolates
|
|
31
|
+
// the line-vs-label collision from any label-vs-label collision.
|
|
32
|
+
const ascii = renderMermaidASCII(
|
|
33
|
+
`erDiagram
|
|
34
|
+
A ||--o{ B : ab
|
|
35
|
+
A ||--o{ C : ac
|
|
36
|
+
A ||--o{ D : helloworld
|
|
37
|
+
B ||--o{ E : be`,
|
|
38
|
+
{ colorMode: 'none' },
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(ascii).toContain('helloworld')
|
|
42
|
+
expect(ascii).toContain('be')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not let one relationship label overwrite another relationship label', () => {
|
|
46
|
+
// A-D's label "longlabelhere" and B-E's label "be" are both centered on
|
|
47
|
+
// the same row (both components span the same two rows) and, without a
|
|
48
|
+
// guard, B-E's label characters land on top of the tail of A-D's label.
|
|
49
|
+
const ascii = renderMermaidASCII(
|
|
50
|
+
`erDiagram
|
|
51
|
+
A ||--o{ B : ab
|
|
52
|
+
A ||--o{ C : ac
|
|
53
|
+
A ||--o{ D : longlabelhere
|
|
54
|
+
B ||--o{ E : be`,
|
|
55
|
+
{ colorMode: 'none' },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(ascii).toContain('longlabelhere')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not let a same-row relationship line overwrite an unrelated entity name it must cross', () => {
|
|
62
|
+
// A-C is a direct (non-adjacent) same-row relationship; entity B sits
|
|
63
|
+
// physically between A and C, so A-C's straight line crosses B's box.
|
|
64
|
+
// The routing itself (line running through the box) is a distinct,
|
|
65
|
+
// out-of-scope defect (see #390's "known limitation" on non-adjacent
|
|
66
|
+
// same-row collisions) — this test only asserts the crossing line
|
|
67
|
+
// doesn't corrupt B's own header text.
|
|
68
|
+
const ascii = renderMermaidASCII(
|
|
69
|
+
`erDiagram
|
|
70
|
+
A ||--o{ B : ab
|
|
71
|
+
A ||--o{ C : ac
|
|
72
|
+
A ||--o{ D : helloworld
|
|
73
|
+
B ||--o{ E : be`,
|
|
74
|
+
{ colorMode: 'none' },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// The A-C line still crosses straight through B's row (the crossing
|
|
78
|
+
// itself is the out-of-scope routing defect) — but B's own header
|
|
79
|
+
// character must survive that crossing intact rather than being
|
|
80
|
+
// replaced by a line glyph.
|
|
81
|
+
const lines = ascii.split('\n')
|
|
82
|
+
const crossingLine = lines.find(
|
|
83
|
+
(l) => l.includes('A') && l.includes('C') && l.includes('B'),
|
|
84
|
+
)
|
|
85
|
+
expect(crossingLine).toBeDefined()
|
|
86
|
+
expect(crossingLine).toContain('B')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('never splices one overlapping label onto another, whatever the outcome', () => {
|
|
90
|
+
// Real catalog sample ("ER: Blog Platform Schema"): USER-COMMENT's
|
|
91
|
+
// "authors" label, POST-COMMENT's "has" label, and POST-TAG's
|
|
92
|
+
// "tagged-with" label all naturally land on the same row. Before any
|
|
93
|
+
// fix, the whole row was overwritten down to just "tagged-with"
|
|
94
|
+
// (silently erasing "authors" and "has" entirely). A naive
|
|
95
|
+
// per-character occupancy guard produces a worse failure mode here:
|
|
96
|
+
// "tagged-with"'s free trailing characters ("ged-with") get written
|
|
97
|
+
// right after "has", producing "hasged-with" — a new, plausible-
|
|
98
|
+
// looking word that is neither original label. Whether a colliding
|
|
99
|
+
// label ends up relocated to a free row nearby or dropped outright
|
|
100
|
+
// (see the row-search test below for which one happens here), it must
|
|
101
|
+
// never partially splice into whatever's already at its natural row.
|
|
102
|
+
const ascii = renderMermaidASCII(
|
|
103
|
+
`erDiagram
|
|
104
|
+
USER ||--o{ POST : writes
|
|
105
|
+
USER ||--o{ COMMENT : authors
|
|
106
|
+
POST ||--o{ COMMENT : has
|
|
107
|
+
POST }|--o{ TAG : tagged-with`,
|
|
108
|
+
{ colorMode: 'none' },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(ascii).not.toContain('hasged-with')
|
|
112
|
+
expect(ascii).not.toContain('gedauthors')
|
|
113
|
+
expect(ascii).toContain('authors')
|
|
114
|
+
expect(ascii).toContain('has')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('relocates a colliding vertical-relationship label to a free row instead of dropping it', () => {
|
|
118
|
+
// Same real catalog sample as above. USER-COMMENT, POST-COMMENT, and
|
|
119
|
+
// POST-TAG all share the same startY/endY (they connect the same pair
|
|
120
|
+
// of entity "rows" in the grid layout), so their labels ("authors",
|
|
121
|
+
// "has", "tagged-with") all compute the identical natural midY. Simply
|
|
122
|
+
// dropping whichever one loses that collision — the earlier behavior —
|
|
123
|
+
// is a real, visible information loss: a rendered relationship with no
|
|
124
|
+
// label at all. There's free space a row away, clear of both text and
|
|
125
|
+
// markers, so "tagged-with" should be relocated there rather than
|
|
126
|
+
// vanish.
|
|
127
|
+
const ascii = renderMermaidASCII(
|
|
128
|
+
`erDiagram
|
|
129
|
+
USER ||--o{ POST : writes
|
|
130
|
+
USER ||--o{ COMMENT : authors
|
|
131
|
+
POST ||--o{ COMMENT : has
|
|
132
|
+
POST }|--o{ TAG : tagged-with`,
|
|
133
|
+
{ colorMode: 'none' },
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(ascii).toContain('tagged-with')
|
|
137
|
+
// And still not spliced together with "has" on its new row either.
|
|
138
|
+
expect(ascii).not.toContain('hastagged-with')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('positions a jogged label next to the marker for the entity it actually describes', () => {
|
|
142
|
+
// Real catalog sample ("ER: Blog Platform Schema", used verbatim — see
|
|
143
|
+
// the row-search test above for why the attribute blocks matter here).
|
|
144
|
+
// A label's X position used to follow the relationship's column at its
|
|
145
|
+
// *upper* entity (POST, in this case) even when a jog moved the path
|
|
146
|
+
// over to the lower entity's column — so "tagged-with" rendered far
|
|
147
|
+
// from TAG's own marker, floating in the gap between it and COMMENT's
|
|
148
|
+
// markers with nothing visually tying it to what it describes. The
|
|
149
|
+
// label now follows the same target column the lower marker uses, so
|
|
150
|
+
// it sits immediately next to that marker instead.
|
|
151
|
+
const ascii = renderMermaidASCII(
|
|
152
|
+
`erDiagram
|
|
153
|
+
USER {
|
|
154
|
+
int id PK
|
|
155
|
+
string username UK
|
|
156
|
+
string email UK
|
|
157
|
+
date joined
|
|
158
|
+
}
|
|
159
|
+
POST {
|
|
160
|
+
int id PK
|
|
161
|
+
string title
|
|
162
|
+
text content
|
|
163
|
+
int author_id FK
|
|
164
|
+
date published
|
|
165
|
+
}
|
|
166
|
+
COMMENT {
|
|
167
|
+
int id PK
|
|
168
|
+
text body
|
|
169
|
+
int post_id FK
|
|
170
|
+
int user_id FK
|
|
171
|
+
date created
|
|
172
|
+
}
|
|
173
|
+
TAG {
|
|
174
|
+
int id PK
|
|
175
|
+
string name UK
|
|
176
|
+
}
|
|
177
|
+
USER ||--o{ POST : writes
|
|
178
|
+
USER ||--o{ COMMENT : authors
|
|
179
|
+
POST ||--o{ COMMENT : has
|
|
180
|
+
POST }|--o{ TAG : tagged-with`,
|
|
181
|
+
{ colorMode: 'none' },
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const lines = ascii.split('\n')
|
|
185
|
+
const labelRowIdx = lines.findIndex((l) => l.includes('tagged-with'))
|
|
186
|
+
expect(labelRowIdx).toBeGreaterThanOrEqual(0)
|
|
187
|
+
const row = lines[labelRowIdx]!
|
|
188
|
+
const idx = row.indexOf('tagged-with')
|
|
189
|
+
// Since issue #453, POST also has a second bottom attachment ("has")
|
|
190
|
+
// converging on COMMENT, so POST's own attachment columns for "has"
|
|
191
|
+
// and "tagged-with" are staggered instead of both centered on POST —
|
|
192
|
+
// which in turn changes where "tagged-with"'s jog (and so its label)
|
|
193
|
+
// lands. The label is preceded by the jog's own corner glyph ('┌ ')
|
|
194
|
+
// rather than sitting flush after the marker on the same row.
|
|
195
|
+
expect(row.slice(idx - 2, idx)).toBe('┌ ')
|
|
196
|
+
const cornerCol = idx - 2
|
|
197
|
+
// The marker sits at or adjacent to the corner's column, connected by
|
|
198
|
+
// the vertical line the corner turns into — but not necessarily on the
|
|
199
|
+
// very next row: issue #351's multi-row-obstruction routing can insert
|
|
200
|
+
// an extra connector row between the jog and the marker, so the exact
|
|
201
|
+
// row offset isn't fixed. Search a small vertical window below the
|
|
202
|
+
// corner instead of requiring literal same-row-plus-one adjacency.
|
|
203
|
+
const nearbyRows = [
|
|
204
|
+
labelRowIdx + 1,
|
|
205
|
+
labelRowIdx + 2,
|
|
206
|
+
labelRowIdx + 3,
|
|
207
|
+
].filter((r) => r < lines.length)
|
|
208
|
+
const hasAlignedMarker = nearbyRows.some((r) =>
|
|
209
|
+
lines[r]!.slice(cornerCol - 1, cornerCol + 2).includes('○╟'),
|
|
210
|
+
)
|
|
211
|
+
expect(hasAlignedMarker).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('drops a whole overlapping label in the same-row (horizontal) branch too', () => {
|
|
215
|
+
// A-C is a direct, non-adjacent same-row relationship skipping over B,
|
|
216
|
+
// so its label's clamped placement region spans the whole A..C gap and
|
|
217
|
+
// can land on top of A-B's own (already-drawn, adjacent) label. The
|
|
218
|
+
// vertical-branch case above exercises canPlaceLabelLine via the
|
|
219
|
+
// vertical label loop; this exercises the same guard in the horizontal
|
|
220
|
+
// label loop.
|
|
221
|
+
const ascii = renderMermaidASCII(
|
|
222
|
+
`erDiagram
|
|
223
|
+
A ||--o{ B : early
|
|
224
|
+
A ||--o{ C : longlonglabelhere
|
|
225
|
+
A ||--o{ D : y
|
|
226
|
+
B ||--o{ E : z`,
|
|
227
|
+
{ colorMode: 'none' },
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(ascii).toContain('early')
|
|
231
|
+
expect(ascii).not.toContain('longlonglabelhere')
|
|
232
|
+
|
|
233
|
+
// Not just the whole string: a per-character collision guard (the
|
|
234
|
+
// naive approach tried and rejected for canPlaceLabelLine, see above)
|
|
235
|
+
// could still leak part of the dropped label — e.g. "longlon" or
|
|
236
|
+
// "glabelhere" — into the gap right after "early" on the same row.
|
|
237
|
+
// Nothing alphabetic should appear there at all.
|
|
238
|
+
const labelRow = ascii.split('\n').find((l) => l.includes('early'))
|
|
239
|
+
expect(labelRow).toBeDefined()
|
|
240
|
+
const afterEarly = labelRow!.slice(
|
|
241
|
+
labelRow!.indexOf('early') + 'early'.length,
|
|
242
|
+
)
|
|
243
|
+
expect(afterEarly).not.toMatch(/[A-Za-z]/)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('preserves an unrelated entity box border when a same-row line crosses through it', () => {
|
|
247
|
+
// Real catalog sample ("ER: Mixed Identifying & Non-Identifying"):
|
|
248
|
+
// ORDER-SHIPMENT is a direct, non-adjacent same-row relationship
|
|
249
|
+
// skipping over LINE_ITEM, so its dashed line crosses straight through
|
|
250
|
+
// LINE_ITEM's box (the routing itself — crossing through the box at all
|
|
251
|
+
// — is the separate, out-of-scope defect from #351/#390's "known
|
|
252
|
+
// limitation"). Text protection alone (the earlier tests above) keeps
|
|
253
|
+
// "LINE_ITEM" itself readable, but previously left the crossing line
|
|
254
|
+
// free to erase the box's border characters on either side of it,
|
|
255
|
+
// leaving an open-looking, broken box. Border cells are protected too.
|
|
256
|
+
const ascii = renderMermaidASCII(
|
|
257
|
+
`erDiagram
|
|
258
|
+
ORDER ||--|{ LINE_ITEM : contains
|
|
259
|
+
ORDER ||..o{ SHIPMENT : ships-via
|
|
260
|
+
PRODUCT ||--o{ LINE_ITEM : includes
|
|
261
|
+
PRODUCT ||..o{ REVIEW : receives`,
|
|
262
|
+
{ colorMode: 'none' },
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
expect(ascii).toContain('┌───────────┐')
|
|
266
|
+
expect(ascii).toContain('└───────────┘')
|
|
267
|
+
// The crossing dashed line still runs right up to LINE_ITEM's box (that
|
|
268
|
+
// routing gap is out of scope), so its own '╌' sits just inside the
|
|
269
|
+
// border on both sides — but the border characters themselves must
|
|
270
|
+
// survive intact, not be replaced by more line/dash glyphs.
|
|
271
|
+
expect(ascii).toMatch(/│.LINE_ITEM.│/)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('pads a label with a blank cell when a later relationship jog runs flush against it', () => {
|
|
275
|
+
// Real catalog sample ("ER: Blog Platform Schema", used verbatim —
|
|
276
|
+
// this needs the entities' attribute blocks to reproduce the exact
|
|
277
|
+
// geometry where it occurs): USER-COMMENT needs no horizontal jog of
|
|
278
|
+
// its own (its two entities are already column-aligned), so its label
|
|
279
|
+
// "authors" sits on a row that POST-COMMENT's much longer jog — a
|
|
280
|
+
// different, later-processed relationship — later fills with dashes
|
|
281
|
+
// clear across. isProtected alone keeps "authors" itself intact
|
|
282
|
+
// (that's what the earlier tests above cover), but without this
|
|
283
|
+
// padding the crossing dashes still ran flush against it on both sides
|
|
284
|
+
// ("────authors────") — readable, but visually indistinguishable from
|
|
285
|
+
// actual corruption at a glance.
|
|
286
|
+
const ascii = renderMermaidASCII(
|
|
287
|
+
`erDiagram
|
|
288
|
+
USER {
|
|
289
|
+
int id PK
|
|
290
|
+
string username UK
|
|
291
|
+
string email UK
|
|
292
|
+
date joined
|
|
293
|
+
}
|
|
294
|
+
POST {
|
|
295
|
+
int id PK
|
|
296
|
+
string title
|
|
297
|
+
text content
|
|
298
|
+
int author_id FK
|
|
299
|
+
date published
|
|
300
|
+
}
|
|
301
|
+
COMMENT {
|
|
302
|
+
int id PK
|
|
303
|
+
text body
|
|
304
|
+
int post_id FK
|
|
305
|
+
int user_id FK
|
|
306
|
+
date created
|
|
307
|
+
}
|
|
308
|
+
TAG {
|
|
309
|
+
int id PK
|
|
310
|
+
string name UK
|
|
311
|
+
}
|
|
312
|
+
USER ||--o{ POST : writes
|
|
313
|
+
USER ||--o{ COMMENT : authors
|
|
314
|
+
POST ||--o{ COMMENT : has
|
|
315
|
+
POST }|--o{ TAG : tagged-with`,
|
|
316
|
+
{ colorMode: 'none' },
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const row = ascii.split('\n').find((l) => l.includes('authors'))
|
|
320
|
+
expect(row).toBeDefined()
|
|
321
|
+
const idx = row!.indexOf('authors')
|
|
322
|
+
expect(row![idx - 1]).toBe(' ')
|
|
323
|
+
expect(row![idx + 'authors'.length]).toBe(' ')
|
|
324
|
+
})
|
|
325
|
+
})
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII ER diagram regression tests for issue #351:
|
|
3
|
+
// 1. Stray/duplicated connector glyphs next to crow's-foot markers
|
|
4
|
+
// 2. Edges (and their labels) routed through/over other relationships or
|
|
5
|
+
// entity boxes
|
|
6
|
+
// 3. Oversized blank gutters between disconnected components
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
11
|
+
import { chooseFreeRow, isRowFree } from '../er-diagram.ts'
|
|
12
|
+
import { mkCanvas, write } from '../canvas.ts'
|
|
13
|
+
import type { Canvas } from '../types.ts'
|
|
14
|
+
|
|
15
|
+
/** Mark every cell in row `y` across [xStart, xEnd] as occupied. */
|
|
16
|
+
function occupyRow(canvas: Canvas, y: number, xStart: number, xEnd: number) {
|
|
17
|
+
for (let x = xStart; x <= xEnd; x++) write(canvas, x, y, '#')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ASCII ER — no stray connector glyph next to a marker (issue #351)', () => {
|
|
21
|
+
it('insets only the colliding "one" marker on a horizontal connector, leaving the non-colliding "zero-many" marker flush (Unicode) — issue #413', () => {
|
|
22
|
+
const ascii = renderMermaidASCII(
|
|
23
|
+
`erDiagram
|
|
24
|
+
CUSTOMER ||--o{ ORDER : places`,
|
|
25
|
+
{ colorMode: 'none' },
|
|
26
|
+
)
|
|
27
|
+
// #390 briefly made every horizontal marker sit flush against the
|
|
28
|
+
// border to avoid a "─│─" run (line-fill dash, lone "one" tick,
|
|
29
|
+
// line-fill dash) that the #351 fix worried would read as a stray,
|
|
30
|
+
// unexplained connector glyph. Review on #351/#413 found that concern
|
|
31
|
+
// doesn't hold in a real terminal (adjacent monospace glyphs don't
|
|
32
|
+
// visually fuse regardless of spacing), but a *blanket* revert back to
|
|
33
|
+
// inset for every horizontal marker was its own overcorrection: it
|
|
34
|
+
// adds an unrequested gap to markers that were never ambiguous in the
|
|
35
|
+
// first place. "zero-many" ('○╟') doesn't share a glyph with the
|
|
36
|
+
// border ('│') the way "one" ('│') does, so only "one" needs the
|
|
37
|
+
// inset — "zero-many" stays flush, matching #390's original intent for
|
|
38
|
+
// that glyph. This test locks in the per-side, collision-conditional
|
|
39
|
+
// result instead of a uniform one.
|
|
40
|
+
expect(ascii).toContain('CUSTOMER │─│')
|
|
41
|
+
expect(ascii).toContain('○╟│ ORDER')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('insets only the colliding "one" marker on a horizontal connector, leaving the non-colliding "zero-many" marker flush (ASCII) — issue #413', () => {
|
|
45
|
+
const ascii = renderMermaidASCII(
|
|
46
|
+
`erDiagram
|
|
47
|
+
CUSTOMER ||--o{ ORDER : places`,
|
|
48
|
+
{ colorMode: 'none', useAscii: true },
|
|
49
|
+
)
|
|
50
|
+
// Same per-side reasoning as the Unicode case above, in the ASCII glyph set.
|
|
51
|
+
expect(ascii).toContain('CUSTOMER |-|')
|
|
52
|
+
expect(ascii).toContain('o<| ORDER')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves both markers flush on a horizontal connector when neither collides with the border', () => {
|
|
56
|
+
const ascii = renderMermaidASCII(
|
|
57
|
+
`erDiagram
|
|
58
|
+
CUSTOMER }o--o{ ORDER : has`,
|
|
59
|
+
{ colorMode: 'none' },
|
|
60
|
+
)
|
|
61
|
+
// Neither "zero-many" marker ('╢○'/'○╟') shares a glyph with the
|
|
62
|
+
// border ('│'), so a blanket inset (the reviewer's original diff,
|
|
63
|
+
// applied uniformly) would add a gap to both sides for no reason.
|
|
64
|
+
// Matches #390's original flush output exactly, since this case was
|
|
65
|
+
// never part of the #413 collision in the first place.
|
|
66
|
+
expect(ascii).toContain('CUSTOMER │╢○')
|
|
67
|
+
expect(ascii).toContain('○╟│ ORDER')
|
|
68
|
+
expect(ascii).not.toContain('CUSTOMER │─')
|
|
69
|
+
expect(ascii).not.toContain('─│ ORDER')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not leave a stray fill row between a vertical marker and its entity border', () => {
|
|
73
|
+
// Forces a vertical (cross-row) connection: three entities with a
|
|
74
|
+
// sqrt-based row limit of 2 per row wrap C onto its own row below A/B.
|
|
75
|
+
const ascii = renderMermaidASCII(
|
|
76
|
+
`erDiagram
|
|
77
|
+
A ||--o{ B : ab
|
|
78
|
+
A ||--o{ C : ac`,
|
|
79
|
+
{ colorMode: 'none' },
|
|
80
|
+
)
|
|
81
|
+
const lines = ascii.split('\n')
|
|
82
|
+
|
|
83
|
+
// The zero-many marker for the A→C relationship ("○╟") must sit on the
|
|
84
|
+
// row immediately before C's box starts — not with a leftover plain
|
|
85
|
+
// "│" line-fill row (visually indistinguishable from a second "one"
|
|
86
|
+
// tick) still separating it from the border. Before the fix, that
|
|
87
|
+
// leftover row rendered as an extra stray "│" directly under "○╟".
|
|
88
|
+
// Match only the *vertical* marker's own row — a bare "○╟" with nothing
|
|
89
|
+
// but leading whitespace before it — not the A→B horizontal
|
|
90
|
+
// relationship's identical-looking marker earlier in the output.
|
|
91
|
+
const markerLineIdx = lines.findIndex((l) => /^\s*○╟\s*$/.test(l))
|
|
92
|
+
expect(markerLineIdx).toBeGreaterThan(-1)
|
|
93
|
+
const nextLine = lines[markerLineIdx + 1]
|
|
94
|
+
expect(nextLine).toBeDefined()
|
|
95
|
+
expect(nextLine!.trimStart().startsWith('┌')).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// The tests above only exercise the "one"/"zero-many" glyphs (`│`, `○╟`).
|
|
99
|
+
// Review on #351 (https://github.com/dfadler/zombie-mermaid/issues/351#issuecomment-5497209031)
|
|
100
|
+
// re-tested with a different cardinality combination — "zero-or-one"
|
|
101
|
+
// (`│○`/`○│`) — confirming the vertical marker stays flush either way.
|
|
102
|
+
// "zero-or-one" also collides with the border on the horizontal axis
|
|
103
|
+
// (its leading/trailing glyph is the same `│` as the "one" marker), so
|
|
104
|
+
// — unlike "zero-many" above — *both* sides of a `C |o--|| D` connector
|
|
105
|
+
// get the inset, not just one. These two tests lock in that finding
|
|
106
|
+
// directly rather than leaving it as a PR-description claim with no
|
|
107
|
+
// regression coverage.
|
|
108
|
+
|
|
109
|
+
it('insets the "zero-or-one" marker from the entity border on a horizontal connector, since it collides on both sides — issue #413', () => {
|
|
110
|
+
const ascii = renderMermaidASCII(
|
|
111
|
+
`erDiagram
|
|
112
|
+
C |o--|| D : owns`,
|
|
113
|
+
{ colorMode: 'none' },
|
|
114
|
+
)
|
|
115
|
+
// Matches the reviewer's own quoted "main" (pre-#390) output exactly:
|
|
116
|
+
// "C │─│○─│─│ D" — a fill dash separates each entity's border from its
|
|
117
|
+
// own marker on both sides of the connector.
|
|
118
|
+
expect(ascii).toContain('C │─│○')
|
|
119
|
+
expect(ascii).toContain('─│─│ D')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not leave a stray fill row before a vertical "zero-or-one" marker', () => {
|
|
123
|
+
// Forces a vertical connection the same way as the test above (a
|
|
124
|
+
// second entity fills row 1, wrapping the third onto its own row), but
|
|
125
|
+
// with the reviewer's "zero-or-one" cardinality on the vertical
|
|
126
|
+
// relationship instead of "zero-many" — confirming flush placement
|
|
127
|
+
// stays clean on the vertical axis regardless of which marker glyph is
|
|
128
|
+
// involved, unlike the horizontal case immediately above.
|
|
129
|
+
const ascii = renderMermaidASCII(
|
|
130
|
+
`erDiagram
|
|
131
|
+
A ||--o{ B : ab
|
|
132
|
+
A |o--|| C : owns`,
|
|
133
|
+
{ colorMode: 'none' },
|
|
134
|
+
)
|
|
135
|
+
const lines = ascii.split('\n')
|
|
136
|
+
// A's own end of the A→C relationship ("│○") must sit on the row
|
|
137
|
+
// immediately after A's box bottom border — not with a leftover blank
|
|
138
|
+
// row still separating it from the border.
|
|
139
|
+
const aBoxBottomIdx = lines.findIndex((l) => l.includes('└───┘ ab'))
|
|
140
|
+
expect(aBoxBottomIdx).toBeGreaterThan(-1)
|
|
141
|
+
const markerLine = lines[aBoxBottomIdx + 1]
|
|
142
|
+
expect(markerLine).toBeDefined()
|
|
143
|
+
expect(markerLine).toContain('│○')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('ASCII ER — relationships routed without colliding (issue #351)', () => {
|
|
148
|
+
it('gives independent relationships between the same two rows distinct jog rows', () => {
|
|
149
|
+
// B→C and A→C both terminate on C's row from A/B's row above; before
|
|
150
|
+
// the fix, every vertical relationship's horizontal jog used the exact
|
|
151
|
+
// same geometric midpoint, so B→C's jog and A→C's label landed on the
|
|
152
|
+
// identical row and overwrote each other.
|
|
153
|
+
const ascii = renderMermaidASCII(
|
|
154
|
+
`erDiagram
|
|
155
|
+
A ||--o{ B : rel1
|
|
156
|
+
B ||--o{ C : rel2
|
|
157
|
+
A ||--o{ C : rel3`,
|
|
158
|
+
{ colorMode: 'none' },
|
|
159
|
+
)
|
|
160
|
+
const lines = ascii.split('\n')
|
|
161
|
+
|
|
162
|
+
const rel2Line = lines.find((l) => l.includes('rel2'))
|
|
163
|
+
const rel3Line = lines.find((l) => l.includes('rel3'))
|
|
164
|
+
expect(rel2Line).toBeDefined()
|
|
165
|
+
expect(rel3Line).toBeDefined()
|
|
166
|
+
// Both labels must render intact (not merged/garbled into the same row).
|
|
167
|
+
expect(rel2Line).not.toBe(rel3Line)
|
|
168
|
+
expect(ascii).toContain('rel1')
|
|
169
|
+
expect(ascii).toContain('rel2')
|
|
170
|
+
expect(ascii).toContain('rel3')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not cut a relationship label with another relationship’s jog line', () => {
|
|
174
|
+
// Five entities across two component rows, closely mirroring the
|
|
175
|
+
// issue's own description: a later relationship's vertical/horizontal
|
|
176
|
+
// segment used to cut straight through an earlier relationship's label,
|
|
177
|
+
// e.g. "appears_in" rendered as "appear│_in".
|
|
178
|
+
const ascii = renderMermaidASCII(
|
|
179
|
+
`erDiagram
|
|
180
|
+
CUSTOMER ||--o{ ORDER : places
|
|
181
|
+
ORDER ||--|{ LINE_ITEM : contains
|
|
182
|
+
PRODUCT ||--o{ LINE_ITEM : appears_in
|
|
183
|
+
CUSTOMER ||--o{ ADDRESS : has`,
|
|
184
|
+
{ colorMode: 'none' },
|
|
185
|
+
)
|
|
186
|
+
// Every label must render as one intact, uninterrupted word — not
|
|
187
|
+
// split by a stray line character routed through its middle.
|
|
188
|
+
expect(ascii).toContain('places')
|
|
189
|
+
expect(ascii).toContain('contains')
|
|
190
|
+
expect(ascii).toContain('appears_in')
|
|
191
|
+
expect(ascii).toContain('has')
|
|
192
|
+
expect(ascii).not.toMatch(/[a-z]+[─│┊╌][a-z]+/)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('does not let a jog row collide with a label whose span falls outside the jog range (CodeRabbit review, PR #390)', () => {
|
|
196
|
+
// A relationship's label is always drawn to the right of the upper
|
|
197
|
+
// entity's own center column, regardless of which way its jog goes —
|
|
198
|
+
// for a leftward jog (or a short rightward one) that label span sits
|
|
199
|
+
// outside [lx, rx], the range renderErAscii originally checked for
|
|
200
|
+
// occupancy before choosing a jog row. Found by a differential search
|
|
201
|
+
// over random small diagrams comparing the old (narrow) and fixed
|
|
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.
|
|
207
|
+
const ascii = renderMermaidASCII(
|
|
208
|
+
`erDiagram
|
|
209
|
+
DDDD ||--o{ A : x0
|
|
210
|
+
BB ||--o{ A : l1
|
|
211
|
+
DDDD ||--o{ CCC : longerlabelhere2`,
|
|
212
|
+
{ colorMode: 'none' },
|
|
213
|
+
)
|
|
214
|
+
const lines = ascii.split('\n')
|
|
215
|
+
const labelLine = lines.find((l) => l.includes('longerlabelhere2'))
|
|
216
|
+
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('│')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('ASCII ER — compact gutters between disconnected components (issue #351)', () => {
|
|
226
|
+
it('does not leave a large blank gutter between two small disconnected components', () => {
|
|
227
|
+
const ascii = renderMermaidASCII(
|
|
228
|
+
`erDiagram
|
|
229
|
+
CUSTOMER ||--o{ ORDER : places
|
|
230
|
+
PRODUCT ||--o{ LINE_ITEM : appears_in`,
|
|
231
|
+
{ colorMode: 'none' },
|
|
232
|
+
)
|
|
233
|
+
const lines = ascii.split('\n')
|
|
234
|
+
|
|
235
|
+
// Count the run of fully-blank lines between the two components.
|
|
236
|
+
const firstBoxLine = lines.findIndex((l) => l.includes('└'))
|
|
237
|
+
const secondComponentLine = lines.findIndex(
|
|
238
|
+
(l, i) => i > firstBoxLine && l.includes('PRODUCT'),
|
|
239
|
+
)
|
|
240
|
+
expect(firstBoxLine).toBeGreaterThan(-1)
|
|
241
|
+
expect(secondComponentLine).toBeGreaterThan(firstBoxLine)
|
|
242
|
+
|
|
243
|
+
let blankRun = 0
|
|
244
|
+
for (let i = firstBoxLine + 1; i < secondComponentLine; i++) {
|
|
245
|
+
if (lines[i]!.trim() === '') blankRun++
|
|
246
|
+
}
|
|
247
|
+
// Before the fix, componentGap=6 produced a run of 6 fully blank rows
|
|
248
|
+
// for this exact diagram. A visual break is still expected, but not a
|
|
249
|
+
// gutter that dwarfs the content itself.
|
|
250
|
+
expect(blankRun).toBeLessThanOrEqual(3)
|
|
251
|
+
|
|
252
|
+
// Sanity: both components still render in full.
|
|
253
|
+
expect(ascii).toContain('CUSTOMER')
|
|
254
|
+
expect(ascii).toContain('ORDER')
|
|
255
|
+
expect(ascii).toContain('PRODUCT')
|
|
256
|
+
expect(ascii).toContain('LINE_ITEM')
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('chooseFreeRow / isRowFree (unit)', () => {
|
|
261
|
+
// renderErAscii's fixed vGap=4 only ever produces a 2-row-wide candidate
|
|
262
|
+
// interval, so the geometric midpoint is always the interval's first
|
|
263
|
+
// (topmost) row — the "scan upward" half of chooseFreeRow's search can
|
|
264
|
+
// never find a free row through the full render pipeline alone. These
|
|
265
|
+
// tests exercise that logic directly against a wider, constructed range.
|
|
266
|
+
|
|
267
|
+
it('returns the geometric midpoint when it is free', () => {
|
|
268
|
+
const canvas = mkCanvas(20, 20)
|
|
269
|
+
expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('scans downward when the midpoint is occupied', () => {
|
|
273
|
+
const canvas = mkCanvas(20, 20)
|
|
274
|
+
occupyRow(canvas, 3, 0, 10) // midpoint occupied
|
|
275
|
+
expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(4)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('scans upward when the midpoint and every row below it are occupied', () => {
|
|
279
|
+
const canvas = mkCanvas(20, 20)
|
|
280
|
+
occupyRow(canvas, 3, 0, 10) // midpoint
|
|
281
|
+
occupyRow(canvas, 4, 0, 10) // one below
|
|
282
|
+
// row 2 (one above the midpoint) stays free
|
|
283
|
+
expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(2)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('falls back to the geometric midpoint when every candidate row is occupied', () => {
|
|
287
|
+
const canvas = mkCanvas(20, 20)
|
|
288
|
+
for (let y = 1; y <= 5; y++) occupyRow(canvas, y, 0, 10)
|
|
289
|
+
expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('ignores the skipped column when checking occupancy', () => {
|
|
293
|
+
const canvas = mkCanvas(20, 20)
|
|
294
|
+
write(canvas, 5, 3, '#') // only the skipped column is occupied
|
|
295
|
+
expect(isRowFree(canvas, 3, 0, 10, 5)).toBe(true)
|
|
296
|
+
expect(isRowFree(canvas, 3, 0, 10, -1)).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// CodeRabbit review on PR #390: a column past the canvas's current width
|
|
300
|
+
// was reported as `undefined` by a plain index and treated as occupied,
|
|
301
|
+
// even though nothing had actually been drawn there yet — a label routinely
|
|
302
|
+
// grows the canvas on write (see increaseSize at the label call sites),
|
|
303
|
+
// so a not-yet-allocated column is "not drawn", not "occupied".
|
|
304
|
+
it('treats a column past the canvas width as free, not occupied', () => {
|
|
305
|
+
const canvas = mkCanvas(5, 5) // columns 0..5 only
|
|
306
|
+
expect(isRowFree(canvas, 2, 0, 10, -1)).toBe(true)
|
|
307
|
+
expect(chooseFreeRow(canvas, 0, 6, 0, 10, -1)).toBe(3) // still the midpoint
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// CodeRabbit review on PR #390: renderErAscii's jog-row selection checked
|
|
311
|
+
// occupancy only across [lx, rx] (the jog's own span), not the
|
|
312
|
+
// relationship's label — which is always drawn to the right of the upper
|
|
313
|
+
// entity's center column, regardless of which way the jog itself goes.
|
|
314
|
+
// For a leftward jog (rx sits at the upper entity's own column), the
|
|
315
|
+
// label span falls entirely outside that checked range.
|
|
316
|
+
it('rejects a midpoint row whose label span (right of the jog range) is occupied', () => {
|
|
317
|
+
const canvas = mkCanvas(30, 20)
|
|
318
|
+
const lineX = 10 // upper entity's center column
|
|
319
|
+
const lowerCX = 4 // lower entity's center column — left of lineX: a leftward jog
|
|
320
|
+
const lx = Math.min(lineX, lowerCX)
|
|
321
|
+
const rx = Math.max(lineX, lowerCX) // === lineX; the label starts past this
|
|
322
|
+
const labelEndX = lineX + 1 + 'some label'.length
|
|
323
|
+
// Nothing occupies [lx, rx] at the midpoint, but the label area
|
|
324
|
+
// (lineX + 2 .. labelEndX) is already occupied by earlier content.
|
|
325
|
+
occupyRow(canvas, 3, lineX + 2, labelEndX)
|
|
326
|
+
// Checking only the jog's own span (the pre-fix behavior) would accept
|
|
327
|
+
// row 3 even though the label that lands there next would collide.
|
|
328
|
+
expect(isRowFree(canvas, 3, lx, rx, lineX)).toBe(true)
|
|
329
|
+
// Widening the checked range to include the label span (the fix) finds
|
|
330
|
+
// the collision and moves to a different row instead.
|
|
331
|
+
const chosen = chooseFreeRow(
|
|
332
|
+
canvas,
|
|
333
|
+
0,
|
|
334
|
+
6,
|
|
335
|
+
lx,
|
|
336
|
+
Math.max(rx, labelEndX),
|
|
337
|
+
lineX,
|
|
338
|
+
)
|
|
339
|
+
expect(chosen).not.toBe(3)
|
|
340
|
+
expect(isRowFree(canvas, chosen, lx, Math.max(rx, labelEndX), lineX)).toBe(
|
|
341
|
+
true,
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
})
|