Gunnar

nordisk/holger-egui

tests/robot_scene.rs

//! holger-server-ui **SCENE robot-UI** suite — drives the newly scene-migrated shell
//! (`facett_app::scene`) *headlessly* through `egui_kittest` (AccessKit clicks) with
//! the reusable `nornir-robotui` framework, asserting the app's observable
//! `state_json()["scene"]` after each interaction. Each `(action → expected)` step is
//! a `nornir-testmatrix` row, so a robot run auto-drives the SCENE surface and **fills
//! the nornir test matrix**.
//!
//! The shell is now ONE `facett_app::scene::Scene` — a grouped menubar over a
//! **draggable master/detail split**: the registry rail (itself a draggable vertical
//! split, and a phone drawer) beside a `Pane::tabs` of foldable slot leaves (one per
//! registered tab) — rendered through facett-app's OWN `Router`/`TabView`/`facett_menu`
//! primitives, DRIVING the existing `<tab>_tab` renderers via a `SceneHost`. This suite
//! comprehensively covers that surface:
//!
//! * every one of the TabView panes, reached by a real header click (route +
//!   `active_tab` reflect it AND the pane's slot state appears in the render tree);
//! * fold / unfold of every foldable pane (the `folded` map flips AND the folded
//!   content leaves the render tree);
//! * **a real pointer DRAG of each divider**, asserted at the RECT level — the dragged
//!   pane's rendered rect grows by (about) the drag distance, its sibling's shrinks by
//!   the same, their sum is preserved, and the live ratio moves off its seed. Both
//!   dividers, both directions. A boolean "it is resizable" would stay green over a
//!   divider that renders but never moves anything; a rect cannot.
//! * the registry rail as the app's MASTER selection — picking a repository in the rail
//!   drives the one `repos.selected` every detail tab reads;
//! * every menubar group (View / Operate / Repositories / Action / Look / Help),
//!   opened + picked as a real `facett_menu` dropdown → the ONE `dispatch_command`;
//! * deep-link navigation to a pane BY NAME (`goto:<pane>`);
//! * responsive behaviour at a phone-class width — the root split collapses to `Tabs`
//!   on the workspace and the rail DRAWER auto-folds, asserted as the device class,
//!   the layout tag and the rail's folded state.
//!
//! Every assertion is RED-when-broken (a dropped tab, fold, menu route, or a
//! responsive regression fails the matching cell). Complements — does not duplicate —
//! `robot_ui_app.rs::robot_drives_scene_shell_tabs_and_folds` (the smoke cell).
//!
//! Gated behind `gui`. Run:
//! `cargo test -p holger-server-ui --features gui --test robot_scene`.
#![cfg(feature = "gui")]

use std::sync::Arc;

use egui_kittest::Harness;
// The EXACT extent a minimised pane collapses to — facett-app's own constant, so the
// geometry assertions below are pinned to the renderer's number, never a copied 22.
use facett_app::scene::CHROME_BAR;
use holger_server_ui::app::HolgerUiApp;
use holger_server_ui::data::UiData;
use nornir_robotui::{status, Action, Assert, Observable, RobotSession, Scenario, Step};
use serde_json::{json, Value};
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{
    ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait,
};

/// The tab leaves in strip order: `(slot id / state_json tab name, header title)`.
/// The index in this table is the scene's `active_tab` value for that pane, and the
/// pane name is `"<slot>_pane"` — the single source the scene builder uses too. Mirrors
/// the app's own `TAB_ORDER`; the shell reports the same roster under `/tabs`, and
/// `every_registered_tab_is_covered` below asserts this table has not fallen behind it.
const TABS: &[(&str, &str)] = &[
    ("status", "Status"),
    ("repos", "Repos"),
    ("browse", "Browse"),
    ("archive", "Archive"),
    ("artifact", "Artifact"),
    ("graph", "Graph"),
    ("helix", "Helix"),
    ("lineage", "Lineage"),
    ("tools", "Tools"),
    ("upload", "Upload"),
    ("signin", "Sign in"),
    // ⚙ Settings — the preferences pane and the ONE home of holger's look & feel
    // (it moved off the root chrome bar and out of the root menu-bar "Look" submenu,
    // matching dwarves-demo's 🎛 `settings` tab). Appended LAST, so every tab above
    // keeps the `active_tab` index this table's other cells assert on.
    ("settings", "Settings"),
];

/// The ROOT pane's name — the app's `SHELL_PANE`, now the DRAGGABLE horizontal
/// master/detail split (rail | workspace). Every route / layout assertion below
/// addresses the shell by this id.
const ROOT: &str = "holger-shell";

/// The registry RAIL pane — the master side, a draggable vertical split and a drawer.
const RAIL: &str = "holger-rail";

/// The rail's top half: the repository roster (the app's one selection).
const RAIL_ROSTER: &str = "rail_roster_pane";

/// The rail's bottom half: the selected repository's cross-tab context.
const RAIL_SELECTION: &str = "rail_selection_pane";

/// The tabbed WORKSPACE pane — the detail side of the master/detail split.
const WORKSPACE: &str = "holger-workspace";

// ── the tab INTERNALS: a decomposed body is a draggable split of two named halves ──
//
// The shell adopted the scene; the tab BODIES did not, so minimise / maximise / drag
// operated on a whole tab and could do nothing inside it. A body that decomposed is
// `Split(axis, ratio, DRAGGABLE)` over `<slot>_main` | `<slot>_inspector`. The helpers
// below read that roster off the SHELL'S OWN `state_json` (`/scene/split_bodies`) rather
// than hard-coding it, so a tab decomposed later is swept by every cell automatically.

/// Did `<slot>`'s tab body decompose into a draggable master/detail split?
fn is_split_body(state: &Value, slot: &str) -> bool {
    let body = format!("{slot}_pane");
    state["scene"]["split_bodies"]
        .as_array()
        .unwrap_or_else(|| panic!("the shell reports its split bodies: {}", state["scene"]))
        .iter()
        .any(|v| v.as_str() == Some(body.as_str()))
}

/// The scene SLOT ids a tab body renders: the two halves of a decomposed body, or the
/// bare tab slot for a body that is honestly one pane.
fn body_slots(state: &Value, slot: &str) -> Vec<String> {
    if is_split_body(state, slot) {
        vec![format!("{slot}-main"), format!("{slot}-inspector")]
    } else {
        vec![slot.to_string()]
    }
}

/// The half PANE names of a decomposed tab body (empty for a single-pane body).
fn body_halves(state: &Value, slot: &str) -> Vec<String> {
    if is_split_body(state, slot) {
        vec![format!("{slot}_main"), format!("{slot}_inspector")]
    } else {
        Vec::new()
    }
}

/// A present-but-empty repository backend — enough that the UI lists real repos (so
/// the scene's Repositories submenu + repo panes carry populated data, LAW 2) without
/// depending on any ecosystem's on-disk format. The surface under test is the scene.
struct StubRepo {
    name: &'static str,
    writable: bool,
}

impl StubRepo {
    fn ro(name: &'static str) -> Self {
        Self { name, writable: false }
    }
    fn rw(name: &'static str) -> Self {
        Self { name, writable: true }
    }
}

impl RepositoryBackendTrait for StubRepo {
    fn name(&self) -> &str {
        self.name
    }
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Rust
    }
    fn is_writable(&self) -> bool {
        self.writable
    }
    fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        Ok(None)
    }
    fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
        Ok(())
    }
    fn list(&self, _name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        Ok(Vec::new())
    }
    fn archive_files(&self, _prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
        Ok(Vec::new())
    }
    fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
        Ok(ArchiveInfo {
            file_count: 0,
            total_uncompressed_bytes: 0,
            archive_path: self.name.to_string(),
        })
    }
    fn handle_http2_request(
        &self,
        _method: &str,
        _suburl: &str,
        _body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        Ok((404, Vec::new(), Vec::new()))
    }
}

