facett-brokkr/examples/brokkr_watch.rs
//! # WATCH the brokkr pipeline BUILDER drive itself on a real window
//!
//! The **visible sibling** of the headless robot scenario in
//! `facett-brokkr/tests/robot.rs::scenario_builds_a_pipeline_by_click_and_fills_the_matrix`:
//! the SAME scripted build — Build mode → add a stage → add a step → set its
//! `needs` → flip to the DAG view → flip to the raw TOML — run through
//! `nornir-robotui`'s live WATCH backend (feature `watch`) so a human WATCHES the
//! robot steer a cursor across the builder, click each control, and see the
//! pipeline take shape: stages and steps appear, the DAG preview grows an edge,
//! and the emitted `brokkr.toml` fills in.
//!
//! One `Observable` + one `draw(ui, app, targets)` closure + one `Scenario` — the
//! 10-line adoption pattern from
//! `../../nornir/crates/nornir-robotui/.nornir/robotui-watch-mode.md`. The headless
//! matrix path in `tests/robot.rs` is untouched; this is purely additive.
//!
//! ## Run it
//!
//! ```sh
//! DISPLAY=:0 cargo run -p facett-brokkr --example brokkr_watch
//! # slower / hold the window open at the end:
//! DISPLAY=:0 ROBOTUI_WATCH_DELAY_MS=1600 ROBOTUI_WATCH_HOLD=1 \
//! cargo run -p facett-brokkr --example brokkr_watch
//! ```
use facett_brokkr::{ContainerRun, Mode, Msg, PipelineBuilder, RunKind};
use nornir_robotui::{
Action, Assert, Observable, RunMode, Scenario, Step, Targets, WatchConfig, run_scenario_mode,
};
use serde_json::{Value, json};
/// The live builder wrapped as an [`Observable`] — `state_json` forwards the
/// builder's own introspection blob, so the SAME JSON pointers the headless robot
/// scenario reads (`/mode`, `/stage_count`, `/step_count`, `/toml`) resolve here.
struct WatchBrokkr(PipelineBuilder);
impl Observable for WatchBrokkr {
fn state_json(&self) -> Value {
self.0.state_json()
}
}
/// ONE closure, BOTH modes. The top control row records a compact button per
/// scenario step (the WATCH driver resolves an `Action`'s label to a recorded rect
/// and steers the cursor there); each button drives the builder through its public
/// API — the exact edits the headless scenario clicks. Below the row the LIVE
/// builder surface is drawn (`ui`), so the growing stage/step list, the DAG
/// preview and the raw TOML are all on screen to watch react.
fn draw(ui: &mut egui::Ui, app: &mut WatchBrokkr, t: &mut Targets) {
ui.horizontal_wrapped(|ui| {
if t.record("Build mode", ui.button("Build mode")).clicked() {
app.0.set_mode(Mode::Build);
}
if t.record("Add stage 'build'", ui.button("Add stage 'build'"))
.clicked()
{
app.0.set_name("nornir-ci");
app.0.add_stage("build");
}
if t.record("Add step 'compile'", ui.button("Add step 'compile'"))
.clicked()
{
app.0
.add_step(0, "compile", RunKind::Variant("server".into()));
}
if t.record(
"Add stage 'test' + step",
ui.button("Add stage 'test' + step"),
)
.clicked()
{
app.0.add_stage("test");
app.0.add_step(
1,
"unit",
RunKind::Container(ContainerRun {
name: "rust:1.85".into(),
cmd: vec!["cargo".into(), "test".into()],
}),
);
}
if t.record("Set needs = compile", ui.button("Set needs = compile"))
.clicked()
{
app.0.set_step_needs(1, 0, ["compile"]);
}
if t.record("DAG view", ui.button("DAG view")).clicked() {
app.0.set_mode(Mode::Graph);
}
if t.record("Disconnect", ui.button("Disconnect")).clicked() {
app.0.update(Msg::Disconnect {
from: "compile".into(),
to: "unit".into(),
});
}
if t.record("Connect", ui.button("Connect")).clicked() {
app.0.update(Msg::Connect {
from: "compile".into(),
to: "unit".into(),
});
}
if t.record("Move compile", ui.button("Move compile"))
.clicked()
{
app.0.update(Msg::MoveNode {
node: "compile".into(),
position: [40.0, 80.0],
});
}
if t.record("Undo edit", ui.button("Undo edit")).clicked() {
app.0.update(Msg::Undo);
}
if t.record("Redo edit", ui.button("Redo edit")).clicked() {
app.0.update(Msg::Redo);
}
if t.record("TOML view", ui.button("TOML view")).clicked() {
app.0.set_mode(Mode::Text);
}
});
ui.separator();
// The LIVE builder — the surface the robot's control row drives.
app.0.ui(ui);
}
/// The self-driving WATCH scenario — the visible twin of the headless build:
/// stage → step → second stage/step → needs → DAG → TOML, asserting the observable
/// state after each driven step.
fn scenario() -> Scenario {
Scenario::new("robot-brokkr-watch")
.step(Step::new("open in Build mode").assert(Assert::State {
pointer: "/mode".into(),
expected: json!("Build"),
}))
.step(
Step::new("add the 'build' stage")
.action(Action::ClickButton("Add stage 'build'".into()))
.assert(Assert::State {
pointer: "/stage_count".into(),
expected: json!(1),
}),
)
.step(
Step::new("add the 'compile' step")
.action(Action::ClickButton("Add step 'compile'".into()))
.assert(Assert::State {
pointer: "/step_count".into(),
expected: json!(1),
}),
)
.step(
Step::new("add a 'test' stage with a 'unit' step")
.action(Action::ClickButton("Add stage 'test' + step".into()))
.assert(Assert::State {
pointer: "/step_count".into(),
expected: json!(2),
}),
)
.step(
Step::new("wire unit needs compile — the DAG grows an edge")
.action(Action::ClickButton("Set needs = compile".into()))
.assert(Assert::StateContains {
pointer: "/toml".into(),
needle: "needs = [\"compile\"]".into(),
}),
)
.step(
Step::new("flip to the DAG preview")
.action(Action::ClickButton("DAG view".into()))
.assert(Assert::State {
pointer: "/mode".into(),
expected: json!("Graph"),
}),
)
.step(
Step::new("disconnect the dependency")
.action(Action::ClickButton("Disconnect".into()))
.assert(Assert::State {
pointer: "/valid".into(),
expected: json!(true),
}),
)
.step(
Step::new("connect through the graph reducer")
.action(Action::ClickButton("Connect".into()))
.assert(Assert::StateContains {
pointer: "/toml".into(),
needle: "needs = [\"compile\"]".into(),
}),
)
.step(
Step::new("move compile in session layout")
.action(Action::ClickButton("Move compile".into()))
.assert(Assert::State {
pointer: "/layout_overrides/compile".into(),
expected: json!([40.0, 80.0]),
}),
)
.step(
Step::new("undo the move")
.action(Action::ClickButton("Undo edit".into()))
.assert(Assert::State {
pointer: "/layout_overrides".into(),
expected: json!({}),
}),
)
.step(
Step::new("redo the move")
.action(Action::ClickButton("Redo edit".into()))
.assert(Assert::State {
pointer: "/layout_overrides/compile".into(),
expected: json!([40.0, 80.0]),
}),
)
.step(
Step::new("flip to the raw brokkr.toml")
.action(Action::ClickButton("TOML view".into()))
.assert(Assert::State {
pointer: "/mode".into(),
expected: json!("Text"),
}),
)
}
fn main() -> anyhow::Result<()> {
let cfg = WatchConfig::from_env().with_title("facett — brokkr pipeline builder (watch)");
let _ = RunMode::Headless; // (documents the other backend the headless test uses)
run_scenario_mode(
WatchBrokkr(PipelineBuilder::new("ci")),
draw,
&scenario(),
"facett",
RunMode::Watch,
cfg,
)?;
Ok(())
}