@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,278 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createEdgeCellStyles,
|
|
4
|
+
findStyleConflict,
|
|
5
|
+
claimPathCells,
|
|
6
|
+
createEdgeCellOwners,
|
|
7
|
+
findUnrelatedOverlap,
|
|
8
|
+
claimPathOwners,
|
|
9
|
+
} from '../edge-cell-styles.ts'
|
|
10
|
+
import { createGrid, placeBlock } from '../grid-occupancy.ts'
|
|
11
|
+
import type { AsciiEdge, AsciiNode } from '../types.ts'
|
|
12
|
+
import { Down } from '../types.ts'
|
|
13
|
+
|
|
14
|
+
describe('edge-cell-styles', () => {
|
|
15
|
+
it('reports no conflict against an empty map', () => {
|
|
16
|
+
const grid = createGrid()
|
|
17
|
+
const cellStyles = createEdgeCellStyles()
|
|
18
|
+
const path = [
|
|
19
|
+
{ x: 0, y: 0 },
|
|
20
|
+
{ x: 3, y: 0 },
|
|
21
|
+
]
|
|
22
|
+
expect(findStyleConflict(grid, cellStyles, path, 'solid')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not flag a second same-style edge through claimed cells', () => {
|
|
26
|
+
const grid = createGrid()
|
|
27
|
+
const cellStyles = createEdgeCellStyles()
|
|
28
|
+
const path = [
|
|
29
|
+
{ x: 0, y: 0 },
|
|
30
|
+
{ x: 3, y: 0 },
|
|
31
|
+
]
|
|
32
|
+
claimPathCells(grid, cellStyles, path, 'solid')
|
|
33
|
+
// A second solid edge reusing the same cells is exactly the "sibling
|
|
34
|
+
// edges share a trunk" / "LR routing merges naturally at corners"
|
|
35
|
+
// behavior other tests rely on — must stay unflagged.
|
|
36
|
+
expect(findStyleConflict(grid, cellStyles, path, 'solid')).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('flags a different-style edge crossing already-claimed cells', () => {
|
|
40
|
+
const grid = createGrid()
|
|
41
|
+
const cellStyles = createEdgeCellStyles()
|
|
42
|
+
const path = [
|
|
43
|
+
{ x: 0, y: 0 },
|
|
44
|
+
{ x: 3, y: 0 },
|
|
45
|
+
]
|
|
46
|
+
claimPathCells(grid, cellStyles, path, 'solid')
|
|
47
|
+
expect(findStyleConflict(grid, cellStyles, path, 'dotted')).toEqual({
|
|
48
|
+
x: 0,
|
|
49
|
+
y: 0,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reports the first conflicting cell in path order', () => {
|
|
54
|
+
const grid = createGrid()
|
|
55
|
+
const cellStyles = createEdgeCellStyles()
|
|
56
|
+
claimPathCells(
|
|
57
|
+
grid,
|
|
58
|
+
cellStyles,
|
|
59
|
+
[
|
|
60
|
+
{ x: 5, y: 0 },
|
|
61
|
+
{ x: 8, y: 0 },
|
|
62
|
+
],
|
|
63
|
+
'thick',
|
|
64
|
+
)
|
|
65
|
+
const crossing = [
|
|
66
|
+
{ x: 0, y: 0 },
|
|
67
|
+
{ x: 10, y: 0 },
|
|
68
|
+
]
|
|
69
|
+
expect(findStyleConflict(grid, cellStyles, crossing, 'solid')).toEqual({
|
|
70
|
+
x: 5,
|
|
71
|
+
y: 0,
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('first claim wins — claiming a cell twice with different styles keeps the original', () => {
|
|
76
|
+
const grid = createGrid()
|
|
77
|
+
const cellStyles = createEdgeCellStyles()
|
|
78
|
+
const cell = [{ x: 1, y: 1 }]
|
|
79
|
+
claimPathCells(grid, cellStyles, cell, 'solid')
|
|
80
|
+
claimPathCells(grid, cellStyles, cell, 'dotted')
|
|
81
|
+
// Still solid from the first claim, so a *third* dotted edge is still
|
|
82
|
+
// reported as a conflict rather than silently "winning" the cell.
|
|
83
|
+
expect(findStyleConflict(grid, cellStyles, cell, 'dotted')).toEqual({
|
|
84
|
+
x: 1,
|
|
85
|
+
y: 1,
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not flag cells the path never touches', () => {
|
|
90
|
+
const grid = createGrid()
|
|
91
|
+
const cellStyles = createEdgeCellStyles()
|
|
92
|
+
claimPathCells(
|
|
93
|
+
grid,
|
|
94
|
+
cellStyles,
|
|
95
|
+
[
|
|
96
|
+
{ x: 0, y: 0 },
|
|
97
|
+
{ x: 0, y: 5 },
|
|
98
|
+
],
|
|
99
|
+
'solid',
|
|
100
|
+
)
|
|
101
|
+
const unrelated = [
|
|
102
|
+
{ x: 10, y: 10 },
|
|
103
|
+
{ x: 12, y: 10 },
|
|
104
|
+
]
|
|
105
|
+
expect(findStyleConflict(grid, cellStyles, unrelated, 'dotted')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Regression: an edge's path always includes its own source/target
|
|
110
|
+
* node's border cell — that's how it connects to the box at all — so
|
|
111
|
+
* two edges sharing a node (one incoming, one outgoing, as in a retry
|
|
112
|
+
* loop's `B -->|No| D; D -.-> A`) legitimately share that exact port
|
|
113
|
+
* cell. An earlier version tracked *every* path cell including
|
|
114
|
+
* node-owned ones, which flagged this as a conflict — and since an edge
|
|
115
|
+
* always returns to its own node's fixed attachment point regardless of
|
|
116
|
+
* grid occupancy, "blocking" a node cell and re-routing found the
|
|
117
|
+
* identical cell again every time, so the fix never actually converged.
|
|
118
|
+
*/
|
|
119
|
+
it('does not flag a shared node-border cell as a conflict', () => {
|
|
120
|
+
const grid = createGrid()
|
|
121
|
+
placeBlock(grid, { x: 4, y: 8 }) // node D's reserved 3x3 block
|
|
122
|
+
const cellStyles = createEdgeCellStyles()
|
|
123
|
+
|
|
124
|
+
// Incoming solid edge terminating at D's top-center port, (5, 8).
|
|
125
|
+
claimPathCells(
|
|
126
|
+
grid,
|
|
127
|
+
cellStyles,
|
|
128
|
+
[
|
|
129
|
+
{ x: 2, y: 5 },
|
|
130
|
+
{ x: 5, y: 5 },
|
|
131
|
+
{ x: 5, y: 8 },
|
|
132
|
+
],
|
|
133
|
+
'solid',
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Outgoing dotted edge leaving from D's right-side port, (6, 9) —
|
|
137
|
+
// inside the same reserved block, so still node-owned — then diverging
|
|
138
|
+
// into its own open column (7) rather than reusing (5, *).
|
|
139
|
+
const conflict = findStyleConflict(
|
|
140
|
+
grid,
|
|
141
|
+
cellStyles,
|
|
142
|
+
[
|
|
143
|
+
{ x: 6, y: 9 },
|
|
144
|
+
{ x: 7, y: 9 },
|
|
145
|
+
{ x: 7, y: 1 },
|
|
146
|
+
],
|
|
147
|
+
'dotted',
|
|
148
|
+
)
|
|
149
|
+
expect(conflict).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('still flags a genuine open-space conflict alongside a shared node border', () => {
|
|
153
|
+
const grid = createGrid()
|
|
154
|
+
placeBlock(grid, { x: 4, y: 8 }) // node D's reserved 3x3 block
|
|
155
|
+
const cellStyles = createEdgeCellStyles()
|
|
156
|
+
|
|
157
|
+
claimPathCells(
|
|
158
|
+
grid,
|
|
159
|
+
cellStyles,
|
|
160
|
+
[
|
|
161
|
+
{ x: 2, y: 5 },
|
|
162
|
+
{ x: 5, y: 5 },
|
|
163
|
+
{ x: 5, y: 8 },
|
|
164
|
+
],
|
|
165
|
+
'solid',
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// A different-style edge that reaches D via the *same open-space*
|
|
169
|
+
// column (5, 5)-(5, 8), not just the shared port cell, is still a
|
|
170
|
+
// real conflict — reported at the first open cell it crosses.
|
|
171
|
+
const conflict = findStyleConflict(
|
|
172
|
+
grid,
|
|
173
|
+
cellStyles,
|
|
174
|
+
[
|
|
175
|
+
{ x: 5, y: 1 },
|
|
176
|
+
{ x: 5, y: 8 },
|
|
177
|
+
],
|
|
178
|
+
'dotted',
|
|
179
|
+
)
|
|
180
|
+
expect(conflict).toEqual({ x: 5, y: 5 })
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
function makeNode(name: string): AsciiNode {
|
|
185
|
+
return {
|
|
186
|
+
name,
|
|
187
|
+
displayLabel: name,
|
|
188
|
+
shape: 'rectangle',
|
|
189
|
+
index: 0,
|
|
190
|
+
gridCoord: null,
|
|
191
|
+
drawingCoord: null,
|
|
192
|
+
drawing: null,
|
|
193
|
+
drawn: false,
|
|
194
|
+
styleClassName: '',
|
|
195
|
+
styleClass: { name: '', styles: {} },
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function makeEdge(from: AsciiNode, to: AsciiNode): AsciiEdge {
|
|
200
|
+
return {
|
|
201
|
+
from,
|
|
202
|
+
to,
|
|
203
|
+
text: '',
|
|
204
|
+
path: [],
|
|
205
|
+
labelLine: [],
|
|
206
|
+
startDir: Down,
|
|
207
|
+
endDir: Down,
|
|
208
|
+
style: 'solid',
|
|
209
|
+
hasArrowStart: false,
|
|
210
|
+
hasArrowEnd: true,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
describe('edge-cell-styles — chain overlap (multi-owner cells)', () => {
|
|
215
|
+
/**
|
|
216
|
+
* Regression for a CodeRabbit finding on PR #1093 (the #1067 fix):
|
|
217
|
+
* `claimPathOwners` originally stored only the *first* edge to claim a
|
|
218
|
+
* cell (`if (!owners.has(key)) owners.set(key, edge)`). When an edge
|
|
219
|
+
* unrelated to any chain claims a shared cell before the real chain
|
|
220
|
+
* partner does (an ordering `graph.edges` doesn't control), the chain
|
|
221
|
+
* partner's own claim was silently dropped — so `findUnrelatedOverlap`
|
|
222
|
+
* only ever saw the unrelated edge's claim, never found a chain pair, and
|
|
223
|
+
* missed the real overlap entirely. Cells must track every edge that
|
|
224
|
+
* claims them, not just the first.
|
|
225
|
+
*/
|
|
226
|
+
it('detects a chain-pair overlap even when an unrelated edge claimed the corridor first', () => {
|
|
227
|
+
const grid = createGrid()
|
|
228
|
+
const owners = createEdgeCellOwners()
|
|
229
|
+
|
|
230
|
+
const a = makeNode('A')
|
|
231
|
+
const b = makeNode('B')
|
|
232
|
+
const c = makeNode('C')
|
|
233
|
+
const x = makeNode('X')
|
|
234
|
+
const y = makeNode('Y')
|
|
235
|
+
|
|
236
|
+
const edgeAB = makeEdge(a, b)
|
|
237
|
+
const edgeBC = makeEdge(b, c)
|
|
238
|
+
const edgeXY = makeEdge(x, y) // unrelated to the A->B->C chain
|
|
239
|
+
|
|
240
|
+
const corridor = [
|
|
241
|
+
{ x: 0, y: 0 },
|
|
242
|
+
{ x: 0, y: 1 },
|
|
243
|
+
{ x: 0, y: 2 },
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
// The unrelated edge claims the corridor first.
|
|
247
|
+
claimPathOwners(grid, owners, corridor, edgeXY)
|
|
248
|
+
// The true chain partner (A->B) also routes through it.
|
|
249
|
+
claimPathOwners(grid, owners, corridor, edgeAB)
|
|
250
|
+
|
|
251
|
+
// B->C, checked against the same corridor, must still find its A->B
|
|
252
|
+
// chain partner's overlap — not be hidden behind X->Y's earlier claim.
|
|
253
|
+
const conflict = findUnrelatedOverlap(grid, owners, corridor, edgeBC)
|
|
254
|
+
expect(conflict).toEqual({ x: 0, y: 0 })
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('still finds nothing when only an unrelated edge claimed the corridor', () => {
|
|
258
|
+
const grid = createGrid()
|
|
259
|
+
const owners = createEdgeCellOwners()
|
|
260
|
+
|
|
261
|
+
const b = makeNode('B')
|
|
262
|
+
const c = makeNode('C')
|
|
263
|
+
const x = makeNode('X')
|
|
264
|
+
const y = makeNode('Y')
|
|
265
|
+
|
|
266
|
+
const edgeBC = makeEdge(b, c)
|
|
267
|
+
const edgeXY = makeEdge(x, y)
|
|
268
|
+
|
|
269
|
+
const corridor = [
|
|
270
|
+
{ x: 0, y: 0 },
|
|
271
|
+
{ x: 0, y: 1 },
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
claimPathOwners(grid, owners, corridor, edgeXY)
|
|
275
|
+
|
|
276
|
+
expect(findUnrelatedOverlap(grid, owners, corridor, edgeBC)).toBeNull()
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createGrid,
|
|
4
|
+
isOccupied,
|
|
5
|
+
isFree,
|
|
6
|
+
isBlockFree,
|
|
7
|
+
placeBlock,
|
|
8
|
+
pathCells,
|
|
9
|
+
cloneGrid,
|
|
10
|
+
NODE_BLOCK_SIZE,
|
|
11
|
+
} from '../grid-occupancy.ts'
|
|
12
|
+
|
|
13
|
+
describe('grid-occupancy', () => {
|
|
14
|
+
it('createGrid starts empty', () => {
|
|
15
|
+
const grid = createGrid()
|
|
16
|
+
expect(isOccupied(grid, { x: 0, y: 0 })).toBe(false)
|
|
17
|
+
expect(isFree(grid, { x: 0, y: 0 })).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('isFree', () => {
|
|
21
|
+
it('is false for negative coordinates even when unoccupied', () => {
|
|
22
|
+
const grid = createGrid()
|
|
23
|
+
expect(isOccupied(grid, { x: -1, y: 0 })).toBe(false)
|
|
24
|
+
expect(isFree(grid, { x: -1, y: 0 })).toBe(false)
|
|
25
|
+
expect(isFree(grid, { x: 0, y: -1 })).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is false for an occupied non-negative cell', () => {
|
|
29
|
+
const grid = createGrid()
|
|
30
|
+
placeBlock(grid, { x: 2, y: 2 }, 1)
|
|
31
|
+
expect(isFree(grid, { x: 2, y: 2 })).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('is true for a free non-negative cell', () => {
|
|
35
|
+
const grid = createGrid()
|
|
36
|
+
expect(isFree(grid, { x: 5, y: 5 })).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('isBlockFree / placeBlock', () => {
|
|
41
|
+
it('reports a fresh block as free, then occupied after placing', () => {
|
|
42
|
+
const grid = createGrid()
|
|
43
|
+
const origin = { x: 0, y: 0 }
|
|
44
|
+
expect(isBlockFree(grid, origin)).toBe(true)
|
|
45
|
+
placeBlock(grid, origin)
|
|
46
|
+
expect(isBlockFree(grid, origin)).toBe(false)
|
|
47
|
+
// Every cell in the default NODE_BLOCK_SIZE x NODE_BLOCK_SIZE block
|
|
48
|
+
// is reserved, not just the origin cell.
|
|
49
|
+
for (let dx = 0; dx < NODE_BLOCK_SIZE; dx++) {
|
|
50
|
+
for (let dy = 0; dy < NODE_BLOCK_SIZE; dy++) {
|
|
51
|
+
expect(isOccupied(grid, { x: origin.x + dx, y: origin.y + dy })).toBe(
|
|
52
|
+
true,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('detects partial overlap with an already-placed block', () => {
|
|
59
|
+
const grid = createGrid()
|
|
60
|
+
placeBlock(grid, { x: 0, y: 0 }) // occupies (0,0)..(2,2)
|
|
61
|
+
// Shifted by 1 in each axis: overlaps the first block at (1,1)-(2,2)
|
|
62
|
+
// even though its own origin cell (1,1) is inside the first block too.
|
|
63
|
+
expect(isBlockFree(grid, { x: 1, y: 1 })).toBe(false)
|
|
64
|
+
// Fully disjoint block (mod-4 pitch) is free.
|
|
65
|
+
expect(isBlockFree(grid, { x: 4, y: 0 })).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws rather than silently overwriting on collision', () => {
|
|
69
|
+
const grid = createGrid()
|
|
70
|
+
placeBlock(grid, { x: 0, y: 0 })
|
|
71
|
+
expect(() => placeBlock(grid, { x: 1, y: 1 })).toThrow(/already occupied/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('supports a single-cell block via size=1', () => {
|
|
75
|
+
const grid = createGrid()
|
|
76
|
+
placeBlock(grid, { x: 3, y: 3 }, 1)
|
|
77
|
+
expect(isOccupied(grid, { x: 3, y: 3 })).toBe(true)
|
|
78
|
+
expect(isOccupied(grid, { x: 4, y: 3 })).toBe(false)
|
|
79
|
+
expect(isOccupied(grid, { x: 3, y: 4 })).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('Grid.delete', () => {
|
|
84
|
+
it('releases a cell so it reports free again', () => {
|
|
85
|
+
const grid = createGrid()
|
|
86
|
+
grid.add('5,5')
|
|
87
|
+
expect(grid.has('5,5')).toBe(true)
|
|
88
|
+
grid.delete('5,5')
|
|
89
|
+
expect(grid.has('5,5')).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('is a no-op for a cell that was never reserved', () => {
|
|
93
|
+
const grid = createGrid()
|
|
94
|
+
expect(() => grid.delete('9,9')).not.toThrow()
|
|
95
|
+
expect(grid.has('9,9')).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('pathCells', () => {
|
|
100
|
+
it('returns an empty array for an empty path', () => {
|
|
101
|
+
expect(pathCells([])).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns the single point for a one-point path', () => {
|
|
105
|
+
expect(pathCells([{ x: 4, y: 7 }])).toEqual([{ x: 4, y: 7 }])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('walks every intermediate cell of a horizontal segment', () => {
|
|
109
|
+
const cells = pathCells([
|
|
110
|
+
{ x: 0, y: 0 },
|
|
111
|
+
{ x: 3, y: 0 },
|
|
112
|
+
])
|
|
113
|
+
expect(cells).toEqual([
|
|
114
|
+
{ x: 0, y: 0 },
|
|
115
|
+
{ x: 1, y: 0 },
|
|
116
|
+
{ x: 2, y: 0 },
|
|
117
|
+
{ x: 3, y: 0 },
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('walks every intermediate cell of a vertical segment', () => {
|
|
122
|
+
const cells = pathCells([
|
|
123
|
+
{ x: 2, y: 5 },
|
|
124
|
+
{ x: 2, y: 2 },
|
|
125
|
+
])
|
|
126
|
+
expect(cells).toEqual([
|
|
127
|
+
{ x: 2, y: 5 },
|
|
128
|
+
{ x: 2, y: 4 },
|
|
129
|
+
{ x: 2, y: 3 },
|
|
130
|
+
{ x: 2, y: 2 },
|
|
131
|
+
])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('walks a clean 45° diagonal', () => {
|
|
135
|
+
const cells = pathCells([
|
|
136
|
+
{ x: 0, y: 0 },
|
|
137
|
+
{ x: 3, y: 3 },
|
|
138
|
+
])
|
|
139
|
+
expect(cells).toEqual([
|
|
140
|
+
{ x: 0, y: 0 },
|
|
141
|
+
{ x: 1, y: 1 },
|
|
142
|
+
{ x: 2, y: 2 },
|
|
143
|
+
{ x: 3, y: 3 },
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Regression for a real crash: `determinePath`'s Case-4 direct fallback
|
|
149
|
+
* (`edge.path = [prefFrom, prefTo]`, used when A* finds no route at
|
|
150
|
+
* all) can produce a segment whose dx/dy ratio is anything, not just
|
|
151
|
+
* 0, equal, or axis-aligned. An earlier version of `pathCells` stepped
|
|
152
|
+
* both axes by `Math.sign(delta)` every iteration, which only ever
|
|
153
|
+
* reaches the target when `|dx| === |dy|` — for a 10-wide, 3-tall
|
|
154
|
+
* segment like this one it walks (10,10), (11,11)... forever, since x
|
|
155
|
+
* and y can never both equal (10,3) on the same iteration. That
|
|
156
|
+
* doesn't just produce a wrong path — it hung the real render until a
|
|
157
|
+
* backing `Set` threw "maximum size exceeded" (see
|
|
158
|
+
* ascii-edge-cross-style-overlap.test.ts for the end-to-end repro).
|
|
159
|
+
* Bresenham handles any ratio and is guaranteed to land exactly on the
|
|
160
|
+
* endpoint in `max(|dx|, |dy|) + 1` steps.
|
|
161
|
+
*/
|
|
162
|
+
it('terminates and lands exactly on the endpoint for a non-45° diagonal', () => {
|
|
163
|
+
const cells = pathCells([
|
|
164
|
+
{ x: 0, y: 0 },
|
|
165
|
+
{ x: 10, y: 3 },
|
|
166
|
+
])
|
|
167
|
+
expect(cells[cells.length - 1]).toEqual({ x: 10, y: 3 })
|
|
168
|
+
expect(cells.length).toBe(11) // max(|10|, |3|) + 1
|
|
169
|
+
// Every step must move by at most one cell on each axis (no skips,
|
|
170
|
+
// no doubling back) — Bresenham can step both axes in the same
|
|
171
|
+
// iteration to stay close to the true line, so Chebyshev distance
|
|
172
|
+
// between consecutive cells is always exactly 1, though Manhattan
|
|
173
|
+
// distance can be 1 or 2.
|
|
174
|
+
for (let i = 1; i < cells.length; i++) {
|
|
175
|
+
const a = cells[i - 1]!
|
|
176
|
+
const b = cells[i]!
|
|
177
|
+
expect(Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y))).toBe(1)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('walks a multi-segment path corner-to-corner without duplicating the shared corner', () => {
|
|
182
|
+
const cells = pathCells([
|
|
183
|
+
{ x: 0, y: 0 },
|
|
184
|
+
{ x: 2, y: 0 },
|
|
185
|
+
{ x: 2, y: 2 },
|
|
186
|
+
])
|
|
187
|
+
expect(cells).toEqual([
|
|
188
|
+
{ x: 0, y: 0 },
|
|
189
|
+
{ x: 1, y: 0 },
|
|
190
|
+
{ x: 2, y: 0 },
|
|
191
|
+
{ x: 2, y: 1 },
|
|
192
|
+
{ x: 2, y: 2 },
|
|
193
|
+
])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Regression: an earlier version silently returned a path truncated at
|
|
198
|
+
* `MAX_WALK_STEPS`, which under-reports to a caller checking "every
|
|
199
|
+
* cell this path touches" (edge-cell-styles.ts's conflict detection,
|
|
200
|
+
* specifically) — a conflict past the cutoff would simply never be
|
|
201
|
+
* seen. Throwing converts that silent blind spot into a loud failure.
|
|
202
|
+
*/
|
|
203
|
+
it('throws rather than silently returning a truncated path past the walk limit', () => {
|
|
204
|
+
expect(() =>
|
|
205
|
+
pathCells([
|
|
206
|
+
{ x: 0, y: 0 },
|
|
207
|
+
{ x: 200_000, y: 0 },
|
|
208
|
+
]),
|
|
209
|
+
).toThrow(/did not reach its endpoint/)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('Grid.keys / cloneGrid', () => {
|
|
214
|
+
it('keys() iterates every reserved cell', () => {
|
|
215
|
+
const grid = createGrid()
|
|
216
|
+
grid.add('1,1')
|
|
217
|
+
grid.add('2,2')
|
|
218
|
+
expect(new Set(grid.keys())).toEqual(new Set(['1,1', '2,2']))
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('cloneGrid copies current reservations into an independent grid', () => {
|
|
222
|
+
const original = createGrid()
|
|
223
|
+
original.add('1,1')
|
|
224
|
+
const clone = cloneGrid(original)
|
|
225
|
+
expect(isOccupied(clone, { x: 1, y: 1 })).toBe(true)
|
|
226
|
+
|
|
227
|
+
// Independent: mutating one after cloning doesn't affect the other.
|
|
228
|
+
original.add('2,2')
|
|
229
|
+
expect(isOccupied(clone, { x: 2, y: 2 })).toBe(false)
|
|
230
|
+
|
|
231
|
+
clone.add('3,3')
|
|
232
|
+
expect(isOccupied(original, { x: 3, y: 3 })).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('cloneGrid on an empty grid produces an empty grid', () => {
|
|
236
|
+
const clone = cloneGrid(createGrid())
|
|
237
|
+
expect([...clone.keys()]).toEqual([])
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural fact extraction for rendered ASCII diagrams — shared by the
|
|
3
|
+
* "form invariant" suites (ascii-sequence-form-invariants.test.ts,
|
|
4
|
+
* ascii-class-box-occupancy.test.ts, ascii-er-box-occupancy.test.ts).
|
|
5
|
+
*
|
|
6
|
+
* These suites assert structural properties of the rendered output (does
|
|
7
|
+
* this box overlap that one, does this label fit inside its container)
|
|
8
|
+
* rather than diffing against a committed fixture. `svg-samples.visual.test.ts`
|
|
9
|
+
* / `ascii-samples.visual.test.ts` only self-diff against their own baseline
|
|
10
|
+
* screenshot, which can never catch a defect that was already present when
|
|
11
|
+
* that baseline was captured — these helpers exist to check the renderer
|
|
12
|
+
* against its own stated semantics instead (e.g. "a box must be wide enough
|
|
13
|
+
* to hold its label", "an unrelated box must never be overwritten").
|
|
14
|
+
*
|
|
15
|
+
* Deliberately works on the *rendered string*, not renderer internals:
|
|
16
|
+
* `renderSequenceAscii`/`renderClassAscii`/`renderErAscii` each do
|
|
17
|
+
* parse→layout→draw→stringify in one function with no layout object
|
|
18
|
+
* exposed, and (checked directly) `src/ascii/er-diagram.ts`'s own occupancy
|
|
19
|
+
* guard (`boxCells`) is a flat, entity-agnostic occupied-cell set — even the
|
|
20
|
+
* renderer's own internals can't answer "which entity owns this cell"
|
|
21
|
+
* without a scan shaped like the one below. Matches this repo's existing
|
|
22
|
+
* convention of small, local, string-based test helpers (see
|
|
23
|
+
* `helpers/terminal-display-width.ts`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface Rect {
|
|
27
|
+
x0: number
|
|
28
|
+
x1: number
|
|
29
|
+
y0: number
|
|
30
|
+
y1: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Border-ish characters in both Unicode and `useAscii: true` box-drawing. */
|
|
34
|
+
const BORDER_CHARS = new Set([
|
|
35
|
+
'│',
|
|
36
|
+
'┌',
|
|
37
|
+
'└',
|
|
38
|
+
'├',
|
|
39
|
+
'┤',
|
|
40
|
+
'┐',
|
|
41
|
+
'┘',
|
|
42
|
+
'─',
|
|
43
|
+
'┬',
|
|
44
|
+
'┴',
|
|
45
|
+
'┼',
|
|
46
|
+
'+',
|
|
47
|
+
'|',
|
|
48
|
+
'-',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
function isBorderish(ch: string | undefined): boolean {
|
|
52
|
+
return ch !== undefined && BORDER_CHARS.has(ch)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function lineAt(lines: string[], y: number, x: number): string | undefined {
|
|
56
|
+
return lines[y]?.[x]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locate a named, bordered box (an actor box, a class box, an entity box) by
|
|
61
|
+
* its header text.
|
|
62
|
+
*
|
|
63
|
+
* Finds the row where `name` sits with a border-ish character immediately to
|
|
64
|
+
* its left and right (skipping padding spaces), *and* a border-ish character
|
|
65
|
+
* directly above that same column (the box's top border or a section
|
|
66
|
+
* divider) — the second check keeps a name that's a substring of another
|
|
67
|
+
* name, or that happens to appear inside an attribute/label string, from
|
|
68
|
+
* false-matching a header row it isn't actually part of. Then walks outward
|
|
69
|
+
* from that row along the left/right border columns until the border stops
|
|
70
|
+
* holding, to find the box's full vertical extent.
|
|
71
|
+
*
|
|
72
|
+
* Works identically in Unicode and `useAscii: true` mode: it only asks "is
|
|
73
|
+
* this column border-ish at this row," never which specific glyph is there.
|
|
74
|
+
*/
|
|
75
|
+
export function findBoxRect(ascii: string, name: string): Rect {
|
|
76
|
+
const lines = ascii.split('\n')
|
|
77
|
+
|
|
78
|
+
for (let y = 0; y < lines.length; y++) {
|
|
79
|
+
const line = lines[y]!
|
|
80
|
+
let searchFrom = 0
|
|
81
|
+
for (;;) {
|
|
82
|
+
const idx = line.indexOf(name, searchFrom)
|
|
83
|
+
if (idx === -1) break
|
|
84
|
+
searchFrom = idx + 1
|
|
85
|
+
|
|
86
|
+
let left = idx - 1
|
|
87
|
+
while (left >= 0 && line[left] === ' ') left--
|
|
88
|
+
let right = idx + name.length
|
|
89
|
+
while (right < line.length && line[right] === ' ') right++
|
|
90
|
+
|
|
91
|
+
if (!isBorderish(line[left]) || !isBorderish(line[right])) continue
|
|
92
|
+
// Require a border-ish char directly above this header row (top
|
|
93
|
+
// border or a divider) — rejects a match inside a box's body text.
|
|
94
|
+
if (!isBorderish(lineAt(lines, y - 1, left))) continue
|
|
95
|
+
|
|
96
|
+
const x0 = left
|
|
97
|
+
const x1 = right
|
|
98
|
+
|
|
99
|
+
let y0 = y
|
|
100
|
+
while (
|
|
101
|
+
isBorderish(lineAt(lines, y0 - 1, x0)) &&
|
|
102
|
+
isBorderish(lineAt(lines, y0 - 1, x1))
|
|
103
|
+
) {
|
|
104
|
+
y0--
|
|
105
|
+
}
|
|
106
|
+
let y1 = y
|
|
107
|
+
while (
|
|
108
|
+
isBorderish(lineAt(lines, y1 + 1, x0)) &&
|
|
109
|
+
isBorderish(lineAt(lines, y1 + 1, x1))
|
|
110
|
+
) {
|
|
111
|
+
y1++
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { x0, x1, y0, y1 }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(`findBoxRect: no bordered box found for "${name}"`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Locate free-floating text (a relationship label, a note's own text) that
|
|
123
|
+
* isn't itself inside a bordered box header — same row/column scan as
|
|
124
|
+
* `findBoxRect` but without the border precondition. Returns the tightest
|
|
125
|
+
* rectangle containing every occurrence-adjacent character of `text` on its
|
|
126
|
+
* line (just the matched span, not a box).
|
|
127
|
+
*/
|
|
128
|
+
export function findTextRect(ascii: string, text: string): Rect {
|
|
129
|
+
const lines = ascii.split('\n')
|
|
130
|
+
for (let y = 0; y < lines.length; y++) {
|
|
131
|
+
const idx = lines[y]!.indexOf(text)
|
|
132
|
+
if (idx !== -1) {
|
|
133
|
+
return { x0: idx, x1: idx + text.length - 1, y0: y, y1: y }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`findTextRect: "${text}" not found`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Half-open interval overlap check between two rectangles. */
|
|
140
|
+
export function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
141
|
+
return a.x0 <= b.x1 && a.x1 >= b.x0 && a.y0 <= b.y1 && a.y1 >= b.y0
|
|
142
|
+
}
|