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"
);
}
}