/// A real holger-server-ui app over a `LocalHolger` seeded with three repositories, splash
/// skipped so the robot starts on the scene. rust-dev is writable (so the server's
/// derived profile is *dynamic* — the app starts non-static, every tab incl. Upload
/// reachable); cache + sparring are read-only.
fn seeded_app() -> HolgerUiApp {
    let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        ("rust-dev".to_string(), Arc::new(StubRepo::rw("rust-dev"))),
        ("cache".to_string(), Arc::new(StubRepo::ro("cache"))),
        ("sparring".to_string(), Arc::new(StubRepo::ro("sparring"))),
    ];
    let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
    let data = UiData::new(holger).expect("runtime");
    let mut app = HolgerUiApp::new(data);
    app.skip_splash();
    app
}

/// Newtype so we can implement the foreign `Observable` trait (orphan rule); a
/// one-line delegate to the app's own `state_json()`.
struct UiApp(HolgerUiApp);

impl Observable for UiApp {
    fn state_json(&self) -> Value {
        self.0.state_json()
    }
}

/// Build a scene-driving robot over a seeded app at the given viewport size, stepped
/// one frame so the scene has rendered once (the render facts are populated).
fn robot_at(width: f32, height: f32) -> RobotSession<'static, UiApp> {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(width, height))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");
    robot.step();
    robot
}

/// The default desktop-class scene robot (the size the existing robot cells use).
fn robot() -> RobotSession<'static, UiApp> {
    robot_at(1100.0, 720.0)
}

// ── rect / divider helpers: the scene reports both, so a robot asserts GEOMETRY ──

/// A rendered pane's rect as `(x, y, w, h)`, read from the scene's own render facts.
/// Panics with the whole scene state when the pane did not render — a pane that
/// silently stops being laid out is exactly the regression these tests exist for.
fn pane_rect(state: &Value, pane: &str) -> (f32, f32, f32, f32) {
    let r = &state["scene"]["render"]["panes"][pane]["rect"];
    let f = |i: usize| {
        r.get(i)
            .and_then(Value::as_f64)
            .unwrap_or_else(|| panic!("pane `{pane}` reports a rect: {}", state["scene"]["render"]))
            as f32
    };
    (f(0), f(1), f(2), f(3))
}

/// A rendered pane's WIDTH.
fn pane_w(state: &Value, pane: &str) -> f32 {
    pane_rect(state, pane).2
}

/// The centre of a split pane's first drag-handle rect, read from the render's
/// observable `separators` — the robot-addressable divider position (the same rect the
/// operator's cursor lands on).
fn sep_center(state: &Value, pane: &str) -> egui::Pos2 {
    let sep = &state["scene"]["render"]["panes"][pane]["separators"][0];
    let f = |i: usize| {
        sep.get(i)
            .and_then(Value::as_f64)
            .unwrap_or_else(|| panic!("split `{pane}` reports a divider handle rect: {}", state["scene"]["render"]["panes"][pane]))
            as f32
    };
    egui::pos2(f(0) + f(2) / 2.0, f(1) + f(3) / 2.0)
}

/// The live first-child ratio of a split pane (what a divider drag moves).
fn split_ratio0(state: &Value, pane: &str) -> f32 {
    state["scene"]["split_ratio"][pane][0]
        .as_f64()
        .unwrap_or_else(|| panic!("split `{pane}` reports live ratios: {}", state["scene"]["split_ratio"]))
        as f32
}

/// Drag a divider handle from `from` by `delta` points, as a REAL pointer gesture
/// (hover → press → move → release), stepping a frame at each stage so the renderer
/// sees the drag the way it sees an operator's.
fn drag_divider(robot: &mut RobotSession<'_, UiApp>, from: egui::Pos2, delta: egui::Vec2) {
    let to = from + delta;
    let h = robot.harness_mut();
    h.event(egui::Event::PointerMoved(from));
    h.drag_at(from);
    h.event(egui::Event::PointerMoved(to));
    // `step`, not `run`: the Graph / Helix / Lineage canvases call `request_repaint`
    // every frame (they animate), so `Harness::run` — which spins until the UI stops
    // asking to repaint — hits its 4-step ceiling and panics the moment one of those
    // tabs is the drawn one. A drag needs exactly one frame per pointer event anyway.
    h.step();
    h.drop_at(to);
    h.step();
    robot.step();
}

/// **DRAG the master/detail divider — the rail and the workspace RECTS move.**
///
/// The shell's root is a draggable horizontal split, so it reports a real handle rect
/// under `separators`. Grab it, drag it 260pt right, and assert GEOMETRY: the rail's
/// rendered rect is ~260pt wider, the workspace's ~260pt narrower, their widths still
/// sum to what they summed to before (the split conserves area), and the live ratio
/// moved off its 0.22 seed. Then drag 420pt LEFT and assert the rail ends up NARROWER
/// than it started.
///
/// RED-when-broken in the exact way this shell was broken before: swap `Pane::split`
/// for `split_fixed` and there is no handle rect at all; keep the handle but stop
/// writing the ratio back and the rects never move.
#[test]
fn robot_scene_drag_master_detail_divider_moves_both_rects() {
    // Wide enough that a 260pt drag is a small fraction of the shell.
    let mut robot = robot_at(1400.0, 800.0);

    let s0 = robot.state();
    assert_eq!(
        s0["scene"]["render"]["panes"][ROOT]["resizable"], json!(true),
        "the master/detail split declares a DRAGGABLE divider: {}", s0["scene"]["render"]["panes"][ROOT]
    );
    // The shell's two dividers come first; everything after them is a decomposed tab
    // BODY's internal divider (the roster the shell publishes itself).
    let splits: Vec<String> = s0["scene"]["draggable_splits"]
        .as_array()
        .expect("the divider roster")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    let bodies: Vec<String> = s0["scene"]["split_bodies"]
        .as_array()
        .expect("the split-body roster")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    let mut expected = vec![ROOT.to_string(), RAIL.to_string()];
    expected.extend(bodies.iter().cloned());
    assert_eq!(
        splits, expected,
        "the two SHELL dividers, root-first, then one per decomposed tab body: {}",
        s0["scene"]["draggable_splits"]
    );
    assert!(
        !bodies.is_empty(),
        "at least one tab BODY decomposed into its own draggable split: {}",
        s0["scene"]["split_bodies"]
    );

    let (rail0, work0) = (pane_w(&s0, RAIL), pane_w(&s0, WORKSPACE));
    let seed = split_ratio0(&s0, ROOT);
    assert!((seed - 0.22).abs() < 1e-4, "the rail seeds at 0.22: {seed}");
    // The rail is genuinely the narrow master column, not a hidden zero-width pane.
    assert!(rail0 > 100.0 && rail0 < work0, "the rail is a real, narrower column: rail={rail0} workspace={work0}");
    let span0 = rail0 + work0;

    // ── drag RIGHT: the rail grows, the workspace gives up the same area ──────────
    let handle = sep_center(&s0, ROOT);
    drag_divider(&mut robot, handle, egui::vec2(260.0, 0.0));

    let s1 = robot.state();
    let (rail1, work1) = (pane_w(&s1, RAIL), pane_w(&s1, WORKSPACE));
    let grew = rail1 - rail0;
    let shrank = work0 - work1;
    assert!(
        (grew - 260.0).abs() < 24.0,
        "dragging the divider 260pt right widened the rail's RECT by ~260pt: {rail0} -> {rail1} (+{grew})"
    );
    assert!(
        (shrank - 260.0).abs() < 24.0,
        "…and narrowed the workspace's RECT by the same ~260pt: {work0} -> {work1} (-{shrank})"
    );
    assert!(
        ((rail1 + work1) - span0).abs() < 8.0,
        "the split conserves area: {rail0}+{work0}={span0} -> {rail1}+{work1}={}",
        rail1 + work1
    );
    let after = split_ratio0(&s1, ROOT);
    assert!(after > seed + 0.1, "the live ratio moved off its seed: {seed} -> {after}");

    // ── drag LEFT past the start: the rail ends up NARROWER than it began ─────────
    let handle = sep_center(&s1, ROOT);
    drag_divider(&mut robot, handle, egui::vec2(-420.0, 0.0));

    let s2 = robot.state();
    let (rail2, work2) = (pane_w(&s2, RAIL), pane_w(&s2, WORKSPACE));
    assert!(rail2 < rail0, "dragging back left shrank the rail below its start: {rail0} -> {rail2}");
    assert!(work2 > work0, "…and the workspace reclaimed the area: {work0} -> {work2}");
    assert!(
        split_ratio0(&s2, ROOT) < seed,
        "the live ratio is below its seed after the reverse drag: {}",
        split_ratio0(&s2, ROOT)
    );
}

