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

nordisk/holger-egui

tests/robot_ui_app.rs

//! holger-server-ui **real robot-UI** cell — drives the *actual egui app* through
//! `egui_kittest` (AccessKit clicks) with the reusable `nornir-robotui`
//! framework, and asserts the app's observable `state_json()` after each click.
//! Each `(action → expected)` step becomes a `nornir-testmatrix` row, so a robot
//! run auto-clicks the holger UI and **fills the nornir test matrix** — nornir
//! sees holger's UI tests.
//!
//! Gated behind `gui` (the egui app only compiles there). Run:
//! `cargo test -p holger-server-ui --features gui --test robot_ui_app`.
//!
//! LAW 1 (inject+assert) + LAW 2 (no empty data): the app is seeded with REAL
//! repositories, so the Repos count and the 3D graph carry populated data — an
//! empty graph would go RED, not green.
#![cfg(feature = "gui")]

use std::sync::Arc;

use egui_kittest::Harness;
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,
};

/// A present-but-empty repository backend — enough that the UI lists real repos
/// (so `repo_count > 0`, LAW 2) without depending on any ecosystem's on-disk
/// format. The surface under test is the egui app + its `state_json`.
struct StubRepo {
    name: &'static str,
    writable: bool,
}

impl StubRepo {
    /// A read-only stub repo.
    fn ro(name: &'static str) -> Self {
        Self { name, writable: false }
    }
    /// A writable stub repo (so the server's derived profile is dynamic).
    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 data-returning repository backend: real artifact listings, an archive with
/// stats + files, and a `fetch` that resolves `serde 1.0.0`. Writable so the
/// derived profile is dynamic. `vuln` swaps in a corpus carrying the demo
/// openssl advisory so the security scan blocks.
struct DataRepo {
    name: &'static str,
    vuln: bool,
}

impl RepositoryBackendTrait for DataRepo {
    fn name(&self) -> &str {
        self.name
    }
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Rust
    }
    fn is_writable(&self) -> bool {
        true
    }
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        match (id.name.as_str(), id.version.as_str()) {
            // serde carries a hosted SBOM + properties (see `SbomHolger`); tokio
            // resolves to bytes but has NEITHER, so it drives the empty inverses.
            ("serde", "1.0.0") => Ok(Some(b"SERDE_CRATE_BYTES".to_vec())),
            ("tokio", "1.2.0") => Ok(Some(b"TOKIO_CRATE_BYTES_1234".to_vec())),
            _ => 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>> {
        let all = vec![
            ArtifactEntry {
                id: ArtifactId { namespace: None, name: "serde".into(), version: "1.0.0".into() },
                size_bytes: 17,
                content_type: "application/octet-stream".into(),
            },
            ArtifactEntry {
                id: ArtifactId { namespace: None, name: "tokio".into(), version: "1.2.0".into() },
                size_bytes: 9,
                content_type: "application/octet-stream".into(),
            },
        ];
        Ok(match name_filter {
            Some(f) => all.into_iter().filter(|e| e.id.name.contains(f)).collect(),
            None => all,
        })
    }
    fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
        let files = if self.vuln {
            vec![
                "crates/serde-1.0.0.crate".to_string(),
                "crates/openssl-0.10.55.crate".to_string(),
                "index/config.json".to_string(),
            ]
        } else {
            vec![
                "crates/serde-1.0.0.crate".to_string(),
                "crates/tokio-1.2.0.crate".to_string(),
                "index/config.json".to_string(),
            ]
        };
        Ok(match prefix {
            Some(p) => files.into_iter().filter(|f| f.starts_with(p)).collect(),
            None => files,
        })
    }
    fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
        Ok(ArchiveInfo {
            file_count: 3,
            total_uncompressed_bytes: 4242,
            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()))
    }
}

/// An app over a single data-returning repo (Browse/Archive/Artifact/Security
/// have real data to render).
fn data_app(vuln: bool) -> HolgerUiApp {
    let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> =
        vec![("rust-data".to_string(), Arc::new(DataRepo { name: "rust-data", vuln }))];
    app_over(repos)
}

/// A real holger-server-ui app over a LocalHolger seeded with three repositories, with
/// the splash skipped so the robot starts on the tabs.
fn seeded_app() -> HolgerUiApp {
    // rust-dev is writable so the server's derived profile is *dynamic* (the
    // default the tool-driving tests exercise); cache + sparring are read-only
    // (so the Upload / static-mode tests have read-only repos to select).
    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"))),
    ];
    app_over(repos)
}

/// Build a real holger-server-ui app over a `LocalHolger` backed by the given repos,
/// with the splash skipped so the robot starts on the tabs. The server-derived
/// profile (read-only iff no repo is writable) drives the initial static mode.
fn app_over(repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)>) -> HolgerUiApp {
    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
}

/// A `HolgerObject` transport that wraps a real [`LocalHolger`] and adds the ONE
/// thing `LocalHolger` leaves at its trait default: a **hosted SBOM** and **custom
/// properties** for a coordinate. `LocalHolger::fetch_sbom` / `get_properties` return
/// `None` / empty (the routes carry only bytes), so without this the SBOM / property
/// surface could never render POPULATED data (LAW 2). `serde 1.0.0` carries both; any
/// other coordinate carries neither (the empty inverse). Everything else delegates to
/// the inner transport unchanged.
struct SbomHolger {
    inner: Arc<dyn HolgerObject>,
}

#[async_trait::async_trait]
impl HolgerObject for SbomHolger {
    async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        self.inner.fetch(repository, id).await
    }
    async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        self.inner.put(repository, id, data).await
    }
    async fn list_repositories(&self) -> anyhow::Result<Vec<traits::RepositoryInfo>> {
        self.inner.list_repositories().await
    }
    async fn list_artifacts(
        &self,
        repository: &str,
        name_filter: Option<String>,
        limit: u32,
        page_token: Option<String>,
    ) -> anyhow::Result<(Vec<ArtifactEntry>, String)> {
        self.inner.list_artifacts(repository, name_filter, limit, page_token).await
    }
    async fn list_archive_files(
        &self,
        repository: &str,
        prefix: Option<String>,
    ) -> anyhow::Result<Vec<String>> {
        self.inner.list_archive_files(repository, prefix).await
    }
    async fn archive_info(&self, repository: &str) -> anyhow::Result<ArchiveInfo> {
        self.inner.archive_info(repository).await
    }
    async fn health(&self) -> anyhow::Result<traits::Health> {
        self.inner.health().await
    }
    async fn server_profile(&self) -> anyhow::Result<traits::ServerProfile> {
        self.inner.server_profile().await
    }
    async fn search(&self, query: &traits::SearchQuery) -> anyhow::Result<traits::SearchResults> {
        self.inner.search(query).await
    }
    async fn fetch_sbom(
        &self,
        _repository: &str,
        id: &ArtifactId,
    ) -> anyhow::Result<Option<traits::SbomDoc>> {
        if id.name == "serde" && id.version == "1.0.0" {
            let document =
                br#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"serde","version":"1.0.0"}]}"#
                    .to_vec();
            let size_bytes = document.len() as i64;
            Ok(Some(traits::SbomDoc {
                document,
                size_bytes,
                content_address: "b3:5eede0c0ffee".into(),
            }))
        } else {
            Ok(None)
        }
    }
    async fn get_properties(
        &self,
        _repository: &str,
        id: &ArtifactId,
    ) -> anyhow::Result<Vec<traits::PropertyKv>> {
        if id.name == "serde" && id.version == "1.0.0" {
            Ok(vec![
                traits::PropertyKv {
                    key: "license".into(),
                    values: vec!["MIT".into(), "Apache-2.0".into()],
                },
                traits::PropertyKv { key: "promoted".into(), values: vec!["prod".into()] },
            ])
        } else {
            Ok(Vec::new())
        }
    }
}

/// An app whose transport serves a real hosted SBOM + properties for `serde 1.0.0`
/// (via [`SbomHolger`]) over a single data-returning repo — so the Artifact tab's
/// SBOM inspector, property list and ⬇ download render POPULATED data, and `tokio`
/// (bytes, no SBOM/props) drives the empty inverses.
fn sbom_app() -> HolgerUiApp {
    let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> =
        vec![("rust-data".to_string(), Arc::new(DataRepo { name: "rust-data", vuln: false }))];
    let local: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
    let holger: Arc<dyn HolgerObject> = Arc::new(SbomHolger { inner: local });
    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()
    }
}

