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

nordisk/facett

facett-buildthing/src/deck.rs

//! **The build-thing DECK** — three real panes, not one body.
//!
//! A pane that merely renders is not a pane. The rule in this constellation is that a
//! pane has its own rect, its own chrome, and a divider a human can drag; anything else
//! is a section of one flow wearing a title, and the first readout that grows pushes
//! its neighbour off the bottom. This deck is the build-thing surface as **three**
//! panes:
//!
//! | idx | pane | what it owns |
//! |---|---|---|
//! | 0 | **Variants** | the role-grouped menu — server / client, linkage, the normal case |
//! | 1 | **Graph** | what the selected variant compiles in and needs running |
//! | 2 | **Result** | what the last build produced |
//!
//! # Two mounting paths, one owner
//!
//! * [`BuildThingDeck::view_pane`] renders **one** pane into a rect the host has
//!   already decided — the seam `facett_app::scene` needs to make a deck out of REAL
//!   scene panes, each with its own chrome, fold, minimise and maximise. This is the
//!   primary path and the one that gets the scene's own splitters. It mirrors
//!   `facett_warehousedeck::WarehouseDeck::view_pane` exactly.
//! * [`Facet::ui`] draws all three at once with its own draggable dividers, for a host
//!   that mounts the deck as a single tab body (dwarves' ⚒ tab today, because its
//!   shell keys pane decomposition off a table this crate does not own).
//!
//! The dividers in the second path are ~30 lines rather than a reuse of
//! `facett_app::SplitPane`, and that is a deliberate, narrow exception: `SplitPane`
//! takes ownership of two `Box<dyn Facet>` children, which is incompatible with also
//! serving `view_pane(idx)` from the same three panes. Two owners of one pane is the
//! twin this session has spent its length deleting, so the deck keeps ONE owner and
//! drives a divider itself. When a host mounts the primary path, the scene owns the
//! real splitters and this code is not reached.
//!
//! # `Facet`, deliberately, and not `Panel`
//!
//! [`BuildThingDeck`] implements **`Facet` only**. facett-core carries a blanket
//! `impl<T: Facet> Panel for T`, so it is a `Panel` for free with nothing to
//! disambiguate — implementing both by hand is what produced `E0034` on `ui` /
//! `update_json` in dwarves' pane earlier today. [`BuildGraphPane`] implements
//! neither: it stays inherent-methods-only and is driven by this shell.

use egui::{Sense, Ui};
use facett_core::Facet;
use nornir_build_thing::plugin::{BuildGraph, BuildMenu, Linkage, Role, VariantRef};

use crate::graph_pane::BuildGraphPane;

/// What a deck pane asks the host to do. Returned by [`BuildThingDeck::view_pane`] so
/// a scene host can act on a click in a pane it drew itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Msg {
    /// The user picked a variant — from the menu, or by clicking a graph node.
    SelectVariant(String),
    /// The user pressed Build on the selected variant.
    Build(String),
}

/// The three panes, by index. Named rather than bare `usize` so a host cannot mount
/// them in the wrong order without saying so.
pub const PANE_VARIANTS: usize = 0;
/// The graph pane's index.
pub const PANE_GRAPH: usize = 1;
/// The result pane's index.
pub const PANE_RESULT: usize = 2;
/// How many panes the deck holds.
pub const PANE_COUNT: usize = 3;

/// What the last build produced, as the deck shows it. Supplied by the host — this
/// crate runs no builds.
#[derive(Debug, Clone, Default)]
pub struct BuildResult {
    /// The variant that was built.
    pub variant: String,
    /// Whether it produced an artifact (not merely whether a command exited zero).
    pub green: bool,
    /// The one-line outcome.
    pub headline: String,
    /// Produced artifacts as `(path, human size)`.
    pub artifacts: Vec<(String, String)>,
    /// Compiler diagnostics, verbatim.
    pub errors: Vec<String>,
    /// Whether a build is in flight.
    pub running: bool,
}