/// **DRAG the rail's own (vertical) divider — the roster and selection RECTS move.**
///
/// The rail is a second draggable split, along the other axis, so this proves the
/// affordance is not a one-off on the root: grab the rail's handle, drag it 120pt DOWN,
/// and assert the roster pane's rendered rect got ~120pt taller while the selection
/// pane's got ~120pt shorter.
///
/// The rail's panes are foldable, so their reported rect is the header/chrome row —
/// which is exactly why this asserts the pane rects of the SPLIT CHILDREN through the
/// split's own live ratios as well: the two together pin both the geometry and the
/// state the geometry is derived from.
#[test]
fn robot_scene_drag_rail_divider_moves_the_rail_panes() {
    let mut robot = robot_at(1400.0, 800.0);

    let s0 = robot.state();
    assert_eq!(
        s0["scene"]["render"]["panes"][RAIL]["resizable"], json!(true),
        "the rail declares a DRAGGABLE divider too: {}", s0["scene"]["render"]["panes"][RAIL]
    );
    let seed = split_ratio0(&s0, RAIL);
    assert!((seed - 0.60).abs() < 1e-4, "the roster seeds at 0.60 of the rail height: {seed}");

    // The rail's two panes both render, stacked (the selection sits BELOW the roster).
    let (_, roster_y0, _, _) = pane_rect(&s0, RAIL_ROSTER);
    let (_, sel_y0, _, _) = pane_rect(&s0, RAIL_SELECTION);
    assert!(
        sel_y0 > roster_y0,
        "the selection pane is stacked below the roster: roster_y={roster_y0} selection_y={sel_y0}"
    );

    let handle = sep_center(&s0, RAIL);
    drag_divider(&mut robot, handle, egui::vec2(0.0, 120.0));

    let s1 = robot.state();
    let after = split_ratio0(&s1, RAIL);
    assert!(after > seed + 0.05, "dragging the rail divider down grew the roster's share: {seed} -> {after}");

    // The GEOMETRY followed: the selection pane's top edge moved down with the divider.
    let (_, sel_y1, _, _) = pane_rect(&s1, RAIL_SELECTION);
    let moved = sel_y1 - sel_y0;
    assert!(
        (moved - 120.0).abs() < 24.0,
        "the selection pane's rect moved down by ~120pt with the divider: {sel_y0} -> {sel_y1} (+{moved})"
    );

    // …and back up again: the divider is not one-way.
    let handle = sep_center(&s1, RAIL);
    drag_divider(&mut robot, handle, egui::vec2(0.0, -200.0));
    let s2 = robot.state();
    assert!(
        split_ratio0(&s2, RAIL) < seed,
        "dragging back up shrank the roster below its seed: {} < {seed}",
        split_ratio0(&s2, RAIL)
    );
    let (_, sel_y2, _, _) = pane_rect(&s2, RAIL_SELECTION);
    assert!(sel_y2 < sel_y0, "the selection pane's rect moved back up: {sel_y0} -> {sel_y2}");
}

/// A rendered pane's **allotted** rect `(x, y, w, h)` — the area its parent handed it
/// this frame. This is the geometry oracle for chrome: a minimised child is allotted
/// exactly `CHROME_BAR` points along the split axis, and a maximised pane's siblings are
/// allotted a ZERO rect. `rect` (what egui then consumed) can lag by a frame or a margin;
/// `alloc` is the apportionment itself.
fn pane_alloc(state: &Value, pane: &str) -> (f32, f32, f32, f32) {
    let r = &state["scene"]["render"]["panes"][pane]["alloc"];
    let f = |i: usize| {
        r.get(i)
            .and_then(Value::as_f64)
            .unwrap_or_else(|| panic!("pane `{pane}` reports an alloc rect: {}", state["scene"]["render"]))
            as f32
    };
    (f(0), f(1), f(2), f(3))
}

/// A rendered pane's allotted WIDTH.
fn alloc_w(state: &Value, pane: &str) -> f32 {
    pane_alloc(state, pane).2
}

/// A rendered pane's allotted AREA (`w * h`) — zero for a pane a maximise hid.
fn alloc_area(state: &Value, pane: &str) -> f32 {
    state["scene"]["render"]["panes"][pane]["area"]
        .as_f64()
        .unwrap_or_else(|| panic!("pane `{pane}` reports its allotted area: {}", state["scene"]["render"]))
        as f32
}

