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

nordisk/nornir

tests/viz_build_robot.rs

//! **Robot-UI live drive of the 🔨 Build tab** (build-thing UI, Tier-2 #5/#26).
//!
//! The reference consumer of `nornir-robotui` for nornir's own viz — the same
//! pattern as [`robot_viz.rs`] and facett's `facett-demo/tests/robot_demo.rs`:
//! fire up the REAL viz headless, drive real AccessKit interactions on the Build
//! tab, and assert — via the app's `state_json()["build"]` slice — that the UI
//! *responded* to every action. Nothing here is "didn't panic".
//!
//! What it drives (inject-assert LAW #1):
//!   1. a real click on the 🔨 Build tab header → `state_json["tab"] == "Build"`;
//!   2. real clicks on the choosable target ROWS (nornir's OWN declared atoms —
//!      read from `.nornir/build-variants.toml`, never named here) →
//!      `state_json["build"]["selected"]` follows the click;
//!   3. a real click on a row's uniquely-labelled **BUILD** button → the build runs
//!      through the SAME async spawn/poll path the live tab uses (a MOCK runner is
//!      injected, so no real `nornir build` subprocess compiles the world), and the
//!      resulting provenance + card status land in `state_json["build"]`.
//!
//! Each scripted `(action → expected outcome)` becomes one `robot-ui`
//! [`TestResultRow`], persisted + round-tripped through a `JsonFileSink` (the
//! matrix-fill proof), and a coverage gate goes RED if the surface wasn't driven.
//!
//! Run: `cargo test --features viz --test viz_build_robot -- --nocapture`
#![cfg(feature = "viz")]

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use egui_kittest::Harness;
use nornir::build::variant::BuildVariantSet;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::build_tab::{BuildRunner, ProvenanceSummary};
use nornir_robotui::{
    Action, Assert, JsonFileSink, Observable, RobotSession, Scenario, Step, TestSink, status,
};
use serde_json::{Value, json};

/// Newtype so we can `impl Observable` for the foreign viz app (orphan rule).
struct VizApp(UrdrThreadsApp);

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

/// This repo's root — the one the Build tab discovers its build space under.
fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

/// **nornir's OWN declared build space**, read through the SAME
/// [`BuildVariantSet::discover`] the Build tab itself calls (`build_tab::BuildTab::
/// new`) — one declaration, no second list written in Rust (LAW 5).
///
/// ⚠ Until 2026-08-22 this file asserted `embedded` / `mini` / `full` and a literal
/// `3`. Those are HOLGER's variants: `BuildTab::new` was seeded from a hardcoded
/// `HOLGER_DEMO` TOML literal until `2026-08-01`, when it started reading the repo's
/// real declaration. nornir declares 5 atoms, so every one of these assertions had
/// been wrong for three weeks and NONE of them could say so — the egui realign
/// (`833b639`) had left this test target uncompilable, so `cargo test` never listed
/// it. Deriving the expectation from the declaration is what stops it re-freezing.
fn declared_variants() -> Vec<String> {
    let set = BuildVariantSet::discover(&repo_root())
        .expect("read .nornir/build-variants.toml")
        .expect("nornir declares a build space");
    assert!(
        !set.variants.is_empty(),
        "the declared build space is empty — an empty expectation asserts nothing"
    );
    set.variants.iter().map(|v| v.name.clone()).collect()
}

/// A mock [`BuildRunner`]: records every `(repo_root, variant)` call and returns a
/// canned provenance summary INSTANTLY — so the BUILD wiring is driven through the
/// real spawn/poll seam WITHOUT compiling anything (LAW: inject the side-work seam).
#[derive(Default)]
struct MockRunner {
    calls: Mutex<Vec<(PathBuf, String)>>,
}

impl BuildRunner for MockRunner {
    fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String> {
        self.calls
            .lock()
            .unwrap()
            .push((repo_root.to_path_buf(), variant.to_string()));
        Ok(ProvenanceSummary {
            digest: "deadbeefcafef00d0011223344556677".to_string(),
            artifacts: 1,
            sealed: true,
            warehouse_row: format!(
                "build_provenance: repo=holger variant={variant} outcome=Success artifacts=1"
            ),
        })
    }
}