/// **The build-thing deck.**
pub struct BuildThingDeck {
    /// The role-grouped menu the plugin returned.
    pub menu: BuildMenu,
    /// The graph pane (inherent methods only — this shell drives it).
    pub graph: BuildGraphPane,
    /// The last build's outcome.
    pub result: BuildResult,
    /// The selected variant.
    pub selected: Option<String>,
    /// Divider fractions for the single-body path: menu | graph | result.
    ratios: [f32; 2],
    /// Messages raised this frame, drained by the host.
    pending: Vec<Msg>,
}

impl Default for BuildThingDeck {
    fn default() -> Self {
        BuildThingDeck {
            menu: BuildMenu {
                repo: String::new(),
                rev: String::new(),
                roles: Vec::new(),
                entries: Vec::new(),
                cargo_set: None,
            },
            graph: BuildGraphPane::default(),
            result: BuildResult::default(),
            selected: None,
            ratios: [0.34, 0.40],
            pending: Vec::new(),
        }
    }
}

impl BuildThingDeck {
    /// A deck over a plugin's menu. The default selection is the SERVER role's
    /// declared normal case — not the first entry, because "first" is an accident of
    /// declaration order and `default_variant` is a decision the app made.
    pub fn new(menu: BuildMenu) -> Self {
        let selected = menu
            .default_for(Role::Server)
            .or_else(|| menu.default_for(Role::Client))
            .map(|s| s.to_string())
            .or_else(|| menu.entries.first().map(|e| e.id.clone()));
        BuildThingDeck { menu, selected, ..Default::default() }
    }

    /// How many panes — what a host declares scene slots from.
    pub fn pane_count(&self) -> usize {
        PANE_COUNT
    }

    /// The pane title at `idx`, for a host naming its panes from the deck.
    pub fn pane_title(&self, idx: usize) -> Option<String> {
        match idx {
            PANE_VARIANTS => Some(format!(
                "Variants — {}",
                if self.menu.repo.is_empty() { "no build thing" } else { &self.menu.repo }
            )),
            PANE_GRAPH => Some(self.graph.title()),
            PANE_RESULT => Some("Result".to_string()),
            _ => None,
        }
    }

    /// Show a variant's graph (the host fetched it from the plugin).
    pub fn set_graph(&mut self, g: BuildGraph) {
        self.graph.set_graph(g);
    }

    /// Record the selection. Returns `true` when it changed, so a host knows to fetch
    /// a fresh graph rather than re-fetching every frame.
    pub fn select(&mut self, variant: &str) -> bool {
        if self.selected.as_deref() == Some(variant) {
            return false;
        }
        self.selected = Some(variant.to_string());
        true
    }

    /// `(compiled_in, gated_out, undecided)` over the graph's source-file layer — the
    /// deck's own oracle for what this variant leaves out.
    pub fn graph_counts(&self) -> (usize, usize, usize) {
        self.graph.gated_counts()
    }

    /// **Set divider fraction `i`, clamped JOINTLY.** The two fractions
    /// (`menu | graph`) can never sum past `1.0 - MIN_THIRD`, so the third
    /// (result) pane always keeps at least [`MIN_THIRD`] of the height.
    ///
    /// Clamping each fraction independently to `[MIN_RATIO, MAX_RATIO]` was **bug
    /// F3**: `{set_ratio:[0,0.70]}` then `{set_ratio:[1,0.70]}` left `[0.70, 0.70]`
    /// (sum 1.4), so the result pane's height (`1 - r0 - r1`) went negative, floored
    /// to 48 px and the panes OVERLAPPED — and `state_json["ratios"]` reported a
    /// layout that could not exist.
    fn set_ratio(&mut self, i: usize, f: f32) {
        if i >= self.ratios.len() {
            return;
        }
        // ratios has exactly two entries; the "other" is the one not being set.
        let other = self.ratios[1 - i];
        // The most this fraction may take: leave `other` AND MIN_THIRD for the result
        // pane. Never below MIN_RATIO, never above MAX_RATIO.
        let hi = (1.0 - MIN_THIRD - other).clamp(MIN_RATIO, MAX_RATIO);
        self.ratios[i] = f.clamp(MIN_RATIO, hi);
    }

