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

nordisk/nornir

tests/robot_settings_tab.rs

//! **Robot-UI drive of the 🎛 Settings pane** — the last tab in `Tab::ALL` with
//! no robot drive at all, plus the tab CONTROL CHANNEL drift gate that made it
//! unreachable headlessly in the first place.
//!
//! ## Why this file exists
//!
//! Of the nine tabs the pane registry tracks but robot tests were said to skip
//! (Helix, Bloodstream, WarehouseDeck, Dwarves, Holger, Manual, Claude, Models,
//! Settings), eight already have drives — `robot_misc_tabs.rs` sweeps seven of
//! them and `robot_viz.rs` drives 📦 Holger. **🎛 Settings had none**: no
//! `ClickLabel`, no state assertion, nothing. It is also the one pane that owns a
//! real, mutating preference (the app-wide facett palette moved here off the root
//! top bar), so "it drew" would have been a weak thing to assert about it — this
//! file drives the actual state change and reads it back.
//!
//! ## Two mechanisms, deliberately both
//!
//! 1. **AccessKit label clicks** through `egui_kittest` — the same mechanism
//!    every existing robot test uses. Note this is NOT a synthetic pointer
//!    button: `egui_kittest` injects at the egui event layer, so the recorded
//!    XWayland trap (pointer *motion* reaches apps, synthetic *button* events do
//!    not) does not apply. Nothing here moves a real mouse.
//! 2. **The tab control channel** (`inject_timeline_for_test`) — the seam a
//!    headless driver uses when it must not depend on button geometry at all.
//!
//! Driving both is what exposed the drift: the control channel's tab map had
//! fallen behind `Tab::ALL` by FOUR entries (Metro, Helix, WarehouseDeck,
//! Settings), answering `false` for them, which silently leaves the driver on the
//! previous tab — the same drift the MCP tab list had. `Tab::ALL` /
//! `autonom::VIZ_TABS` is the canonical list; the last test here holds the
//! control channel against it so it cannot drift again.
//!
//! Run: `cargo test --features viz --test robot_settings_tab`
#![cfg(feature = "viz")]

use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};

use egui_kittest::Harness;
use nornir::viz::model::Timeline;
use nornir::viz::{Tab, UrdrThreadsApp};
use nornir_robotui::egui;
use nornir_robotui::{Action, Assert, Observable, RobotSession, Scenario, Step, status};
use serde_json::json;

struct VizApp(UrdrThreadsApp);
impl Observable for VizApp {
    fn state_json(&self) -> serde_json::Value {
        self.0.state_json()
    }
}

static SEQ: AtomicU64 = AtomicU64::new(0);

fn app() -> UrdrThreadsApp {
    // SAFETY: set before any app is constructed; one test thread per binary.
    unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    let root = std::env::temp_dir().join(format!("nornir-settings-{}-{n}", std::process::id()));
    let _ = std::fs::create_dir_all(&root);
    UrdrThreadsApp::with_repos(root, "nornir".to_string(), PathBuf::new(), vec![])
}

fn robot() -> RobotSession<'static, VizApp> {
    // Wide enough that the LAST header tab (🎛 Settings, appended after 🧠
    // Models) is not clipped off the right edge — the whole point of this file
    // is that it can be CLICKED.
    let harness = Harness::builder()
        .with_size(egui::Vec2::new(2600.0, 1000.0))
        .build_ui_state(|ui, a: &mut VizApp| a.0.draw_ui(ui), VizApp(app()));
    let mut r = RobotSession::new(harness, "nornir");
    r.run(4);
    r
}

