nornir 0.4.54

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
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
//! Shared pan/zoom **dependency-graph renderer** β€” the single egui draw routine
//! behind BOTH the πŸ› Architecture wiring board (`arch_tab`) and the 🧬 nornir
//! RELEASE DASHBOARD (`nornir_tab`). Extracted from `arch_tab::draw_board` so the
//! canvas (layered layout, PCB-style edge curves + arrowheads, node chips with a
//! kind fill + a status ring, pan/drag/scroll-zoom, click-a-node β†’ BFS-downstream
//! highlight) lives in ONE place.
//!
//! # Domain-agnostic by design (a future `facett-graphview` candidate)
//! This module knows NOTHING about architecture node-kinds or release gates. The
//! caller hands it:
//!   * a [`GraphModel`] β€” nodes (id + label + fill/stroke colour) + edges
//!     (from/to + colour + dashed flag + optional mid-label), already laid out in
//!     world space (caller owns the layout so each domain keeps its own columns);
//!   * a [`Decorations`] overlay β€” **caller-supplied**, domain-agnostic: a per-node
//!     status ring colour + an optional corner badge (glyph/text + colour), and a
//!     set of edges to draw dashed-and-labelled (e.g. a cycle's suggested cut).
//!
//! The πŸ› Architecture tab calls [`draw_graph`] with [`Decorations::default()`]
//! (no overlay β€” its own coverage rings ride in the model's node ring colour);
//! the 🧬 dashboard calls the SAME function passing gate markers. No copy-pasted
//! canvas/pan-zoom code, and facett needn't learn what a "release gate" is.

use std::collections::{BTreeSet, HashMap, VecDeque};

use eframe::egui::{self, Align2, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use eframe::egui::style::ScrollAnimation;

/// Default node chip size (world units, before zoom). Matches the arch board.
pub const BOX_W: f32 = 184.0;
pub const BOX_H: f32 = 34.0;

/// One laid-out node: its stable id, the label painted in the chip, the chip
/// fill + stroke (kind colour, caller's choice), and its world-space centre.
#[derive(Clone)]
pub struct GraphNode {
    pub id: String,
    pub label: String,
    pub fill: Color32,
    pub stroke: Color32,
    pub pos: Pos2,
}

/// One edge: endpoints by node id, its colour, a `dashed` flag (transitive /
/// suggested-cut style), and an optional mid-edge label (e.g. a cut rationale).
#[derive(Clone)]
pub struct GraphEdge {
    pub from: String,
    pub to: String,
    pub color: Color32,
    pub dashed: bool,
    pub label: Option<String>,
}

/// The full graph to paint β€” nodes (laid out in world space) + edges.
#[derive(Clone, Default)]
pub struct GraphModel {
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
}

/// A per-node decoration the caller layers over the base chip: an optional status
/// **ring** colour (drawn outside the kind stroke) and an optional **badge** (a
/// short glyph/text painted at the chip's top-right corner in `badge_color`).
#[derive(Clone, Default)]
pub struct NodeDecoration {
    pub ring: Option<Color32>,
    pub badge: Option<String>,
    pub badge_color: Option<Color32>,
}

/// The caller-supplied overlay layer (domain-agnostic). Per-node rings/badges
/// keyed by node id, plus extra edges to draw on top dashed-and-labelled (e.g. a
/// dependency cycle's suggested cut edge + its rationale). Empty = no overlay,
/// which is exactly how the πŸ› Architecture tab calls [`draw_graph`].
#[derive(Clone, Default)]
pub struct Decorations {
    /// node id β†’ its ring/badge overlay.
    pub nodes: HashMap<String, NodeDecoration>,
    /// Extra emphasis edges painted ON TOP of the base edges (the cut lines).
    pub edges: Vec<GraphEdge>,
}

/// Persistent navigation transform + selection for the canvas. Owned by the
/// caller (the tab/dashboard state) so pan/zoom/selection survive across frames.
#[derive(Clone)]
pub struct GraphView {
    pub pan: Vec2,
    pub zoom: f32,
    /// The selected node id (its downstream subtree is highlighted).
    pub selected: Option<String>,
}

impl Default for GraphView {
    fn default() -> Self {
        Self { pan: Vec2::ZERO, zoom: 1.0, selected: None }
    }
}

impl GraphView {
    /// Reset pan + zoom (the βŠ™ fit button).
    pub fn fit(&mut self) {
        self.pan = Vec2::ZERO;
        self.zoom = 1.0;
    }
    /// Clear the selection (and thus the downstream highlight).
    pub fn clear_selection(&mut self) {
        self.selected = None;
    }
}

/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
/// space. The visual twin of `nornir arch trace` β€” used to dim everything off the
/// lit path when a node is selected. `pub` so a caller can fold the lit set into
/// its own `state_json` without re-deriving it.
pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
    for e in &model.edges {
        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
    }
    let mut lit: BTreeSet<String> = BTreeSet::new();
    let mut q: VecDeque<&str> = VecDeque::new();
    lit.insert(seed.to_string());
    q.push_back(seed);
    while let Some(cur) = q.pop_front() {
        if let Some(outs) = adj.get(cur) {
            for &nxt in outs {
                if lit.insert(nxt.to_string()) {
                    q.push_back(nxt);
                }
            }
        }
    }
    lit
}