    /// Take the messages raised since the last call.
    pub fn take_msgs(&mut self) -> Vec<Msg> {
        // A graph-node click is a selection too — this is the round trip that makes
        // the graph a way to PICK a build rather than a picture beside one.
        if let Some(v) = self.graph.take_selection() {
            self.pending.push(Msg::SelectVariant(v));
        }
        std::mem::take(&mut self.pending)
    }

    /// **Render ONE pane into the rect the caller has already decided** — the seam a
    /// scene host needs to make a deck out of real panes. No grid, no scroll area, no
    /// title strip: the scene's chrome bar carries the title, and a second one would be
    /// a second header row.
    ///
    /// An out-of-range `idx` draws a visible marker rather than nothing, so a host
    /// whose pane tree has drifted from the deck sees the drift instead of a blank rect
    /// that reads as "this pane has no data".
    pub fn view_pane(&mut self, idx: usize, ui: &mut Ui) -> Vec<Msg> {
        match idx {
            PANE_VARIANTS => self.ui_variants(ui),
            PANE_GRAPH => self.graph.ui(ui),
            PANE_RESULT => self.ui_result(ui),
            _ => {
                ui.weak(format!("no deck pane at index {idx} (deck holds {PANE_COUNT})"));
            }
        }
        self.take_msgs()
    }

    // ── pane 0: the role-grouped menu ────────────────────────────────────────

    fn ui_variants(&mut self, ui: &mut Ui) {
        if self.menu.entries.is_empty() {
            ui.label(
                egui::RichText::new(
                    "no build thing loaded — this is an ABSENT declaration, not an empty \
                     build space",
                )
                .small(),
            );
            return;
        }
        ui.label(
            egui::RichText::new(format!(
                "{} @ {}",
                self.menu.repo,
                if self.menu.rev.is_empty() {
                    "<rev UNKNOWN>".to_string()
                } else {
                    self.menu.rev.chars().take(12).collect::<String>()
                }
            ))
            .small()
            .monospace(),
        );
        // A declaration bug is shown, not swallowed.
        for problem in self.menu.check_defaults() {
            ui.label(egui::RichText::new(format!("⚠ {problem}")).small().color(WARN));
        }
        ui.separator();

        let mut pick: Option<String> = None;
        egui::ScrollArea::vertical().id_salt("bt-variants").show(ui, |ui| {
            for group in &self.menu.roles {
                ui.label(
                    egui::RichText::new(format!("{} ", group.role.as_str().to_uppercase()))
                        .small()
                        .strong(),
                );
                ui.label(egui::RichText::new(&group.summary).small().weak());
                for id in &group.variants {
                    let Some(e) = self.menu.entries.iter().find(|e| &e.id == id) else { continue };
                    ui.horizontal_wrapped(|ui| {
                        chip(ui, e.linkage.as_str(), linkage_color(e.linkage));
                        if e.is_default {
                            chip(ui, "normal", TEAL);
                        }
                        let sel = self.selected.as_deref() == Some(id.as_str());
                        if ui
                            .selectable_label(sel, egui::RichText::new(id).monospace())
                            .clicked()
                        {
                            pick = Some(id.clone());
                        }
                    });
                    // What this choice COSTS at runtime — the thing a caller needs.
                    ui.label(egui::RichText::new(format!("    {}", e.runtime)).small().weak());
                }
                ui.add_space(6.0);
            }
        });
        if let Some(p) = pick && self.select(&p) {
            self.pending.push(Msg::SelectVariant(p.clone()));
        }

        ui.separator();
        let can = self.selected.clone();
        ui.horizontal(|ui| {
            let running = self.result.running;
            let label = if running { "⏳ building…" } else { "▶ Build" };
            if ui
                .add_enabled(!running && can.is_some(), egui::Button::new(egui::RichText::new(label).strong()))
                .clicked()
                && let Some(v) = can.clone()
            {
                self.pending.push(Msg::Build(v));
            }
            if let Some(v) = &can {
                ui.label(egui::RichText::new(v).small().monospace());
            }
        });
    }

