Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/front_tree.rs

//! **🌳 Tree — the front page of 🏠 Workspace**: the selected workspace's build/read
//! graph with live marks.
//!
//! Nothing is folded here. The tab asks the forge ONE surface, `Dwarves.Tree`, on the
//! code-room door it already speaks ([`crate::room`]); the forge folds the tree from
//! nornir's build board (`Coderoom.Units`) on its own jera lane and answers at once —
//! `queued` / `running` while the first fold runs (the ask is repeated until it is
//! there, off the UI thread), then the tree. While the tab is open it asks again every
//! [`REFRESH`]; a click on a repo or a crate drills in (asked as its own question).
//!
//! Drawn on the ONE graph renderer ([`crate::dag_graph::DagPane`], facett-graphview) —
//! the same engine the Release DAG and the build graph use. Marks: grey waiting ·
//! amber queued · pulsing blue compiling · glowing reading · green done · red failed.
//! A shared crate / unit carries `×N` (how many repos use it).

use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::{Duration, Instant};

use facett_core::Facet;
use facett_graphview::{Color, LayoutOpts};

use crate::dag_graph::{DagNode, DagPane, DagSpec};
use crate::room::{self, ForgeRoom, Pending, RoomAnswer, RoomSource};

/// The forge surface (answered by the forge itself, never forwarded to nornir).
pub const SURFACE: &str = "Dwarves.Tree";

/// How often an open tab asks again.
pub const REFRESH: Duration = Duration::from_secs(2);

/// The fewest rows a column of the tree holds before a rank is staggered (the repo
/// column sets it when it is taller).
pub const MIN_COLUMN_ROWS: usize = 8;

/// The fill of a mark.
pub fn mark_fill(state: &str) -> Color {
    match state {
        "waiting" => Color::rgb(118, 122, 132),
        "queued" => Color::rgb(214, 160, 20),
        "compiling" => Color::rgb(40, 104, 214),
        "reading" => Color::rgb(70, 178, 210),
        "done" => Color::rgb(46, 150, 72),
        "failed" => Color::rgb(198, 40, 40),
        _ => Color::rgb(90, 90, 90),
    }
}

/// The ring of a mark at `t` seconds: compiling PULSES (blue, alpha follows a 1.2 s
/// sine), reading GLOWS (a steady bright ring), failed is ringed red; the rest none.
pub fn mark_ring(state: &str, t: f32) -> Option<Color> {
    match state {
        "compiling" => {
            let a = 0.5 + 0.5 * (t * std::f32::consts::TAU / 1.2).sin();
            Some(Color::rgba(90, 160, 255, (60.0 + 195.0 * a) as u8))
        }
        "reading" => Some(Color::rgba(190, 245, 255, 255)),
        "failed" => Some(Color::rgb(255, 90, 90)),
        _ => None,
    }
}

/// The tree answer as a spec for the graph renderer, its rings at `t` seconds.
pub fn spec_of(body: &serde_json::Value, t: f32) -> DagSpec {
    let nodes = body["nodes"]
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[])
        .iter()
        .map(|n| {
            let state = n["state"].as_str().unwrap_or("");
            DagNode {
                id: n["id"].as_str().unwrap_or("?").to_string(),
                label: n["label"].as_str().unwrap_or("?").to_string(),
                fill: mark_fill(state),
                ring: mark_ring(state, t),
                badge: n["badge"]
                    .as_str()
                    .filter(|b| !b.is_empty())
                    .map(str::to_string),
            }
        })
        .collect();
    let edges = body["edges"]
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[])
        .iter()
        .filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string())))
        .collect();
    DagSpec {
        nodes,
        edges,
        consumes: Vec::new(),
    }
}

/// The repo node ids of a tree answer — the engine pins them to the first column.
pub fn repo_ids(body: &serde_json::Value) -> Vec<String> {
    body["nodes"]
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[])
        .iter()
        .filter(|n| n["kind"] == "repo")
        .filter_map(|n| n["id"].as_str().map(str::to_string))
        .collect()
}