/// **WINDOW CHROME: minimise · maximise · restore, asserted as RECTS.**
///
/// Every pane but the root draws a chrome title bar (facett-app's `Chrome::Auto`), and
/// each control carries a unique AccessKit label (`"minimize <pane>"`) — so this drives
/// the affordance the way an operator does, by CLICKING the button, and then reads the
/// resulting geometry rather than a flag:
///
/// * **minimise the rail** → the rail collapses to its `CHROME_BAR`-wide title strip and
///   the workspace GROWS by the width the rail gave up (space is handed back, not lost);
/// * **restore** → both rects return to EXACTLY what they were (the split ratios are
///   never overwritten by chrome, so restore is lossless);
/// * **maximise the workspace** → the rail is allotted ZERO area and the workspace fills
///   the shell; **restore** puts the master/detail layout back byte-for-byte.
///
/// RED-when-broken in the way that matters: a chrome bar that draws but doesn't
/// re-apportion, or a restore that "resets" to the seed instead of the prior layout,
/// both pass a boolean check and fail this one.
#[test]
fn robot_scene_chrome_minimise_maximise_restore_move_the_rects() {
    let mut robot = robot_at(1400.0, 800.0);

    // ── the affordance roster: every pane but the root wears chrome ──────────────
    let s0 = robot.state();
    let chrome: Vec<String> = s0["scene"]["chrome"]
        .as_array()
        .expect("the shell reports which panes draw a chrome title bar")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    assert!(!chrome.contains(&ROOT.to_string()), "the scene ROOT is bare (nothing to minimise into): {chrome:?}");
    for pane in [RAIL, RAIL_ROSTER, RAIL_SELECTION, WORKSPACE, "status_pane", "signin_pane"] {
        assert!(chrome.contains(&pane.to_string()), "`{pane}` draws a chrome title bar: {chrome:?}");
    }
    // Every pane in the tree except the root — counted off the tree, not a literal.
    let pane_count = s0["scene"]["pane_names"].as_array().expect("pane tree").len();
    assert_eq!(chrome.len(), pane_count - 1, "chrome on every pane but the root: {chrome:?}");
    // The rail's bar really drew the three controls this shell drives. Asserted per
    // FIELD, not against a whole object literal: facett-app grows the spec (it just
    // gained `editor`), and a shell that only drives fold/minimise/maximise must not go
    // red because a field it does not use appeared beside them.
    let spec = s0["scene"]["render"]["panes"][RAIL]["chrome"]["spec"].clone();
    for control in ["bar", "fold", "minimize", "maximize"] {
        assert_eq!(
            spec[control], json!(true),
            "the rail's title bar carries `{control}`: {}", s0["scene"]["render"]["panes"][RAIL]["chrome"]
        );
    }

    let (rail0, work0) = (alloc_w(&s0, RAIL), alloc_w(&s0, WORKSPACE));
    let span0 = rail0 + work0;
    assert!(rail0 > 100.0, "the rail starts as a real column: {rail0}");

    // ── MINIMISE the rail, by clicking its chrome control ────────────────────────
    robot.click_label(&format!("minimize {RAIL}")).expect("the rail's chrome minimise control");
    robot.step();

    let s1 = robot.state();
    assert_eq!(s1["scene"]["minimized"][RAIL], json!(true), "the rail is minimised: {}", s1["scene"]["minimized"]);
    assert_eq!(
        s1["scene"]["last_chrome"], json!(format!("minimize:{RAIL}")),
        "the click landed on the rail's minimise control: {}", s1["scene"]["last_chrome"]
    );
    let (rail1, work1) = (alloc_w(&s1, RAIL), alloc_w(&s1, WORKSPACE));
    assert!(
        (rail1 - CHROME_BAR).abs() < 2.0,
        "a minimised rail collapses to its {CHROME_BAR}pt title strip, got {rail1} (was {rail0})"
    );
    assert!(
        (work1 - (span0 - CHROME_BAR)).abs() < 8.0,
        "…and the workspace is handed the space back: {work0} -> {work1} (of {span0})"
    );

    // ── RESTORE: the layout returns EXACTLY, not to some reset default ───────────
    robot.click_label(&format!("restore {RAIL}")).expect("the minimised rail's restore control");
    robot.step();

    let s2 = robot.state();
    assert_eq!(s2["scene"]["minimized"][RAIL], json!(false), "the rail is restored: {}", s2["scene"]["minimized"]);
    let (rail2, work2) = (alloc_w(&s2, RAIL), alloc_w(&s2, WORKSPACE));
    assert!(
        (rail2 - rail0).abs() < 1.0 && (work2 - work0).abs() < 1.0,
        "restore returns the EXACT prior geometry: rail {rail0} -> {rail2}, workspace {work0} -> {work2}"
    );

    // ── MAXIMISE the workspace: the rail is allotted nothing at all ──────────────
    robot.click_label(&format!("maximize {WORKSPACE}")).expect("the workspace's chrome maximise control");
    robot.step();

    let s3 = robot.state();
    assert_eq!(s3["scene"]["maximized"], json!(WORKSPACE), "the workspace holds the maximise: {}", s3["scene"]["maximized"]);
    assert_eq!(
        alloc_area(&s3, RAIL), 0.0,
        "a maximise allots the rail ZERO area: {}", s3["scene"]["render"]["panes"][RAIL]["alloc"]
    );
    let work3 = alloc_w(&s3, WORKSPACE);
    assert!(
        work3 > span0 - 8.0,
        "the maximised workspace fills the shell: {work3} (the whole split was {span0})"
    );

    // ── RESTORE the maximise: the master/detail layout comes back unchanged ──────
    robot.click_label(&format!("restore {WORKSPACE}")).expect("the maximised workspace's restore control");
    robot.step();

    let s4 = robot.state();
    assert_eq!(s4["scene"]["maximized"], Value::Null, "the maximise is dropped: {}", s4["scene"]["maximized"]);
    let (rail4, work4) = (alloc_w(&s4, RAIL), alloc_w(&s4, WORKSPACE));
    assert!(
        (rail4 - rail0).abs() < 1.0 && (work4 - work0).abs() < 1.0,
        "restoring the maximise returns the EXACT master/detail layout: rail {rail0} -> {rail4}, workspace {work0} -> {work4}"
    );
}

/// **Every chromed pane can be minimised, and minimising HIDES its body.**
///
/// Sweeps the whole chrome roster — the rail, its two panes, the workspace and every tab
/// leaf — over the headless drive point (the same state the bar's control mutates),
/// asserting for each: it starts un-minimised; minimising flips the state AND drops the
/// pane's slot content out of the render tree (the body is genuinely not laid out, so it
/// leaves the AccessKit tree too); restoring brings it back. One pane's chrome working is
/// a demo; every pane's chrome working is the feature.
#[test]
fn robot_scene_every_chromed_pane_minimises_and_restores() {
    let mut robot = robot_at(1400.0, 800.0);
    let chrome: Vec<String> = robot.state()["scene"]["chrome"]
        .as_array()
        .expect("the chrome roster")
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect();
    assert!(chrome.len() >= TABS.len() + 4, "the roster covers the rail, its panes, the workspace and every tab: {chrome:?}");

    // The slot a chromed pane hosts, when it is a leaf that owns one — the rail's two
    // panes, plus one per registered tab (read off TABS, so a new tab is swept too).
    // A `Tabs` layout only draws its ACTIVE child, so a tab leaf is made active first;
    // otherwise it is simply not in this frame's render tree and there is no geometry to
    // assert. The structural panes (the rail, the workspace) are always drawn.
    //
    // A tab BODY that decomposed hosts no slot of its own — its two HALVES do — so the
    // resolver returns both: the tab to activate first, and every scene slot whose
    // presence in `render.slots` proves the pane's body is (or is not) in the tree.
    let s_roster = robot.state();
    let resolve = |pane: &str| -> (Option<String>, Vec<String>) {
        match pane {
            RAIL_ROSTER => (None, vec!["rail-roster".to_string()]),
            RAIL_SELECTION => (None, vec!["rail-selection".to_string()]),
            _ => {
                for (slot, _) in TABS {
                    if pane == format!("{slot}_pane") {
                        return (Some((*slot).to_string()), body_slots(&s_roster, slot));
                    }
                    if pane == format!("{slot}_main") {
                        return (Some((*slot).to_string()), vec![format!("{slot}-main")]);
                    }
                    if pane == format!("{slot}_inspector") {
                        return (Some((*slot).to_string()), vec![format!("{slot}-inspector")]);
                    }
                }
                (None, Vec::new())
            }
        }
    };

    for pane in &chrome {
        // Make the owning tab the drawn tab, so this pane's chrome is on screen. (A
        // `Tabs` layout only lays out its ACTIVE child; an inactive tab's body — and both
        // halves of a decomposed one — is simply not in this frame's render tree.)
        let (tab, slots) = resolve(pane);
        if let Some(slot) = &tab {
            robot.app_mut().0.run_command(&format!("goto:{slot}"));
            robot.step();
            robot.step();
        }
        assert_eq!(
            robot.state()["scene"]["minimized"][pane], json!(false),
            "`{pane}` starts un-minimised"
        );

        let minimized = robot.app_mut().0.toggle_scene_minimize(pane);
        assert!(minimized, "`{pane}` minimised");
        robot.step();
        let s = robot.state();
        assert_eq!(s["scene"]["minimized"][pane], json!(true), "`{pane}` is minimised in state_json: {}", s["scene"]["minimized"]);
        assert_eq!(
            s["scene"]["render"]["panes"][pane]["minimized"], json!(true),
            "the RENDER agrees `{pane}` is minimised: {}", s["scene"]["render"]["panes"][pane]
        );
        for slot in &slots {
            assert!(
                s["scene"]["render"]["slots"].get(slot).is_none(),
                "minimising `{pane}` drops its `{slot}` body out of the render tree: {}", s["scene"]["render"]["slots"]
            );
        }

        assert!(robot.app_mut().0.restore_scene_pane(pane), "`{pane}` restored");
        robot.step();
        let s = robot.state();
        assert_eq!(s["scene"]["minimized"][pane], json!(false), "`{pane}` is restored: {}", s["scene"]["minimized"]);
        for slot in &slots {
            assert!(
                s["scene"]["render"]["slots"].get(slot).is_some(),
                "restoring `{pane}` brings its `{slot}` body back: {}", s["scene"]["render"]["slots"]
            );
        }
    }

    // `restore_scene_layout` is the "reset layout" one-liner: minimise three panes and a
    // maximise, then clear the lot in one call.
    robot.app_mut().0.toggle_scene_minimize(RAIL_ROSTER);
    robot.app_mut().0.toggle_scene_minimize("status_pane");
    robot.app_mut().0.toggle_scene_maximize(WORKSPACE);
    robot.step();
    let s = robot.state();
    assert_eq!(s["scene"]["maximized"], json!(WORKSPACE), "a maximise is in force: {}", s["scene"]["maximized"]);
    assert!(robot.app_mut().0.restore_scene_layout(), "reset layout changed something");
    robot.step();
    let s = robot.state();
    assert_eq!(s["scene"]["maximized"], Value::Null, "reset dropped the maximise: {}", s["scene"]["maximized"]);
    let any_min = s["scene"]["minimized"].as_object().expect("the minimise roster").values().any(|v| v == &json!(true));
    assert!(!any_min, "reset cleared every minimise: {}", s["scene"]["minimized"]);
}