    // ── pane 2: the result ───────────────────────────────────────────────────

    fn ui_result(&mut self, ui: &mut Ui) {
        let r = &self.result;
        if r.variant.is_empty() {
            ui.label(egui::RichText::new("no build yet").small().weak());
            return;
        }
        ui.horizontal(|ui| {
            if r.running {
                ui.spinner();
                chip(ui, "BUILDING", WARN);
            } else if r.green {
                chip(ui, "BUILT", TEAL);
            } else {
                chip(ui, "FAILED", RED);
            }
            ui.label(egui::RichText::new(&r.headline).monospace().small());
        });
        for (path, size) in &r.artifacts {
            ui.label(egui::RichText::new(format!("artifact  {path}  ({size})")).small().monospace());
        }
        if !r.green && r.artifacts.is_empty() && !r.running {
            ui.label(
                egui::RichText::new("no artifact on disk — whatever the exit code said")
                    .small()
                    .color(RED),
            );
        }
        if !r.errors.is_empty() {
            egui::ScrollArea::vertical().max_height(220.0).id_salt("bt-errors").show(ui, |ui| {
                for e in &r.errors {
                    ui.label(egui::RichText::new(e).small().monospace().color(RED));
                }
            });
        }
    }

    // ── the single-body path: three panes, two draggable dividers ────────────

    /// Draw all three panes stacked with draggable dividers between them. Used only
    /// when a host mounts the deck as ONE tab body; a scene host calls
    /// [`view_pane`](Self::view_pane) per pane instead and gets the scene's own
    /// splitters, chrome, fold and maximise.
    fn ui_all(&mut self, ui: &mut Ui) {
        let total = ui.available_height().max(120.0);
        let mut heights =
            [total * self.ratios[0], total * self.ratios[1], total * (1.0 - self.ratios[0] - self.ratios[1])];
        for h in heights.iter_mut() {
            *h = h.max(48.0);
        }
        for (idx, &height) in heights.iter().enumerate() {
            let title = self.pane_title(idx).unwrap_or_default();
            ui.allocate_ui(egui::vec2(ui.available_width(), height), |ui| {
                ui.group(|ui| {
                    ui.set_min_height(height - 10.0);
                    ui.vertical(|ui| {
                        ui.strong(egui::RichText::new(title).small());
                        ui.separator();
                        let msgs = self.view_pane(idx, ui);
                        self.pending.extend(msgs);
                    });
                });
            });
            if idx < PANE_COUNT - 1 && let Some(delta) = divider(ui, idx) {
                // Joint clamp (F3): a drag can never push the two fractions past
                // `1 - MIN_THIRD`, so the result pane never collapses/overlaps.
                self.set_ratio(idx, self.ratios[idx] + delta / total);
            }
        }
    }

    /// The observable state (L4).
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "component": "BuildThingDeck",
            "repo": self.menu.repo,
            "rev": self.menu.rev,
            "pane_count": self.pane_count(),
            "panes": (0..PANE_COUNT).filter_map(|i| self.pane_title(i)).collect::<Vec<_>>(),
            "ratios": self.ratios,
            "selected": self.selected,
            "roles": self.menu.roles.iter().map(|g| serde_json::json!({
                "role": g.role.as_str(),
                "variants": g.variants,
                "default": g.default_variant,
            })).collect::<Vec<_>>(),
            "default_problems": self.menu.check_defaults(),
            "graph": self.graph.state_json(),
            "result": {
                "variant": self.result.variant,
                "green": self.result.green,
                "running": self.result.running,
                "headline": self.result.headline,
                "artifact_count": self.result.artifacts.len(),
                "error_count": self.result.errors.len(),
            },
        })
    }
}

