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