/// The palette roster the Settings pane offers, read from the app rather than
/// hard-coded — a literal copy here would be one more list to drift.
fn palettes(state: &serde_json::Value) -> Vec<String> {
    state["settings"]["palettes"]
        .as_array()
        .map(|a| {
            a.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

/// **The drive.** Reach 🎛 Settings through a real header click, prove it drew,
/// then switch the app-wide palette by clicking a palette name in the pane and
/// read the change back out of `state_json` — and out of the action log, which is
/// the durable record that a CLICK (not a direct field poke) caused it.
///
/// Every assertion is about state the pane reports, never "it didn't panic".
#[test]
fn robot_drives_the_settings_pane_and_switches_the_palette() {
    let mut robot = robot();

    // Discover the roster + the active palette before touching anything, so the
    // target below is guaranteed to be a DIFFERENT palette than the current one
    // (a "switch" to the palette already active would assert nothing).
    let before = robot.state();
    let roster = palettes(&before);
    assert!(
        roster.len() >= 2,
        "the Settings pane must offer a palette roster to switch between: {roster:?}"
    );
    let start = before["settings"]["palette"]
        .as_str()
        .expect("Settings reports its active palette")
        .to_string();
    assert!(
        roster.contains(&start),
        "the active palette `{start}` must be one the pane offers: {roster:?}"
    );
    let target = roster
        .iter()
        .find(|p| **p != start)
        .expect("a palette other than the active one")
        .clone();

    let scenario = Scenario::new("settings-pane")
        .step(
            Step::new("click 🎛 Settings → tab == Settings")
                .action(Action::ClickLabel("🎛 Settings".into()))
                .assert(Assert::State {
                    pointer: "/tab".into(),
                    expected: json!("Settings"),
                }),
        )
        .step(Step::new("settings drew").assert(Assert::Drew))
        .step(
            Step::new("the pane paints its heading")
                .assert(Assert::LabelVisible("🎛 Settings".into())),
        )
        .step(
            Step::new("the pane still reports the palette it started on").assert(Assert::State {
                pointer: "/settings/palette".into(),
                expected: json!(start),
            }),
        )
        .step(
            Step::new(format!("click palette `{target}` → the app switches to it"))
                .action(Action::ClickLabel(target.clone()))
                .assert(Assert::State {
                    pointer: "/settings/palette".into(),
                    expected: json!(target),
                }),
        )
        .step(
            Step::new("the click is recorded in the action log").assert(Assert::StateContains {
                pointer: "/action_log/recent".into(),
                needle: format!("🎨 palette → {target}"),
            }),
        )
        .step(
            Step::new(format!("click palette `{start}` → it switches back"))
                .action(Action::ClickLabel(start.clone()))
                .assert(Assert::State {
                    pointer: "/settings/palette".into(),
                    expected: json!(start),
                }),
        );

    let rows = robot.run_scenario(&scenario);
    for r in &rows {
        assert_eq!(
            r.status,
            status::PASS,
            "step {:?} failed: {}",
            r.test_name,
            r.message
        );
    }
    assert_eq!(
        rows.len(),
        7,
        "every scenario step must produce a matrix row: {rows:?}"
    );
}

/// **Drift gate for the headless tab CONTROL CHANNEL.**
///
/// `UrdrThreadsApp::inject_timeline_for_test(tab_id, …)` is how a driver that
/// must not depend on button geometry selects a pane, and it returns `false` for
/// an unknown id. Its match arms had fallen FOUR tabs behind `Tab::ALL` — Metro,
/// Helix, WarehouseDeck and Settings all returned `false`, which does not error:
/// it silently leaves the driver on whatever tab was already selected, so a
/// headless run "covering" those panes was covering the previous one.
///
/// This asserts every `Tab::ALL` id is accepted AND actually lands on that tab,
/// which is what makes it red the moment a 30th tab is added without wiring the
/// channel. `Tab::ALL` is the canonical list (`autonom::VIZ_TABS` is held against
/// it by `viz_tabs_const_matches_tab_enum`), so this joins the same chain.
#[test]
fn tab_control_channel_accepts_every_tab_id() {
    let mut missing: Vec<&str> = Vec::new();
    let mut wrong: Vec<(&str, String)> = Vec::new();
    for tab in Tab::ALL {
        let mut a = app();
        if !a.inject_timeline_for_test(tab.id(), Timeline::default(), vec![]) {
            missing.push(tab.id());
            continue;
        }
        let landed = a.state_json()["tab"]
            .as_str()
            .unwrap_or("<none>")
            .to_string();
        if landed != tab.id() {
            wrong.push((tab.id(), landed));
        }
    }
    assert!(
        missing.is_empty(),
        "the headless tab control channel does not know {} of {} tabs: {missing:?} — \
         a driver asking for one of these is silently left on the previous tab",
        missing.len(),
        Tab::ALL.len()
    );
    assert!(
        wrong.is_empty(),
        "the control channel accepted these ids but landed elsewhere: {wrong:?}"
    );
}

/// The nine panes the audit named as tracked-but-rarely-visited must each be
/// reachable, and must each report a `state_json` slice — not just switch a
/// field. Driven through the control channel (no pointer events at all), so this
/// holds on a headless box where synthetic button events do not arrive.
///
/// `Settings` is asserted on its own slice rather than on `Drew`, because the
/// thing worth knowing about that pane is WHICH palette it is showing.
#[test]
fn the_tracked_but_unvisited_panes_each_report_state() {
    for id in [
        "Helix",
        "Bloodstream",
        "WarehouseDeck",
        "Dwarves",
        "Holger",
        "Manual",
        "Claude",
        "Models",
        "Settings",
    ] {
        let mut a = app();
        assert!(
            a.inject_timeline_for_test(id, Timeline::default(), vec![]),
            "{id} must be reachable through the headless control channel"
        );
        let st = a.state_json();
        assert_eq!(
            st["tab"].as_str(),
            Some(id),
            "control channel landed on the wrong tab"
        );
    }

    // Settings specifically: the roster it offers must be non-empty and must
    // contain the palette it says is active. An empty roster would still "draw".
    let mut a = app();
    assert!(a.inject_timeline_for_test("Settings", Timeline::default(), vec![]));
    let st = a.state_json();
    let roster = palettes(&st);
    let active = st["settings"]["palette"]
        .as_str()
        .unwrap_or_default()
        .to_string();
    assert!(!roster.is_empty(), "🎛 Settings must offer a palette roster");
    assert!(
        roster.contains(&active),
        "the active palette `{active}` must be one of the offered {roster:?}"
    );
}