/// The header line of a tree answer: units done/total · speed · ETA · which box.
pub fn header_line(body: &serde_json::Value) -> String {
    let h = &body["header"];
    let (done, total) = (
        h["done"].as_u64().unwrap_or(0),
        h["total"].as_u64().unwrap_or(0),
    );
    let speed = match h["units_per_min"].as_f64() {
        // seeded from the board's timestamps (no two readings yet): an estimate, said so
        Some(r) if h["rate_source"] == "board" => format!("~{r:.0} units/min"),
        Some(r) => format!("{r:.0} units/min"),
        None => "no rate yet".to_string(),
    };
    let eta = match h["eta_s"].as_u64() {
        Some(0) if done >= total && total > 0 => "all done".to_string(),
        Some(s) if s >= 3600 => format!("ETA {}h{:02}m", s / 3600, (s % 3600) / 60),
        Some(s) => format!("ETA {}m{:02}s", s / 60, s % 60),
        None => "no ETA yet".to_string(),
    };
    let pct = if total > 0 {
        format!(" ({:.0} %)", done as f64 * 100.0 / total as f64)
    } else {
        String::new()
    };
    format!(
        "{done}/{total} units done{pct} · {speed} · {eta} · builds on {}",
        h["box"].as_str().unwrap_or("?")
    )
}

/// The tab.
pub struct FrontTree {
    source: Arc<dyn RoomSource>,
    /// The forge endpoint + token, when the source is the shipped one (re-made on a
    /// workspace switch); `None` for an injected source.
    forge: Option<(String, String)>,
    workspace: String,
    expand: BTreeSet<String>,
    answer: RoomAnswer,
    pending: Option<Pending<RoomAnswer>>,
    landed: Option<Instant>,
    pane: DagPane,
    spec: DagSpec,
    /// The node ids the graph holds now (a new set = lay out again).
    shown: Vec<String>,
    epoch: Instant,
}

impl FrontTree {
    /// The shipped tab: asks the forge at `endpoint` about `workspace`.
    pub fn new(endpoint: String, token: String, workspace: String) -> Self {
        let source = ForgeRoom::shared(&endpoint, &token, &workspace);
        let mut me = Self::with_source(source, &workspace);
        me.forge = Some((endpoint, token));
        me
    }

    /// A tab over an injected source (tests).
    pub fn with_source(source: Arc<dyn RoomSource>, workspace: &str) -> Self {
        let via = source.describe();
        Self {
            source,
            forge: None,
            workspace: workspace.to_string(),
            expand: BTreeSet::new(),
            answer: RoomAnswer::pending(SURFACE, &via),
            pending: None,
            landed: None,
            pane: DagPane::new("Tree"),
            spec: DagSpec::default(),
            shown: Vec::new(),
            epoch: Instant::now(),
        }
    }

    /// Ask the forge (on a worker; the frame polls).
    pub fn ask(&mut self) {
        let source = Arc::clone(&self.source);
        let params = serde_json::json!({ "expand": self.expand.iter().collect::<Vec<_>>() });
        self.pending = Some(Pending::spawn("front-tree-ask", move || {
            source.ask(SURFACE, &params)
        }));
    }

    fn land(&mut self, a: RoomAnswer) {
        if a.is_measured() {
            let t = self.epoch.elapsed().as_secs_f32();
            self.spec = spec_of(&a.body, t);
            let ids: Vec<String> = self.spec.nodes.iter().map(|n| n.id.clone()).collect();
            let edges_now = self.spec.edges.len();
            // the repos line up in the first column, what they use flows right; no
            // column grows taller than the repo column (a taller rank is staggered)
            let repos = repo_ids(&a.body);
            let mut layout = LayoutOpts::roots_left(repos.iter().cloned());
            layout.max_rows = Some(repos.len().max(MIN_COLUMN_ROWS));
            self.pane.set_layout(layout);
            if self.shown.is_empty() {
                // the very first view: frame it (after that only ⊙ fit frames)
                self.pane.set_spec_fit(&self.spec);
                self.shown = ids;
            } else if ids != self.shown || edges_now != self.pane.edge_count() {
                // a drill in / out, a new plan: the camera stays, the clicked node keeps
                // its spot (📌 Stay) or flies to the centre (🎯 Focus)
                self.pane.set_spec_keep_view(&self.spec);
                self.shown = ids;
            } else {
                // a plain refresh: the marks move, the user's camera stays
                self.pane.restyle(&self.spec);
            }
        }
        self.answer = a;
        self.landed = Some(Instant::now());
    }