/// Build the REAL viz app with a MOCK build runner injected into the 🔨 Build tab,
/// and wrap it in a [`RobotSession`]. Each call gets its own warehouse dir so
/// parallel tests never contend on the redb catalog lock.
fn robot() -> (RobotSession<'static, VizApp>, Arc<MockRunner>) {
    use std::sync::atomic::{AtomicU64, Ordering};
    // Skip the durable action-log sink's drain thread (it would hold the catalog
    // lock for the app's lifetime) — this test never reads the durable trail back.
    // SAFETY: set before any app is constructed, single test thread per binary.
    unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    let warehouse_root =
        std::env::temp_dir().join(format!("nornir-build-robot-{}-{n}", std::process::id()));
    let _ = std::fs::create_dir_all(&warehouse_root);

    let mut app = UrdrThreadsApp::with_repos(
        warehouse_root,
        "nornir".to_string(),
        repo_root(),
        vec!["nornir".to_string()],
    );
    // Inject the mock runner + dismiss the splash. The Build tab renders without a
    // workspace; it discovers its rows from `repo_root`'s own declaration.
    let runner = Arc::new(MockRunner::default());
    app.set_build_runner_for_test(runner.clone());

    // 1200 px tall: at 900 the last row (nornir-solo, 735-766) ran under the
    // Workspace pane's clip edge (757) once a build added its action buttons, and
    // a click there lands on nothing — the robot's visibility check reads the
    // AccessKit rect, which is not clipped.
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(1400.0, 1200.0))
        .build_ui_state(|ui, app: &mut VizApp| app.0.draw_ui(ui), VizApp(app));
    (RobotSession::new(harness, "nornir"), runner)
}

/// Step frames (up to `max`) until `pred(state_json)` holds — the async build runs
/// on a worker thread and lands via `poll()` on a later frame, so we pump until the
/// mock's canned result has been fed back through the Elm core. Returns whether it
/// converged.
fn pump_until<F: Fn(&Value) -> bool>(
    robot: &mut RobotSession<'static, VizApp>,
    max: usize,
    pred: F,
) -> bool {
    for _ in 0..max {
        if pred(&robot.state()) {
            return true;
        }
        robot.step();
    }
    pred(&robot.state())
}