/// Dim a colour toward transparency for the un-traced background when a node is
/// selected (so the lit downstream path pops). Verbatim from the arch board.
fn dim(c: Color32) -> Color32 {
    c.linear_multiply(0.22)
}

/// What a [`draw_graph`] frame did: the node (if any) the user clicked this frame
/// (so the caller can run its own side effects β€” click-to-code, scope a release),
/// and whether the click landed on empty space (a deselect). The view's
/// `selected` + pan/zoom are already mutated in place.
#[derive(Default)]
pub struct GraphResponse {
    /// `Some(node_id)` if a node was clicked this frame.
    pub clicked_node: Option<String>,
    /// `true` if the click hit empty canvas (cleared the selection).
    pub clicked_empty: bool,
}

/// THE shared renderer. Paints `model` decorated by `decorations` onto the egui
/// `ui`, driving pan/drag + scroll-zoom and click-to-select (which lights the
/// clicked node's downstream subtree). Mutates `view` (pan/zoom/selected) and
/// returns what was clicked so the caller can run domain side effects.
///
/// The πŸ› Architecture tab calls this with `Decorations::default()`; the 🧬
/// dashboard passes gate rings/badges + cut edges.
#[allow(clippy::too_many_arguments)]
pub fn draw_graph(
    ui: &mut egui::Ui,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    bg: Color32,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }

    let (resp, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
    painter.rect_filled(resp.rect, 4.0, bg);
    if resp.dragged() {
        view.pan += resp.drag_delta();
    }
    if resp.hovered() {
        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
        if scroll != 0.0 {
            view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
        }
    }
    // Origin is the viewport centre offset by pan; the graph is centred on (0,0).
    let origin = resp.rect.center() + view.pan;
    let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
    paint_graph(
        &painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
    )
}

/// The painted extent of `model` in WORLD units (before zoom): the axis-aligned
/// bounding box of every chip (node centre Β± half-box) grown by a margin. This is
/// the TRUE content size β€” the figure a wrapping `ScrollArea` must allocate so its
/// scrollbar handle scales to the real overflow (rather than to the viewport).
/// Returns at least one box so an empty/one-node board still has a sane size.
pub fn content_extent(model: &GraphModel) -> Vec2 {
    const MARGIN: f32 = 48.0;
    if model.nodes.is_empty() {
        return Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN);
    }
    let (mut min, mut max) = (Pos2::new(f32::MAX, f32::MAX), Pos2::new(f32::MIN, f32::MIN));
    let half = Vec2::new(BOX_W, BOX_H) * 0.5;
    for n in &model.nodes {
        min.x = min.x.min(n.pos.x - half.x);
        min.y = min.y.min(n.pos.y - half.y);
        max.x = max.x.max(n.pos.x + half.x);
        max.y = max.y.max(n.pos.y + half.y);
    }
    (max - min) + Vec2::splat(2.0 * MARGIN)
}