/// A draggable horizontal divider; returns the vertical drag delta when moved.
fn divider(ui: &mut Ui, idx: usize) -> Option<f32> {
    let (rect, resp) =
        ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), Sense::drag());
    let hot = resp.hovered() || resp.dragged();
    let col = if hot { ui.visuals().selection.bg_fill } else { ui.visuals().weak_text_color() };
    ui.painter().rect_filled(rect.shrink2(egui::vec2(0.0, 2.0)), 2.0, col);
    if hot {
        ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
    }
    let _ = idx;
    resp.dragged().then(|| resp.drag_delta().y)
}

/// The minimum fraction either divider may take — a pane can shrink to this but
/// never past it, so no pane collapses to nothing.
const MIN_RATIO: f32 = 0.12;
/// The maximum a single divider fraction may take.
const MAX_RATIO: f32 = 0.70;
/// The floor the THIRD (result) pane keeps: the joint budget the first two
/// fractions must leave (`ratios[0] + ratios[1] <= 1.0 - MIN_THIRD`). See
/// [`BuildThingDeck::set_ratio`] — this is what bug F3 violated.
const MIN_THIRD: f32 = 0.12;

const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
const WARN: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);

fn linkage_color(l: Linkage) -> egui::Color32 {
    match l {
        Linkage::Fat => egui::Color32::from_rgb(0x6a, 0x1b, 0x9a),
        Linkage::Thin => WARN,
        Linkage::Mixed => egui::Color32::from_rgb(0x2e, 0x50, 0x6a),
        // An inapplicable axis is drawn as its own thing, never as fat's colour —
        // that conflation is the bug the value exists to prevent.
        Linkage::NotApplicable => egui::Color32::from_rgb(0x45, 0x4a, 0x4d),
    }
}

use facett_core::look::chip;

/// **`Facet` only** — facett-core's blanket `impl<T: Facet> Panel for T` supplies
/// `Panel`, so there is nothing to disambiguate at a call site. Implementing both by
/// hand is what produced `E0034` on `ui` / `update_json` in dwarves' pane.
impl Facet for BuildThingDeck {
    fn title(&self) -> &str {
        "Build Thing"
    }

    fn ui(&mut self, ui: &mut Ui) {
        self.ui_all(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        BuildThingDeck::state_json(self)
    }

    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(s) = v.get("select_variant").and_then(|x| x.as_str()) && self.select(s) {
            self.pending.push(Msg::SelectVariant(s.to_string()));
        }
        if let Some(s) = v.get("build").and_then(|x| x.as_str()) {
            self.pending.push(Msg::Build(s.to_string()));
        }
        if let Some(r) = v.get("set_ratio").and_then(|x| x.as_array())
            && let (Some(i), Some(f)) = (r.first().and_then(|x| x.as_u64()), r.get(1).and_then(|x| x.as_f64()))
        {
            // Joint clamp (F3): the two fractions can never sum past 1 - MIN_THIRD.
            self.set_ratio(i as usize, f as f32);
        }
        // F1: the graph FILTER is a driveable verb too (build / files / excluded), so a
        // robot can drive the interaction the pane exists for — "what does this build
        // leave out" — headlessly and assert it in `state_json["graph"]["filter"]`.
        if let Some(s) = v.get("set_filter").and_then(|x| x.as_str()) {
            let filter = match s {
                "build" => Some(crate::graph_pane::Filter::Build),
                "files" => Some(crate::graph_pane::Filter::Files),
                "excluded" => Some(crate::graph_pane::Filter::Excluded),
                _ => None,
            };
            if let Some(filter) = filter {
                self.graph.set_filter(filter);
            }
        }
    }
}