/// **The imperative live drive** — click the Build header, select target rows, press
/// BUILD, and assert the whole `select → BUILD → provenance` chain landed in
/// `state_json`, plus that the mock runner was invoked exactly once for `full`.
#[test]
fn robot_drives_build_tab_select_build_and_state_reflects_provenance() {
    let (mut robot, runner) = robot();
    robot.run(4); // settle layout + first paint

    // ── (1) a real AccessKit click on the 🔨 Build tab header switches the tab ──
    robot
        .click_label("⚒ Dwarves")
        .expect("⚒ Dwarves tab header is clickable");
    assert_eq!(
        robot
            .state_at("/tab")
            .and_then(|v| v.as_str().map(String::from)),
        Some("Dwarves".to_string()),
        "the real click switched the active tab to Dwarves (default pane = 🔨 Build)"
    );

    // The build-thing surface reflected THIS repo's own declared target rows.
    let names = declared_variants();
    let last = names.len() - 1;
    let st = robot.state();
    assert_eq!(
        st["build"]["surface"],
        json!("build"),
        "the Build slice is present"
    );
    assert_eq!(
        st["build"]["variant_count"],
        json!(names.len()),
        "every declared variant reached the surface: {names:?}"
    );
    assert_eq!(
        st["build"]["valid_count"],
        json!(names.len()),
        "every declared variant is valid: {names:?}"
    );
    for (i, name) in names.iter().enumerate() {
        assert_eq!(
            st["build"]["rows"][i]["name"],
            json!(name),
            "row {i} is the declared variant `{name}`"
        );
    }

    // ── (2) clicking a target ROW selects it — the selection follows the click ──
    robot
        .click_label(&names[0])
        .unwrap_or_else(|e| panic!("the `{}` target row is clickable: {e}", names[0]));
    assert_eq!(
        robot.state_at("/build/selected"),
        Some(json!(names[0])),
        "clicking the `{}` row selected it",
        names[0]
    );
    robot
        .click_label(&names[last])
        .unwrap_or_else(|e| panic!("the `{}` target row is clickable: {e}", names[last]));
    assert_eq!(
        robot.state_at("/build/selected"),
        Some(json!(names[last])),
        "clicking the `{}` row moved the selection",
        names[last]
    );

    // ── (3) press the uniquely-labelled BUILD button for the last row ──────────
    // Before the build: that card is idle and no provenance exists.
    assert_eq!(
        robot.state_at(&format!("/build/model/cards/{last}/name")),
        Some(json!(names[last]))
    );
    assert_eq!(
        robot.state_at(&format!("/build/model/cards/{last}/status/kind")),
        Some(json!("idle"))
    );
    assert!(robot.state()["build"]["provenance"][&names[last]].is_null());

    // Every row's actions are ON the surface, not only the row clicked below:
    // the features column once pushed BUILD to x=1408 on this 1400-px surface
    // (egui 0.36 bump, 2026-10-02) — a row whose button no pointer can reach.
    for n in &names {
        let rect = robot
            .label_visibility(&format!("BUILD {n}"))
            .unwrap_or_else(|e| panic!("the `{n}` row's BUILD button is on the surface: {e}"));
        assert!(rect.max.x <= 1400.0, "the `{n}` row's BUILD button ends at x={} > 1400", rect.max.x);
    }
    robot
        .click_label(&format!("BUILD {}", names[last]))
        .unwrap_or_else(|e| panic!("the `{}` row's BUILD button is clickable: {e}", names[last]));
    // The build runs on a worker thread; pump frames until poll() fed the mock
    // result back through the Elm core (building → false + provenance recorded).
    let done = pump_until(&mut robot, 300, |s| {
        s["build"]["building"] == json!(false) && !s["build"]["provenance"][&names[last]].is_null()
    });
    assert!(
        done,
        "the BUILD completed and its provenance was fed back into the tab"
    );

    // The provenance summary the completed build surfaced (the warehouse row facts).
    let st = robot.state();
    let prov = &st["build"]["provenance"][&names[last]];
    assert_eq!(prov["artifacts"], json!(1));
    assert_eq!(prov["sealed"], json!(true));
    assert!(
        prov["warehouse_row"]
            .as_str()
            .unwrap_or_default()
            .contains(&format!("variant={}", names[last])),
        "the warehouse row names the built variant: {:?}",
        prov["warehouse_row"]
    );
    // That card ended in the Built state (the Elm model reflects the build).
    assert_eq!(
        st["build"]["model"]["cards"][last]["status"]["kind"],
        json!("built"),
        "the `{}` card is Built after the drive",
        names[last]
    );
    assert_eq!(
        st["build"]["model"]["cards"][last]["status"]["sealed"],
        json!(true)
    );

    // The mock runner was driven exactly once, for `full` — the BUILD click fired
    // the real spawn/poll seam (not the blocking test helper).
    let calls = runner.calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "exactly one build ran");
    assert_eq!(
        calls[0].1, names[last],
        "the driven build was for the selected `{}` target",
        names[last]
    );
}