/// **The registry rail IS the app's master selection.** Clicking a repository row in the
/// rail drives the SAME `repos.selected` the Repos tab's combo box and the `repo:<name>`
/// menu command drive — so the detail tabs (Browse / Archive / Artifact / Upload /
/// Lineage) follow the rail. The rail's selection pane then reports that repository's
/// cross-tab context. RED-when-broken if the rail ever grows a selection of its own.
#[test]
fn robot_scene_rail_drives_the_one_master_selection() {
    let mut robot = robot_at(1400.0, 800.0);

    // The roster shows the whole seeded roster and nothing is selected yet.
    let s = robot.state();
    assert_eq!(
        s["scene"]["render"]["slots"]["rail-roster"]["total"], json!(3),
        "the rail lists every reported repository: {}", s["scene"]["render"]["slots"]["rail-roster"]
    );
    assert_eq!(
        s["scene"]["render"]["slots"]["rail-roster"]["shown"], json!(3),
        "…all of them, unfiltered: {}", s["scene"]["render"]["slots"]["rail-roster"]
    );

    // Click the read-only `cache` row (sorted index 0 — the roster is alphabetical:
    // cache · rust-dev · sparring). The rail's row labels are prefixed (`▪ 🔒 …`)
    // precisely so they cannot shadow the bare repo names the Repos combo popup and the
    // Repositories menu render.
    robot.click_label("▪ 🔒 cache").expect("the rail lists `cache` as a clickable row");
    robot.step();

    let s = robot.state();
    assert_eq!(s["repos"]["selected"], json!(0), "the rail row drove the ONE app selection: {s}");
    assert_eq!(
        s["scene"]["render"]["slots"]["rail-selection"]["selected"], json!("cache"),
        "the rail's selection pane reports the picked repository: {}", s["scene"]["render"]["slots"]["rail-selection"]
    );
    assert_eq!(
        s["scene"]["render"]["slots"]["rail-selection"]["upload_enabled"], json!(false),
        "…and that a read-only repo leaves Upload locked: {}", s["scene"]["render"]["slots"]["rail-selection"]
    );

    // Pick the writable one: the same one selection moves, and Upload unlocks.
    robot.click_label("▪ ✎ rust-dev").expect("the rail lists `rust-dev` as a clickable row");
    robot.step();
    let s = robot.state();
    assert_eq!(s["repos"]["selected"], json!(1), "the rail moved the selection to rust-dev: {s}");
    assert_eq!(
        s["scene"]["render"]["slots"]["rail-selection"]["upload_enabled"], json!(true),
        "a writable selection unlocks Upload: {}", s["scene"]["render"]["slots"]["rail-selection"]
    );
}

/// A collision-safe visiting order for the scene-tab scenario: `(slot, title, index)`.
/// A few pane bodies render a widget whose label equals a *tab title* — the Repos pane
/// has "Browse"/"Archive" row-action buttons and the Tools pane a "Tools" heading — so
/// a header click made while such a pane is active would match two nodes. This order
/// never transitions FROM the Repos pane straight TO the Browse/Archive headers, so
/// every source pane is free of the next target's title (the header is then the sole
/// match). The `index` is the pane's true `active_tab` value (its slot order).
const VISIT: &[(&str, &str, usize)] = &[
    ("status", "Status", 0),
    ("repos", "Repos", 1),
    ("graph", "Graph", 5),
    ("browse", "Browse", 2),
    ("archive", "Archive", 3),
    ("artifact", "Artifact", 4),
    ("helix", "Helix", 6),
    ("lineage", "Lineage", 7),
    ("tools", "Tools", 8),
    ("upload", "Upload", 9),
    // ⚙ Settings — the preferences pane (the look & feel's home). Visited before
    // Sign-in: its body carries only the Appearance block's preset labels, none of
    // which is a tab title, so it is a safe source for the next header click.
    ("settings", "Settings", 11),
    // Visited last: the Sign-in pane's own body carries a "Sign in" button that would
    // shadow this header, so nothing is clicked while that pane is the active source.
    ("signin", "Sign in", 10),
];

/// Click a scene `TabView` header UNAMBIGUOUSLY: first reset to the neutral Status pane
/// (whose body carries none of the tab-title labels) via the `goto:status` command, so
/// the ONLY node labelled `title` is the header — the Repos pane's "Browse"/"Archive"
/// buttons and the Tools heading would otherwise collide with those headers. Leaves the
/// newly-selected tab's slot drawn (an extra frame after the header click).
fn click_tab_header(robot: &mut RobotSession<'_, UiApp>, title: &str) {
    robot.app_mut().0.run_command("goto:status");
    robot.step();
    robot.click_label(title).unwrap_or_else(|e| panic!("scene tab header {title:?}: {e}"));
    robot.step(); // second frame: the newly-active tab's slot renders
}

/// **This suite covers EVERY registered tab.** The shell reports its own tab roster
/// under `/tabs` (the app's `TAB_ORDER`); this asserts the local [`TABS`] and [`VISIT`]
/// tables list exactly that set, in that order. RED-when-broken the moment a tab is
/// added to the shell without being covered here — which is what stops this suite from
/// silently going green over a pane it never visits.
#[test]
fn every_registered_tab_is_covered() {
    let robot = robot();
    let s = robot.state();
    let roster: Vec<String> = s["tabs"]
        .as_array()
        .expect("the shell reports its tab roster under /tabs")
        .iter()
        .map(|v| v.as_str().unwrap_or("").to_string())
        .collect();
    let covered: Vec<String> = TABS.iter().map(|(slot, _)| slot.to_string()).collect();
    assert_eq!(roster, covered, "TABS covers every registered shell tab, in strip order");
    // VISIT is a re-ordering of the same set, and each entry carries its true index.
    let mut visit: Vec<(usize, String)> =
        VISIT.iter().map(|(slot, _, i)| (*i, slot.to_string())).collect();
    visit.sort();
    let expected: Vec<(usize, String)> =
        roster.iter().enumerate().map(|(i, s)| (i, s.clone())).collect();
    assert_eq!(visit, expected, "VISIT is the same tab set with true active_tab indices");
}