    /// Take a finished ask, if any.
    pub fn poll(&mut self) {
        let Some(p) = &self.pending else { return };
        match p.try_take() {
            None => {}
            Some(Ok(a)) => {
                self.pending = None;
                self.land(a);
            }
            Some(Err(())) => {
                self.pending = None;
                let a = room::worker_died(SURFACE, &*self.source);
                self.land(a);
            }
        }
    }

    /// Block until the ask in flight lands (robot seam / tests).
    pub fn wait(&mut self) {
        if self.pending.is_none() {
            self.ask();
        }
        if let Some(p) = self.pending.take() {
            let a = p
                .wait()
                .unwrap_or_else(|_| room::worker_died(SURFACE, &*self.source));
            self.land(a);
        }
    }

    /// Drill into / back out of a node (a repo shows its crates, a crate its units).
    pub fn toggle(&mut self, id: &str) {
        let expandable = self.answer.body["nodes"].as_array().is_some_and(|ns| {
            ns.iter()
                .any(|n| n["id"] == id && n["expandable"].as_bool() == Some(true))
        });
        if !expandable {
            return;
        }
        if !self.expand.remove(id) {
            self.expand.insert(id.to_string());
        }
        self.ask();
    }

    fn set_workspace(&mut self, ws: &str) {
        if let Some((endpoint, token)) = &self.forge {
            self.source = ForgeRoom::shared(endpoint, token, ws);
        }
        self.workspace = ws.to_string();
        self.expand.clear();
        self.pending = None;
        self.answer = RoomAnswer::pending(SURFACE, &self.source.describe());
        self.landed = None;
        self.spec = DagSpec::default();
        self.shown.clear();
        self.pane = DagPane::new("Tree");
    }

    /// The node ids laid out in the leftmost column (the repos, when the engine pinned
    /// them there).
    fn first_column(&self) -> Vec<String> {
        let pos = self.pane.positions();
        let x0 = pos.iter().map(|(_, p)| p.x).fold(f32::INFINITY, f32::min);
        pos.into_iter()
            .filter(|(_, p)| (p.x - x0).abs() < 0.5)
            .map(|(id, _)| id)
            .collect()
    }

    /// Node counts by mark in the current answer.
    fn marks(&self) -> std::collections::BTreeMap<String, u64> {
        let mut m = std::collections::BTreeMap::new();
        for n in self.answer.body["nodes"]
            .as_array()
            .map(Vec::as_slice)
            .unwrap_or(&[])
        {
            *m.entry(n["state"].as_str().unwrap_or("?").to_string())
                .or_insert(0) += 1;
        }
        m
    }
}