/// **Scenario form** — the drivable steps expressed declaratively so each
/// `(action → outcome)` fills a `robot-ui` matrix row; then persist + round-trip the
/// rows through a `JsonFileSink` (the matrix-fill proof) and gate on full coverage.
#[test]
fn scenario_drives_build_tab_and_fills_the_matrix() {
    let (mut robot, runner) = robot();
    robot.run(4);

    // Drive the async BUILD imperatively FIRST so the settled provenance is stable
    // for the scenario's assert-only steps (the scenario runner steps one frame per
    // step, which can't span the worker-thread round-trip deterministically).
    let names = declared_variants();
    let last = names.len() - 1;
    let (first_row, last_row) = (names[0].clone(), names[last].clone());
    robot.click_label("⚒ Dwarves").expect("Build tab header");
    robot
        .click_label(&last_row)
        .unwrap_or_else(|e| panic!("select {last_row}: {e}"));
    robot
        .click_label(&format!("BUILD {last_row}"))
        .unwrap_or_else(|e| panic!("BUILD {last_row}: {e}"));
    let built = pump_until(&mut robot, 300, |s| {
        !s["build"]["provenance"][&last_row].is_null()
    });
    assert!(built, "the imperative BUILD settled before the scenario");
    assert_eq!(runner.calls.lock().unwrap().len(), 1);

    let scenario = Scenario::new("robot-build")
        .step(
            Step::new("Dwarves header renders the tab").assert(Assert::State {
                pointer: "/tab".into(),
                expected: json!("Dwarves"),
            }),
        )
        .step(
            Step::new(format!(
                "the build surface reflected all {} declared rows",
                names.len()
            ))
            .assert(Assert::State {
                pointer: "/build/variant_count".into(),
                expected: json!(names.len()),
            }),
        )
        .step(
            Step::new(format!("re-click `{first_row}` row → selection follows the click"))
                .action(Action::ClickLabel(first_row.clone()))
                .assert(Assert::State {
                    pointer: "/build/selected".into(),
                    expected: json!(first_row),
                }),
        )
        .step(
            Step::new(format!("re-click `{last_row}` row → selection moves back"))
                .action(Action::ClickLabel(last_row.clone()))
                .assert(Assert::State {
                    pointer: "/build/selected".into(),
                    expected: json!(last_row),
                }),
        )
        .step(
            Step::new("the driven BUILD recorded znippy-sealed provenance").assert(Assert::State {
                pointer: format!("/build/provenance/{last_row}/sealed"),
                expected: json!(true),
            }),
        )
        .step(
            Step::new("the provenance carries the build's artifact count").assert(Assert::State {
                pointer: format!("/build/provenance/{last_row}/artifacts"),
                expected: json!(1),
            }),
        )
        .step(
            Step::new(format!("the `{last_row}` card ended Built")).assert(Assert::State {
                pointer: format!("/build/model/cards/{last}/status/kind"),
                expected: json!("built"),
            }),
        )
        .step(Step::new("the Build tab drew widgets").assert(Assert::Drew));

    let rows = robot.run_scenario(&scenario);

    assert_eq!(rows.len(), 8, "one matrix row per scenario step");
    assert!(
        rows.iter().all(|r| r.aspect == "robot-ui"),
        "rows tagged robot-ui"
    );
    assert!(
        rows.iter().all(|r| r.repo == "nornir"),
        "rows stamped repo=nornir"
    );
    assert!(
        rows.iter().all(|r| r.suite == "robot-build"),
        "suite carried through"
    );
    let run_id = rows[0].run_id.clone();
    assert!(
        rows.iter().all(|r| r.run_id == run_id),
        "all rows one matrix run"
    );
    for r in &rows {
        assert_eq!(
            r.status,
            status::PASS,
            "step {:?} should pass; message: {}",
            r.test_name,
            r.message
        );
    }

    // ── PROVE the rows fill a matrix sink (round-trip a JsonFileSink) ───────────
    let sink_path = std::env::temp_dir().join(format!(
        "nornir-build-robot-matrix-{}.jsonl",
        std::process::id()
    ));
    let _ = std::fs::remove_file(&sink_path);
    let sink = JsonFileSink::new(&sink_path);
    sink.append(&rows)
        .expect("ship the robot rows to the matrix sink");
    let back = sink.read_all().expect("read the matrix back");
    assert_eq!(back.len(), 8, "the matrix sink has all 8 robot rows");
    assert!(back.iter().all(|r| r.aspect == "robot-ui"));

    // ── COVERAGE GATE: the surface's interactions were actually driven (goes RED
    //    if a row for select / BUILD / provenance is missing) ────────────────────
    let driven = back
        .iter()
        .map(|r| r.test_name.clone())
        .collect::<Vec<_>>()
        .join("\n");
    for needle in ["select", "BUILD", "provenance", "surface", "Built"] {
        assert!(
            driven.to_lowercase().contains(&needle.to_lowercase()),
            "coverage gate: the Build surface was not driven for `{needle}`: {driven}"
        );
    }

    eprintln!(
        "ROBOT-UI MATRIX (suite=robot-build, repo=nornir):\n{}",
        nornir_robotui::nornir_testmatrix::render_matrix(&back)
    );
    let _ = std::fs::remove_file(&sink_path);
}