/// THE shared renderer in a **scrolling** frame, for the static πŸ› Wiring board.
///
/// Unlike [`draw_graph`] (which pans a viewport-sized canvas β€” drag to move), this
/// allocates a canvas at the graph's TRUE [`content_extent`] (Γ— zoom) inside a
/// styled [`egui::ScrollArea`]. Because the allocated size is the real painted
/// bounds, the scrollbar handle scales correctly (the prior bug: the canvas was
/// allocated at `available_size`, so the bar reported the viewport, not the
/// overflow). Scrolling is smooth/kinetic via the context's `scroll_animation`,
/// and the bars are made wide + always-visible. `view.pan` is NOT used for
/// navigation here (the ScrollArea offset is); zoom still works (it resizes the
/// allocated canvas, so the bars re-scale). Returns the same [`GraphResponse`].
#[allow(clippy::too_many_arguments)]
pub fn draw_graph_scrolled(
    ui: &mut egui::Ui,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    bg: Color32,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }
    // Smooth/kinetic scroll: glide to the target instead of jumping. Cheap, local
    // to this Ui (egui reads `scroll_animation` off the active style each frame).
    ui.style_mut().scroll_animation = ScrollAnimation::new(1200.0, egui::Rangef::new(0.08, 0.30));
    // A wider, always-on, high-contrast bar so the handle is easy to grab + read
    // (the prior bar was the 6px default and faded out when idle).
    {
        let s = &mut ui.style_mut().spacing.scroll;
        s.bar_width = 12.0;
        s.handle_min_length = 24.0;
        s.floating = false;
        s.dormant_background_opacity = 1.0;
        s.active_background_opacity = 1.0;
        s.interact_background_opacity = 1.0;
    }

    let extent = content_extent(model) * view.zoom;
    let mut out = GraphResponse::default();
    egui::ScrollArea::both()
        .id_salt("graph_scroll")
        .auto_shrink([false, false])
        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
        .show(ui, |ui| {
            // Allocate the canvas at the TRUE extent β€” this is what makes the
            // scrollbar honest. The graph is centred on world (0,0), so shift the
            // projection origin to the canvas centre.
            let (resp, painter) =
                ui.allocate_painter(extent.max(ui.available_size()), Sense::click_and_drag());
            painter.rect_filled(resp.rect, 4.0, bg);
            // Zoom on hover (ctrl-less wheel still scrolls; we only zoom with the
            // wheel when the cursor is over the board AND the modifier is held, so
            // plain wheel = smooth scroll, not an accidental zoom).
            if resp.hovered() {
                let (scroll, zoom_mod) =
                    ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.command || i.modifiers.ctrl));
                if zoom_mod && scroll != 0.0 {
                    view.zoom = (view.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
                }
            }
            let origin = resp.rect.center();
            let click = resp.clicked().then(|| resp.interact_pointer_pos()).flatten();
            out = paint_graph(
                &painter, model, decorations, view, origin, click, text, selection_ring, text_dim,
            );
        });
    out
}