/// The entry a menu-less host uses: an honest empty deck that says so.
pub fn empty_deck() -> BuildThingDeck {
    BuildThingDeck::default()
}

/// Build a deck over a menu, selecting its declared normal case.
pub fn deck_for(menu: BuildMenu) -> BuildThingDeck {
    BuildThingDeck::new(menu)
}

/// The **seeded demo menu** — a holger-shaped [`BuildMenu`]: a SERVER role group
/// (embedded / mini / full, `full` the declared normal case) + a CLIENT group
/// (ui / agent), each entry carrying its linkage + runtime note. Promoted from the
/// `tests/robot_deck.rs` fixture (which now delegates here — one writer, LAW 5) so a
/// host-less gallery (facett-demo) mounts a POPULATED deck instead of the honest
/// empty one.
#[must_use]
pub fn demo_menu() -> BuildMenu {
    use nornir_build_thing::plugin::{Ecosystem, RoleGroup};
    let entry = |id: &str, role: Role, linkage: Linkage, is_default: bool| VariantRef {
        id: id.into(),
        role,
        ecosystem: Ecosystem::Cargo,
        linkage,
        runtime: match linkage {
            Linkage::Fat => "embeds skade — nothing else needs to be running".into(),
            Linkage::Thin => "NEEDS SKADE RUNNING — reached over gRPC".into(),
            _ => "—".into(),
        },
        is_default,
        selector: serde_json::Value::Null,
    };
    BuildMenu {
        repo: "holger".into(),
        rev: "c0ffee".repeat(6) + "abcd",
        roles: vec![
            RoleGroup {
                role: Role::Server,
                // This fixture is a repo shipping ONE unnamed product, so the menu
                // has no suite axis — `None` is the documented value for that
                // (nornir_build_thing::plugin::RoleGroup::suite), not a placeholder.
                suite: None,
                summary: "three tiers".into(),
                variants: vec!["embedded".into(), "mini".into(), "full".into()],
                default_variant: Some("full".into()),
            },
            RoleGroup {
                role: Role::Client,
                suite: None,
                summary: "two clients".into(),
                variants: vec!["ui".into(), "agent".into()],
                default_variant: Some("ui".into()),
            },
        ],
        entries: vec![
            entry("embedded", Role::Server, Linkage::Thin, false),
            entry("mini", Role::Server, Linkage::Thin, false),
            entry("full", Role::Server, Linkage::Fat, true),
            entry("ui", Role::Client, Linkage::Thin, true),
            entry("agent", Role::Client, Linkage::Thin, false),
        ],
        cargo_set: None,
    }
}

/// A **seeded demo deck** over [`demo_menu`] — opens on the declared normal case
/// (`full`) with all three panes populated. The fixture the facett-demo gallery
/// mounts.
#[must_use]
pub fn demo() -> BuildThingDeck {
    BuildThingDeck::new(demo_menu())
}

