mermaid-text 0.42.2

Render Mermaid diagrams as Unicode box-drawing text — no browser, no image protocols, pure Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
//! Edge routing strategy layer.
//!
//! Provides a single entry point [`route_all`] that routes every edge in a
//! graph end-to-end with one A\* call per edge, preceded by two fast-path
//! checks:
//!
//! 1. **Straight route** — if every cell between source and target on the
//!    same row or column is free, draw the direct path in O(n) without
//!    running A\*.
//! 2. **L-route** — try both single-bend shapes (horizontal-first and
//!    vertical-first); return the one whose cells are cheaper (fewest
//!    obstacles), again without a full A\*.
//! 3. **A\* fallback** — for all other cases, delegate to
//!    [`Grid::route_edge`] / [`Grid::route_back_edge`], which run the full
//!    obstacle-aware pathfinder.
//!
//! Edges are routed in ascending Manhattan-distance order (shortest first).
//! This distributes short edges into clean channels before long edges need
//! to route around them, reducing avoidable crossings.
//!
//! The output is a `Vec<Option<Vec<(usize, usize)>>>` indexed by the
//! **original** edge index from `graph.edges`, so downstream code can look up
//! `paths[edge_idx]` without any remapping.

use crate::layout::Grid;
use crate::layout::grid::{Attach, DIR_DOWN, DIR_UP};
use crate::types::{Direction, Graph};

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Route all edges in `graph` and return their pixel paths.
///
/// Edges are routed in ascending Manhattan-distance order. Each edge is tried
/// through three strategies in order: straight line, single-bend L, and
/// full A\* pathfinding.
///
/// # Arguments
///
/// * `grid`          — the canvas; updated in-place as each edge is drawn.
/// * `graph`         — the parsed flowchart (used for `is_back_edge`
///   classification and edge ordering).
/// * `attach_points` — per-edge `(src, dst)` attach points as produced by
///   `compute_spread_attaches`; indexed by edge index. `None` entries are
///   skipped (missing position data for that edge).
/// * `tip_for`       — closure returning the arrow-tip character for edge `i`.
/// * `is_back`       — closure returning `true` when edge `i` is a back-edge.
///
/// # Returns
///
/// A `Vec` with one entry per edge in `graph.edges`. Each entry is:
/// - `Some(path)` — the routed pixel path, suitable for label placement and
///   style post-processing.
/// - `None` — edge had no valid attach point (node missing from positions).
pub(crate) fn route_all(
    grid: &mut Grid,
    graph: &Graph,
    attach_points: &[Option<(Attach, Attach)>],
    tip_for: impl Fn(usize) -> char,
    is_back: impl Fn(usize) -> bool,
) -> Vec<Option<Vec<(usize, usize)>>> {
    let n = graph.edges.len();
    let mut paths: Vec<Option<Vec<(usize, usize)>>> = vec![None; n];

    // Route shortest edges first: fewer cells between endpoints means fewer
    // obstacles to compete with when claiming corridor space.
    let order = order_edges(graph, attach_points);
    let horizontal_first = graph.direction.is_horizontal();

    for edge_idx in order {
        let Some(Some((src, dst))) = attach_points.get(edge_idx) else {
            continue;
        };
        let (src, dst) = (*src, *dst);
        let tip = tip_for(edge_idx);
        let back = is_back(edge_idx);

        let path = if back {
            // Back-edges always use A* with high InnerArea cost to bias them
            // toward the perimeter corridor. The fast-path checks don't apply
            // because back-edges must avoid the diagram body.
            grid.route_back_edge(src.col, src.row, dst.col, dst.row, horizontal_first, tip)
        } else if let Some(p) = try_straight_route(grid, src, dst, horizontal_first, tip) {
            Some(p)
        } else if let Some(p) = try_l_route(grid, src, dst, horizontal_first, tip) {
            Some(p)
        } else if let Some(p) = try_u_route(grid, src, dst, tip) {
            Some(p)
        } else {
            grid.route_edge(src.col, src.row, dst.col, dst.row, horizontal_first, tip)
        };

        // Source-attach: only for TD/BT layouts whose route turns
        // sideways at the source cell.
        //
        // The cell at the route's first position renders from its
        // direction bits. For TD/BT layouts the source attach sits
        // immediately below/above the box (against a horizontal `─`
        // border). When the route's first step is also horizontal, the
        // single-bit cell renders as `─`, which stacks confusingly
        // beside the box's `─` border — both look like horizontal
        // segments at adjacent rows. Adding the "back into box" UP/DOWN
        // bit converts the cell to a corner glyph (`└ ┘ ┌ ┐`) that
        // visibly exits the box border.
        //
        // Skipped for:
        //   - vertical first steps (cell is `│`, sits cleanly next to
        //     any box wall — no anchor needed)
        //   - LR/RL layouts (the back-into-box bit would land on the
        //     same axis as the route's first step, OR-ing into a
        //     visual no-op `─` while still polluting the cell's
        //     direction bits, which subtly increases edge_occupied
        //     weight for downstream L-route cost calculations and
        //     measurably worsens crossings on dense LR graphs)
        //
        // The 1.22.1 release added the anchor unconditionally, which
        // produced spurious corners (`│┐` `│┘`) on every edge with a
        // vertical first step (back-edges, mid-side attach points in
        // LR layouts containing internal TB subgraphs).
        if let Some(p) = &path
            && p.len() >= 2
        {
            let (c0, r0) = p[0];
            let (_, r1) = p[1];
            let route_first_step_horizontal = r0 == r1;
            if route_first_step_horizontal {
                let anchor = match graph.direction {
                    Direction::TopToBottom => Some(DIR_UP),
                    Direction::BottomToTop => Some(DIR_DOWN),
                    Direction::LeftToRight | Direction::RightToLeft => None,
                };
                if let Some(bits) = anchor {
                    grid.add_dirs(c0, r0, bits);
                }
            }
        }

        paths[edge_idx] = path;
    }

    paths
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/// Order edge indices by ascending Manhattan distance between their attach
/// points. Edges with no attach point (missing position data) are appended
/// last in declaration order.
///
/// Routing shortest-first opens clean corridors before long edges compete for
/// the same space, reducing unnecessary crossings.
fn order_edges(graph: &Graph, attach_points: &[Option<(Attach, Attach)>]) -> Vec<usize> {
    let n = graph.edges.len();
    let mut with_dist: Vec<(usize, usize)> = (0..n)
        .map(|i| {
            let dist = attach_points
                .get(i)
                .and_then(|p| p.as_ref())
                .map(|(src, dst)| src.col.abs_diff(dst.col) + src.row.abs_diff(dst.row))
                .unwrap_or(usize::MAX); // no attach → sort to end
            (i, dist)
        })
        .collect();
    // Stable sort preserves declaration order within same-distance ties.
    with_dist.sort_by_key(|&(_, d)| d);
    with_dist.into_iter().map(|(i, _)| i).collect()
}

/// Attempt a straight-line route between `src` and `dst`.
///
/// Succeeds when source and target share the same row OR the same column AND
/// every cell between them is free (not a `NodeBox`). When it succeeds the
/// path is drawn on the grid and returned. When it fails `None` is returned
/// and the grid is unchanged.
///
/// This avoids A\* for the common case of adjacent nodes in a well-spaced
/// layout where no obstacles fall on the direct line.
fn try_straight_route(
    grid: &mut Grid,
    src: Attach,
    dst: Attach,
    horizontal_first: bool,
    tip: char,
) -> Option<Vec<(usize, usize)>> {
    if src.col == dst.col {
        // Same column — try vertical straight line.
        let (r0, r1) = min_max(src.row, dst.row);
        if (r0..=r1).all(|r| !grid.is_node_box(src.col, r)) {
            return grid.route_edge(src.col, src.row, dst.col, dst.row, horizontal_first, tip);
        }
    } else if src.row == dst.row {
        // Same row — try horizontal straight line.
        let (c0, c1) = min_max(src.col, dst.col);
        if (c0..=c1).all(|c| !grid.is_node_box(c, src.row)) {
            return grid.route_edge(src.col, src.row, dst.col, dst.row, horizontal_first, tip);
        }
    }
    None
}

/// Attempt a single-bend L-shaped route between `src` and `dst`.
///
/// Tries both bend orientations (H-then-V and V-then-H) and returns the
/// route whose cells have the lowest total obstacle weight — i.e. the one
/// that crosses fewer existing edges or node boxes. Returns `None` if both
/// orientations have hard obstacles (NodeBox cells) blocking them.
///
/// The grid is updated only when a route is found and accepted.
fn try_l_route(
    grid: &mut Grid,
    src: Attach,
    dst: Attach,
    horizontal_first: bool,
    tip: char,
) -> Option<Vec<(usize, usize)>> {
    if src.col == dst.col || src.row == dst.row {
        // Degenerate L (already straight) — handled by try_straight_route or A*.
        return None;
    }

    // Two L-shape options. The corner cell of each:
    //   H-first (horizontal-then-vertical) → corner at (dst.col, src.row)
    //                                        → bend NEAR the SOURCE row.
    //   V-first (vertical-then-horizontal) → corner at (src.col, dst.row)
    //                                        → bend NEAR the TARGET row.
    //
    // For TB/BT flow we want the bend near the target so the source side
    // is a clean straight `│`, which makes the edge visibly continuous
    // out of the source box. Symmetrically for LR/RL we want the bend
    // near the target column, i.e. H-first. The `horizontal_first` flag
    // already captures this — it's `true` for LR/RL, `false` for TB/BT.
    let cost_hv = l_cost(grid, src, dst, true);
    let cost_vh = l_cost(grid, src, dst, false);

    let prefer_hv = match (cost_hv, cost_vh) {
        (None, None) => return None, // both blocked by hard obstacles
        (Some(_), None) => true,     // only H-bend clear
        (None, Some(_)) => false,    // only V-bend clear
        (Some(ch), Some(cv)) if ch != cv => ch < cv, // strictly cheaper wins
        (Some(_), Some(_)) => {
            // Tie — bend near the target. For TB/BT (`horizontal_first =
            // false`) that's V-first; for LR/RL (`horizontal_first = true`)
            // that's H-first. Either way `prefer_hv = horizontal_first`.
            horizontal_first
        }
    };
    grid.route_edge(src.col, src.row, dst.col, dst.row, prefer_hv, tip)
}

/// Compute the soft-obstacle weight of an L-shaped path.
///
/// Returns `None` if any cell on the two segments is a `NodeBox` (hard
/// obstacle). Returns `Some(total_cost)` otherwise, where `total_cost`
/// counts soft-obstacle crossings (existing edges) along both segments.
///
/// `hv_first = true` → horizontal then vertical (corner at `(dst.col, src.row)`).
/// `hv_first = false` → vertical then horizontal (corner at `(src.col, dst.row)`).
fn l_cost(grid: &Grid, src: Attach, dst: Attach, hv_first: bool) -> Option<u32> {
    let (corner_c, corner_r) = if hv_first {
        (dst.col, src.row)
    } else {
        (src.col, dst.row)
    };

    let mut cost = 0u32;

    // First segment: src → corner.
    let (c0, c1) = if hv_first {
        min_max(src.col, corner_c)
    } else {
        (src.col, src.col)
    };
    let (r0, r1) = if hv_first {
        (src.row, src.row)
    } else {
        min_max(src.row, corner_r)
    };
    for c in c0..=c1 {
        for r in r0..=r1 {
            if grid.is_node_box(c, r) && !(c == dst.col && r == dst.row) {
                return None;
            }
            cost += grid.edge_occupied_cost(c, r);
        }
    }

    // Second segment: corner → dst.
    let (c0, c1) = if hv_first {
        (corner_c, corner_c)
    } else {
        min_max(corner_c, dst.col)
    };
    let (r0, r1) = if hv_first {
        min_max(corner_r, dst.row)
    } else {
        (dst.row, dst.row)
    };
    for c in c0..=c1 {
        for r in r0..=r1 {
            if grid.is_node_box(c, r) && !(c == dst.col && r == dst.row) {
                return None;
            }
            cost += grid.edge_occupied_cost(c, r);
        }
    }

    Some(cost)
}

/// Attempt a U-shaped route for LR forward edges when both L-routes are blocked.
///
/// Activates only when:
/// 1. Both L-route orientations returned `None` (NodeBox obstacle on both L-corners).
/// 2. The flow is left-to-right (`src.col < dst.col`).
///
/// Produces a 4-segment (at most) path that bypasses the blocking obstacle by
/// routing DOWNWARD below it rather than letting A\* escape upward over the top
/// of the diagram:
///
/// ```text
///   src ──┐
//////   ┌─────┘   ← below_row, free horizontal corridor
///   └──────── dst
/// ```
///
/// The search sweeps `turn_col` from `src.col` rightward and `below_row`
/// downward from `max(src.row, dst.row) + 1` until it finds a combination
/// where all four segments (H → V → H → V) are free of `NodeBox` cells.  If
/// no combination is found within the grid bounds, returns `None` and A\* runs
/// as the fallback.
fn try_u_route(
    grid: &mut Grid,
    src: Attach,
    dst: Attach,
    tip: char,
) -> Option<Vec<(usize, usize)>> {
    // Only applies to left-to-right forward edges.
    if src.col >= dst.col {
        return None;
    }

    let grid_h = grid.rows();

    // Sweep below_row first (outer), turn_col second (inner) so we find the
    // shallowest bypass first — stays visually compact.
    let below_start = src.row.max(dst.row).saturating_add(1);
    for below_row in below_start..grid_h {
        for turn_col in src.col..dst.col {
            if u_route_clear(grid, src, dst, turn_col, below_row) {
                let path = build_u_path(src, dst, turn_col, below_row);
                return grid.draw_path(path, tip);
            }
        }
    }
    None
}

/// Return `true` when the 4-segment U-route through `(turn_col, below_row)`
/// does not pass through any `NodeBox` cell (excluding the destination cell
/// itself, which is a node border and is always treated as reachable).
fn u_route_clear(grid: &Grid, src: Attach, dst: Attach, turn_col: usize, below_row: usize) -> bool {
    // Segment 1 — horizontal from src to (turn_col, src.row).
    let (c0, c1) = min_max(src.col, turn_col);
    for c in c0..=c1 {
        if grid.is_node_box(c, src.row) {
            return false;
        }
    }
    // Segment 2 — vertical from (turn_col, src.row) to (turn_col, below_row).
    let (r0, r1) = min_max(src.row, below_row);
    for r in r0..=r1 {
        if grid.is_node_box(turn_col, r) {
            return false;
        }
    }
    // Segment 3 — horizontal from (turn_col, below_row) to (dst.col, below_row).
    let (c0, c1) = min_max(turn_col, dst.col);
    for c in c0..=c1 {
        if grid.is_node_box(c, below_row) {
            return false;
        }
    }
    // Segment 4 — vertical from (dst.col, below_row) to dst.  The dst cell
    // itself is the arrow-tip node border — treat it as reachable.
    let (r0, r1) = min_max(below_row, dst.row);
    for r in r0..r1 {
        if grid.is_node_box(dst.col, r) {
            return false;
        }
    }
    true
}

/// Build the flat waypoint list for a U-route through `(turn_col, below_row)`.
///
/// Preconditions guaranteed by `try_u_route`:
/// - `src.col <= turn_col < dst.col`   → seg 1 goes right, seg 3 goes right
/// - `below_row > max(src.row, dst.row)` → seg 2 goes down, seg 4 goes up
fn build_u_path(
    src: Attach,
    dst: Attach,
    turn_col: usize,
    below_row: usize,
) -> Vec<(usize, usize)> {
    let mut path: Vec<(usize, usize)> = Vec::new();
    // Seg 1: right along src.row from src.col to turn_col (inclusive).
    for c in src.col..=turn_col {
        path.push((c, src.row));
    }
    // Seg 2: down from src.row+1 to below_row at turn_col.
    for r in (src.row + 1)..=below_row {
        path.push((turn_col, r));
    }
    // Seg 3: right from turn_col+1 to dst.col at below_row.
    for c in (turn_col + 1)..=dst.col {
        path.push((c, below_row));
    }
    // Seg 4: up from below_row-1 to dst.row at dst.col.
    for r in (dst.row..below_row).rev() {
        path.push((dst.col, r));
    }
    path
}

/// Sort two values into `(min, max)` order.
fn min_max(a: usize, b: usize) -> (usize, usize) {
    if a <= b { (a, b) } else { (b, a) }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Direction, Edge, Graph, Node, NodeShape};

    /// Construct a minimal two-node LR graph with one edge.
    fn two_node_lr() -> (Graph, Vec<Option<(Attach, Attach)>>) {
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "B", None));
        // Manually craft attach points: A exits at (7,1), B enters at (9,1).
        let attaches = vec![Some((Attach { col: 7, row: 1 }, Attach { col: 9, row: 1 }))];
        (g, attaches)
    }

    #[test]
    fn route_all_straight_line_uses_fast_path() {
        let (g, attaches) = two_node_lr();
        let mut grid = Grid::new(20, 5);
        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::RIGHT,
            |_| false,
        );
        assert_eq!(paths.len(), 1);
        // A horizontal straight route returns a path from col 7 to col 9.
        let path = paths[0].as_ref().expect("expected a routed path");
        assert_eq!(path.first(), Some(&(7, 1)));
        assert_eq!(path.last(), Some(&(9, 1)));
    }

    #[test]
    fn route_all_back_edge_skips_fast_paths() {
        // Source is to the right of destination → back-edge.
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.edges.push(Edge::new("B", "A", None));
        // Dst is left of src → back-edge in LR flow.
        let attaches = vec![Some((Attach { col: 7, row: 3 }, Attach { col: 2, row: 3 }))];
        let mut grid = Grid::new(30, 10);
        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::LEFT,
            |_| true, // caller says it's a back-edge
        );
        // Must produce a path (A* back-edge routing can always find one in a
        // 30x10 grid with no obstacles).
        assert!(paths[0].is_some(), "back-edge should produce a path");
    }

    #[test]
    fn route_all_l_route_single_bend_with_obstacle() {
        // Source at (0,0), destination at (4,2): no straight route possible.
        // Block the V-then-H corner at (0,2) so the H-then-V route is cheaper.
        // With one corner blocked, try_l_route returns the clear orientation.
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "B", None));
        let attaches = vec![Some((Attach { col: 0, row: 0 }, Attach { col: 4, row: 2 }))];
        let mut grid = Grid::new(10, 5);
        // Block the VH corner (src.col, dst.row) = (0, 2) to force HV route.
        grid.mark_node_box(0, 2, 1, 1);
        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::RIGHT,
            |_| false,
        );
        let path = paths[0].as_ref().expect("expected a routed path");
        // Path starts at (0,0) and ends at (4,2).
        assert_eq!(path.first(), Some(&(0, 0)));
        assert_eq!(path.last(), Some(&(4, 2)));
        // At least 3 cells: start, one bend, end.
        assert!(path.len() >= 3, "L-path should have at least 3 cells");
    }

    #[test]
    fn route_all_astar_fallback_detours_around_obstacle() {
        // Put a NodeBox obstacle at the corner of the preferred L-shape,
        // forcing the router past try_straight and try_l into A*.
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "B", None));
        let src = Attach { col: 0, row: 0 };
        let dst = Attach { col: 5, row: 2 };
        let attaches = vec![Some((src, dst))];
        let mut grid = Grid::new(10, 5);
        // Block both L-corners: (5,0) and (0,2).
        grid.mark_node_box(5, 0, 1, 1);
        grid.mark_node_box(0, 2, 1, 1);
        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::RIGHT,
            |_| false,
        );
        let path = paths[0].as_ref().expect("A* must find a path");
        assert_eq!(path.first(), Some(&(0, 0)));
        assert_eq!(path.last(), Some(&(5, 2)));
    }

    #[test]
    fn route_all_stable_indexing_multi_edge() {
        // Three edges; middle one has no attach point (None).
        // Result must have 3 slots indexed by original edge index.
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.nodes.push(Node::new("C", "C", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "B", None));
        g.edges.push(Edge::new("B", "C", None));
        g.edges.push(Edge::new("A", "C", None));
        let attaches = vec![
            Some((Attach { col: 0, row: 0 }, Attach { col: 4, row: 0 })),
            None, // missing positions for B→C
            Some((Attach { col: 0, row: 0 }, Attach { col: 8, row: 0 })),
        ];
        let mut grid = Grid::new(15, 5);
        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::RIGHT,
            |_| false,
        );
        assert_eq!(paths.len(), 3);
        assert!(paths[0].is_some());
        assert!(paths[1].is_none(), "None attach must yield None path");
        assert!(paths[2].is_some());
    }

    #[test]
    fn order_edges_ascending_distance() {
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.nodes.push(Node::new("C", "C", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "C", None)); // long edge (dist=8)
        g.edges.push(Edge::new("A", "B", None)); // short edge (dist=4)
        let attaches = vec![
            Some((Attach { col: 0, row: 0 }, Attach { col: 8, row: 0 })), // edge 0: dist 8
            Some((Attach { col: 0, row: 0 }, Attach { col: 4, row: 0 })), // edge 1: dist 4
        ];
        let order = order_edges(&g, &attaches);
        // Shorter edge (idx 1) must come before longer edge (idx 0).
        let pos_short = order.iter().position(|&i| i == 1).unwrap();
        let pos_long = order.iter().position(|&i| i == 0).unwrap();
        assert!(
            pos_short < pos_long,
            "short edge should route before long edge"
        );
    }

    #[test]
    fn order_edges_no_attach_sorted_last() {
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
        g.edges.push(Edge::new("A", "B", None));
        g.edges.push(Edge::new("A", "B", None));
        let attaches = vec![
            None,                                                         // no attach
            Some((Attach { col: 0, row: 0 }, Attach { col: 4, row: 0 })), // dist 4
        ];
        let order = order_edges(&g, &attaches);
        let pos_none = order.iter().position(|&i| i == 0).unwrap();
        let pos_some = order.iter().position(|&i| i == 1).unwrap();
        assert!(pos_some < pos_none, "edge with no attach should sort last");
    }

    /// Reproduces the B3 bug scenario: an LR forward edge from App to PostgreSQL
    /// where both L-route orientations are blocked by a NodeBox obstacle
    /// (representing RabbitMQ) sitting at the same row as the source exit.
    ///
    /// Layout:
    /// - App   : cols 0–6, rows 2–4 (NodeBox)
    /// - Rabbit: cols 9–17, rows 2–4 (NodeBox — blocks both L-corners)
    /// - Src   : attach at (7, 2) — top exit of App in spread order
    /// - Dst   : attach at (18, 3) — middle entry of PostgreSQL
    ///
    /// The H-first L-corner (18, 2) is inside the NodeBox of Rabbit → blocked.
    /// The V-first L-corner (7, 3) is free, but the horizontal segment at row 3
    /// from col 7 to col 18 passes through Rabbit's NodeBox → blocked.
    ///
    /// Expected: try_u_route finds a clean path going DOWN below row 4 and
    /// looping below the obstacle. The result must:
    /// 1. Be Some (a path was found).
    /// 2. Not contain any cell that lies inside App's NodeBox (cols 0–6, rows 2–4).
    /// 3. Not contain any cell inside Rabbit's NodeBox (cols 9–17, rows 2–4).
    /// 4. Contain at least one cell whose row is strictly greater than 4 (goes below
    ///    the obstacle) — confirming U-shape, not a top-wrap.
    #[test]
    fn forward_edge_uses_u_route_when_l_routes_blocked() {
        let mut g = Graph::new(Direction::LeftToRight);
        g.nodes.push(Node::new("App", "App", NodeShape::Rectangle));
        g.nodes
            .push(Node::new("PostgreSQL", "PostgreSQL", NodeShape::Rectangle));
        g.edges.push(Edge::new("App", "PostgreSQL", None));

        // Src exits from App's top spread row at (7, 2); dst enters PostgreSQL at (18, 3).
        let src = Attach { col: 7, row: 2 };
        let dst = Attach { col: 18, row: 3 };
        let attaches = vec![Some((src, dst))];

        // Grid large enough for a down-and-around route (10 rows).
        let mut grid = Grid::new(25, 10);

        // App bounding box: cols 0–6, rows 2–4.
        grid.mark_node_box(0, 2, 7, 3);
        // RabbitMQ bounding box: cols 9–17, rows 2–4 — blocks both L-routes.
        // H-first corner at (18, 2): passes through cols 9–17 row 2 → NodeBox.
        // V-first corner at (7, 3): horizontal at row 3 from col 7 to col 18 →
        //   passes through cols 9–17 → NodeBox.
        grid.mark_node_box(9, 2, 9, 3);

        let paths = route_all(
            &mut grid,
            &g,
            &attaches,
            |_| crate::layout::grid::arrow::RIGHT,
            |_| false,
        );

        let path = paths[0].as_ref().expect("expected a path for the LR edge");

        // Must start at the source exit and end at the destination.
        assert_eq!(
            path.first(),
            Some(&(src.col, src.row)),
            "path must start at source exit"
        );
        assert_eq!(
            path.last(),
            Some(&(dst.col, dst.row)),
            "path must end at destination"
        );

        // Must not pass through the App NodeBox (cols 0–6, rows 2–4).
        for &(c, r) in path.iter() {
            assert!(
                !(c <= 6 && (2..=4).contains(&r)),
                "path must not enter App's bounding box at ({c}, {r})"
            );
        }

        // Must not pass through Rabbit's NodeBox (cols 9–17, rows 2–4).
        for &(c, r) in path.iter() {
            assert!(
                !((9..=17).contains(&c) && (2..=4).contains(&r)),
                "path must not enter obstacle's bounding box at ({c}, {r})"
            );
        }

        // Must contain at least one cell below row 4 — confirms U-route used the
        // below-obstacle corridor rather than escaping upward over the top.
        assert!(
            path.iter().any(|&(_, r)| r > 4),
            "U-route must descend below the obstacle (row > 4); path stayed at or above the obstacle"
        );
    }
}