tests/robot_funnel_journey.rs
//! **Robot-UI Funnel journey** — the 🗂 Funnel tab driven end to end: intake →
//! submit → 🌱 demo plan → ▶ Simulate flow (the project-flow time-stepper) →
//! 3-D view. Proves the funnel's interactive functions fill matrix rows, and that
//! `simulate_flow` advances the DAG (`/funnel/sim/done` climbs).
//!
//! Run: `cargo test --features "viz" --test robot_funnel_journey`
#![cfg(feature = "viz")]
use egui_kittest::Harness;
use nornir::viz::UrdrThreadsApp;
use nornir_robotui::egui;
use nornir_robotui::{
Action, Assert, FieldValue, Observable, RobotSession, Scenario, Step, Target, status,
};
use serde_json::json;
struct VizApp(UrdrThreadsApp);
impl Observable for VizApp {
fn state_json(&self) -> serde_json::Value {
self.0.state_json()
}
}
/// A local app with a REAL `workspace_root` so `🌱 Run demo` (which writes fake
/// repos on disk + a funnel DAG into the warehouse) works through the real
/// handler, and so `▶ Simulate flow` finds a plan to advance.
fn funnel_robot() -> (RobotSession<'static, VizApp>, tempfile::TempDir) {
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
let warehouse = ws.join(".nornir").join("warehouse");
std::fs::create_dir_all(&warehouse).unwrap();
let app = UrdrThreadsApp::with_repos(warehouse, "nornir".to_string(), ws, vec![]);
let harness = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 900.0))
.build_ui_state(|ui, app: &mut VizApp| app.0.draw_ui(ui), VizApp(app));
let mut r = RobotSession::new(harness, "nornir");
r.run(4);
(r, tmp)
}
fn assert_all_pass(rows: &[nornir_robotui::TestResultRow]) {
for r in rows {
assert_eq!(
r.status,
status::PASS,
"step {:?} failed: {}",
r.test_name,
r.message
);
}
}
#[test]
fn robot_funnel_intake_and_history() {
let (mut robot, _tmp) = funnel_robot();
robot.click_label("🗂 Funnel").expect("Funnel header");
robot.run(2);
let scenario = Scenario::new("funnel-intake")
.step(Step::new("on the Funnel tab").assert(Assert::State {
pointer: "/tab".into(),
expected: json!("Funnel"),
}))
.step(
Step::new("select 💡 idea mode → intake.is_error == false")
.action(Action::ClickLabel("💡 idea".into()))
.assert(Assert::State {
pointer: "/funnel/intake/is_error".into(),
expected: json!(false),
}),
)
.step(
Step::new("type an idea into paste: → state reflects it")
.action(Action::SetField {
target: Target::label("paste:"),
value: FieldValue::Text("add a dark theme".into()),
})
.assert(Assert::State {
pointer: "/funnel/intake/text".into(),
expected: json!("add a dark theme"),
}),
)
.step(
Step::new("💡 Submit idea → history grows to 1")
.action(Action::ClickLabel("💡 Submit idea".into()))
.assert(Assert::State {
pointer: "/funnel/history/count".into(),
expected: json!(1),
}),
)
.step(
Step::new("intake cleared after submit").assert(Assert::State {
pointer: "/funnel/intake/text_len".into(),
expected: json!(0),
}),
)
// NEGATIVE (inject-assert LAW)
.step(
Step::new("wrong history count → RED cell").assert(Assert::State {
pointer: "/funnel/history/count".into(),
expected: json!(999),
}),
);
let rows = robot.run_scenario(&scenario);
let (last, head) = rows.split_last().unwrap();
assert_all_pass(head);
assert_eq!(last.status, status::FAIL, "the wrong count is a red cell");
}
/// The demo plan + ▶ Simulate flow — the project-flow time-stepper drains the DAG
/// so `/funnel/sim/done` advances above zero.
#[test]
fn robot_funnel_demo_then_simulate_flow_advances() {
let (mut robot, _tmp) = funnel_robot();
robot.click_label("🗂 Funnel").expect("Funnel header");
robot.run(2);
// Inject the demo plan through the seam (writes the funnel DAG to the
// warehouse), then re-render so the pane + demo controls reflect it.
robot
.harness_mut()
.state_mut()
.0
.funnel_tab_for_test()
.run_demo_for_test(1)
.expect("run demo");
robot.run(2);
assert_eq!(
robot.state_at("/funnel/plan_count"),
Some(json!(1)),
"the demo injected exactly one plan"
);
// Before simulating, no sim result yet.
assert_eq!(robot.state_at("/funnel/sim"), Some(json!(null)));
// ▶ Simulate flow — drive the real button; the DAG drains to Done.
let scenario = Scenario::new("funnel-simulate").step(
Step::new("click ▶ Simulate flow → the pane drew")
.action(Action::ClickLabel("▶ Simulate flow".into()))
.assert(Assert::Drew),
);
assert_all_pass(&robot.run_scenario(&scenario));
// The sim advanced the DAG: some node reached Done, total > 0.
let total = robot
.state_at("/funnel/sim/total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let done = robot
.state_at("/funnel/sim/done")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert!(
total > 0,
"the demo plan has nodes to simulate (total={total})"
);
assert!(
done > 0,
"▶ Simulate flow advanced nodes to Done (done={done})"
);
let waves = robot
.state_at("/funnel/sim/waves")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert!(waves >= 1, "at least one wave ran (waves={waves})");
}
/// **The plan-DAG GROWS.** The headline funnel proof (super-priority C/D): drive
/// the 🗂 Funnel tab, 🌱 run the demo so a real idea→plan→nodes DAG lands, then
/// toggle the metric-bearing **Plan-DAG** (facett-plandag) and assert the SAME
/// live pane grows from 0 rendered nodes to the plan's full node set through the
/// real render (not an isolated slice). Records ONE nornir-testmatrix functional
/// cell for the idea→plan-DAG flow. Runs headless (matrix) and, via the same
/// scenario, under `watch` on DISPLAY:0.
#[test]
fn robot_funnel_plandag_grows_as_nodes_land() {
let (mut robot, _tmp) = funnel_robot();
robot.click_label("🗂 Funnel").expect("Funnel header");
robot.run(2);
// 🌱 Run the demo: a real idea→plan→nodes DAG is injected and selected. The
// Plan-DAG toggle only surfaces once a plan exists, so this comes first.
let scenario_demo = Scenario::new("funnel-demo-plan")
.step(
Step::new("🌱 Run demo → the plan-DAG drew")
.action(Action::ClickLabel("🌱 Run demo".into()))
.assert(Assert::Drew),
)
.step(Step::new("a plan now exists").assert(Assert::State {
pointer: "/funnel/plan_count".into(),
expected: json!(1),
}));
assert_all_pass(&robot.run_scenario(&scenario_demo));
// The Plan-DAG pane is lazy — nothing rendered into it until it's toggled on.
let before = robot
.state_at("/funnel/plandag/node_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(before, 0, "Plan-DAG not built yet ⇒ 0 rendered nodes");
// Toggle the metric-bearing Plan-DAG (facett-plandag) — it builds from the
// selected plan and the DAG grows from empty to the plan's full node set.
let scenario_grow = Scenario::new("funnel-plandag-grows").step(
Step::new("toggle 🗂 Plan-DAG → the live mode is Plan-DAG")
.action(Action::ClickLabel("🗂 Plan-DAG".into()))
.assert(Assert::State {
pointer: "/funnel/view_mode".into(),
expected: json!("plandag"),
}),
);
assert_all_pass(&robot.run_scenario(&scenario_grow));
robot.run(3); // let facett-plandag build + repaint
let after = robot
.state_at("/funnel/plandag/node_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let grew = after > before;
// ONE nornir-testmatrix cell for the funnel idea→plan-DAG flow.
nornir_testmatrix::functional_status(
"funnel-plandag",
"plandag_grows_from_empty_to_populated",
grew && before == 0,
&format!("live Plan-DAG node_count grew {before} → {after} after 🌱 demo + 🗂 toggle"),
);
assert!(
grew,
"the Plan-DAG grew as nodes landed: {before} → {after}"
);
assert_eq!(
robot.state_at("/funnel/plandag_on"),
Some(json!(true)),
"the Plan-DAG pane is the live view",
);
// NEGATIVE (inject-assert LAW): an empty DAG would be a red cell.
assert!(
after > 0,
"populated Plan-DAG has nodes (sensitivity: 0 would be RED)"
);
}
/// Click-to-Yggdrasil: the funnel tab exposes each plan node's system-DAG anchor
/// as data. Drives the demo (a real plan-DAG), asserts the anchor state surfaces
/// (`/funnel/anchored_nodes` present as a count, `/funnel/selected_node` null until
/// a node is clicked) — proving the plan-DAG→Yggdrasil edge is remote-observable
/// in code. Runnable headless (matrix) and, via the same scenario, visible under
/// `watch` on DISPLAY:0 (the RunMode the watch examples drive).
#[test]
fn robot_funnel_anchor_state_is_exposed() {
let (mut robot, _tmp) = funnel_robot();
robot.click_label("🗂 Funnel").expect("Funnel header");
robot.run(2);
let scenario = Scenario::new("funnel-anchors")
.step(
Step::new("🌱 Run demo → the plan-DAG drew")
.action(Action::ClickLabel("🌱 Run demo".into()))
.assert(Assert::Drew),
)
.step(
Step::new("no node selected yet → selected_node is null").assert(Assert::State {
pointer: "/funnel/selected_node".into(),
expected: json!(null),
}),
);
assert_all_pass(&robot.run_scenario(&scenario));
// anchored_nodes is surfaced as a real count (the plan→Yggdrasil edge count).
let anchored = robot.state_at("/funnel/anchored_nodes");
assert!(anchored.is_some(), "anchored_nodes surfaced as data");
assert!(
anchored.unwrap().as_u64().is_some(),
"anchored_nodes is a count"
);
}