/// **The declarative shell (facett-scene), robot-driven.** The holger shell is now
/// a `facett_app::scene::Scene` — a menubar over a `Pane::tabs` of eleven foldable slot
/// leaves — rendered through facett-app's own TabView/Router/menu primitives, DRIVING
/// the existing tab renderers. This asserts the scene as DATA (`/scene`): the pane
/// tree, all-foldable leaves, the six menu groups; that clicking a **scene TabView
/// header** switches the active tab (the strip is the scene's, not a hand-rolled
/// `selectable_value`); and that folding a tab body is observable. RED-when-broken if
/// the scene wiring, the tab bridge, or the fold state ever drops.
#[test]
fn robot_drives_scene_shell_tabs_and_folds() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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();

    // The shell is declared as data: the root tabbed shell pane + one foldable leaf per
    // tab, and the six grouped menus (View/Operate/Repositories/Action/Look/Help).
    let s = robot.state();
    let panes: Vec<String> = s["scene"]["pane_names"]
        .as_array()
        .expect("scene reports its pane tree")
        .iter()
        .map(|v| v.as_str().unwrap_or("").to_string())
        .collect();
    assert!(panes.contains(&"holger-shell".to_string()), "root master/detail split pane: {panes:?}");
    assert!(panes.contains(&"holger-workspace".to_string()), "the tabbed workspace pane: {panes:?}");
    assert!(panes.contains(&"holger-rail".to_string()), "the registry rail pane: {panes:?}");
    assert!(panes.contains(&"graph_pane".to_string()), "a tab body pane: {panes:?}");
    // One foldable body per registered tab — the roster the app reports under `/tabs` —
    // PLUS the rail and its two panes (the master side folds too).
    let tab_count = s["tabs"].as_array().expect("the shell reports its tab roster").len();
    // …plus the two HALVES of every tab body that decomposed into its own draggable
    // master/detail split (the tab INTERNALS adopted the scene too, not just the shell).
    let split_bodies = s["scene"]["split_bodies"]
        .as_array()
        .expect("the shell reports which tab bodies decomposed")
        .len();
    assert_eq!(
        s["scene"]["foldable"].as_array().map(|a| a.len()),
        Some(tab_count + 3 + 2 * split_bodies),
        "every one of the {tab_count} tab bodies (+ {split_bodies} × 2 split halves) + the \
         rail and its 2 panes are foldable: {s}"
    );
    // The shell carries REAL structure: two draggable dividers and one drawer — and then
    // one INTERNAL divider per decomposed tab body, in tab order.
    let splits: Vec<String> = s["scene"]["draggable_splits"]
        .as_array()
        .expect("the divider roster")
        .iter()
        .map(|v| v.as_str().unwrap_or("").to_string())
        .collect();
    let mut expected = vec!["holger-shell".to_string(), "holger-rail".to_string()];
    expected.extend(
        s["scene"]["split_bodies"].as_array().unwrap().iter().map(|v| v.as_str().unwrap_or("").to_string()),
    );
    assert_eq!(splits, expected, "the shell's two dividers, then one per decomposed body: {s}");
    assert!(split_bodies > 0, "at least one tab body decomposed: {s}");
    // FIVE grouped menus. There were six: a top-level **Look** submenu listed every
    // theme preset. Appearance is a local preference, so it moved into the ⚙ Settings
    // pane and the root menubar kept only navigation + actions (Repositories · Action
    // · Help).
    assert_eq!(s["scene"]["menus"], json!(5), "five grouped menus: {s}");
    assert_eq!(s["scene"]["render"]["scene"], json!("holger"), "the scene rendered: {s}");

    // Clicking a scene TabView header switches the active tab (the strip is the
    // scene's own TabView, addressed by its title).
    robot.click_label("Browse").expect("scene tab header 'Browse'");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("browse"), "the scene tab strip drives the tab");
    assert_eq!(
        robot.state()["scene"]["active_tab"], json!(2),
        "the scene's active_tab tracks the Browse leaf (index 2)"
    );
    robot.click_label("Graph").expect("scene tab header 'Graph'");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("graph"), "and back to Graph via the strip");

    // Folding a tab body is observable in the scene state (the fold interaction).
    assert_eq!(
        robot.state()["scene"]["folded"]["graph_pane"], json!(false),
        "the graph tab body starts unfolded"
    );
    robot.app_mut().0.toggle_scene_fold("graph_pane");
    robot.step();
    assert_eq!(
        robot.state()["scene"]["folded"]["graph_pane"], json!(true),
        "folding the tab body is observable in state_json"
    );
}

/// The robot auto-clicks the holger UI: Graph tab (3D graph builds from real
/// repos), Tools menu (selects the Skíðblaðnir deploy function), and the static
/// "rigged for silent running" toggle (writes disabled) — every step asserted
/// against the live `state_json` and emitted as a matrix row.
#[test]
fn robot_drives_tabs_tools_and_static_mode() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-robot")
        .step(
            Step::new("repos populated (LAW2: no empty data)").assert(Assert::State {
                pointer: "/repo_count".into(),
                expected: json!(3),
            }),
        )
        .step(
            Step::new("click Graph → tab == graph")
                .action(Action::ClickLabel("Graph".into()))
                .assert(Assert::State {
                    pointer: "/tab".into(),
                    expected: json!("graph"),
                }),
        )
        .step(
            Step::new("3D graph built from real repos (LAW2)").assert(Assert::State {
                pointer: "/graph_built".into(),
                expected: json!(true),
            }),
        )
        .step(Step::new("graph drew widgets").assert(Assert::Drew))
        .step(
            Step::new("click Tools → tab == tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State {
                    pointer: "/tab".into(),
                    expected: json!("tools"),
                }),
        )
        .step(
            Step::new("select Deploy (Skíðblaðnir) → tool == deploy")
                .action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
                .assert(Assert::State {
                    pointer: "/tool".into(),
                    expected: json!("deploy"),
                }),
        )
        .step(
            Step::new("engage static silent-running → static_mode == true")
                .action(Action::ClickLabel("⚓ static".into()))
                .assert(Assert::State {
                    pointer: "/static_mode".into(),
                    expected: json!(true),
                }),
        )
        .step(
            Step::new("writes disabled in static → write_enabled == false").assert(Assert::State {
                pointer: "/write_enabled".into(),
                expected: json!(false),
            }),
        );

    // Run the scenario: each step is driven on the real app and becomes a matrix row.
    let rows = robot.run_scenario(&scenario);
    assert_eq!(rows.len(), 8, "one matrix row per robot step");
    for r in &rows {
        assert_eq!(
            r.status,
            status::PASS,
            "robot step {:?} should pass; message: {}",
            r.test_name,
            r.message
        );
    }
}

/// The robot drives the NEW **Lineage** tab (the live proxy-cache-filling facet):
/// open it (3D fill renders + the demo backlog begins dripping through the live
/// feed), assert it drew, toggle to the time-helix companion and back, and assert
/// the render mode each step — every assertion against the live `state_json`, and
/// the growing lineage graph observed as DATA (`/lineage/total_events` climbs as
/// the cache fills).
#[test]
fn robot_drives_lineage_tab() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-lineage-robot")
        .step(
            Step::new("open Lineage → tab == lineage")
                .action(Action::ClickLabel("Lineage".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("lineage") }),
        )
        .step(
            Step::new("default mode is the rotating 3D fill")
                .assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("graph3d") }),
        )
        .step(Step::new("3D cache-fill graph drew").assert(Assert::Drew))
        .step(
            Step::new("toggle time-helix companion → mode == helix")
                .action(Action::ClickLabel("🌀 time helix".into()))
                .assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("helix") }),
        )
        .step(Step::new("time-helix drew").assert(Assert::Drew))
        .step(
            Step::new("back to 3D fill → mode == graph3d")
                .action(Action::ClickLabel("◉ 3D fill".into()))
                .assert(Assert::State { pointer: "/lineage_mode".into(), expected: json!("graph3d") }),
        )
        .step(Step::new("cache kept filling (drew again)").assert(Assert::Drew));

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

    // The cache filled as the tab ran: the live feed drip grew the lineage graph.
    let filled = robot.app().0.state_json();
    let events = filled["lineage"]["total_events"].as_u64().unwrap_or(0);
    let artifacts = filled["lineage"]["nodes"]
        .as_array()
        .map(|a| a.iter().filter(|n| n["kind"] == "artifact").count())
        .unwrap_or(0);
    assert!(events >= 1, "the live cache-fill produced arrivals: total_events={events}");
    assert!(artifacts >= 1, "cached artifact nodes popped in: {artifacts}");
}