/// The shared paint + hit-test core used by both [`draw_graph`] (pan/zoom canvas)
/// and [`draw_graph_scrolled`] (ScrollArea canvas). Given the projection `origin`
/// (graph centred on world 0,0 β†’ screen) and an optional `click` position, it
/// hit-tests the click, lights the selection's downstream subtree, and paints the
/// edges + chips. Mutates `view.selected`; returns the click outcome.
#[allow(clippy::too_many_arguments)]
fn paint_graph(
    painter: &egui::Painter,
    model: &GraphModel,
    decorations: &Decorations,
    view: &mut GraphView,
    origin: Pos2,
    click: Option<Pos2>,
    text: Color32,
    selection_ring: Color32,
    text_dim: Color32,
) -> GraphResponse {
    let mut out = GraphResponse::default();
    let zoom = view.zoom;
    let project = |p: Pos2| origin + p.to_vec2() * zoom;

    // id β†’ index for edge endpoint lookup.
    let idx: HashMap<&str, usize> =
        model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();

    // Click-to-select: nearest node within its box; clicking empty clears.
    if let Some(click) = click {
        let hit = model.nodes.iter().find_map(|n| {
            let c = project(n.pos);
            let half = Vec2::new(BOX_W, BOX_H) * 0.5 * zoom;
            let rect = egui::Rect::from_center_size(c, half * 2.0);
            rect.contains(click).then(|| n.id.clone())
        });
        match hit {
            Some(id) => {
                view.selected = Some(id.clone());
                out.clicked_node = Some(id);
            }
            None => {
                view.selected = None;
                out.clicked_empty = true;
            }
        }
    }

    // The downstream-lit set for the current selection (dims everything else).
    let lit: BTreeSet<String> = view
        .selected
        .as_deref()
        .map(|s| downstream_of(model, s))
        .unwrap_or_default();
    let highlighting = view.selected.is_some();

    // ── edges first (so chips sit on top) ────────────────────────────────────
    for e in &model.edges {
        draw_edge(painter, model, &idx, &project, e, zoom, highlighting, &lit, text_dim, false);
    }
    // Decoration edges painted ON TOP (the cut lines) β€” always emphasised.
    for e in &decorations.edges {
        draw_edge(painter, model, &idx, &project, e, zoom, false, &lit, text_dim, true);
    }

    // ── chips (nodes) ────────────────────────────────────────────────────────
    for n in &model.nodes {
        let c = project(n.pos);
        let on_trace = highlighting && lit.contains(&n.id);
        let (fill, stroke) = if highlighting && !on_trace {
            (dim(n.fill), dim(n.stroke))
        } else {
            (n.fill, n.stroke)
        };
        let size = Vec2::new(BOX_W, BOX_H) * zoom;
        let rect = egui::Rect::from_center_size(c, size);
        painter.rect_filled(rect, 5.0 * zoom, fill);

        let deco = decorations.nodes.get(&n.id);
        // Ring precedence: explicit selection wins; then a decoration ring; then
        // the node's own stroke (the arch coverage ring rides in here already).
        let ring = if view.selected.as_deref() == Some(n.id.as_str()) {
            Stroke::new(3.0, selection_ring)
        } else if let Some(rc) = deco.and_then(|d| d.ring) {
            let rc = if highlighting && !on_trace { dim(rc) } else { rc };
            Stroke::new(2.4, rc)
        } else {
            Stroke::new(1.4, stroke)
        };
        painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);

        if zoom > 0.45 {
            painter.text(
                c,
                Align2::CENTER_CENTER,
                &n.label,
                FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
                text,
            );
            // The decoration badge at the top-right corner (e.g. 🟑 / β›”).
            if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
                let col = deco.and_then(|d| d.badge_color).unwrap_or(text);
                let col = if highlighting && !on_trace { dim(col) } else { col };
                painter.text(
                    rect.right_top() + Vec2::new(-2.0, 1.0),
                    Align2::RIGHT_TOP,
                    badge,
                    FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
                    col,
                );
            }
        }
    }

    out
}