/// **Every TabView pane is reachable via the scene's own header strip.** Clicking each
/// of the scene `TabView` headers switches `active_tab` (a matrix row per tab),
/// and — once the newly-active tab's slot draws — its `state_json()` appears in the
/// rendered scene tree (`/scene/render/slots/<slot>`) while the root route stays the
/// single shell pane. RED-when-broken if a tab leaf, the header strip, or a slot
/// host ever drops.
#[test]
fn robot_scene_tabview_visits_every_pane() {
    let mut robot = robot();

    // One matrix row per tab: click the scene header → the scene's active_tab tracks
    // it. Driven in a collision-safe order (see VISIT) so each header is the sole node
    // carrying its label at click time.
    let mut sc = Scenario::new("holger-server-ui-scene-tabs");
    for &(slot, title, index) in VISIT.iter() {
        sc = sc.step(
            Step::new(format!("scene tab '{title}' ({slot}) → active_tab == {index}"))
                .action(Action::ClickLabel(title.into()))
                .assert(Assert::State { pointer: "/scene/active_tab".into(), expected: json!(index) }),
        );
    }
    let rows = robot.run_scenario(&sc);
    assert_eq!(rows.len(), VISIT.len(), "one matrix row per tab pane");
    for r in &rows {
        assert_eq!(r.status, status::PASS, "tab step {:?}: {}", r.test_name, r.message);
    }

    // The pane's slot state actually appears in the render tree: reach each pane by a
    // header click (from the neutral Status pane, so the click is unambiguous), then
    // assert the route/active_tab reflect it AND the slot's own view-model state is
    // present in `render.slots`.
    for (i, &(slot, title)) in TABS.iter().enumerate() {
        click_tab_header(&mut robot, title);
        let s = robot.state();
        assert_eq!(s["tab"], json!(slot), "the scene strip drove the tab to {slot}: {s}");
        assert_eq!(s["scene"]["active_tab"], json!(i), "active_tab tracks the {slot} leaf: {s}");
        assert_eq!(
            s["scene"]["render"]["route"], json!(ROOT),
            "the root route stays the shell pane: {s}"
        );
        // Every slot the body hosts is in the render tree — BOTH halves for a decomposed
        // body, so "the inspector silently stopped drawing" is a red cell.
        for id in body_slots(&s, slot) {
            assert!(
                s["scene"]["render"]["slots"].get(&id).map(|v| !v.is_null()).unwrap_or(false),
                "the {slot} body's `{id}` slot state appears in the rendered scene: {s}"
            );
        }
    }
}

/// **Fold / unfold every foldable pane.** For every tab body: make it the
/// active (drawn) pane, assert it starts unfolded with its slot in the tree, FOLD it
/// (the `folded` map flips true AND the pane's slot content leaves `render.slots` — a
/// folded `CollapsingHeader` omits its body from the layout), then UNFOLD (the slot
/// content returns). RED-when-broken if a leaf loses its fold or the fold no longer
/// prunes the content.
#[test]
fn robot_scene_folds_and_unfolds_every_pane() {
    let mut robot = robot();

    for &(slot, _title) in TABS.iter() {
        let pane = format!("{slot}_pane");
        let p = pane.as_str();

        // Make this pane the active (drawn) tab and let its slot render.
        robot.app_mut().0.run_command(&format!("goto:{slot}"));
        robot.step();
        robot.step();
        let s = robot.state();
        let hosted = body_slots(&s, slot);
        assert_eq!(s["scene"]["folded"][p], json!(false), "{p} starts unfolded: {s}");
        for id in &hosted {
            assert!(
                s["scene"]["render"]["slots"].get(id).is_some(),
                "the `{id}` slot renders while {p} is unfolded: {s}"
            );
        }

        // Fold → the tab body's slot content leaves the render tree.
        let folded = robot.app_mut().0.toggle_scene_fold(p);
        assert!(folded, "toggle_scene_fold({p}) folded it");
        robot.step();
        let s = robot.state();
        assert_eq!(s["scene"]["folded"][p], json!(true), "{p} is folded in state_json: {s}");
        assert_eq!(
            s["scene"]["render"]["panes"][p]["folded"], json!(true),
            "the rendered {p} reports folded: {s}"
        );
        for id in &hosted {
            assert!(
                s["scene"]["render"]["slots"].get(id).is_none(),
                "folding {p} removes the `{id}` slot content from the tree: {s}"
            );
        }

        // Unfold → the slot content returns.
        let folded = robot.app_mut().0.toggle_scene_fold(p);
        assert!(!folded, "toggle again unfolded {p}");
        robot.step();
        let s = robot.state();
        assert_eq!(s["scene"]["folded"][p], json!(false), "{p} unfolded again: {s}");
        for id in &hosted {
            assert!(
                s["scene"]["render"]["slots"].get(id).is_some(),
                "unfolding {p} restores the `{id}` slot content: {s}"
            );
        }

        // …and the HALVES of a decomposed body fold independently INSIDE the tab — the
        // whole point of the decomposition. Folding the inspector leaves the master's
        // slot in the tree; folding the master leaves the inspector's.
        let halves = body_halves(&s, slot);
        for half in &halves {
            let mine = format!("{}-{}", slot, half.rsplit('_').next().unwrap_or_default());
            assert!(robot.app_mut().0.toggle_scene_fold(half), "{half} folded");
            robot.step();
            let s = robot.state();
            assert!(
                s["scene"]["render"]["slots"].get(&mine).is_none(),
                "folding `{half}` drops its own `{mine}` slot: {s}"
            );
            for other in hosted.iter().filter(|id| **id != mine) {
                assert!(
                    s["scene"]["render"]["slots"].get(other).is_some(),
                    "folding `{half}` leaves its SIBLING `{other}` drawing — the fold is \
                     INSIDE the tab, not over it: {s}"
                );
            }
            assert!(!robot.app_mut().0.toggle_scene_fold(half), "{half} unfolded again");
            robot.step();
        }
    }
}