/// The robot drives the NEW operator Tools panes wired this round: Tool #1
/// holger-ds (scale slider), Tool #3 Móðguðr security (scan the selected repo →
/// a real Pass verdict over an empty corpus), and Tool #5 airgap populate (the
/// 5th menu item). Every step asserts the live `state_json` — the panes as DATA.
#[test]
fn robot_drives_tools_1_3_5_panes() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-tools-robot")
        .step(
            Step::new("open Tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
        )
        .step(
            Step::new("select Tool #1 holger-ds → tool == holger-ds")
                .action(Action::ClickLabel("1 · holger-ds".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("holger-ds") }),
        )
        .step(
            Step::new("holger-ds default scale == micro")
                .assert(Assert::State { pointer: "/holger_ds/scale".into(), expected: json!("micro") }),
        )
        .step(
            Step::new("select Tool #3 security → tool == security")
                .action(Action::ClickLabel("3 · security · Móðguðr".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
        )
        .step(
            Step::new("scan selected repo → Pass verdict over the empty corpus")
                .action(Action::ClickLabel("Scan selected repo".into()))
                .assert(Assert::State { pointer: "/security/decision".into(), expected: json!("pass") }),
        )
        .step(
            Step::new("select Tool #5 airgap → tool == airgap")
                .action(Action::ClickLabel("5 · airgap populate".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("airgap") }),
        )
        .step(
            Step::new("airgap default target == holger")
                .assert(Assert::State { pointer: "/airgap/target".into(), expected: json!("holger") }),
        )
        .step(
            Step::new("select Tool #2 migrate → tool == migrate")
                .action(Action::ClickLabel("2 · migrate / suck".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("migrate") }),
        )
        .step(
            Step::new("migrate default source == nexus")
                .assert(Assert::State { pointer: "/migrate/source".into(), expected: json!("nexus") }),
        );

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

/// The buttons now WORK: the robot clicks each operator tool's run button and
/// asserts its REAL action ran (as DATA in `state_json`) — Tool #1 holger-ds
/// produced a throughput result over the package protocol, Tool #2 migrate was
/// invoked (and surfaced its clear "url required" validation for the empty demo
/// url), Tool #3 security produced a Pass verdict, Tool #5 airgap produced a
/// dry-run plan/result — plus the splash carries a version string.
#[test]
fn robot_runs_each_tool_real_action_and_sees_version() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // The version string is presented (under the splash wordmark + in the header).
    let v = json!(holger_server_ui::app::HOLGER_UI_VERSION);
    assert!(
        holger_server_ui::app::HOLGER_UI_VERSION.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false),
        "version looks like a version: {}",
        holger_server_ui::app::HOLGER_UI_VERSION
    );

    let scenario = Scenario::new("holger-server-ui-run-tools")
        .step(
            Step::new("splash carries a version string")
                .assert(Assert::State { pointer: "/version".into(), expected: v }),
        )
        .step(
            Step::new("open Tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
        )
        // Tool #1 holger-ds — RUN the benchmark over the package protocol.
        .step(
            Step::new("select holger-ds")
                .action(Action::ClickLabel("1 · holger-ds".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("holger-ds") }),
        )
        // The RUN button exists only under `bench` (⇒ `gui-embedded`): the driver
        // stands up a REAL holger HTTP gateway in this process, which is exactly what
        // the thin-client rule keeps out of the shipped `holger-server-ui`. The thin build
        // still renders the pane, its config and the CLI command — the step below
        // asserts the ACTION, so it rides on the same feature the button does.
        ;
    #[cfg(feature = "bench")]
    let scenario = scenario.step(
        Step::new("Run benchmark → a REAL throughput result")
            .action(Action::ClickLabel("Run benchmark… (over the package protocol)".into()))
            .assert(Assert::State { pointer: "/holger_ds/result/ran".into(), expected: json!(true) }),
    );
    let scenario = scenario
        // Tool #2 migrate — RUN invokes the connector; empty demo url → clear msg.
        .step(
            Step::new("select migrate")
                .action(Action::ClickLabel("2 · migrate / suck".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("migrate") }),
        )
        .step(
            Step::new("Run migrate → connector invoked, clear url validation")
                .action(Action::ClickLabel("Run migrate… (via holger-agent)".into()))
                .assert(Assert::State { pointer: "/migrate/result/ran".into(), expected: json!(false) }),
        )
        // Tool #3 security — scan the selected repo → Pass verdict.
        .step(
            Step::new("select security")
                .action(Action::ClickLabel("3 · security · Móðguðr".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
        )
        .step(
            Step::new("Scan → Pass verdict over the empty corpus")
                .action(Action::ClickLabel("Scan selected repo".into()))
                .assert(Assert::State { pointer: "/security/decision".into(), expected: json!("pass") }),
        )
        // Tool #5 airgap — the runnable demo default is a dry-run plan.
        .step(
            Step::new("select airgap")
                .action(Action::ClickLabel("5 · airgap populate".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("airgap") }),
        )
        .step(
            Step::new("Run populate (dry-run) → a real plan/result")
                .action(Action::ClickLabel("Run populate… (dry-run plan)".into()))
                .assert(Assert::State { pointer: "/airgap/run/ran".into(), expected: json!(true) }),
        );

    let rows = robot.run_scenario(&scenario);
    // Ten steps under `bench` (⇒ `gui-embedded`), nine without: the holger-ds RUN
    // button only exists where the in-process gateway driver does.
    let expected_steps = if cfg!(feature = "bench") { 10 } else { 9 };
    assert_eq!(rows.len(), expected_steps, "one matrix row per robot step");
    for r in &rows {
        assert_eq!(r.status, status::PASS, "robot step {:?}: {}", r.test_name, r.message);
    }

    // The migrate run carried a clear url validation (not a silent no-op).
    let s = robot.state();
    assert!(
        s["migrate"]["result"]["error"].as_str().unwrap_or("").contains("url"),
        "migrate surfaced a clear url-required message: {s}"
    );
    // The holger-ds result is real throughput over the protocol — only where the
    // driver is compiled in (`bench` ⇒ `gui-embedded`); the thin client shows the
    // pane and the CLI command and stands up no gateway of its own.
    #[cfg(feature = "bench")]
    assert!(
        s["holger_ds"]["result"]["ops"].as_u64().unwrap_or(0) > 0,
        "holger-ds completed GETs over the package protocol: {s}"
    );
    // The airgap dry-run plan lists the populate driver invocation.
    assert!(
        s["airgap"]["run"]["plan"].as_str().unwrap_or("").contains("airgap populate"),
        "airgap produced a real plan: {s}"
    );
}

/// Render-time introspection: each of the 5 Tools panes self-reports — the moment
/// it is shown — that it drew a usable operator surface (`tools_pane.ok == true`),
/// mirroring the gRPC handlers' server-side `functional_status` markers so
/// `nornir test --features testmatrix` SEES the operator surfaces fire (a per-frame
/// `holger-server-ui/tools/<pane>` `pane_rendered` row) without a human at the GUI. The
/// **RED-on-empty** contract is driven too: engaging static read-only silences the
/// write-tools (#2–#5) to a disabled notice, so the selected pane flips to
/// `ok == false`. Every step asserts the live `state_json` — the render report as
/// DATA — and becomes a nornir-testmatrix row.
#[test]
fn robot_tools_panes_self_report_render_status() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-tools-render-status")
        .step(
            Step::new("open Tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
        )
        // #1 holger-ds — always draws its config (never blank), ok on render.
        .step(
            Step::new("#1 holger-ds pane identifies itself")
                .action(Action::ClickLabel("1 · holger-ds".into()))
                .assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("holger-ds") }),
        )
        .step(
            Step::new("#1 holger-ds self-reports ok=true on render")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
        )
        // #2 migrate.
        .step(
            Step::new("#2 migrate pane identifies itself")
                .action(Action::ClickLabel("2 · migrate / suck".into()))
                .assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("migrate") }),
        )
        .step(
            Step::new("#2 migrate self-reports ok=true on render")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
        )
        // #3 security · Móðguðr.
        .step(
            Step::new("#3 security pane identifies itself")
                .action(Action::ClickLabel("3 · security · Móðguðr".into()))
                .assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("security") }),
        )
        .step(
            Step::new("#3 security self-reports ok=true on render")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
        )
        // #4 deploy · Skíðblaðnir.
        .step(
            Step::new("#4 deploy pane identifies itself")
                .action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
                .assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("deploy") }),
        )
        .step(
            Step::new("#4 deploy self-reports ok=true on render")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
        )
        // #5 airgap populate.
        .step(
            Step::new("#5 airgap pane identifies itself")
                .action(Action::ClickLabel("5 · airgap populate".into()))
                .assert(Assert::State { pointer: "/tools_pane/pane".into(), expected: json!("airgap") }),
        )
        .step(
            Step::new("#5 airgap self-reports ok=true on render")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(true) }),
        )
        // RED-on-empty: engage static read-only → the selected write-tool blanks to
        // a disabled notice → the pane self-reports ok=false (a real red-when-broken
        // branch: were the static gate dropped, this flips green and fails).
        .step(
            Step::new("engage static → read-only silent running")
                .action(Action::ClickLabel("⚓ static".into()))
                .assert(Assert::State { pointer: "/static_mode".into(), expected: json!(true) }),
        )
        .step(
            Step::new("RED on empty: airgap pane blanked under static → ok=false")
                .assert(Assert::State { pointer: "/tools_pane/ok".into(), expected: json!(false) }),
        );

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

    // The pane detail carries a real one-liner (not a placeholder) — under static
    // the write-tool self-reports the blanked reason, the RED-on-empty evidence.
    let s = robot.state();
    assert_eq!(
        s["tools_pane"]["detail"],
        json!("static-disabled"),
        "the blanked pane names why it went RED: {s}"
    );
}

