@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,98 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII ER diagram regression test for issue #411:
|
|
3
|
+
//
|
|
4
|
+
// Two *unrelated* relationships' vertical stems can land on adjacent (not
|
|
5
|
+
// identical) columns purely by coincidence — when that happens, nothing in
|
|
6
|
+
// the rendered output distinguishes them, so a reader sees what looks like
|
|
7
|
+
// one connector that inexplicably bends sideways partway down, rather than
|
|
8
|
+
// two independent lines. This is different from #350 (content overwrite,
|
|
9
|
+
// already fixed) and #351 (routing collisions/gutters, already fixed): no
|
|
10
|
+
// character is ever corrupted here, the two stems just sit close enough to
|
|
11
|
+
// be mistaken for each other.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
16
|
+
|
|
17
|
+
describe('ASCII ER — unrelated relationship stems stay visually separated (issue #411)', () => {
|
|
18
|
+
it('keeps CUSTOMER→ADDRESS and PRODUCT→LINE_ITEM stems at least one column apart', () => {
|
|
19
|
+
// The exact repro from issue #411: CUSTOMER's own stem (toward ADDRESS)
|
|
20
|
+
// and PRODUCT/LINE_ITEM's own stem land on entity-center columns that
|
|
21
|
+
// are only 1 apart by coincidence (12-wide CUSTOMER vs. 11-wide
|
|
22
|
+
// PRODUCT, both starting at column 0 in their own row) — before the
|
|
23
|
+
// fix, that put a bare '│' from one relationship immediately beside a
|
|
24
|
+
// '┌'/'┼' from the other, with no blank column between them:
|
|
25
|
+
//
|
|
26
|
+
// ┼ ╢○
|
|
27
|
+
// ┌│appears_in ───── has ─────────────────┘
|
|
28
|
+
// │└───────────────┐
|
|
29
|
+
// ┼ ○╟
|
|
30
|
+
//
|
|
31
|
+
// ("appears_in" is PRODUCT→LINE_ITEM's own label; "has" is
|
|
32
|
+
// CUSTOMER→ADDRESS's own label — the two relationships have nothing to
|
|
33
|
+
// do with each other, so their stems must read as visually distinct.)
|
|
34
|
+
const ascii = renderMermaidASCII(
|
|
35
|
+
`erDiagram
|
|
36
|
+
CUSTOMER ||--o{ ORDER : places
|
|
37
|
+
ORDER ||--|{ LINE_ITEM : contains
|
|
38
|
+
PRODUCT ||--o{ LINE_ITEM : appears_in
|
|
39
|
+
CUSTOMER ||--o{ ADDRESS : has`,
|
|
40
|
+
{ colorMode: 'none' },
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// The pre-fix signature: PRODUCT/LINE_ITEM's corner immediately
|
|
44
|
+
// followed, with no gap, by CUSTOMER/ADDRESS's vertical stem.
|
|
45
|
+
expect(ascii).not.toContain('┌│appears_in')
|
|
46
|
+
expect(ascii).not.toContain('│└───────────────┐')
|
|
47
|
+
|
|
48
|
+
// A blank column now separates the two relationships' stems on every
|
|
49
|
+
// row where both are present.
|
|
50
|
+
expect(ascii).toContain('┌ appears_in')
|
|
51
|
+
expect(ascii).toContain('│ │ has')
|
|
52
|
+
expect(ascii).toContain('┼ └')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not corrupt a corner glyph when escalating a jog to avoid an unrelated stem (issue #411 fix side effect)', () => {
|
|
56
|
+
// A denser diagram where two different vertical relationships'
|
|
57
|
+
// natural columns land close enough to trigger the #411 separation
|
|
58
|
+
// logic on both sides. Before a follow-up fix, the escalation could
|
|
59
|
+
// pick a "via" column identical to another relationship's own column,
|
|
60
|
+
// silently overwriting its corner glyph with a plain vertical bar.
|
|
61
|
+
const ascii = renderMermaidASCII(
|
|
62
|
+
`erDiagram
|
|
63
|
+
STUDENT {
|
|
64
|
+
int id PK
|
|
65
|
+
string name
|
|
66
|
+
date dob
|
|
67
|
+
string grade
|
|
68
|
+
}
|
|
69
|
+
TEACHER {
|
|
70
|
+
int id PK
|
|
71
|
+
string name
|
|
72
|
+
string department
|
|
73
|
+
}
|
|
74
|
+
COURSE {
|
|
75
|
+
int id PK
|
|
76
|
+
string title
|
|
77
|
+
int teacher_id FK
|
|
78
|
+
int credits
|
|
79
|
+
}
|
|
80
|
+
ENROLLMENT {
|
|
81
|
+
int id PK
|
|
82
|
+
int student_id FK
|
|
83
|
+
int course_id FK
|
|
84
|
+
string semester
|
|
85
|
+
float grade
|
|
86
|
+
}
|
|
87
|
+
TEACHER ||--o{ COURSE : teaches
|
|
88
|
+
STUDENT ||--o{ ENROLLMENT : enrolled
|
|
89
|
+
COURSE ||--o{ ENROLLMENT : has`,
|
|
90
|
+
{ colorMode: 'none' },
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// TEACHER→COURSE's own corner, where its jog turns down into COURSE,
|
|
94
|
+
// must survive intact rather than being overwritten by
|
|
95
|
+
// STUDENT→ENROLLMENT's bypass column landing on the same cell.
|
|
96
|
+
expect(ascii).toContain('┌ teaches')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII ER diagram: a vertical relationship's "one" (`||`) cardinality
|
|
3
|
+
// marker was completely invisible when it sat on the upper or lower entity.
|
|
4
|
+
//
|
|
5
|
+
// getCrowsFootChars() was written for horizontal use, where a '│'/'|' tick
|
|
6
|
+
// is perpendicular to the horizontal line ('─'/'-') it crosses — that reads
|
|
7
|
+
// correctly. The same function was reused verbatim for vertical markers,
|
|
8
|
+
// where a '│'/'|' tick is instead *parallel* to the vertical connecting
|
|
9
|
+
// line ('│'/'|'), so for "one" cardinality specifically the marker glyph
|
|
10
|
+
// and the line-fill glyph were identical: drawing the marker produced no
|
|
11
|
+
// visible change at all, not just a subtle one.
|
|
12
|
+
//
|
|
13
|
+
// The fix adds a `vertical` parameter to getCrowsFootChars, used only for
|
|
14
|
+
// the 'one' case, so a vertical "one" marker renders as '┼'/'+' (crossing
|
|
15
|
+
// the vertical line) instead of '│'/'|' (blending into it). 'zero-one',
|
|
16
|
+
// 'many', and 'zero-many' are untouched — they already render distinctly
|
|
17
|
+
// on the vertical axis.
|
|
18
|
+
//
|
|
19
|
+
// An earlier version of this fix used a bare '─'/'-' for the marker. That
|
|
20
|
+
// made it visible, but a bare dash has no vertical extent, so it read as a
|
|
21
|
+
// gap severing the line rather than a tick crossing it (the vertical line
|
|
22
|
+
// stops at the row above the marker and resumes at the row below, with
|
|
23
|
+
// nothing connecting through the marker's own cell). '┼'/'+' carry both
|
|
24
|
+
// strokes, so the line reads as continuous with a crossing tick — the same
|
|
25
|
+
// way '│'/'|' crosses the horizontal line in the non-vertical case.
|
|
26
|
+
//
|
|
27
|
+
// Found while reviewing PR #439's vertical-relationship example.
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest'
|
|
31
|
+
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
32
|
+
|
|
33
|
+
describe('ASCII ER vertical "one" cardinality marker (invisible-marker fix)', () => {
|
|
34
|
+
it('renders a distinct tick for the upper entity\'s "one" marker, not the invisible line glyph', () => {
|
|
35
|
+
// A connects to both B (horizontal) and C (vertical, forced by the
|
|
36
|
+
// 3-entity sqrt-based row layout) — the exact repro that surfaced the
|
|
37
|
+
// bug. Before the fix, A's vertical "one" marker toward C rendered as
|
|
38
|
+
// '│', identical to the plain connecting-line fill character, so
|
|
39
|
+
// nothing distinguished "here is A's cardinality marker" from "just
|
|
40
|
+
// more line".
|
|
41
|
+
const ascii = renderMermaidASCII(
|
|
42
|
+
`erDiagram
|
|
43
|
+
A ||--o{ B : ab
|
|
44
|
+
A ||--o{ C : ac`,
|
|
45
|
+
{ colorMode: 'none' },
|
|
46
|
+
)
|
|
47
|
+
const lines = ascii.split('\n')
|
|
48
|
+
// The marker sits flush against A's own border (#390's flush-marker
|
|
49
|
+
// change — merged after this test was first written, which shifted
|
|
50
|
+
// the vertical label off the marker's own row; the label's position
|
|
51
|
+
// is a separate geometric-midpoint calculation, not anchored to the
|
|
52
|
+
// marker — see the row-by-row check below instead of assuming they
|
|
53
|
+
// share a line).
|
|
54
|
+
const markerLineIndex = lines.findIndex((l) => /^\s*┼\s*$/.test(l))
|
|
55
|
+
expect(markerLineIndex).toBeGreaterThanOrEqual(0)
|
|
56
|
+
expect(lines[markerLineIndex - 1]).toContain('└───┘')
|
|
57
|
+
|
|
58
|
+
// The marker's own line must not be a lone '│' (the pre-fix, invisible
|
|
59
|
+
// rendering) or a bare '─' (an earlier fix attempt, which visually
|
|
60
|
+
// severs the line — see the header comment above).
|
|
61
|
+
expect(lines[markerLineIndex]).not.toMatch(/^\s*[│─]\s*$/)
|
|
62
|
+
|
|
63
|
+
// The line continues unbroken below the marker, through the label row.
|
|
64
|
+
expect(lines[markerLineIndex + 1]).toContain('│ ac')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders distinct ticks at both ends when both entities have "one" cardinality', () => {
|
|
68
|
+
const ascii = renderMermaidASCII(
|
|
69
|
+
`erDiagram
|
|
70
|
+
A ||--o{ B : ab
|
|
71
|
+
A ||--|| C : ac`,
|
|
72
|
+
{ colorMode: 'none' },
|
|
73
|
+
)
|
|
74
|
+
// Two separate '┼' marker ticks: one at A's lower edge, one at C's
|
|
75
|
+
// upper edge, both distinct from the '│' line around them.
|
|
76
|
+
const lines = ascii.split('\n')
|
|
77
|
+
const tickLineIndexes = lines
|
|
78
|
+
.map((l, i) => (/^\s*┼\s*$/.test(l) ? i : -1))
|
|
79
|
+
.filter((i) => i >= 0)
|
|
80
|
+
expect(tickLineIndexes.length).toBe(2)
|
|
81
|
+
const [upperTick, lowerTick] = tickLineIndexes as [number, number]
|
|
82
|
+
// Upper tick flush against A's border, lower tick flush against C's
|
|
83
|
+
// (#390's flush-marker change), with the connecting line still
|
|
84
|
+
// reading as continuous — every row between the two ticks starts with
|
|
85
|
+
// the plain '│' line-fill character.
|
|
86
|
+
expect(lines[upperTick - 1]).toContain('└───┘')
|
|
87
|
+
expect(lines[lowerTick + 1]).toContain('┌───┐')
|
|
88
|
+
for (let i = upperTick + 1; i < lowerTick; i++) {
|
|
89
|
+
expect(lines[i]).toMatch(/^\s*│/)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders the ASCII-mode equivalent ("+" instead of "|") for the vertical "one" marker', () => {
|
|
94
|
+
const ascii = renderMermaidASCII(
|
|
95
|
+
`erDiagram
|
|
96
|
+
A ||--o{ B : ab
|
|
97
|
+
A ||--o{ C : ac`,
|
|
98
|
+
{ colorMode: 'none', useAscii: true },
|
|
99
|
+
)
|
|
100
|
+
const lines = ascii.split('\n')
|
|
101
|
+
const markerLineIndex = lines.findIndex((l) => /^\s*\+\s*$/.test(l))
|
|
102
|
+
expect(markerLineIndex).toBeGreaterThanOrEqual(0)
|
|
103
|
+
expect(lines[markerLineIndex - 1]).toContain('+---+')
|
|
104
|
+
// The pre-fix invisible rendering used a lone '|' on the marker line;
|
|
105
|
+
// an earlier fix attempt used a bare '-', which visually severs the
|
|
106
|
+
// line (no vertical extent) — see the header comment above.
|
|
107
|
+
expect(lines[markerLineIndex]).not.toMatch(/^\s*[|-]\s*$/)
|
|
108
|
+
expect(lines[markerLineIndex + 1]).toContain('| ac')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression/coverage test for the deepest fallback tier of
|
|
3
|
+
* `determineLabelLine` (src/ascii/edge-routing.ts) — the one that runs when
|
|
4
|
+
* *no* segment is both wide enough for the label and clear of nodes, so it
|
|
5
|
+
* falls back to `clearLaterSegments` (non-terminal segments clear of
|
|
6
|
+
* nodes), then `clearSegments` (any segment clear of nodes, terminal or
|
|
7
|
+
* not), then finally every segment regardless.
|
|
8
|
+
*
|
|
9
|
+
* Exercises two branches #450's fix introduced that the rest of the suite
|
|
10
|
+
* never reaches on their own:
|
|
11
|
+
*
|
|
12
|
+
* - A non-terminal segment (touching neither the source's nor the
|
|
13
|
+
* target's own border) that still fails `clearOfNodes` because its
|
|
14
|
+
* interior runs through an unrelated node's block — so
|
|
15
|
+
* `!isTerminalSegment(s) && clearOfNodes(s.line)` evaluates the right
|
|
16
|
+
* operand and gets `false` from it, rather than short-circuiting on a
|
|
17
|
+
* terminal segment (the only case the rest of the suite happens to hit).
|
|
18
|
+
* - `clearLaterSegments` ending up non-empty, so the ternary at
|
|
19
|
+
* `determineLabelLine` uses it directly instead of falling through to
|
|
20
|
+
* `clearSegments`.
|
|
21
|
+
*
|
|
22
|
+
* The two terminal segments are deliberately given a *larger* combined
|
|
23
|
+
* column width than the middle (non-terminal) one, all still under the
|
|
24
|
+
* label's length. This matters for the second test below: with the
|
|
25
|
+
* terminal exclusion removed (the mutation a sabotage check should catch),
|
|
26
|
+
* `clearLaterSegments` would include the terminal segments too, and being
|
|
27
|
+
* wider they'd win the width-descending sort — silently picking a
|
|
28
|
+
* box-border-adjacent segment instead of the safe middle one, the exact
|
|
29
|
+
* class of bug #450 was filed against. Equal widths would let the middle
|
|
30
|
+
* segment win the tie-break by filter order alone, masking that mutation.
|
|
31
|
+
*
|
|
32
|
+
* Constructed directly against `determineLabelLine` (bypassing the parser
|
|
33
|
+
* and full grid layout, following the same pattern as
|
|
34
|
+
* ascii-edge-routing-single-point-path.test.ts) so the exact segment/label
|
|
35
|
+
* widths and the blocked cell can be pinned precisely, rather than hoping
|
|
36
|
+
* some real diagram happens to hit this deep a fallback tier.
|
|
37
|
+
*/
|
|
38
|
+
import { describe, it, expect } from 'vitest'
|
|
39
|
+
import { determineLabelLine } from '../edge-routing.ts'
|
|
40
|
+
import { Down, gridKey } from '../types.ts'
|
|
41
|
+
import type { AsciiEdge, AsciiGraph, AsciiNode } from '../types.ts'
|
|
42
|
+
import { createGrid } from '../grid-occupancy.ts'
|
|
43
|
+
|
|
44
|
+
function makeNode(name: string, x: number, y: number): AsciiNode {
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
displayLabel: name,
|
|
48
|
+
shape: 'rectangle',
|
|
49
|
+
index: 0,
|
|
50
|
+
gridCoord: { x, y },
|
|
51
|
+
drawingCoord: null,
|
|
52
|
+
drawing: null,
|
|
53
|
+
drawn: false,
|
|
54
|
+
styleClassName: '',
|
|
55
|
+
styleClass: { name: '', styles: {} },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeGraph(nodes: AsciiNode[]): AsciiGraph {
|
|
60
|
+
return {
|
|
61
|
+
nodes,
|
|
62
|
+
edges: [],
|
|
63
|
+
canvas: [],
|
|
64
|
+
roleCanvas: [],
|
|
65
|
+
grid: createGrid(),
|
|
66
|
+
columnWidth: new Map(),
|
|
67
|
+
rowHeight: new Map(),
|
|
68
|
+
subgraphs: [],
|
|
69
|
+
config: {
|
|
70
|
+
useAscii: false,
|
|
71
|
+
paddingX: 5,
|
|
72
|
+
paddingY: 5,
|
|
73
|
+
boxBorderPadding: 1,
|
|
74
|
+
graphDirection: 'TD',
|
|
75
|
+
},
|
|
76
|
+
offsetX: 0,
|
|
77
|
+
offsetY: 0,
|
|
78
|
+
bundles: [],
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Shared path/width setup for both tests: a 3-segment path (0,0)-(2,0)-
|
|
84
|
+
* (5,0)-(8,0), where segment 1 (index 1, x=0..2, touching the source) and
|
|
85
|
+
* segment 3 (index 3, x=5..8, touching the target) are terminal, and
|
|
86
|
+
* segment 2 (index 2, x=2..5) is the only non-terminal one.
|
|
87
|
+
*
|
|
88
|
+
* Columns 0,1,2,5,6,7,8 are set to width 10 (terminal segments 1 and 3
|
|
89
|
+
* total 30 and 40 respectively); columns 3,4 stay at width 1 (segment 2
|
|
90
|
+
* totals 22, via its shared boundary columns 2 and 5). All three stay
|
|
91
|
+
* under the 60-character label below, so the primary and "any suitable
|
|
92
|
+
* segment" tiers both fail on width alone regardless of clearOfNodes.
|
|
93
|
+
*/
|
|
94
|
+
function makeAsymmetricWidthGraph(): { graph: AsciiGraph; edge: AsciiEdge } {
|
|
95
|
+
const from = makeNode('A', 0, 0)
|
|
96
|
+
const to = makeNode('B', 8, 0)
|
|
97
|
+
const graph = makeGraph([from, to])
|
|
98
|
+
|
|
99
|
+
for (const x of [0, 1, 2, 5, 6, 7, 8]) graph.columnWidth.set(x, 10)
|
|
100
|
+
for (const x of [3, 4]) graph.columnWidth.set(x, 1)
|
|
101
|
+
|
|
102
|
+
const edge: AsciiEdge = {
|
|
103
|
+
from,
|
|
104
|
+
to,
|
|
105
|
+
// 60 characters: wider than every segment's combined width above
|
|
106
|
+
// (the widest, segment 3, totals 40).
|
|
107
|
+
text: 'this label is deliberately far too long to ever possibly fit',
|
|
108
|
+
path: [
|
|
109
|
+
{ x: 0, y: 0 },
|
|
110
|
+
{ x: 2, y: 0 },
|
|
111
|
+
{ x: 5, y: 0 },
|
|
112
|
+
{ x: 8, y: 0 },
|
|
113
|
+
],
|
|
114
|
+
labelLine: [],
|
|
115
|
+
startDir: Down,
|
|
116
|
+
endDir: Down,
|
|
117
|
+
style: 'solid',
|
|
118
|
+
hasArrowStart: false,
|
|
119
|
+
hasArrowEnd: true,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { graph, edge }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('determineLabelLine terminal-segment-exclusion fallback tiers', () => {
|
|
126
|
+
it('falls back to clearSegments when the only non-terminal segment is blocked by an unrelated node', () => {
|
|
127
|
+
const { graph, edge } = makeAsymmetricWidthGraph()
|
|
128
|
+
|
|
129
|
+
// Block the middle (non-terminal) segment's sole interior cell with an
|
|
130
|
+
// unrelated node's occupied cell, so clearOfNodes reports it blocked —
|
|
131
|
+
// the only non-terminal segment available is then unusable, leaving
|
|
132
|
+
// clearLaterSegments empty and forcing the fallback to clearSegments
|
|
133
|
+
// (any segment clear of nodes, terminal or not).
|
|
134
|
+
graph.grid.add(gridKey({ x: 3, y: 0 }))
|
|
135
|
+
// A cell wedged between the two edge nodes stands in for that
|
|
136
|
+
// unrelated node's own footprint (not registered as a real AsciiNode —
|
|
137
|
+
// clearOfNodes only consults grid occupancy, not node identity).
|
|
138
|
+
|
|
139
|
+
expect(() => determineLabelLine(graph, edge)).not.toThrow()
|
|
140
|
+
|
|
141
|
+
// Falls back to a terminal segment (clearSegments), not the blocked
|
|
142
|
+
// middle one — confirms the cascade actually walked past the empty
|
|
143
|
+
// clearLaterSegments tier instead of using the blocked segment anyway.
|
|
144
|
+
const [p1, p2] = edge.labelLine
|
|
145
|
+
expect(p1).toBeDefined()
|
|
146
|
+
expect(p2).toBeDefined()
|
|
147
|
+
const usedMiddleSegment =
|
|
148
|
+
(p1!.x === 2 && p2!.x === 5) || (p1!.x === 5 && p2!.x === 2)
|
|
149
|
+
expect(usedMiddleSegment).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('uses the non-terminal segment via clearLaterSegments even though the terminal segments are wider', () => {
|
|
153
|
+
const { graph, edge } = makeAsymmetricWidthGraph()
|
|
154
|
+
|
|
155
|
+
// Unlike the previous test, nothing blocks the middle (non-terminal)
|
|
156
|
+
// segment's interior this time, so clearLaterSegments ends up with
|
|
157
|
+
// exactly that one segment. The terminal segments are wider (30/40 vs
|
|
158
|
+
// 22) and would win a width-descending sort if they weren't excluded —
|
|
159
|
+
// this is what actually catches a mutation that drops the terminal
|
|
160
|
+
// exclusion from clearLaterSegments's filter (see this file's header).
|
|
161
|
+
expect(() => determineLabelLine(graph, edge)).not.toThrow()
|
|
162
|
+
|
|
163
|
+
const [p1, p2] = edge.labelLine
|
|
164
|
+
const usedMiddleSegment =
|
|
165
|
+
(p1!.x === 2 && p2!.x === 5) || (p1!.x === 5 && p2!.x === 2)
|
|
166
|
+
expect(usedMiddleSegment).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// `findFreeLane` (src/ascii/lane-search.ts) is the search algorithm that
|
|
2
|
+
// `er-diagram.ts`'s `chooseFreeRow` and `class-diagram.ts`'s
|
|
3
|
+
// `findClearColumn` independently grew, extracted in issue #617.
|
|
4
|
+
//
|
|
5
|
+
// Two kinds of test here:
|
|
6
|
+
//
|
|
7
|
+
// 1. Direct unit tests pinning the contract — search order, inclusive
|
|
8
|
+
// bounds, the deliberately unbounded `preferred` probe, and the
|
|
9
|
+
// `undefined` return that each caller turns into its own fallback.
|
|
10
|
+
// 2. Differential tests against verbatim copies of *both* pre-extraction
|
|
11
|
+
// loops, over randomized occupancy. The extraction's acceptance bar is
|
|
12
|
+
// byte-identical rendered output, and the rendered sample suite alone
|
|
13
|
+
// doesn't reach every branch of either original search — so the
|
|
14
|
+
// originals are kept here as reference implementations and the shared
|
|
15
|
+
// function is required to agree with them on every generated input.
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { findFreeLane } from '../lane-search.ts'
|
|
18
|
+
|
|
19
|
+
describe('findFreeLane', () => {
|
|
20
|
+
it('returns the preferred lane when it is free', () => {
|
|
21
|
+
expect(findFreeLane(5, 0, 10, () => true)).toBe(5)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('probes the preferred lane exactly once, before any bounds apply', () => {
|
|
25
|
+
const probed: number[] = []
|
|
26
|
+
// `preferred` sits outside [min, max] and is still tested — the ER
|
|
27
|
+
// caller depends on this for a gap too narrow to hold a candidate.
|
|
28
|
+
const result = findFreeLane(3, 10, 20, (c) => {
|
|
29
|
+
probed.push(c)
|
|
30
|
+
return c === 3
|
|
31
|
+
})
|
|
32
|
+
expect(result).toBe(3)
|
|
33
|
+
expect(probed).toEqual([3])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('scans forward before backward at each distance', () => {
|
|
37
|
+
const probed: number[] = []
|
|
38
|
+
findFreeLane(5, 0, 10, (c) => {
|
|
39
|
+
probed.push(c)
|
|
40
|
+
return false
|
|
41
|
+
})
|
|
42
|
+
expect(probed.slice(0, 7)).toEqual([5, 6, 4, 7, 3, 8, 2])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('prefers the nearer free lane, forward winning a tie', () => {
|
|
46
|
+
// Both 4 and 6 are free and equidistant from 5.
|
|
47
|
+
expect(findFreeLane(5, 0, 10, (c) => c === 4 || c === 6)).toBe(6)
|
|
48
|
+
// Backward-only: 4 is free, 6 is not.
|
|
49
|
+
expect(findFreeLane(5, 0, 10, (c) => c === 4)).toBe(4)
|
|
50
|
+
// A nearer backward lane beats a further forward one.
|
|
51
|
+
expect(findFreeLane(5, 0, 10, (c) => c === 3 || c === 9)).toBe(3)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('treats min and max as inclusive bounds on outward candidates', () => {
|
|
55
|
+
expect(findFreeLane(5, 0, 7, (c) => c === 7)).toBe(7)
|
|
56
|
+
expect(findFreeLane(5, 0, 6, (c) => c === 7)).toBeUndefined()
|
|
57
|
+
expect(findFreeLane(5, 2, 10, (c) => c === 2)).toBe(2)
|
|
58
|
+
expect(findFreeLane(5, 3, 10, (c) => c === 2)).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('never calls the occupancy check on an out-of-range candidate', () => {
|
|
62
|
+
const probed: number[] = []
|
|
63
|
+
findFreeLane(5, 4, 6, (c) => {
|
|
64
|
+
probed.push(c)
|
|
65
|
+
return false
|
|
66
|
+
})
|
|
67
|
+
expect(probed).toEqual([5, 6, 4])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns undefined when nothing in range is free', () => {
|
|
71
|
+
expect(findFreeLane(5, 0, 10, () => false)).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns undefined when the bounds leave no candidates at all', () => {
|
|
75
|
+
// min > max — ER's narrow-gap case, where only `preferred` is probed.
|
|
76
|
+
const probed: number[] = []
|
|
77
|
+
const result = findFreeLane(5, 6, 4, (c) => {
|
|
78
|
+
probed.push(c)
|
|
79
|
+
return false
|
|
80
|
+
})
|
|
81
|
+
expect(result).toBeUndefined()
|
|
82
|
+
expect(probed).toEqual([5])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('searches far enough to reach whichever bound is further away', () => {
|
|
86
|
+
expect(findFreeLane(5, 0, 100, (c) => c === 100)).toBe(100)
|
|
87
|
+
expect(findFreeLane(95, 0, 100, (c) => c === 0)).toBe(0)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// --- Differential tests against the pre-extraction implementations -------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Deterministic 32-bit LCG. A fixed seed keeps a failure reproducible;
|
|
95
|
+
* `Math.random` would make one impossible to re-run.
|
|
96
|
+
*/
|
|
97
|
+
function makeRng(seed: number): () => number {
|
|
98
|
+
let state = seed >>> 0
|
|
99
|
+
return () => {
|
|
100
|
+
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
|
101
|
+
return state / 0x100000000
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `chooseFreeRow`'s search loop, copied verbatim from before #617. */
|
|
106
|
+
function legacyChooseRow(
|
|
107
|
+
startY: number,
|
|
108
|
+
endY: number,
|
|
109
|
+
isRowFree: (y: number) => boolean,
|
|
110
|
+
): number {
|
|
111
|
+
const preferred = Math.floor((startY + endY) / 2)
|
|
112
|
+
if (isRowFree(preferred)) return preferred
|
|
113
|
+
|
|
114
|
+
const maxOffset = endY - startY
|
|
115
|
+
for (let d = 1; d <= maxOffset; d++) {
|
|
116
|
+
const below = preferred + d
|
|
117
|
+
if (below < endY && isRowFree(below)) return below
|
|
118
|
+
const above = preferred - d
|
|
119
|
+
if (above > startY && isRowFree(above)) return above
|
|
120
|
+
}
|
|
121
|
+
return preferred
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `findClearColumn`'s search loop, copied verbatim from before #617. */
|
|
125
|
+
function legacyClearColumn(
|
|
126
|
+
startX: number,
|
|
127
|
+
totalW: number,
|
|
128
|
+
isColumnClear: (x: number) => boolean,
|
|
129
|
+
): number {
|
|
130
|
+
if (isColumnClear(startX)) return startX
|
|
131
|
+
|
|
132
|
+
for (let offset = 1; offset < totalW + 10; offset++) {
|
|
133
|
+
const rightX = startX + offset
|
|
134
|
+
if (isColumnClear(rightX)) return rightX
|
|
135
|
+
const leftX = startX - offset
|
|
136
|
+
if (leftX >= 0 && isColumnClear(leftX)) return leftX
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return totalW + 2
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('findFreeLane matches the implementations it replaced', () => {
|
|
143
|
+
it("reproduces chooseFreeRow's row search on randomized occupancy", () => {
|
|
144
|
+
const rng = makeRng(0x5eed_617)
|
|
145
|
+
for (let trial = 0; trial < 2000; trial++) {
|
|
146
|
+
const startY = Math.floor(rng() * 20)
|
|
147
|
+
const endY = startY + Math.floor(rng() * 20)
|
|
148
|
+
// Density spans "almost everything free" to "almost nothing free" so
|
|
149
|
+
// both the hit and the exhausted-fallback paths get exercised.
|
|
150
|
+
const density = rng()
|
|
151
|
+
const occupied = new Set<number>()
|
|
152
|
+
for (let y = startY - 5; y <= endY + 5; y++) {
|
|
153
|
+
if (rng() < density) occupied.add(y)
|
|
154
|
+
}
|
|
155
|
+
const isRowFree = (y: number): boolean => !occupied.has(y)
|
|
156
|
+
|
|
157
|
+
const preferred = Math.floor((startY + endY) / 2)
|
|
158
|
+
const extracted =
|
|
159
|
+
findFreeLane(preferred, startY + 1, endY - 1, isRowFree) ?? preferred
|
|
160
|
+
|
|
161
|
+
expect({ startY, endY, chosen: extracted }).toEqual({
|
|
162
|
+
startY,
|
|
163
|
+
endY,
|
|
164
|
+
chosen: legacyChooseRow(startY, endY, isRowFree),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("reproduces findClearColumn's column search on randomized occupancy", () => {
|
|
170
|
+
const rng = makeRng(0xc01_5e5)
|
|
171
|
+
for (let trial = 0; trial < 2000; trial++) {
|
|
172
|
+
const totalW = 4 + Math.floor(rng() * 40)
|
|
173
|
+
const startX = Math.floor(rng() * totalW)
|
|
174
|
+
const density = rng()
|
|
175
|
+
// Only columns within the canvas can be blocked by a box — past the
|
|
176
|
+
// widest box every column is clear, which is why the original's
|
|
177
|
+
// `totalW + 2` fallback was unreachable in practice.
|
|
178
|
+
const blocked = new Set<number>()
|
|
179
|
+
for (let x = 0; x < totalW; x++) {
|
|
180
|
+
if (rng() < density) blocked.add(x)
|
|
181
|
+
}
|
|
182
|
+
const isColumnClear = (x: number): boolean => !blocked.has(x)
|
|
183
|
+
|
|
184
|
+
const extracted =
|
|
185
|
+
findFreeLane(startX, 0, startX + totalW + 9, isColumnClear) ??
|
|
186
|
+
totalW + 2
|
|
187
|
+
|
|
188
|
+
expect({ totalW, startX, chosen: extracted }).toEqual({
|
|
189
|
+
totalW,
|
|
190
|
+
startX,
|
|
191
|
+
chosen: legacyClearColumn(startX, totalW, isColumnClear),
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("reproduces findClearColumn's fallback when every column is blocked", () => {
|
|
197
|
+
// Not reachable through the real renderer (a column past the widest box
|
|
198
|
+
// is always clear), but the fallback is still wired up — so pin the
|
|
199
|
+
// agreement rather than leaving the branch untested.
|
|
200
|
+
const totalW = 12
|
|
201
|
+
const startX = 3
|
|
202
|
+
const neverClear = (): boolean => false
|
|
203
|
+
expect(
|
|
204
|
+
findFreeLane(startX, 0, startX + totalW + 9, neverClear) ?? totalW + 2,
|
|
205
|
+
).toBe(legacyClearColumn(startX, totalW, neverClear))
|
|
206
|
+
})
|
|
207
|
+
})
|