facett-brokkr/tests/robot.rs
//! **Robot-UI scenario + matrix-fill proof for facett-brokkr.**
//!
//! Two forms over the SAME builder, the nordisk choosable convention:
//!
//! * an **imperative** self-driving journey — toggle Text ⇄ Build ⇄ Graph, add a
//! stage, add a step, set its `needs`, and assert the observable `state_json`
//! and the *emitted `brokkr.toml`* after every driven step (rendering a real
//! `egui_kittest` frame so the surface is proven non-blank at each step);
//! * a **[`Scenario`]** driven through [`RobotSession::run_scenario`] — clicking
//! the REAL mode toggles + the REAL "+ Add stage/step" buttons in the AccessKit
//! tree, one `robot-ui` matrix row per step, then round-tripped through a
//! [`JsonFileSink`] (the matrix-fill proof). The visible twin of this scenario
//! is `examples/brokkr_watch.rs` (`RunMode::Watch`).
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
use nornir_robotui::{
status, Action, Assert, JsonFileSink, Observable, RobotSession, Scenario, Step, TestSink,
};
use serde_json::{json, Value};
use facett_brokkr::{ContainerRun, Mode, PipelineBuilder, RunKind};
/// Newtype so we can `impl Observable` for the builder (orphan rule).
struct App(PipelineBuilder);
impl Observable for App {
fn state_json(&self) -> Value {
self.0.state_json()
}
}
/// A `RobotSession` over an EMPTY builder (so "+ Add step" is unambiguous once a
/// single stage exists — the robot can click it by label with no collision).
fn robot() -> RobotSession<'static, App> {
let harness = Harness::builder()
.with_size(egui::vec2(1000.0, 900.0))
.build_ui_state(|ui, app: &mut App| app.0.ui(ui), App(PipelineBuilder::new("ci")));
RobotSession::new(harness, "facett-brokkr")
}
/// **The imperative self-driving journey**: build a pipeline by CLICK, flipping
/// modes, and assert the state + emitted TOML at each step. Renders a real frame
/// per step so the surface is proven non-blank throughout.
#[test]
fn robot_builds_a_pipeline_and_toggles_modes() {
let mut b = PipelineBuilder::new("ci");
b.set_name("nornir-ci");
// ── toggle Text ⇄ Build ⇄ Graph (the two authoring modes + the DAG view) ─
for (mode, want) in
[(Mode::Text, "Text"), (Mode::Graph, "Graph"), (Mode::Build, "Build")]
{
b.set_mode(mode);
assert_eq!(b.state_json()["mode"], want);
// The surface paints non-blank in this mode (LAW #4).
let mut probe = clone_into_app(&b);
let mut h = Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui(move |ui| probe.0.ui(ui));
h.run();
assert!(h.query_by_label("⚙ Build").is_some(), "mode {want}: the toggles painted");
}
// ── add a stage ─────────────────────────────────────────────────────────
let build = b.add_stage("build");
assert_eq!(b.state_json()["stage_count"], 1);
// ── add a step ──────────────────────────────────────────────────────────
b.add_step(build, "compile", RunKind::Variant("server".into()));
assert_eq!(b.state_json()["step_count"], 1);
// ── a second stage + step, then SET NEEDS across the stage boundary ──────
let test = b.add_stage("test");
b.add_step(test, "unit", RunKind::Container(ContainerRun {
name: "rust:1.85".into(),
cmd: vec!["cargo".into(), "test".into()],
}));
b.set_step_needs(test, 0, ["compile"]);
let st = b.state_json();
assert_eq!(st["stage_count"], 2);
assert_eq!(st["step_count"], 2);
assert_eq!(st["valid"], true, "the built pipeline validates: {:?}", st["errors"]);
// ── the EMITTED brokkr.toml carries the dependency edge + the container run ─
let toml_text = b.pipeline_toml();
assert!(toml_text.contains("[pipeline]"));
assert!(toml_text.contains("needs = [\"compile\"]"), "the needs edge is emitted:\n{toml_text}");
assert!(toml_text.contains("run = { container = { name = \"rust:1.85\", cmd = [\"cargo\", \"test\"] } }"));
// It parses back through the same serde shape brokkr's descriptor parser uses.
let back: facett_brokkr::PipelineFile = toml::from_str(&toml_text).expect("emitted toml parses");
assert_eq!(back.pipeline.name, "nornir-ci");
assert_eq!(back.pipeline.stages[1].steps[0].needs, vec!["compile".to_string()]);
}
fn clone_into_app(b: &PipelineBuilder) -> App {
// Rebuild an equivalent builder from the emitted TOML (PipelineBuilder isn't
// Clone; the TOML round-trip is the faithful copy).
App(PipelineBuilder::from_toml_str("ci", &b.pipeline_toml()))
}
/// **Scenario form — fills the `robot-ui` matrix by really clicking the UI.**
#[test]
fn scenario_builds_a_pipeline_by_click_and_fills_the_matrix() {
let mut robot = robot();
robot.run(2);
let scenario = Scenario::new("robot-brokkr-build")
.step(
Step::new("the builder opens in Build mode")
.assert(Assert::State { pointer: "/mode".into(), expected: json!("Build") }),
)
.step(
Step::new("toggle to the Graph (DAG) view")
.action(Action::ClickLabel("◈ Graph".into()))
.assert(Assert::State { pointer: "/mode".into(), expected: json!("Graph") }),
)
.step(
Step::new("toggle to the raw TOML text editor")
.action(Action::ClickLabel("✎ Text".into()))
.assert(Assert::State { pointer: "/mode".into(), expected: json!("Text") }),
)
.step(
Step::new("back to the click builder")
.action(Action::ClickLabel("⚙ Build".into()))
.assert(Assert::State { pointer: "/mode".into(), expected: json!("Build") }),
)
.step(
Step::new("add a stage — the model grows a stage")
.action(Action::ClickLabel("+ Add stage".into()))
.assert(Assert::State { pointer: "/stage_count".into(), expected: json!(1) }),
)
.step(
Step::new("add a step to that stage — the model grows a step")
.action(Action::ClickLabel("+ Add step".into()))
.assert(Assert::State { pointer: "/step_count".into(), expected: json!(1) }),
)
.step(
Step::new("the emitted brokkr.toml now carries a step")
.assert(Assert::StateContains {
pointer: "/toml".into(),
needle: "[[pipeline.stage.step]]".into(),
}),
)
.step(Step::new("the builder 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.suite == "robot-brokkr-build"), "suite carried through");
for r in &rows {
assert_eq!(r.status, status::PASS, "step {:?} RED: {}", 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!("facett-brokkr-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");
let _ = std::fs::remove_file(&sink_path);
}
/// **Port fidelity** — a WRONG expectation lands as a RED cell, not a panic.
#[test]
fn wrong_expectation_is_a_red_cell_not_a_panic() {
let mut robot = robot();
robot.run(2);
let scenario = Scenario::new("brokkr-fidelity")
.step(
Step::new("good: opens in Build mode")
.assert(Assert::State { pointer: "/mode".into(), expected: json!("Build") }),
)
.step(
Step::new("bad: stage_count == 99 (wrong)")
.assert(Assert::State { pointer: "/stage_count".into(), expected: json!(99) }),
)
.step(
Step::new("bad: click a control that does not exist")
.action(Action::ClickLabel("Nonesuch".into())),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].status, status::PASS);
assert_eq!(rows[1].status, status::FAIL, "a wrong expectation is a red cell");
assert!(rows[1].message.contains("99"), "the red cell records why: {}", rows[1].message);
assert_eq!(rows[2].status, status::FAIL, "a missing-widget action is a red cell");
}