/// The DEMO SELLPOINT: the robot drives the neon 3D graph. Navigating to the
/// Graph tab builds the holger graph (server → repositories → upstream/artifact
/// nodes) under `DecorPolicy::Full` (neon glow/shockwave/dim); then clicking a
/// repository node lights its subgraph and dims the rest. Every claim is asserted
/// against the live `state_json["graph"]` — the lit subgraph as DATA, not pixels
/// (LAW 2: real populated data, an empty subgraph would go RED).
#[test]
fn robot_clicks_repo_node_and_lights_its_subgraph() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // Navigate to the Graph tab and let the neon graph build from real repos.
    robot.click_label("Graph").expect("Graph tab is clickable");
    robot.step();

    // The graph is built, neon-lit (Full effects), with the server + 3 repos at
    // least (LAW 2: populated). Nothing selected yet ⇒ nothing dimmed.
    let s = robot.state();
    assert_eq!(s["graph_built"], json!(true), "graph built from real repos: {s}");
    assert_eq!(
        s["graph"]["effects_policy"],
        json!("full"),
        "the beauty is ON — DecorPolicy::Full: {s}"
    );
    assert_eq!(s["graph"]["palette"], json!("neon"), "neon palette active: {s}");
    let nodes = s["graph"]["nodes"].as_u64().unwrap_or(0);
    assert!(nodes >= 4, "server + 3 repositories (≥4 nodes): {s}");
    // The HOLGER NEON PALETTE maps every node to a kind (server/repository/…).
    let kinds = s["graph"]["kinds"].as_array().expect("per-node kind palette");
    assert_eq!(kinds.len() as u64, nodes, "one kind per node: {s}");
    assert_eq!(kinds[0], json!("server"), "node 0 is the holger server anchor: {s}");
    assert!(
        s["graph"]["selected_node"].is_null(),
        "no selection yet → calm default: {s}"
    );
    assert_eq!(s["graph"]["highlighted_count"], json!(0), "nothing lit yet: {s}");
    assert_eq!(s["graph"]["dimmed_alpha"], json!(1.0), "nothing dimmed yet: {s}");

    // Click (select) the first repository node — index 1, since node 0 is the
    // server. This is the same path the in-view 3D click drives (Graph3D::select);
    // the facett kernel lights its subgraph and dims the rest.
    robot.app_mut().0.select_graph_node(1);
    robot.step();

    let s = robot.state();
    assert_eq!(s["graph"]["selected_node"], json!(1), "the repo node is selected: {s}");
    // The selected node is one of the seeded repositories (node 0 is the server).
    let sel_label = s["graph"]["selected_label"].as_str().unwrap_or("");
    assert!(
        ["rust-dev", "cache", "sparring"].contains(&sel_label),
        "the selected node is a seeded repository, got {sel_label:?}: {s}"
    );
    assert!(
        s["graph"]["selected_kind"].is_string(),
        "the selected node carries a neon-palette kind: {s}"
    );
    // The lit subgraph is REAL and non-empty (the repo's neighbours light up).
    let lit = s["graph"]["highlighted_neighbours"]
        .as_array()
        .expect("the lit subgraph is an array");
    assert!(!lit.is_empty(), "clicking a repo lights a NON-EMPTY subgraph: {s}");
    assert!(
        s["graph"]["highlighted_count"].as_u64().unwrap_or(0) >= 1,
        "≥1 neighbour lit: {s}"
    );
    // The dim is real: unrelated nodes/edges drop below full alpha.
    assert!(
        s["graph"]["dimmed_alpha"].as_f64().unwrap_or(1.0) < 1.0,
        "selecting a repo dims the unrelated rest: {s}"
    );
}

/// T5 (lands WITH B5/P5): the static "silent running" mode is driven off the
/// server-reported profile (`AdminService.ServerProfile`), NOT a local toggle.
/// (a) A server with ONLY read-only repos derives a static/read-only profile, so
/// the app starts static WITHOUT anyone clicking the toggle. (b) A server with a
/// writable repo derives a dynamic profile, so the app starts non-static.
#[test]
fn robot_static_mode_reflects_server_profile() {
    // (a) all read-only → server profile is static/read-only → static by default.
    let ro: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        ("locked-a".to_string(), Arc::new(StubRepo::ro("locked-a"))),
        ("locked-b".to_string(), Arc::new(StubRepo::ro("locked-b"))),
    ];
    let app = app_over(ro);
    let s = app.state_json();
    assert_eq!(s["static_mode"], json!(true), "all-read-only server starts static: {s}");
    assert_eq!(s["profile"]["read_only"], json!(true), "server profile is read-only: {s}");
    assert_eq!(s["profile"]["loaded"], json!(true), "profile was fetched: {s}");
    assert!(
        s["profile"]["label"].as_str().unwrap_or("").contains("SILENT RUNNING"),
        "profile carries the silent-running label: {s}"
    );
    assert_eq!(s["profile"]["writable_repo_count"], json!(0), "no writable repos: {s}");
    // The badge / look follow the static profile.
    assert_eq!(s["look"], json!("skade_vinter"), "static look engaged from the profile: {s}");

    // (b) ≥1 writable repo → dynamic profile → NOT static by default.
    let rw: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        ("live".to_string(), Arc::new(StubRepo::rw("live"))),
        ("locked".to_string(), Arc::new(StubRepo::ro("locked"))),
    ];
    let app = app_over(rw);
    let s = app.state_json();
    assert_eq!(s["static_mode"], json!(false), "a writable server starts dynamic: {s}");
    assert_eq!(s["profile"]["read_only"], json!(false), "server profile is dynamic: {s}");
    assert_eq!(s["profile"]["writable_repo_count"], json!(1), "one writable repo: {s}");
}

/// T3: Tools #2-#5 (and, after B6, #1 holger-ds) are disabled — non-clickable —
/// in static read-only mode. Direct-drive style (app_mut) so a swallowed click
/// can be asserted as "no change from the prior value". seeded_app starts
/// dynamic (it has a writable repo), so the operator can first select holger-ds,
/// then engage static and watch the other tools stop responding.
#[test]
fn robot_static_mode_disables_other_tools() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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.click_label("Tools").expect("Tools tab");
    robot.step();
    robot.click_label("1 · holger-ds").expect("holger-ds selectable (dynamic)");
    robot.step();
    assert_eq!(robot.state()["tool"], json!("holger-ds"), "tool == holger-ds");

    // Engage static silent-running.
    robot.click_label("⚓ static").expect("static toggle");
    robot.step();
    assert_eq!(robot.state()["static_mode"], json!(true), "static engaged");
    assert_eq!(robot.state()["look"], json!("skade_vinter"), "winter look engaged");

    // #2-#5 selectables are now disabled: clicking them is swallowed, the tool
    // stays holger-ds. (click_label may not even find an enabled target; either
    // way the tool must not change.)
    for label in ["2 · migrate / suck", "3 · security · Móðguðr", "4 · deploy · Skíðblaðnir", "5 · airgap populate"] {
        let _ = robot.click_label(label);
        robot.step();
        assert_eq!(
            robot.state()["tool"],
            json!("holger-ds"),
            "tool unchanged after swallowed click on disabled {label:?}"
        );
    }

    // Toggle static back off → #2 migrate becomes selectable again.
    robot.click_label("⚓ static").expect("static toggle off");
    robot.step();
    assert_eq!(robot.state()["static_mode"], json!(false), "static disengaged");
    robot.click_label("2 · migrate / suck").expect("migrate selectable again (dynamic)");
    robot.step();
    assert_eq!(robot.state()["tool"], json!("migrate"), "migrate selectable once dynamic");
}

/// T6: clicking a repository row drives selection (the flow every other tab
/// depends on), and the selection propagates downstream (the security scan
/// targets the selected repo). Needs T1 (`/repos` in state_json).
#[test]
fn robot_repo_row_selection_propagates() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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.click_label("Repos").expect("Repos tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("repos"));
    // FastRoutes sorts the repos to [cache, rust-dev, sparring]; first selected.
    assert_eq!(robot.state()["repos"]["selected"], json!(0), "first repo selected by default");

    // Drive the row selection (the row name collides with the facett Table cell,
    // so a label-addressed click is ambiguous; select_repo runs the same path).
    robot.app_mut().0.select_repo(2);
    robot.step();
    assert_eq!(robot.state()["repos"]["selected"], json!(2), "selecting sparring → index 2");
    assert_eq!(robot.state()["repos"]["repos"][2]["name"], json!("sparring"), "index 2 is sparring");

    // Propagates downstream: the security scan targets the selected repo.
    robot.click_label("Tools").expect("Tools");
    robot.step();
    robot.click_label("3 · security · Móðguðr").expect("security");
    robot.step();
    robot.click_label("Scan selected repo").expect("scan");
    robot.step();
    assert_eq!(
        robot.state()["security"]["repository"],
        json!("sparring"),
        "the scan targets the row the operator selected"
    );
}