/// Draw ONE edge as a PCB-style cubic with a mid breakpoint + an arrowhead, dimmed
/// off-trace when a selection is active. `emphasise` (decoration edges) forces a
/// thicker, undimmed stroke and paints the optional mid-label.
#[allow(clippy::too_many_arguments)]
fn draw_edge(
    painter: &egui::Painter,
    model: &GraphModel,
    idx: &HashMap<&str, usize>,
    project: &impl Fn(Pos2) -> Pos2,
    e: &GraphEdge,
    zoom: f32,
    highlighting: bool,
    lit: &BTreeSet<String>,
    text_dim: Color32,
    emphasise: bool,
) {
    let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
        return;
    };
    let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
    let (pa, pb) = (project(model.nodes[fi].pos), project(model.nodes[ti].pos));
    let a = pa + Vec2::new(BOX_W * 0.5 * zoom, 0.0);
    let b = pb - Vec2::new(BOX_W * 0.5 * zoom, 0.0);
    let color = if !emphasise && highlighting && !on_trace { dim(e.color) } else { e.color };
    let w = if emphasise { 2.6 } else if on_trace { 2.4 } else { 1.4 };

    let mid = Pos2::new((a.x + b.x) * 0.5, a.y);
    let mid2 = Pos2::new((a.x + b.x) * 0.5, b.y);
    let curve = egui::epaint::CubicBezierShape::from_points_stroke(
        [a, mid, mid2, b],
        false,
        Color32::TRANSPARENT,
        Stroke::new(w, color),
    );
    if e.dashed {
        // Dashed cubic β€” sample the curve and stroke alternate segments.
        let pts: Vec<Pos2> = (0..=20).map(|i| curve.sample(i as f32 / 20.0)).collect();
        for (i, win) in pts.windows(2).enumerate() {
            if i % 2 == 0 {
                painter.line_segment([win[0], win[1]], Stroke::new(w, color));
            }
        }
    } else {
        painter.add(egui::Shape::CubicBezier(curve));
    }
    // Arrowhead at the callee.
    let dir = (b - mid2).normalized();
    let perp = Vec2::new(-dir.y, dir.x);
    let head = 6.0 * zoom.clamp(0.6, 1.6);
    painter.line_segment([b, b - dir * head + perp * head * 0.5], Stroke::new(w, color));
    painter.line_segment([b, b - dir * head - perp * head * 0.5], Stroke::new(w, color));

    // Mid-edge label (cut rationale), painted for emphasis edges that carry one.
    if let Some(lbl) = &e.label {
        if zoom > 0.45 && !lbl.is_empty() {
            let mp = Pos2::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5 - 6.0 * zoom);
            painter.text(
                mp,
                Align2::CENTER_BOTTOM,
                lbl,
                FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
                if emphasise { color } else { text_dim },
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn node(id: &str, x: f32) -> GraphNode {
        GraphNode {
            id: id.into(),
            label: id.into(),
            fill: Color32::GRAY,
            stroke: Color32::WHITE,
            pos: Pos2::new(x, 0.0),
        }
    }
    fn edge(from: &str, to: &str) -> GraphEdge {
        GraphEdge { from: from.into(), to: to.into(), color: Color32::WHITE, dashed: false, label: None }
    }

    #[test]
    fn downstream_is_bfs_closure_from_seed() {
        // A β†’ B β†’ C, A β†’ C. Downstream of A = {A, B, C}; of B = {B, C}.
        let model = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 100.0), node("C", 200.0)],
            edges: vec![edge("A", "B"), edge("B", "C"), edge("A", "C")],
        };
        let from_a = downstream_of(&model, "A");
        assert!(from_a.contains("A") && from_a.contains("B") && from_a.contains("C"));
        let from_b = downstream_of(&model, "B");
        assert!(from_b.contains("B") && from_b.contains("C"));
        assert!(!from_b.contains("A"), "BFS is forward-only β€” A is not downstream of B");
    }

    #[test]
    fn content_extent_is_the_true_bounding_box_not_the_viewport() {
        // The task #23 root cause: the canvas was sized at the viewport, so the
        // scrollbar lied about the overflow. `content_extent` must instead grow
        // with the laid-out graph (chip bbox + a fixed margin), independent of any
        // available size β€” that's what makes a wrapping ScrollArea's handle honest.
        const MARGIN: f32 = 48.0;

        // Empty board β†’ one chip's worth + margins (never zero, so the canvas is sane).
        let empty = GraphModel::default();
        let e0 = content_extent(&empty);
        assert_eq!(e0, Vec2::new(BOX_W, BOX_H) + Vec2::splat(2.0 * MARGIN));

        // Two chips 300 apart on x: width = span + box + 2Β·margin; height = box + 2Β·margin.
        let two = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 300.0)],
            edges: vec![],
        };
        let e = content_extent(&two);
        assert_eq!(e.x, 300.0 + BOX_W + 2.0 * MARGIN, "width spans both chips + margins");
        assert_eq!(e.y, BOX_H + 2.0 * MARGIN, "height is one chip row + margins");

        // Spreading the graph wider MUST grow the extent (the handle re-scales).
        let wider = GraphModel {
            nodes: vec![node("A", 0.0), node("B", 900.0)],
            edges: vec![],
        };
        assert!(content_extent(&wider).x > e.x, "a wider graph reports a wider extent");
    }

    #[test]
    fn view_fit_and_clear_reset_state() {
        let mut v = GraphView { pan: Vec2::new(5.0, 5.0), zoom: 2.0, selected: Some("A".into()) };
        v.fit();
        assert_eq!(v.pan, Vec2::ZERO);
        assert_eq!(v.zoom, 1.0);
        assert_eq!(v.selected.as_deref(), Some("A"));
        v.clear_selection();
        assert!(v.selected.is_none());
    }
}