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

nordisk/nornir

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