impl Facet for FrontTree {
    fn title(&self) -> &str {
        "Tree"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.poll();
        let due = self.landed.is_none_or(|l| l.elapsed() >= REFRESH);
        if self.pending.is_none() && due {
            self.ask();
        }
        ui.horizontal_wrapped(|ui| {
            ui.strong(format!(
                "🌳 {}",
                if self.workspace.is_empty() {
                    "(no workspace selected)"
                } else {
                    &self.workspace
                }
            ));
            ui.separator();
            if self.answer.is_measured() {
                ui.label(header_line(&self.answer.body));
                let age = self.answer.body["age_ms"].as_u64().unwrap_or(0);
                if age > 0 {
                    ui.weak(format!("· as of {:.1}s ago", age as f32 / 1000.0));
                }
            }
            if let Some(p) = &self.pending {
                ui.spinner();
                ui.weak(room::asking_line(p.started));
            }
        });
        if self.answer.is_measured() {
            let jobs = self.answer.body["header"]["jobs"]
                .as_array()
                .cloned()
                .unwrap_or_default();
            if !jobs.is_empty() {
                ui.horizontal_wrapped(|ui| {
                    ui.weak("jera:");
                    for j in &jobs {
                        ui.small(format!(
                            "{} {} ({})",
                            j["kind"].as_str().unwrap_or("?"),
                            j["target"].as_str().unwrap_or(""),
                            j["status"].as_str().unwrap_or("?")
                        ));
                    }
                });
            }
            ui.horizontal_wrapped(|ui| {
                for s in dwarves::unit_tree::BUSY_ORDER {
                    let c = mark_fill(s);
                    ui.label(
                        egui::RichText::new(format!(" {s} "))
                            .small()
                            .color(egui::Color32::WHITE)
                            .background_color(egui::Color32::from_rgb(c.r, c.g, c.b)),
                    );
                }
                ui.weak(format!(
                    "· {} of {} crates shown — click a repo or a crate to drill in",
                    self.answer.body["crates_shown"], self.answer.body["crates_total"]
                ));
            });
            let ev = self.answer.evidence_text();
            if !ev.is_empty() {
                ui.label(egui::RichText::new(ev).small());
            }
            ui.separator();
            // the live marks: re-ring every frame while something pulses
            if self.spec.nodes.iter().any(|n| n.ring.is_some()) {
                let t = self.epoch.elapsed().as_secs_f32();
                for n in &mut self.spec.nodes {
                    if n.ring.is_some() {
                        let state = self.answer.body["nodes"]
                            .as_array()
                            .and_then(|ns| ns.iter().find(|x| x["id"] == n.id.as_str()))
                            .and_then(|x| x["state"].as_str())
                            .unwrap_or("");
                        n.ring = mark_ring(state, t);
                    }
                }
                self.pane.restyle(&self.spec);
                ui.ctx().request_repaint_after(Duration::from_millis(60));
            }
            self.pane.ui(ui);
            if let Some(id) = self.pane.take_click() {
                self.toggle(&id);
            }
        } else {
            room::callout(ui, "the tree", &self.answer);
        }
        ui.ctx().request_repaint_after(Duration::from_millis(250));
    }