/// **The grouped menubar routes real commands.** Each scene menu group is a live
/// `facett_menu` dropdown: open the top-level menu button, then click a leaf whose
/// `Action::Command(<id>)` routes into the ONE shared `dispatch_command` — asserting
/// the effect in `state_json`. Covers View (`goto:`), Operate (`tool:`), Repositories
/// (`repo:`) and Action (`action:`) — four of the five groups, each a matrix row.
/// RED-when-broken if a group, a wired command, or the shared dispatcher drops.
/// (Help·About is its own cell below.)
///
/// There used to be a fifth row here: a top-level **Look** menu listing every theme
/// preset. Appearance is a preference, not root navigation, so it moved into the ⚙
/// **Settings** pane (matching dwarves-demo's 🎛 `settings` tab). The coverage moved
/// with it rather than being deleted: the scenario now navigates **View ▸ Settings**
/// and clicks the preset INSIDE the pane, so this cell still proves the look control
/// is reachable from the root and still re-themes — just from where it now lives.
#[test]
fn robot_scene_menubar_drives_nav_commands() {
    let mut robot = robot();

    let sc = Scenario::new("holger-server-ui-scene-menubar")
        // View → Helix (goto:helix). The View items carry a Ctrl+n shortcut hint.
        .step(Step::new("open View menu").action(Action::ClickLabel("View".into())))
        .step(
            Step::new("View · Helix → active_tab 6 (goto:helix)")
                .action(Action::ClickLabel("Helix      Ctrl+7".into()))
                .assert(Assert::State { pointer: "/scene/active_tab".into(), expected: json!(6) }),
        )
        // Operate → Security scan (tool:security).
        .step(Step::new("open Operate menu").action(Action::ClickLabel("Operate".into())))
        .step(
            Step::new("Operate · Security → tool security (tool:security)")
                .action(Action::ClickLabel("Security scan (Móðguðr)".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
        )
        // Repositories → Open · sparring (repo:sparring → select the row + Browse).
        .step(Step::new("open Repositories menu").action(Action::ClickLabel("Repositories".into())))
        .step(
            Step::new("Repositories · sparring → Browse (repo:sparring)")
                .action(Action::ClickLabel("Open · sparring".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("browse") }),
        )
        // Tab strip → Settings — the look & feel's new home, one click from the root.
        // (Clicked on the STRIP rather than through View ▸ Settings because the View
        // leaf and the strip button carry the same plain label "Settings": Settings is
        // the twelfth tab, past `view_accelerator`'s ten digits, so its menu entry gets
        // no `Ctrl+n` suffix to disambiguate it by. That the View menu DOES carry a
        // working `goto:settings` leaf is asserted in
        // `app::scene::tests::menubar_groups_route_existing_commands`.)
        .step(
            Step::new("tab strip · Settings → the ⚙ Settings pane")
                .action(Action::ClickLabel("Settings".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("settings") }),
        )
        // …and the preset picked INSIDE that pane re-themes the app (what the retired
        // root "Look ▸ Look: device" menu leaf used to do).
        .step(
            Step::new("⚙ Settings · Appearance · device → look device")
                .action(Action::ClickLabel("device".into()))
                .assert(Assert::State { pointer: "/look".into(), expected: json!("device") }),
        )
        // Action → Toggle static (action:toggle-static). LAST: it re-themes the shell.
        .step(Step::new("open Action menu").action(Action::ClickLabel("Action".into())))
        .step(
            Step::new("Action · Toggle static → static_mode true (action:toggle-static)")
                .action(Action::ClickLabel("Toggle static (Skade Vinter)".into()))
                .assert(Assert::State { pointer: "/static_mode".into(), expected: json!(true) }),
        );

    let rows = robot.run_scenario(&sc);
    assert_eq!(rows.len(), 10, "one matrix row per menubar step");
    for r in &rows {
        assert_eq!(r.status, status::PASS, "menubar step {:?}: {}", r.test_name, r.message);
    }

    // Effects propagated through the shared dispatcher: repo:sparring selected the
    // sparring row (sorted index 2), and the Action·toggle engaged the winter look
    // (which overrode the earlier Settings·device pick — proof both routes fired).
    let s = robot.state();
    assert_eq!(s["repos"]["selected"], json!(2), "repo:sparring selected the sparring row: {s}");
    assert_eq!(
        s["look"], json!("skade_vinter"),
        "static overrides the look after Action·toggle (both routes fired): {s}"
    );
}

/// **Help · About** is the sixth menu group: open the Help dropdown and pick "About
/// holger" (`about:open`) → the About surface opens and reports holger's identity.
/// Driven as a real menu click; dismissed via the app hook (NOT the positional Close,
/// which the known pre-existing LFS-logo flake targets). RED-when-broken if the Help
/// group or the `about:open` route drops.
#[test]
fn robot_scene_menubar_help_opens_about() {
    let mut robot = robot();

    robot.click_label("Help").expect("Help menu button");
    robot.click_label("About holger").expect("Help · About holger menu item");
    let s = robot.state();
    assert_eq!(s["about"]["open"], json!(true), "Help·About routed about:open → About opened: {s}");
    assert_eq!(s["about"]["wordmark"], json!("holger"), "About reports holger's identity: {s}");

    // Dismiss via the app hook (avoids the pre-existing off-viewport Close flake).
    robot.app_mut().0.toggle_about();
    robot.step();
    assert_eq!(robot.state()["about"]["open"], json!(false), "About dismissed");
}

/// **Deep-link a pane BY NAME.** holger's shell is name-addressable: a `goto:<pane>`
/// command deep-links straight to that tab by name (the exact id the View menu and the
/// Ctrl-K palette route into the one dispatcher). Each jump moves `active_tab` to that
/// pane while the root route stays the single shell pane (holger navigates by
/// tab, not by a URL route). RED-when-broken if name-addressed navigation breaks.
#[test]
fn robot_scene_deep_link_navigates_pane_by_name() {
    let mut robot = robot();

    for (i, &(slot, _title)) in TABS.iter().enumerate() {
        robot.app_mut().0.run_command(&format!("goto:{slot}"));
        robot.step();
        robot.step();
        let s = robot.state();
        assert_eq!(s["tab"], json!(slot), "deep-link goto:{slot} navigated by name: {s}");
        assert_eq!(s["scene"]["active_tab"], json!(i), "active_tab tracks the deep-linked {slot}: {s}");
        assert_eq!(
            s["scene"]["render"]["route"], json!(ROOT),
            "the root route stays the shell pane through deep-links: {s}"
        );
    }

    // The last deep-link is recorded on the shared command interpreter (proof it went
    // through the ONE dispatcher the menu + palette also feed). Derived from TABS rather
    // than hardcoded, so appending a tab does not silently strand this assertion.
    let (last_slot, _) = TABS.last().expect("the shell has at least one tab");
    assert_eq!(
        robot.state()["palette"]["invoked"], json!(format!("goto:{last_slot}")),
        "the last deep-link ({last_slot}) was recorded on the command interpreter"
    );
}

/// **Responsive: the master/detail split becomes a phone shell.** Below the 760pt
/// breakpoint the render classifies the device `phone`, the root SPLIT collapses to a
/// `Tabs` layout on the WORKSPACE child (a narrow screen gets the whole detail surface,
/// not a 22% sliver of each side), the rail DRAWER auto-folds, and tab-switching via the
/// workspace header strip + folding still work. A wide viewport classifies `desktop` and
/// lays the root out as a real `split` with the rail open — the contrast is the point.
/// RED-when-broken if the breakpoint, the phone layout fork, or the drawer regresses.
#[test]
fn robot_scene_responsive_phone_collapses_split_and_shuts_the_drawer() {
    let mut robot = robot_at(420.0, 720.0);

    let s = robot.state();
    assert_eq!(s["scene"]["render"]["device"], json!("phone"), "narrow viewport is phone-class: {s}");
    assert_eq!(
        s["scene"]["render"]["panes"][ROOT]["layout"], json!("tabs"),
        "the root split collapses to Tabs at phone width: {s}"
    );
    assert_eq!(
        s["scene"]["folded"][RAIL], json!(true),
        "the rail DRAWER auto-folds crossing into phone-class: {s}"
    );
    assert_eq!(
        s["scene"]["drawers"], json!([RAIL]),
        "the rail is the shell's one declared drawer: {}", s["scene"]["drawers"]
    );

    // Tab-switching via the scene TabView strip still works narrow (an early header).
    robot.click_label("Repos").expect("scene tab header 'Repos' clickable at phone width");
    robot.step();
    assert_eq!(
        robot.state()["scene"]["active_tab"], json!(1),
        "the scene tab strip still switches tabs at phone width"
    );

    // Folding still works narrow: fold the (active) repos pane → its slot leaves the tree.
    let folded = robot.app_mut().0.toggle_scene_fold("repos_pane");
    assert!(folded, "repos_pane folded at phone width");
    robot.step();
    let s = robot.state();
    assert_eq!(s["scene"]["folded"]["repos_pane"], json!(true), "fold observable at phone width: {s}");
    assert!(
        s["scene"]["render"]["slots"].get("repos").is_none(),
        "the folded pane's content leaves the tree at phone width: {s}"
    );

    // ── Contrast: a wide viewport is desktop-class and lays the root out as a REAL
    // draggable split, with the rail open and holding real width beside the workspace.
    let wide = robot_at(1400.0, 800.0);
    let w = wide.state();
    assert_eq!(w["scene"]["render"]["device"], json!("desktop"), "a wide viewport is desktop-class: {w}");
    assert_eq!(
        w["scene"]["render"]["panes"][ROOT]["layout"], json!("split"),
        "the root is a split at desktop width: {}", w["scene"]["render"]["panes"][ROOT]
    );
    assert_eq!(w["scene"]["folded"][RAIL], json!(false), "the drawer rail is open on the desktop: {w}");
    let (rail_w, work_w) = (pane_w(&w, RAIL), pane_w(&w, WORKSPACE));
    assert!(
        rail_w > 100.0 && work_w > rail_w * 2.0,
        "the rail holds a real master column beside a wider workspace: rail={rail_w} workspace={work_w}"
    );
}

// ── the tab INTERNALS, proved with RECTS ────────────────────────────────────────────

/// Every tab body that decomposed, read off the shell's own roster.
fn split_body_panes(state: &Value) -> Vec<String> {
    state["scene"]["split_bodies"]
        .as_array()
        .unwrap_or_else(|| panic!("the shell reports its split bodies: {}", state["scene"]))
        .iter()
        .map(|v| v.as_str().unwrap_or_default().to_string())
        .collect()
}

/// The extent of a pane's ALLOTTED rect along a split axis (`true` = x / width).
fn alloc_extent(state: &Value, pane: &str, along_x: bool) -> f32 {
    let (_, _, w, h) = pane_alloc(state, pane);
    if along_x {
        w
    } else {
        h
    }
}

/// **THE tab-internals cell: every decomposed body's divider DRAGS, and its halves
/// MAXIMISE / MINIMISE / RESTORE — all asserted as RECTS.**
///
/// This is the regression the whole decomposition exists to kill. The shell adopted
/// `facett_app::scene`, but every tab BODY stayed one imperative egui flow in a single
/// slot pane — so minimise / maximise / drag operated on the whole tab and could do
/// NOTHING inside it. A stats readout that grew simply pushed the canvas off the bottom
/// of the screen with no divider to correct it.
///
/// For EVERY body the shell reports under `/scene/split_bodies` (so a tab decomposed
/// later is swept without touching this test):
///
/// 1. **Drag** the body's own divider one way → the master's allotted extent grows by
///    ~the drag, the inspector's shrinks by the same, **their sum is conserved**; drag
///    back → the master ends up smaller than it began. Two halves moving OPPOSITELY with
///    a conserved sum is the thing a boolean `resizable: true` cannot prove.
/// 2. **Maximise** the master → the inspector's allotted **area is exactly 0**; the
///    master fills the body.
/// 3. **Restore** → both halves return to EXACTLY the rects they held before (chrome
///    never writes the split ratios, so restore is lossless, not a reset-to-seed).
/// 4. **Minimise** the inspector → it collapses to exactly `CHROME_BAR` along the split
///    axis (its chrome title bar, nothing else) and the master takes the space back.
#[test]
fn robot_scene_every_decomposed_tab_body_drags_maximises_and_restores_by_rect() {
    let mut robot = robot_at(1500.0, 900.0);
    let bodies = split_body_panes(&robot.state());
    assert!(
        !bodies.is_empty(),
        "at least one tab body decomposed into a draggable master/detail split"
    );

    for body in &bodies {
        let slot = body.trim_end_matches("_pane").to_string();
        let main = format!("{slot}_main");
        let insp = format!("{slot}_inspector");

        // Make this the drawn tab — a `Tabs` layout only lays out its ACTIVE child.
        robot.app_mut().0.run_command(&format!("goto:{slot}"));
        robot.step();
        robot.step();

        let s0 = robot.state();
        assert_eq!(
            s0["scene"]["render"]["panes"][body]["resizable"], json!(true),
            "`{body}` declares a DRAGGABLE divider INSIDE the tab: {}",
            s0["scene"]["render"]["panes"][body]
        );

        // Which way does this body divide? The handle rect says so without guessing: a
        // HORIZONTAL split's handle is a tall thin bar (drag along x), a vertical one's
        // is a wide flat bar (drag along y).
        let (_, _, sep_w, sep_h) = {
            let sep = &s0["scene"]["render"]["panes"][body]["separators"][0];
            let f = |i: usize| {
                sep.get(i).and_then(Value::as_f64).unwrap_or_else(|| {
                    panic!("`{body}` publishes a divider handle rect: {}", s0["scene"]["render"]["panes"][body])
                }) as f32
            };
            (f(0), f(1), f(2), f(3))
        };
        let along_x = sep_w < sep_h;
        let d = 160.0;
        let delta = if along_x { egui::vec2(d, 0.0) } else { egui::vec2(0.0, d) };

        let (m0, i0) = (alloc_extent(&s0, &main, along_x), alloc_extent(&s0, &insp, along_x));
        let span0 = m0 + i0;
        assert!(
            m0 > 40.0 && i0 > 40.0,
            "`{body}` really has TWO live halves, not a hidden one: main={m0} inspector={i0}"
        );

        // ── 1. drag: the halves move oppositely, the sum is conserved ──────────────
        let handle = sep_center(&s0, body);
        drag_divider(&mut robot, handle, delta);
        let s1 = robot.state();
        let (m1, i1) = (alloc_extent(&s1, &main, along_x), alloc_extent(&s1, &insp, along_x));
        assert!(
            (m1 - m0 - d).abs() < 26.0,
            "dragging `{body}`'s divider {d}pt grew `{main}` by ~{d}: {m0} -> {m1}"
        );
        assert!(
            (i0 - i1 - d).abs() < 26.0,
            "…and shrank `{insp}` by the same ~{d}: {i0} -> {i1}"
        );
        assert!(
            ((m1 + i1) - span0).abs() < 8.0,
            "`{body}` conserves its extent: {m0}+{i0}={span0} -> {m1}+{i1}={}",
            m1 + i1
        );

        // …and back the other way: the master ends up NARROWER than it started.
        let handle = sep_center(&s1, body);
        drag_divider(&mut robot, handle, -delta * 2.0);
        let s2 = robot.state();
        let (m2, i2) = (alloc_extent(&s2, &main, along_x), alloc_extent(&s2, &insp, along_x));
        assert!(m2 < m0, "dragging back shrank `{main}` below its seed: {m0} -> {m2}");
        assert!(i2 > i0, "…and `{insp}` reclaimed it: {i0} -> {i2}");

        // Put the divider back where it started so the restore comparison below is
        // against a settled layout.
        let handle = sep_center(&s2, body);
        drag_divider(&mut robot, handle, delta);
        robot.step();

        // ── 2/3. maximise a half → its sibling is allotted ZERO; restore is EXACT ──
        let before = robot.state();
        let (rm, ri) = (pane_alloc(&before, &main), pane_alloc(&before, &insp));

        assert!(robot.app_mut().0.toggle_scene_maximize(&main), "`{main}` maximised");
        robot.step();
        let s3 = robot.state();
        assert_eq!(
            alloc_area(&s3, &insp), 0.0,
            "maximising `{main}` drove its sibling `{insp}` to ZERO area: {}",
            s3["scene"]["render"]["panes"][&insp]
        );
        assert!(
            alloc_extent(&s3, &main, along_x) > m0,
            "`{main}` took the whole body: {} > {m0}",
            alloc_extent(&s3, &main, along_x)
        );

        assert!(robot.app_mut().0.restore_scene_pane(&main), "`{main}` restored");
        robot.step();
        let s4 = robot.state();
        for (pane, was) in [(&main, rm), (&insp, ri)] {
            let now = pane_alloc(&s4, pane);
            assert!(
                (now.0 - was.0).abs() < 0.5
                    && (now.1 - was.1).abs() < 0.5
                    && (now.2 - was.2).abs() < 0.5
                    && (now.3 - was.3).abs() < 0.5,
                "restoring returned `{pane}` to EXACTLY its prior rect: {was:?} -> {now:?}"
            );
        }

        // ── 4. minimise the inspector → only its chrome bar is left ────────────────
        assert!(robot.app_mut().0.toggle_scene_minimize(&insp), "`{insp}` minimised");
        robot.step();
        let s5 = robot.state();
        let mini = alloc_extent(&s5, &insp, along_x);
        assert!(
            (mini - CHROME_BAR).abs() < 1.0,
            "minimising `{insp}` left exactly its {CHROME_BAR}pt chrome bar: {mini}"
        );
        assert!(
            alloc_extent(&s5, &main, along_x) > m0,
            "…and handed the space to `{main}`: {} > {m0}",
            alloc_extent(&s5, &main, along_x)
        );
        assert!(robot.app_mut().0.restore_scene_pane(&insp), "`{insp}` restored");
        robot.step();
    }
}