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

nordisk/facett

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