/// The variant entry for `id`, if the menu has one.
pub fn entry<'a>(menu: &'a BuildMenu, id: &str) -> Option<&'a VariantRef> {
    menu.entries.iter().find(|e| e.id == id)
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_build_thing::plugin::{Ecosystem, RoleGroup};

    fn menu() -> BuildMenu {
        let entry = |id: &str, role: Role, linkage: Linkage, is_default: bool| VariantRef {
            id: id.into(),
            role,
            ecosystem: Ecosystem::Cargo,
            linkage,
            runtime: match linkage {
                Linkage::Fat => "embeds skade — nothing else needs to be running".into(),
                Linkage::Thin => "NEEDS SKADE RUNNING — reached over gRPC".into(),
                _ => "—".into(),
            },
            is_default,
            selector: serde_json::Value::Null,
        };
        BuildMenu {
            repo: "holger".into(),
            rev: "c0ffee".repeat(6) + "abcd",
            roles: vec![
                RoleGroup {
                    role: Role::Server,
                    // This fixture is a repo shipping ONE unnamed product, so the menu has
                    // no suite axis — `None` is the documented value for that
                    // (nornir_build_thing::plugin::RoleGroup::suite), not a placeholder.
                    suite: None,
                    summary: "three tiers".into(),
                    variants: vec!["embedded".into(), "mini".into(), "full".into()],
                    default_variant: Some("full".into()),
                },
                RoleGroup {
                    role: Role::Client,
                    suite: None,
                    summary: "two clients".into(),
                    variants: vec!["ui".into(), "agent".into()],
                    default_variant: Some("ui".into()),
                },
            ],
            entries: vec![
                entry("embedded", Role::Server, Linkage::Thin, false),
                entry("mini", Role::Server, Linkage::Thin, false),
                entry("full", Role::Server, Linkage::Fat, true),
                entry("ui", Role::Client, Linkage::Thin, true),
                entry("agent", Role::Client, Linkage::Thin, false),
            ],
            cargo_set: None,
        }
    }

    /// **It is a pane SET.** Three panes, each with its own title and its own render
    /// call — not one body with three sections in a shared flow.
    #[test]
    fn the_deck_is_three_addressable_panes() {
        let d = BuildThingDeck::new(menu());
        assert_eq!(d.pane_count(), 3);
        assert!(d.pane_title(PANE_VARIANTS).unwrap().contains("holger"));
        assert!(d.pane_title(PANE_GRAPH).unwrap().contains("Build graph"));
        assert_eq!(d.pane_title(PANE_RESULT).as_deref(), Some("Result"));
        assert_eq!(d.pane_title(3), None, "out of range is None, not a silent pane");
    }

    /// The default selection is the app's DECLARED normal case, not the first entry —
    /// "first" is an accident of declaration order.
    #[test]
    fn the_deck_opens_on_the_declared_normal_case() {
        let d = BuildThingDeck::new(menu());
        assert_eq!(d.selected.as_deref(), Some("full"), "not `embedded`, which is merely first");
    }

    /// **The round trip that is the payoff for returning data instead of SVG**: a
    /// graph-node click becomes a variant selection.
    #[test]
    fn clicking_a_graph_node_selects_that_variant() {
        use nornir_build_thing::plugin::{GraphNode as BgNode, NodeKind};
        let mut d = BuildThingDeck::new(menu());
        d.set_graph(BuildGraph {
            repo: "holger".into(),
            root: "mini".into(),
            nodes: vec![BgNode {
                id: "variant:mini".into(),
                label: "mini".into(),
                kind: NodeKind::Variant,
                detail: None,
                selects: Some("mini".into()),
            }],
            edges: Vec::new(),
        });
        // What `BuildGraphPane::ui` does when the node is clicked.
        d.graph.pending_select_for_test("mini");
        let msgs = d.take_msgs();
        assert_eq!(msgs, vec![Msg::SelectVariant("mini".into())]);
    }

    /// Selection is idempotent — a host must not re-fetch a graph every frame.
    #[test]
    fn selecting_the_same_variant_twice_reports_no_change() {
        let mut d = BuildThingDeck::new(menu());
        assert!(!d.select("full"), "already selected");
        assert!(d.select("mini"));
        assert!(!d.select("mini"));
    }

    /// The headless seam: the deck drives over `update_json` / `state_json` like every
    /// other facet, so a robot can drive it with no window.
    #[test]
    fn the_deck_drives_headless_over_the_seam() {
        let mut d = BuildThingDeck::new(menu());
        Facet::update_json(&mut d, r#"{"select_variant":"embedded"}"#);
        let s = Facet::state_json(&d);
        assert_eq!(s["selected"], "embedded");
        assert_eq!(s["pane_count"], 3);
        assert_eq!(s["panes"].as_array().unwrap().len(), 3);
        assert_eq!(s["roles"][0]["default"], "full");
        assert!(s["default_problems"].as_array().unwrap().is_empty());

        // F1: the graph filter is a driveable verb, surfaced in the graph pane state.
        assert_eq!(
            Facet::state_json(&d)["graph"]["filter"], "build",
            "the deck opens on the Build filter (no file layer)"
        );
        Facet::update_json(&mut d, r#"{"set_filter":"excluded"}"#);
        assert_eq!(
            Facet::state_json(&d)["graph"]["filter"], "excluded",
            "set_filter drives the graph pane's filter — the 'what does this build leave out' view"
        );

        // The divider is state, so a robot can assert a drag moved it — but the two
        // fractions are clamped JOINTLY (F3): they can never sum past `1 - MIN_THIRD`,
        // so the result pane keeps its floor. `ratios[1]` defaults to 0.40, so asking
        // `ratios[0]` for 0.5 lands at `1 - MIN_THIRD - 0.40 = 0.48`, not 0.5.
        Facet::update_json(&mut d, r#"{"set_ratio":[0,0.5]}"#);
        let r0 = Facet::state_json(&d)["ratios"][0].as_f64().unwrap();
        assert!(
            (r0 - 0.48).abs() < 1e-6,
            "joint clamp caps ratios[0] at 1 - MIN_THIRD - ratios[1], got {r0}"
        );

        // **F3 (bug) — the over-100% case.** Independent clamping let BOTH fractions
        // reach MAX_RATIO (0.70) and sum to 1.4, driving the result pane's height
        // (`1 - r0 - r1`) negative so the panes overlapped and `state_json["ratios"]`
        // misreported the layout. Drive the exact reported sequence and assert the joint
        // budget holds: the two fractions leave the result pane at least MIN_THIRD.
        let mut d2 = BuildThingDeck::new(menu());
        Facet::update_json(&mut d2, r#"{"set_ratio":[0,0.70]}"#);
        Facet::update_json(&mut d2, r#"{"set_ratio":[1,0.70]}"#);
        let rr = Facet::state_json(&d2)["ratios"].clone();
        let (a, b) = (rr[0].as_f64().unwrap(), rr[1].as_f64().unwrap());
        assert!(a + b <= 1.0 - 0.12 + 1e-6, "the two dividers leave the result pane its floor: {a}+{b}={}", a + b);
        assert!(1.0 - a - b >= 0.12 - 1e-6, "the third (result) pane keeps at least MIN_THIRD — never negative");

        let msgs = d.take_msgs();
        assert!(msgs.contains(&Msg::SelectVariant("embedded".into())));
    }

    /// An empty deck says the declaration is ABSENT — never an empty build space.
    #[test]
    fn an_empty_deck_states_that_the_declaration_is_absent() {
        let d = empty_deck();
        let s = Facet::state_json(&d);
        assert_eq!(s["pane_count"], 3, "the panes exist even with nothing to show");
        assert_eq!(s["selected"], serde_json::Value::Null);
        assert_eq!(s["graph"]["node_count"], 0);
    }

    /// A declaration bug (a role with no normal case) is SURFACED in the pane's state,
    /// not swallowed.
    #[test]
    fn a_role_with_no_default_is_reported_in_the_pane_state() {
        let mut m = menu();
        m.roles[0].default_variant = None;
        for e in m.entries.iter_mut() {
            if e.id == "full" {
                e.is_default = false;
            }
        }
        let d = BuildThingDeck::new(m);
        let problems = Facet::state_json(&d)["default_problems"].as_array().unwrap().clone();
        assert!(!problems.is_empty());
        assert!(problems[0].as_str().unwrap().contains("NO default"), "{problems:?}");
    }

    #[test]
    fn an_inapplicable_linkage_is_not_drawn_as_fat() {
        assert_ne!(
            linkage_color(Linkage::NotApplicable),
            linkage_color(Linkage::Fat),
            "conflating 'the axis does not apply' with 'self-contained' is the bug \
             NotApplicable exists to prevent"
        );
    }
}