/// **Port fidelity** — a WRONG expectation must land as a RED cell, not a panic, so
/// the pass/fail of the drive is genuinely assertion-driven (inject-assert LAW).
#[test]
fn robot_runner_records_a_wrong_build_expectation_as_a_red_cell() {
    let (mut robot, _runner) = robot();
    robot.run(4);
    robot.click_label("⚒ Dwarves").expect("Build header");

    let names = declared_variants();
    let row = names[names.len() - 1].clone();
    let scenario = Scenario::new("build-fidelity")
        .step(
            Step::new(format!("good: select the `{row}` row"))
                .action(Action::ClickLabel(row.clone()))
                .assert(Assert::State {
                    pointer: "/build/selected".into(),
                    expected: json!(row),
                }),
        )
        .step(
            // WRONG on purpose: no repo declares 999 variants.
            Step::new("bad: variant_count == 999 (wrong)").assert(Assert::State {
                pointer: "/build/variant_count".into(),
                expected: json!(999),
            }),
        )
        .step(
            // WRONG on purpose: no such row → the action itself fails → red cell.
            Step::new("bad: click a target row that doesn't exist")
                .action(Action::ClickLabel("no-such-variant".into())),
        );

    let rows = robot.run_scenario(&scenario);
    assert_eq!(rows.len(), 3);
    assert_eq!(rows[0].status, status::PASS, "the real select passed");
    assert_eq!(
        rows[1].status,
        status::FAIL,
        "a wrong expectation is a red cell, not a panic"
    );
    assert!(
        rows[1].message.contains("999"),
        "the red cell records why: {}",
        rows[1].message
    );
    assert_eq!(
        rows[2].status,
        status::FAIL,
        "a missing-widget action is a red cell"
    );
    assert!(rows.iter().all(|r| r.aspect == "robot-ui"));
}

/// **functional-status row for the Build surface** (nornir-testmatrix) — under the
/// `testmatrix` feature, drive the tab then emit ONE `functional_status` row per
/// real check over the driven `state_json["build"]`, drain the process-global buffer
/// and assert the rows landed GREEN (EMIT-DOCTRINE: assert on real state + record
/// the verdict as a matrix row). A no-op build strips the emit entirely.
#[cfg(feature = "testmatrix")]
#[test]
fn build_surface_emits_functional_status_rows() {
    use nornir_robotui::nornir_testmatrix::{
        drain_functional_rows, functional_status, status as ts,
    };

    let (mut robot, _runner) = robot();
    robot.run(4);
    let names = declared_variants();
    let row = names[names.len() - 1].clone();
    robot.click_label("⚒ Dwarves").expect("Build header");
    robot
        .click_label(&row)
        .unwrap_or_else(|e| panic!("select {row}: {e}"));
    robot
        .click_label(&format!("BUILD {row}"))
        .unwrap_or_else(|e| panic!("BUILD {row}: {e}"));
    let _ = pump_until(&mut robot, 300, |s| {
        !s["build"]["provenance"][&row].is_null()
    });

    // Clear anything a sibling might have left, then emit the Build-surface rows.
    let _ = drain_functional_rows();
    let s = robot.state();
    let checks: &[(&str, bool, String)] = &[
        (
            "build_surface_reflects_variants",
            s["build"]["surface"] == json!("build")
                && s["build"]["variant_count"] == json!(names.len()),
            format!(
                "surface={} variants={}",
                s["build"]["surface"], s["build"]["variant_count"]
            ),
        ),
        (
            "build_action_records_provenance",
            s["build"]["provenance"][&row]["sealed"] == json!(true)
                && s["build"]["provenance"][&row]["artifacts"] == json!(1),
            format!("provenance.{row}={}", s["build"]["provenance"][&row]),
        ),
    ];
    for (check, ok, detail) in checks {
        functional_status("viz-build-surface", check, *ok, detail);
        assert!(ok, "Build functional check `{check}` failed: {detail}");
    }

    let rows = drain_functional_rows();
    for (check, _, _) in checks {
        let row = rows
            .iter()
            .find(|r| r.suite == "viz-build-surface" && r.test_name == *check)
            .unwrap_or_else(|| panic!("no drained functional row for `{check}`: {rows:?}"));
        assert_eq!(
            row.status,
            ts::PASS,
            "functional row `{check}` drained RED: {}",
            row.message
        );
        assert_eq!(
            row.aspect, "functional",
            "the Build-surface row is a functional-aspect row"
        );
    }
}