    fn state_json(&self) -> serde_json::Value {
        let b = &self.answer.body;
        serde_json::json!({
            "kind": "front_tree",
            "workspace": self.workspace,
            "answer": self.answer.state_json(),
            "header": if self.answer.is_measured() { serde_json::Value::String(header_line(b)) } else { serde_json::Value::Null },
            "done": b["header"]["done"],
            "total": b["header"]["total"],
            "units_per_min": b["header"]["units_per_min"],
            "eta_s": b["header"]["eta_s"],
            "box": b["header"]["box"],
            "nodes": self.pane.node_count(),
            "edges": self.pane.edge_count(),
            "marks": self.marks(),
            "expand": self.expand,
            "pending": self.pending.is_some(),
            "graph": self.pane.report(),
            "first_column": self.first_column(),
            // where the graph sits on screen vs the part of its canvas the viewer sees
            // (a robot checks "centred, fully visible" on these)
            "screen": {
                "box": self.pane.screen_box().map(|b| [b.min.x, b.min.y, b.max.x, b.max.y]),
                "visible": self.pane.last_visible().map(|b| [b.min.x, b.min.y, b.max.x, b.max.y]),
            },
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return;
        };
        if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
            self.set_workspace(ws);
            return;
        }
        if let Some(id) = v.get("toggle").and_then(|t| t.as_str()) {
            self.toggle(id);
            return;
        }
        if v.get("reread").and_then(|t| t.as_bool()) == Some(true) {
            self.ask();
            self.wait();
            return;
        }
        if v.get("wait").and_then(|t| t.as_bool()) == Some(true) {
            self.wait();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::room::testing::CannedRoom;
    use serde_json::json;

    fn tree() -> serde_json::Value {
        json!({
            "surface": SURFACE, "state": "complete", "workspace": "w",
            "header": { "done": 30, "total": 120, "units_per_min": 45.0, "eta_s": 120, "box": "dwarf", "jobs": [] },
            "nodes": [
                { "id": "repo:a", "kind": "repo", "label": "a", "state": "compiling", "badge": "compiling 10/60", "expandable": true },
                { "id": "repo:b", "kind": "repo", "label": "b", "state": "done", "badge": "landed 60/60", "expandable": true },
                { "id": "crate:serde", "kind": "crate", "label": "serde@1.0.0", "state": "reading", "shared_by": 2, "badge": "×2 · 2 units", "expandable": true },
            ],
            "edges": [["repo:a", "crate:serde"], ["repo:b", "crate:serde"]],
            "crates_total": 40, "crates_shown": 1,
            "evidence": { "missing": [], "notes": [] },
        })
    }

    /// The tab asks the forge `Dwarves.Tree` and draws its answer on the shared graph
    /// renderer: every node, every edge, the header line, the marks.
    #[test]
    fn the_tab_draws_the_forges_tree() {
        let src = CannedRoom::new(&[(SURFACE, tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        let s = t.state_json();
        assert_eq!(
            (s["nodes"].as_u64(), s["edges"].as_u64()),
            (Some(3), Some(2)),
            "{s}"
        );
        assert_eq!(
            s["header"],
            "30/120 units done (25 %) · 45 units/min · ETA 2m00s · builds on dwarf"
        );
        assert_eq!(
            s["marks"],
            json!({ "compiling": 1, "done": 1, "reading": 1 })
        );
        assert!(
            s["graph"]["node_cap"].is_null(),
            "the shared renderer: no cap"
        );
        assert_eq!(
            src.asked.lock().unwrap()[0],
            (SURFACE.to_string(), json!({ "expand": [] }))
        );
    }

    /// A click drills in: the next ask names the node, and a second click backs out.
    #[test]
    fn a_click_drills_in_and_out() {
        let src = CannedRoom::new(&[(SURFACE, tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        t.update_json(r#"{"toggle":"crate:serde"}"#);
        t.wait();
        assert_eq!(
            src.asked.lock().unwrap().last().unwrap().1,
            json!({ "expand": ["crate:serde"] })
        );
        t.update_json(r#"{"toggle":"crate:serde"}"#);
        t.wait();
        assert_eq!(
            src.asked.lock().unwrap().last().unwrap().1,
            json!({ "expand": [] })
        );
        t.update_json(r#"{"toggle":"nope"}"#);
        assert_eq!(
            t.state_json()["pending"],
            false,
            "an unknown node asks nothing"
        );
    }

    /// A rate seeded from the board's timestamps reads as an estimate.
    #[test]
    fn a_board_seeded_rate_reads_as_an_estimate() {
        let mut b = tree();
        b["header"]["rate_source"] = json!("board");
        assert!(
            header_line(&b).contains("· ~45 units/min ·"),
            "{}",
            header_line(&b)
        );
        b["header"]["rate_source"] = json!("readings");
        assert!(header_line(&b).contains("· 45 units/min ·"));
    }

    /// No plan yet / queued: the state and its sentence, no graph, no number.
    #[test]
    fn no_plan_yet_is_a_state_not_an_empty_graph() {
        let src = CannedRoom::new(&[(
            SURFACE,
            json!({ "surface": SURFACE, "state": "empty_gap", "evidence": { "missing": [{ "input": "plan", "why": "no plan yet — the board fills when the server reads the workspace" }] } }),
        )]);
        let mut t = FrontTree::with_source(src, "w");
        t.wait();
        let s = t.state_json();
        assert_eq!(s["answer"]["state"], "empty_gap");
        assert!(s["answer"]["evidence"]
            .as_str()
            .unwrap()
            .contains("no plan yet"));
        assert!(s["header"].is_null() && s["nodes"] == 0, "{s}");
    }

    /// Three repos at different depths, a chain of shared crates under them.
    fn deep_tree() -> serde_json::Value {
        let n = |id: &str, kind: &str| json!({ "id": id, "kind": kind, "label": id, "state": "done", "badge": "", "expandable": true });
        json!({
            "surface": SURFACE, "state": "complete", "workspace": "w",
            "header": { "done": 1, "total": 2, "units_per_min": null, "eta_s": null, "box": "dwarf", "jobs": [] },
            "nodes": [n("repo:a", "repo"), n("repo:b", "repo"), n("repo:c", "repo"),
                      n("crate:x", "crate"), n("crate:y", "crate"), n("crate:z", "crate"), n("crate:w", "crate")],
            "edges": [["repo:a", "crate:x"], ["crate:x", "crate:y"], ["crate:y", "crate:z"], ["crate:z", "crate:w"],
                      ["repo:b", "crate:w"], ["repo:c", "crate:z"]],
            "crates_total": 4, "crates_shown": 4,
            "evidence": { "missing": [], "notes": [] },
        })
    }

    /// Paint the tab `frames` times in a 1200 × 800 harness.
    fn paint(t: FrontTree, frames: usize) -> FrontTree {
        let cell = std::cell::RefCell::new(t);
        {
            let mut h = egui_kittest::Harness::builder()
                .with_size(egui::vec2(1200.0, 800.0))
                .build_ui(|ui| cell.borrow_mut().ui(ui));
            for _ in 0..frames {
                h.step();
            }
        }
        cell.into_inner()
    }

    /// **The first view is framed, the repos in column 0.** Rendered: every repo sits
    /// at the same (leftmost) x, every crate right of it; every node lands inside the
    /// canvas and the graph's box fills it in one direction (zoomed to fit, not off to
    /// the right at zoom 1). A plain refresh keeps the user's camera, and so does a new
    /// shape (a drill-in): only the first view and ⊙ fit frame.
    #[test]
    fn the_first_view_is_framed_with_the_repos_in_the_first_column() {
        let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        let t = paint(t, 4);
        let pos: std::collections::BTreeMap<String, facett_graphview::Pos> =
            t.pane.positions().into_iter().collect();
        let repo_x: BTreeSet<i64> = ["repo:a", "repo:b", "repo:c"]
            .iter()
            .map(|r| pos[*r].x as i64)
            .collect();
        assert_eq!(repo_x.len(), 1, "the repos share one column: {pos:?}");
        let x0 = *repo_x.iter().next().unwrap() as f32;
        assert!(
            pos.iter()
                .filter(|(id, _)| id.starts_with("crate:"))
                .all(|(_, p)| p.x > x0),
            "crates flow right of the repos: {pos:?}"
        );
        assert!(
            pos.values().all(|p| p.x >= x0),
            "column 0 is the leftmost: {pos:?}"
        );

        let rect = t.pane.last_rect().expect("painted");
        assert!(!t.pane.fit_pending(), "the fit ran");
        assert!(rect.height() >= 0.6 * 800.0, "the graph covers most of the tab's height: {rect:?}");
        let screen: Vec<egui::Pos2> = pos
            .values()
            .map(|p| t.pane.world_to_screen(*p).unwrap())
            .collect();
        assert!(
            screen.iter().all(|p| rect.contains(*p)),
            "every node on the canvas {rect:?}: {screen:?}"
        );
        let (min_x, max_x) = screen
            .iter()
            .fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
        let (min_y, max_y) = screen
            .iter()
            .fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
        assert!(
            (max_x - min_x) / rect.width() > 0.6 || (max_y - min_y) / rect.height() > 0.6,
            "fitted: box {min_x}..{max_x} × {min_y}..{max_y} in {rect:?}"
        );
        assert!(
            ((min_x + max_x) * 0.5 - rect.center().x).abs() < rect.width() * 0.1,
            "centred, not off to the right"
        );

        // a plain refresh keeps the user's camera
        let mut t = t;
        t.pane.set_camera(1.7, 33.0, -12.0);
        t.update_json(r#"{"reread":true}"#);
        let t = paint(t, 2);
        assert_eq!(
            t.pane.camera(),
            (1.7, 33.0, -12.0),
            "a refresh keeps the camera"
        );

        // a new shape (a drill-in, a new plan) KEEPS the camera: only the first view
        // and ⊙ fit frame
        let mut t = t;
        let mut drilled = deep_tree();
        drilled["nodes"].as_array_mut().unwrap().push(json!({ "id": "crate:v", "kind": "crate", "label": "v", "state": "done", "badge": "", "expandable": true }));
        drilled["edges"].as_array_mut().unwrap().push(json!(["repo:a", "crate:v"]));
        src.set(SURFACE, drilled);
        t.update_json(r#"{"reread":true}"#);
        let t = paint(t, 2);
        assert_eq!(t.pane.camera(), (1.7, 33.0, -12.0), "a new shape keeps the camera");
    }

    /// The tab in a live harness: frames at 60 fps, real pointer events.
    struct Live {
        h: egui_kittest::Harness<'static>,
        tab: std::rc::Rc<std::cell::RefCell<FrontTree>>,
    }

    impl Live {
        fn new(t: FrontTree) -> Self {
            let tab = std::rc::Rc::new(std::cell::RefCell::new(t));
            let t2 = tab.clone();
            let h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui(move |ui| t2.borrow_mut().ui(ui));
            let mut me = Self { h, tab };
            me.steps(4);
            me
        }
        fn steps(&mut self, n: usize) {
            for _ in 0..n {
                self.h.step();
            }
        }
        /// Frames until the ask in flight landed and the camera stopped flying.
        fn settle(&mut self) {
            for _ in 0..400 {
                self.h.step();
                let t = self.tab.borrow();
                if t.pending.is_none() && !t.pane.flying() {
                    break;
                }
                drop(t);
                std::thread::sleep(Duration::from_millis(2));
            }
            self.steps(2);
        }
        fn screen_of(&self, id: &str) -> egui::Pos2 {
            let t = self.tab.borrow();
            let p = t.pane.positions().into_iter().find(|(i, _)| i == id).unwrap().1;
            t.pane.world_to_screen(p).unwrap()
        }
        fn click(&mut self, p: egui::Pos2) {
            self.h.input_mut().events.push(egui::Event::PointerMoved(p));
            self.h.step();
            let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
            self.h.input_mut().events.push(b(true));
            self.h.step();
            self.h.input_mut().events.push(b(false));
            self.h.step();
        }
    }

    /// The drilled-in answer for repo:c: its own crates join, and the layout moves.
    fn drilled_c() -> serde_json::Value {
        let mut d = deep_tree();
        for v in ["crate:c1", "crate:c2"] {
            d["nodes"].as_array_mut().unwrap().push(json!({ "id": v, "kind": "crate", "label": v, "state": "done", "badge": "", "expandable": true }));
            d["edges"].as_array_mut().unwrap().push(json!(["repo:c", v]));
        }
        d["nodes"].as_array_mut().unwrap().insert(0, json!({ "id": "repo:0", "kind": "repo", "label": "0", "state": "done", "badge": "", "expandable": true }));
        d
    }

    /// **📌 Stay: a click never moves the view.** Rendered: a real click on repo:c drills
    /// in (the forge answers a bigger tree, the layout shifts) — repo:c stays EXACTLY
    /// where it was on screen and the zoom is untouched. RED before: the drill-in
    /// refitted the whole graph.
    #[test]
    fn stay_a_click_keeps_the_clicked_node_where_it_was() {
        let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        let mut live = Live::new(t);
        assert_eq!(live.tab.borrow().pane.nav_mode(), facett_core::fly::NavMode::Stay, "Stay by default");
        let zoom = live.tab.borrow().pane.camera().0;
        let before = live.screen_of("repo:c");
        src.set(SURFACE, drilled_c());
        live.click(before);
        live.settle();
        let t = live.tab.borrow();
        assert_eq!(t.expand.iter().cloned().collect::<Vec<_>>(), vec!["repo:c".to_string()], "the click drilled in");
        assert_eq!(t.pane.node_count(), 10, "the bigger tree landed");
        drop(t);
        let after = live.screen_of("repo:c");
        assert!((after - before).length() < 0.5, "repo:c stayed put: {before:?} → {after:?}");
        assert_eq!(live.tab.borrow().pane.camera().0, zoom, "the zoom did not move");
    }

    /// **🎯 Focus: a click flies the node to the centre.** Rendered: after the click (and
    /// the drill-in landing) repo:c's screen centre is the canvas centre (±2 px) at the
    /// focus zoom — the readable zoom, or less so repo:c's children fit around it.
    #[test]
    fn focus_a_click_flies_the_node_to_the_canvas_centre() {
        let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        let mut live = Live::new(t);
        live.tab.borrow_mut().pane.set_nav_mode(facett_core::fly::NavMode::Focus);
        let before = live.screen_of("repo:c");
        src.set(SURFACE, drilled_c());
        live.click(before);
        live.settle();
        let c = live.tab.borrow().pane.last_rect().unwrap().center();
        let at = live.screen_of("repo:c");
        assert!((at - c).length() <= 2.0, "repo:c at the canvas centre: {at:?} vs {c:?}");
        let want = live.tab.borrow().pane.focus_zoom("repo:c").unwrap();
        assert!(want <= facett_graphview::decorated_view::FOCUS_ZOOM, "readable at most");
        assert!((live.tab.borrow().pane.camera().0 - want).abs() < 1e-3, "at the focus zoom {want} (repo:c and its children framed): {:?}", live.tab.borrow().pane.camera());
    }

    /// **⊙ fit is centred**: the toolbar button flies to the whole graph with its box
    /// centre on the canvas centre (±2 px) — not world (0,0) at the centre, the graph
    /// hanging off to the right.
    #[test]
    fn fit_centres_the_whole_graph() {
        let src = CannedRoom::new(&[(SURFACE, deep_tree())]);
        let mut t = FrontTree::with_source(src.clone(), "w");
        t.wait();
        let mut live = Live::new(t);
        live.tab.borrow_mut().pane.set_camera(1.0, 0.0, 0.0);
        live.steps(2);
        use egui_kittest::kittest::Queryable;
        live.h.get_by_label("⊙ fit").click();
        live.settle();
        let t = live.tab.borrow();
        let rect = t.pane.last_rect().unwrap();
        let pts: Vec<egui::Pos2> = t.pane.positions().into_iter().map(|(_, p)| t.pane.world_to_screen(p).unwrap()).collect();
        let (min, max) = pts.iter().fold((egui::pos2(f32::MAX, f32::MAX), egui::pos2(f32::MIN, f32::MIN)), |(a, b), p| (a.min(*p), b.max(*p)));
        let mid = egui::pos2((min.x + max.x) * 0.5, (min.y + max.y) * 0.5);
        assert!((mid - rect.center()).length() <= 2.0, "the graph's box is centred: {mid:?} vs {:?}", rect.center());
    }

    /// The marks: six fills, compiling pulses (its ring moves with time), reading glows.
    #[test]
    fn compiling_pulses_and_reading_glows() {
        let fills: BTreeSet<(u8, u8, u8)> = dwarves::unit_tree::BUSY_ORDER
            .iter()
            .map(|s| {
                let c = mark_fill(s);
                (c.r, c.g, c.b)
            })
            .collect();
        assert_eq!(fills.len(), 6, "six distinct marks");
        let (a, b) = (
            mark_ring("compiling", 0.0).unwrap(),
            mark_ring("compiling", 0.3).unwrap(),
        );
        assert_ne!(a.a, b.a, "the compiling ring pulses");
        assert_eq!(
            mark_ring("reading", 0.0),
            mark_ring("reading", 0.7),
            "reading glows steadily"
        );
        assert!(mark_ring("done", 0.0).is_none() && mark_ring("queued", 0.0).is_none());
    }
}