/// T13: the Status tab is reachable by nav, its refresh button loads health, and
/// the status grid carries real values. Needs T1 (`/status` in state_json).
#[test]
fn robot_status_tab_nav_and_refresh() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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.click_label("Graph").expect("Graph tab");
    robot.step();
    robot.click_label("Status").expect("Status tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("status"));

    robot.click_label("⟳ refresh").expect("refresh button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["status"]["loaded"], json!(true), "health loaded: {s}");
    assert!(s["status"]["version"].as_str().map(|v| !v.is_empty()).unwrap_or(false), "version present: {s}");
    assert_eq!(s["status"]["error"], json!(null), "no error on a healthy server: {s}");
    assert_eq!(s["status"]["status"], json!("ok"), "status ok: {s}");
}

/// T19: the Upload flow reflects the selected repo's writability. seeded_app has
/// a writable repo (rust-dev) and read-only repos (cache/sparring). Needs T1
/// (`/upload` section).
#[test]
fn robot_upload_writable_reflects_selected_repo() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // FastRoutes sorts to [cache(ro), rust-dev(rw), sparring(ro)]; the default
    // selection is cache (read-only) → upload disabled.
    robot.click_label("Upload").expect("Upload tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("upload"));
    assert_eq!(robot.state()["upload"]["writable"], json!(false), "read-only repo → upload disabled");

    // Select the writable repo (rust-dev, index 1) → upload enabled, warning gone.
    robot.app_mut().0.select_repo(1);
    robot.step();
    let s = robot.state();
    assert_eq!(s["repos"]["repos"][1]["name"], json!("rust-dev"), "index 1 is rust-dev");
    assert_eq!(s["upload"]["writable"], json!(true), "writable repo → upload enabled: {s}");
}

/// T4: in static read-only mode the Upload tab is unreachable — selecting it
/// while static redirects to Status, and the disabled Upload nav stays inert.
#[test]
fn robot_static_mode_redirects_upload_to_status() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // Dynamic by default: Upload is reachable.
    robot.click_label("Upload").expect("Upload tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("upload"));

    // Engage static → the next frame redirects Upload to Status.
    robot.click_label("⚓ static").expect("static toggle");
    robot.step();
    assert_eq!(robot.state()["static_mode"], json!(true));
    assert_eq!(robot.state()["tab"], json!("status"), "static mode forces Upload → Status");

    // The disabled (strikethrough) Upload nav is inert: clicking leaves Status.
    let _ = robot.click_label("Upload");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("status"), "disabled Upload nav stays on Status");
}

/// T18: the look-preset switcher is driven by real clicks (not just the direct
/// setter), and the static override wins while engaged then restores the
/// operator's manual preset.
#[test]
fn robot_look_preset_switcher_and_static_override() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // Default look (collapsed roster — dark/light/device).
    assert_eq!(robot.state()["look"], json!("dark"), "default look");

    // The preset switcher lives in the ⚙ **Settings** pane now — it is no longer on
    // the root chrome bar or in a root "Look" menu — so the robot navigates there
    // first, exactly as a human would.
    robot.click_label("Settings").expect("Settings tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("settings"), "the Settings pane is open");
    robot.click_label("device").expect("device preset label");
    robot.step();
    assert_eq!(robot.state()["look"], json!("device"), "clicking device switches the look");

    // Static override wins.
    robot.click_label("⚓ static").expect("static toggle");
    robot.step();
    assert_eq!(robot.state()["look"], json!("skade_vinter"), "static overrides the preset");

    // Disengage → the manual preset (device) is restored.
    robot.click_label("⚓ static").expect("static toggle off");
    robot.step();
    assert_eq!(robot.state()["look"], json!("device"), "manual preset restored after static");
}

/// T15: the Tool #1/#2/#5 config selectables drive real state changes (the egui
/// widget binding, not just the data-layer defaults).
#[test]
fn robot_tool_config_selectables_change_state() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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.click_label("Tools").expect("Tools");
    robot.step();

    // Tool #1 holger-ds scale.
    robot.click_label("1 · holger-ds").expect("holger-ds");
    robot.step();
    robot.click_label("large").expect("large scale");
    robot.step();
    assert_eq!(robot.state()["holger_ds"]["scale"], json!("large"), "scale → large");

    // Tool #2 migrate source.
    robot.click_label("2 · migrate / suck").expect("migrate");
    robot.step();
    robot.click_label("artifactory").expect("artifactory source");
    robot.step();
    assert_eq!(robot.state()["migrate"]["source"], json!("artifactory"), "source → artifactory");

    // Tool #5 airgap target + auth.
    robot.click_label("5 · airgap populate").expect("airgap");
    robot.step();
    robot.click_label("nexus").expect("nexus target");
    robot.step();
    assert_eq!(robot.state()["airgap"]["target"], json!("nexus"), "target → nexus");
    robot.click_label("oidc").expect("oidc auth");
    robot.step();
    assert_eq!(robot.state()["airgap"]["auth"], json!("oidc"), "auth → oidc");
}

/// T16 (lands WITH B2): the airgap validate() result is a pure state pointer.
/// The runnable demo default is a dry-run plan, so `can_run` is true by default;
/// a push with no creds drives it back to the red "cannot run" branch.
#[test]
fn robot_airgap_validate_can_run_state() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .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.click_label("Tools").expect("Tools");
    robot.step();
    robot.click_label("5 · airgap populate").expect("airgap");
    robot.step();
    // Default is dry-run → validates → can_run true, no error.
    let s = robot.state();
    assert_eq!(s["airgap"]["dry_run"], json!(true), "demo default is dry-run: {s}");
    assert_eq!(s["airgap"]["can_run"], json!(true), "dry-run plan can run: {s}");
    assert_eq!(s["airgap"]["validate_error"], json!(null), "no validation error by default: {s}");

    // Drive it into the red branch: a real push (dry_run off) with no creds.
    {
        let app = robot.app_mut();
        app.0.airgap_set_dry_run(false);
    }
    robot.step();
    let s = robot.state();
    assert_eq!(s["airgap"]["can_run"], json!(false), "push with no creds cannot run: {s}");
    assert!(
        s["airgap"]["validate_error"].as_str().map(|e| !e.is_empty()).unwrap_or(false),
        "the red branch carries a validation error: {s}"
    );
}

/// T17 (demo must-have #1): the startup logo splash is shown on frame 1 (tabs
/// hidden) and dismisses on a click.
#[test]
fn robot_startup_splash_dismisses_on_click() {
    // Build WITHOUT skip_splash so the splash is live.
    let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        ("rust-dev".to_string(), Arc::new(StubRepo::rw("rust-dev"))),
    ];
    let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
    let data = UiData::new(holger).expect("runtime");
    let app = HolgerUiApp::new(data); // no skip_splash
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(app));
    let mut robot = RobotSession::new(harness, "holger-server-ui");
    robot.step();

    // Frame 1: splash up, tabs hidden.
    assert_eq!(robot.state()["splash_done"], json!(false), "splash shows on startup");
    assert!(!robot.label_visible("Status"), "tabs hidden behind the splash");

    // Inject a pointer click → splash dismisses.
    let pos = egui::Pos2::new(120.0, 120.0);
    let m = egui::Modifiers::default();
    robot.inject_events([
        egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m },
        egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m },
    ]);
    robot.step();
    robot.step();

    assert_eq!(robot.state()["splash_done"], json!(true), "splash dismissed on click");
    assert!(robot.label_visible("Status"), "tabs visible after dismiss");
}

/// T7: the Artifact fetch flow — a found hit and a clean not-found, driven via
/// the UI and asserted as DATA (`/artifact`). Needs T1.
#[test]
fn robot_artifact_fetch_found_and_not_found() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    robot.click_label("Artifact").expect("Artifact tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("artifact"));

    // A hit: serde 1.0.0 resolves to bytes.
    robot.app_mut().0.set_artifact_query("rust-data", "serde", "1.0.0");
    robot.click_label("fetch").expect("fetch button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["name"], json!("serde"), "fetched name: {s}");
    assert_eq!(s["artifact"]["found"], json!(true), "serde found: {s}");
    assert!(s["artifact"]["size_bytes"].as_u64().unwrap_or(0) > 0, "non-zero size: {s}");
    assert_eq!(s["artifact"]["error"], json!(null), "no transport error: {s}");

    // A clean miss: NOT_FOUND is found==false with no error (distinct from a
    // transport error).
    robot.app_mut().0.set_artifact_query("rust-data", "ghost", "9.9.9");
    robot.click_label("fetch").expect("fetch button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["found"], json!(false), "ghost not found: {s}");
    assert_eq!(s["artifact"]["error"], json!(null), "clean NOT_FOUND, not an error: {s}");
}

/// The **SBOM inspector + property list + ⬇ download** surface — the shipped
/// operator UI's Artifact-tab read-tier extras (these were mini-only and absent from
/// the full `gui` build; now present in every tier). Driven through the REAL widgets
/// (`fetch` → `📄 Fetch SBOM` → `⬇ Save artifact`) and asserted as VALUES off
/// `state_json["artifact"]`. Sweeps populated vs empty (LAW 2/3): `serde 1.0.0`
/// carries a real hosted SBOM + two properties; `tokio 1.2.0` carries bytes but
/// NEITHER, so the same oracle goes to its empty/error value; the download is proven
/// both to a writable temp path (success) and to a bad path (`save_error`).
#[test]
fn robot_artifact_sbom_properties_and_download() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(sbom_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    robot.click_label("Artifact").expect("Artifact tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("artifact"));

    // ── serde 1.0.0: a real fetch loads its custom PROPERTIES alongside the bytes ──
    robot.app_mut().0.set_artifact_query("rust-data", "serde", "1.0.0");
    robot.click_label("fetch").expect("fetch button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["found"], json!(true), "serde fetched: {s}");
    let props = s["artifact"]["properties"].as_array().expect("properties array");
    assert_eq!(props.len(), 2, "serde carries two custom properties (LAW2 populated): {s}");
    let license = props
        .iter()
        .find(|p| p["key"] == json!("license"))
        .expect("the license property is present");
    let vals = license["values"].as_array().expect("license values array");
    assert!(vals.iter().any(|v| v == &json!("MIT")), "license property carries MIT: {s}");
    assert!(vals.iter().any(|v| v == &json!("Apache-2.0")), "…and Apache-2.0: {s}");
    // The SBOM is not loaded until the operator asks (it can be large).
    assert_eq!(s["artifact"]["sbom"], json!(null), "no SBOM until '📄 Fetch SBOM' is clicked: {s}");
    assert_eq!(s["artifact"]["sbom_error"], json!(null), "…and no error yet: {s}");

    // ── click the REAL '📄 Fetch SBOM' button → the hosted SBOM populates as DATA ──
    robot.click_label("📄 Fetch SBOM").expect("Fetch SBOM button");
    robot.step();
    let s = robot.state();
    assert!(
        s["artifact"]["sbom"]["size_bytes"].as_i64().unwrap_or(0) > 0,
        "the hosted SBOM has real bytes (LAW2): {s}"
    );
    assert!(
        !s["artifact"]["sbom"]["content_address"].as_str().unwrap_or("").is_empty(),
        "the SBOM reports its blake3 content address: {s}"
    );
    assert!(
        s["artifact"]["sbom"]["preview"].as_str().unwrap_or("").contains("CycloneDX"),
        "the inspector preview carries the real document text: {s}"
    );
    assert_eq!(s["artifact"]["sbom_error"], json!(null), "a populated SBOM has no error: {s}");

    // ── ⬇ download the fetched bytes to a writable temp path → saved_to is the value ──
    let good = std::env::temp_dir()
        .join(format!("holger-robot-serde-{}.crate", std::process::id()));
    let good_s = good.to_str().unwrap().to_string();
    robot.app_mut().0.set_save_path(&good_s);
    robot.click_label("⬇ Save artifact").expect("Save artifact button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["saved_to"], json!(good_s), "the download outcome is a path VALUE: {s}");
    assert_eq!(s["artifact"]["save_error"], json!(null), "a successful save has no error: {s}");
    assert!(good.exists(), "the bytes were actually written to disk");
    let _ = std::fs::remove_file(&good);

    // ── download FAILURE inverse (LAW2): a save into a non-existent dir is an error VALUE ──
    robot.app_mut().0.set_save_path("/holger-robot-nonexistent-dir-zzz/nope.crate");
    robot.click_label("⬇ Save artifact").expect("Save artifact button (fail)");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["saved_to"], json!(null), "a failed save clears saved_to: {s}");
    assert!(
        !s["artifact"]["save_error"].as_str().unwrap_or("").is_empty(),
        "the failed download is a readable error value, not a silent no-op: {s}"
    );

    // ── EMPTY inverse: tokio has bytes but no SBOM + no properties (same oracles → empty) ──
    robot.app_mut().0.set_artifact_query("rust-data", "tokio", "1.2.0");
    robot.click_label("fetch").expect("fetch tokio");
    robot.step();
    assert_eq!(robot.state()["artifact"]["found"], json!(true), "tokio resolves to bytes");
    assert_eq!(
        robot.state()["artifact"]["properties"].as_array().map(|a| a.len()),
        Some(0),
        "tokio carries no properties (the empty inverse asserted as a value)"
    );
    robot.click_label("📄 Fetch SBOM").expect("Fetch SBOM (empty)");
    robot.step();
    let s = robot.state();
    assert_eq!(s["artifact"]["sbom"], json!(null), "tokio has no hosted SBOM: {s}");
    assert_eq!(
        s["artifact"]["sbom_error"], json!("no SBOM attached"),
        "the 'no SBOM' empty state is an ASSERTED value, never green-by-vacuum (LAW2): {s}"
    );
}

/// The **real-write outcome** of the Upload tab (`put_artifact`) as a READABLE VALUE
/// (LAW 6), not a transient notice label. Driven through the REAL `upload` button
/// over the shipped shell: a writable repo + a real temp source file → the write
/// succeeds and `state_json["upload"]["outcome"]` reports `ok:true` + the exact byte
/// count; a missing source file → the SAME oracle goes to `ok:false` with the failing
/// `stage` + a populated `error` (LAW 2 — the failure path is an asserted value, and
/// the increment is swept: attempted flips false→true, ok null→true→false).
#[test]
fn robot_upload_writes_outcome_value() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    // Select the writable repo (FastRoutes sorts to [cache, rust-dev, sparring]).
    robot.app_mut().0.select_repo(1);
    robot.click_label("Upload").expect("Upload tab");
    robot.step();
    let s = robot.state();
    assert_eq!(s["tab"], json!("upload"));
    assert_eq!(s["upload"]["writable"], json!(true), "rust-dev is writable: {s}");
    // Clean slate: no upload attempted yet (the outcome is a value, even empty).
    assert_eq!(s["upload"]["outcome"]["attempted"], json!(false), "no upload run yet: {s}");
    assert_eq!(s["upload"]["outcome"]["ok"], json!(null), "ok is null before any attempt: {s}");

    // A real temp source file → the write SUCCEEDS with an exact byte count.
    let src = std::env::temp_dir()
        .join(format!("holger-robot-upload-{}.bin", std::process::id()));
    std::fs::write(&src, b"ROBOT UPLOAD PAYLOAD BYTES").expect("write temp upload source");
    let src_len = std::fs::metadata(&src).unwrap().len();
    robot.app_mut().0.set_upload_coord("mycrate", "0.1.0");
    robot.app_mut().0.set_upload_path(src.to_str().unwrap());
    robot.click_label("⬆ Upload artifact").expect("upload button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["upload"]["outcome"]["attempted"], json!(true), "the upload ran: {s}");
    assert_eq!(s["upload"]["outcome"]["ok"], json!(true), "the write SUCCEEDED as a value: {s}");
    assert_eq!(
        s["upload"]["outcome"]["bytes"].as_u64(), Some(src_len),
        "the stored byte count is the exact source length: {s}"
    );
    assert_eq!(s["upload"]["outcome"]["repo"], json!("rust-dev"), "the write targeted rust-dev: {s}");
    assert_eq!(s["upload"]["outcome"]["error"], json!(null), "a successful write has no error: {s}");
    assert!(
        s["upload"]["notice"].as_str().unwrap_or("").contains("uploaded"),
        "the human notice mirrors the outcome: {s}"
    );
    let _ = std::fs::remove_file(&src);

    // FAILURE inverse (LAW2): a missing source file → ok:false, stage:"read", error set.
    robot.app_mut().0.set_upload_path("/holger-robot-nonexistent-dir-zzz/missing.bin");
    robot.click_label("⬆ Upload artifact").expect("upload button (fail)");
    robot.step();
    let s = robot.state();
    assert_eq!(s["upload"]["outcome"]["ok"], json!(false), "the read failure is a value: {s}");
    assert_eq!(s["upload"]["outcome"]["stage"], json!("read"), "the failing stage is named: {s}");
    assert!(
        !s["upload"]["outcome"]["error"].as_str().unwrap_or("").is_empty(),
        "the failure carries a readable error, never a silent green: {s}"
    );
}

/// T8: the Browse tab refresh button lists the repo's artifacts as DATA. Needs T1.
#[test]
fn robot_browse_tab_refresh_lists_artifacts() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    robot.click_label("Browse").expect("Browse tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("browse"));
    robot.click_label("⟳ refresh").expect("refresh button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["browse"]["repository"], json!("rust-data"), "browse targets the selected repo: {s}");
    let entries = s["browse"]["entries"].as_array().expect("entries array");
    assert_eq!(entries.len(), 2, "serde + tokio listed: {s}");
    assert!(entries.iter().any(|e| e["name"] == json!("serde")), "serde row: {s}");
    assert!(entries.iter().any(|e| e["name"] == json!("tokio")), "tokio row: {s}");
}

/// T9: the Archive tab refresh button exposes the stats grid + file tree, and the
/// graph grows with artifact nodes. Needs T1.
#[test]
fn robot_archive_tab_refresh_stats_and_files() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    robot.click_label("Archive").expect("Archive tab");
    robot.step();
    assert_eq!(robot.state()["tab"], json!("archive"));
    robot.click_label("⟳ refresh").expect("refresh button");
    robot.step();
    let s = robot.state();
    assert_eq!(s["archive"]["file_count"], json!(3), "archive file_count: {s}");
    assert_eq!(s["archive"]["total_uncompressed_bytes"], json!(4242), "archive bytes: {s}");
    let files = s["archive"]["files"].as_array().expect("files array");
    assert_eq!(files.len(), 3, "three archive entries: {s}");

    // The graph grows artifact nodes from the loaded archive.
    robot.click_label("Graph").expect("Graph tab");
    robot.step();
    let s = robot.state();
    let kinds = s["graph"]["kinds"].as_array().expect("kinds array");
    assert!(kinds.iter().any(|k| k == &json!("artifact")), "graph grew artifact nodes: {s}");
}

/// T10: the security scan BLOCK verdict + findings are driven via the UI over a
/// vuln corpus (the openssl advisory), asserted as DATA.
#[test]
fn robot_security_scan_block_verdict() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(true)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    robot.click_label("Tools").expect("Tools");
    robot.step();
    robot.click_label("3 · security · Móðguðr").expect("security");
    robot.step();
    robot.click_label("Scan selected repo").expect("scan");
    robot.step();
    let s = robot.state();
    assert_eq!(s["security"]["decision"], json!("block"), "vuln corpus blocks: {s}");
    assert!(s["security"]["block"].as_u64().unwrap_or(0) >= 1, "≥1 blocked artifact: {s}");
    assert_eq!(s["security"]["error"], json!(null), "block is a verdict, not a transport error: {s}");
    // The openssl finding is present with its advisory id.
    let findings = s["security"]["findings"].as_array().expect("findings array");
    let openssl = findings.iter().find(|f| f["name"] == json!("openssl")).expect("openssl finding");
    let ids = openssl["ids"].as_array().expect("ids array");
    assert!(
        ids.iter().any(|i| i.as_str().map(|s| s.contains("RUSTSEC-2023-0044")).unwrap_or(false)),
        "openssl finding carries the advisory id: {s}"
    );

    // The scan's PROVENANCE + TALLIES as values (LAW 6): which corpus fed the verdict,
    // how many artifacts were scanned, and the per-decision counts — not just the
    // top-line decision. `loaded` marks a real scan ran (RAGNARÖK: an unscanned pane
    // has loaded==false, so a zero-tally green can't sneak through).
    assert_eq!(s["security"]["loaded"], json!(true), "a real scan populated the pane: {s}");
    assert_eq!(
        s["security"]["source"], json!("demo"),
        "the advisory corpus provenance is the built-in demo feed (not a silent warehouse fallback): {s}"
    );
    assert!(
        s["security"]["scanned"].as_u64().unwrap_or(0) >= 2,
        "both serde + openssl were scanned (LAW2 populated): {s}"
    );
    assert!(
        s["security"]["pass"].as_u64().unwrap_or(0) >= 1,
        "the clean serde artifact is tallied as a pass: {s}"
    );

    // The dependency-GRAPH view backs the same scan: every scanned artifact is a
    // verdict-coloured node, and the per-colour tallies + the honest has_edges flag are
    // asserted as VALUES (the graph mode was entirely value-blind before).
    let dep = &s["dep_graph"];
    assert_eq!(dep["loaded"], json!(true), "the dep-graph view populated from the scan: {s}");
    let dep_nodes = dep["nodes"].as_array().expect("dep_graph nodes array");
    assert!(dep_nodes.len() >= 2, "every scanned artifact is a graph node (LAW2): {s}");
    assert!(dep["block"].as_u64().unwrap_or(0) >= 1, "≥1 block-coloured node: {s}");
    assert_eq!(
        dep["block"].as_u64().unwrap_or(0) + dep["warn"].as_u64().unwrap_or(0)
            + dep["pass"].as_u64().unwrap_or(0),
        dep_nodes.len() as u64,
        "every node is colour-tallied exactly once (the tallies reconcile to the node set): {s}"
    );
    // A served archive carries no resolved dependency edges — the HONEST empty flag is
    // asserted as a value, not eyeballed (LAW2 populate-diagnostic: no warehouse snapshot).
    assert_eq!(
        dep["has_edges"], json!(false),
        "no warehouse snapshot for this subject → the 'no resolved graph' flag is honest: {s}"
    );
    assert_eq!(
        dep["edges"].as_array().map(|a| a.len()), Some(0),
        "…and the edge set is correspondingly empty: {s}"
    );
}

/// Tool #4 Deploy (Skíðblaðnir) — the button now WORKS (was a no-op "** being
/// implemented" stub). The robot opens Tools, selects Deploy, picks the nexus
/// target, and clicks "Deploy to test…"; the app reads the selected repo's REAL
/// sealed znippy bundle (`archive_info`) and produces a real airgap-engine
/// push-only deploy plan. Every claim is asserted against the live `state_json`
/// — the deploy result as DATA, not pixels.
#[test]
fn robot_deploys_selected_repo_sealed_bundle() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(false)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-deploy")
        .step(
            Step::new("open Tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
        )
        .step(
            Step::new("select Deploy (Skíðblaðnir) → tool == deploy")
                .action(Action::ClickLabel("4 · deploy · Skíðblaðnir".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("deploy") }),
        )
        .step(
            Step::new("pick the nexus target")
                .action(Action::ClickLabel("nexus".into()))
                .assert(Assert::State { pointer: "/deploy/target".into(), expected: json!("nexus") }),
        )
        .step(
            Step::new("Deploy to test… → a REAL airgap deploy plan over the sealed bundle")
                .action(Action::ClickLabel("Deploy to test…".into()))
                .assert(Assert::State { pointer: "/deploy/result/ran".into(), expected: json!(true) }),
        );

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

    // The deploy carried the REAL sealed bundle (rust-data's archive_info: 3 files)
    // and a real airgap push-only plan — not a no-op.
    let s = robot.state();
    assert_eq!(s["deploy"]["result"]["file_count"], json!(3), "sealed bundle file count: {s}");
    assert_eq!(s["deploy"]["result"]["target"], json!("nexus"), "deployed to the picked target: {s}");
    assert!(
        s["deploy"]["result"]["plan"].as_str().unwrap_or("").contains("airgap populate"),
        "deploy produced a real airgap-engine plan: {s}"
    );
    assert!(
        s["deploy"]["result"]["plan"].as_str().unwrap_or("").contains("push-only"),
        "deploy carries the already-sealed bundle (push-only): {s}"
    );
    assert_eq!(s["deploy"]["result"]["error"], json!(null), "a clean deploy plan, no error: {s}");
}

/// **About + Tools·Demo — the shared common-framework surfaces, robot-clicked.** The
/// robot opens the **About** identity popup (the `ℹ About` header button), asserts the
/// live `state_json` reports it open with holger's identity, dismisses it with Close,
/// then opens **Tools → 6 · demo appliance** and clicks "Start demo appliance…" — the
/// jera-backed demo. Every step asserts the live `state_json`: the demo dispatch is
/// recorded through the shared job manager (a non-empty jera job id), not a bespoke
/// spawn. Real AccessKit clicks; matrix rows a robot run fills.
#[test]
fn robot_opens_about_and_dispatches_the_jera_demo() {
    // Tall enough that the shared `facett_about::AboutCard`'s hero logo (a full-height
    // LFS PNG when the blob is materialised) does not push the window's "Close" button
    // past the viewport — otherwise the positional Close click lands off-screen. The
    // height is a test-env concern only; every assertion below is unchanged.
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 2400.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-about-and-demo")
        // About: the shared identity surface opens + reports holger's identity.
        .step(
            Step::new("open About → popup open + identity reported")
                .action(Action::ClickLabel("ℹ About".into()))
                .assert(Assert::State { pointer: "/about/open".into(), expected: json!(true) }),
        )
        .step(
            Step::new("About reports the wordmark identity")
                .assert(Assert::State { pointer: "/about/wordmark".into(), expected: json!("holger") }),
        )
        .step(
            Step::new("dismiss About with Close")
                .action(Action::ClickLabel("Close".into()))
                .assert(Assert::State { pointer: "/about/open".into(), expected: json!(false) }),
        )
        // Tools · Demo appliance: dispatched THROUGH jera (observable job id).
        .step(
            Step::new("open Tools")
                .action(Action::ClickLabel("Tools".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("tools") }),
        )
        .step(
            Step::new("select the demo appliance tool")
                .action(Action::ClickLabel("6 · demo appliance".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("demo") }),
        )
        .step(
            Step::new("Start demo appliance → dispatched as a jera job")
                .action(Action::ClickLabel("Start demo appliance… (as a jera job)".into()))
                .assert(Assert::State { pointer: "/demo_appliance/dispatched".into(), expected: json!(true) }),
        );

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

    // The demo went through the shared job manager: a real jera job id + the pinned
    // demo registry image — not a bespoke spawn.
    let s = robot.state();
    let job_id = s["demo_appliance"]["job_id"].as_str().unwrap_or("");
    assert!(!job_id.is_empty(), "the demo dispatch recorded a jera job id: {s}");
    assert!(
        s["demo_appliance"]["image"].as_str().unwrap_or("").contains("registry"),
        "the dispatched demo container is the pinned registry image: {s}"
    );
    // About carried holger's version + the shared framework it is built on (incl. jera).
    let fw = s["about"]["framework"].as_array().expect("framework list");
    assert!(
        fw.iter().any(|c| c.as_str().unwrap_or("").contains("jera")),
        "About names the shared jera job manager: {s}"
    );
}

/// **The ADOPTED shared pane/scene host (facett_app::scene).** holger's shell is a
/// declarative `Scene` — a draggable master/detail `Split` (the registry rail beside the
/// tabbed workspace) rendered through facett's OWN TabView + pane engine by
/// `scene::render`, NOT a hand-rolled tab strip. The robot proves the adoption is live and
/// observable: the app reports the `holger` scene, a desktop-class device on a wide
/// viewport with controls unfolded, the root pane is a `split` and the workspace a `tabs`
/// layout, and clicking a TabView header (through the scene) moves the active tab. Every
/// step asserts the live `state_json` and becomes a nornir-testmatrix row. RED-when-broken:
/// if the scene stopped rendering (bespoke twin resurrected / render dropped) the
/// `/scene/*` facts go missing; if the master/detail split were flattened back to a bare
/// Tabs root the layout step fails.
#[test]
fn robot_drives_the_adopted_facett_scene() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-facett-scene")
        // The adopted scene is live and named.
        .step(
            Step::new("the shared facett-scene rendered (scene == holger)").assert(Assert::State {
                pointer: "/scene/scene".into(),
                expected: json!("holger"),
            }),
        )
        // The root pane is the shared DRAGGABLE master/detail `Split` (rail | workspace).
        .step(
            Step::new("the shell root pane is a facett draggable Split").assert(Assert::State {
                pointer: "/scene/panes/holger-shell/layout".into(),
                expected: json!("split"),
            }),
        )
        .step(
            Step::new("…and its divider is draggable, not a static fraction").assert(Assert::State {
                pointer: "/scene/panes/holger-shell/resizable".into(),
                expected: json!(true),
            }),
        )
        // The tab strip lives on the workspace pane (the detail side), still facett's TabView.
        .step(
            Step::new("the workspace pane is a facett Tabs layout").assert(Assert::State {
                pointer: "/scene/panes/holger-workspace/layout".into(),
                expected: json!("tabs"),
            }),
        )
        // The registry rail wears a chrome title bar with minimise + maximise controls.
        .step(
            Step::new("the registry rail wears a minimise control").assert(Assert::State {
                pointer: "/scene/panes/holger-rail/chrome/spec/minimize".into(),
                expected: json!(true),
            }),
        )
        .step(
            Step::new("…and a maximise control").assert(Assert::State {
                pointer: "/scene/panes/holger-rail/chrome/spec/maximize".into(),
                expected: json!(true),
            }),
        )
        // A wide viewport ⇒ desktop-class, controls shown inline (not folded into the drawer).
        .step(
            Step::new("wide viewport ⇒ scene device == desktop").assert(Assert::State {
                pointer: "/scene/device".into(),
                expected: json!("desktop"),
            }),
        )
        .step(
            Step::new("controls shown inline on desktop (not folded)").assert(Assert::State {
                pointer: "/controls_folded".into(),
                expected: json!(false),
            }),
        )
        .step(Step::new("the scene drew its tab canvas").assert(Assert::Drew))
        // Navigation THROUGH the scene's TabView: clicking the header moves the active tab.
        .step(
            Step::new("click Browse (scene TabView) → tab == browse")
                .action(Action::ClickLabel("Browse".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("browse") }),
        )
        .step(
            Step::new("click Graph (scene TabView) → tab == graph")
                .action(Action::ClickLabel("Graph".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("graph") }),
        );

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

/// **Responsive canvas-first fold (facett-scene `Device::from_width`).** On a phone-class
/// (narrow) viewport the shell reports the `phone` device class and folds its control chrome
/// (the static toggle + look switcher) behind the ⚙ drawer so the tab canvas gets the screen —
/// the same mobile fold facett-demo uses. The robot drives the REAL app at a narrow size and
/// asserts the observable device class + fold flag, while the scene still renders its tab
/// canvas. RED-when-broken: if the responsive fold regressed, `device`/`controls_folded` lie.
#[test]
fn robot_scene_folds_controls_on_narrow_viewport() {
    // A narrow (phone-class) window — below facett-scene's 600pt phone breakpoint.
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(460.0, 820.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-server-ui-responsive-fold")
        .step(
            Step::new("narrow viewport ⇒ scene device == phone").assert(Assert::State {
                pointer: "/scene/device".into(),
                expected: json!("phone"),
            }),
        )
        .step(
            Step::new("controls fold behind the ⚙ drawer (canvas-first)").assert(Assert::State {
                pointer: "/controls_folded".into(),
                expected: json!(true),
            }),
        )
        .step(
            Step::new("the scene still rendered its tab canvas").assert(Assert::State {
                pointer: "/scene/scene".into(),
                expected: json!("holger"),
            }),
        )
        .step(Step::new("the canvas drew on a narrow viewport").assert(Assert::Drew));

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

/// The **Helix** top-level tab — the ONLY operator tab that was un-robot-tested (the
/// lineage *companion* helix was covered, but the standalone Helix tab that winds a
/// scanned repo's dependency DAG along a synthesized time axis was not). The robot runs a
/// real Móðguðr scan (block corpus) to populate the dep-graph, opens Helix, and asserts the
/// wound coil AS DATA via the newly-exposed `state_json["helix"]` (the `HelixView`'s own
/// `state_json`). RED-when-broken: an empty/mis-wired `build_helix`, or a silent fall-back
/// to the self-contained `HelixView::demo()` scene, fails the node-count / title / verdict
/// assertions instead of going quietly green.
#[test]
fn robot_helix_tab_winds_the_scanned_repo_dag() {
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(data_app(true)));
    let mut robot = RobotSession::new(harness, "holger-server-ui");

    let scenario = Scenario::new("holger-helix-tab")
        .step(
            Step::new("open Tools").action(Action::ClickLabel("Tools".into())).assert(
                Assert::State { pointer: "/tab".into(), expected: json!("tools") },
            ),
        )
        .step(
            Step::new("select the Móðguðr security pane")
                .action(Action::ClickLabel("3 · security · Móðguðr".into()))
                .assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
        )
        .step(
            Step::new("scan the selected repo → block verdict populates the dep-graph")
                .action(Action::ClickLabel("Scan selected repo".into()))
                .assert(Assert::State {
                    pointer: "/security/decision".into(),
                    expected: json!("block"),
                }),
        )
        .step(
            Step::new("Helix tab → wind the coil")
                .action(Action::ClickLabel("Helix".into()))
                .assert(Assert::State { pointer: "/tab".into(), expected: json!("helix") }),
        )
        .step(Step::new("the Helix view built its coil").assert(Assert::State {
            pointer: "/helix_built".into(),
            expected: json!(true),
        }))
        .step(Step::new("the time-helix drew").assert(Assert::Drew));

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

    // Deep-assert the wound coil AS DATA (the robot's oracle) — every claim red-when-broken.
    let s = robot.state();
    let helix = &s["helix"];
    let dep_nodes = s["dep_graph"]["nodes"].as_array().map(|a| a.len()).unwrap_or(0) as u64;
    assert!(dep_nodes >= 1, "the scan populated the dep-graph (LAW2: no empty data): {s}");
    assert!(
        helix["title"].as_str().unwrap_or("").starts_with("holger · "),
        "a REAL scan winds a repo-titled coil, not the demo fallback: {helix}"
    );
    assert_eq!(
        helix["total_nodes"].as_u64().unwrap_or(0),
        dep_nodes + 1,
        "every scanned artifact + the repo root is wound onto the coil: {helix}"
    );
    assert_eq!(helix["coil_count"], json!(1), "one repository → one coil: {helix}");
    assert_eq!(
        helix["winds_forward"],
        json!(true),
        "the synthesized time axis advances monotonically along the coil: {helix}"
    );
    // The block verdict rides in as a Blocked helix node (verdict → run status).
    let coil0 = &helix["coils"][0];
    let nodes = coil0["nodes"].as_array().expect("coil-0 nodes array in helix state");
    assert!(
        nodes.iter().any(|n| n["status"] == json!("blocked")),
        "a block-verdict artifact winds in as a Blocked helix node: {helix}"
    );
}