facett-demo/src/robot.rs
//! **facett-demo robot-UI glue** — the reusable [`nornir_robotui`] seam wired onto
//! the main demo window: ONE [`Observable`], ONE `draw(ui, app, targets)` closure
//! and ONE [`Scenario`] that runs BOTH ways from the SAME data:
//!
//! * **Headless** ([`RunMode::Headless`](nornir_robotui::RunMode::Headless)) — the
//! off-screen `egui_kittest`/AccessKit drive that fills the nornir test matrix
//! (CI). One [`Step`] → one `TestResultRow`.
//! * **Watch** ([`RunMode::Watch`](nornir_robotui::RunMode::Watch)) — a REAL,
//! visible eframe window that replays the same steps paced so a human watches the
//! robot move a cursor and click facett's primary controls. On the `watch-wgpu`
//! build the OSM 3D city paints on the GPU (not the glow→CPU fallback).
//!
//! The `facett_robot` bin picks the mode (`facett_robot watch` / `run`, or the
//! `ROBOTUI_WATCH=1` env toggle) and calls
//! [`run_scenario_mode`](nornir_robotui::run_scenario_mode).
//!
//! ## Drive the REAL deck, resolve by AccessKit label (nornir-robotui 0.2.6)
//!
//! There is **no `▶ robot rail`** toolbar. Both modes resolve a step's target
//! through the LIVE AccessKit tree (0.2.6, edda `194ce3f`, made Watch resolve clicks
//! the way Headless always did), so the scenario drives facett's OWN controls: the
//! deck **tab headers** (`ClickLabel("OSM 3D")` clicks the real tab, flipping
//! `/deck/active`), the toolbar's **`glow`** / **`legend`** selectables, the
//! **`node:*`** syschart node-select toolbar, and the icebergview **`next ▶` /
//! `◀ prev`** pager buttons. [`facett_robot_draw`] therefore just renders the whole
//! demo ([`draw_app`](DemoState::draw_app)) — the same render path the eframe binary
//! uses — and the unused [`Targets`] side-channel stays empty. What the robot clicks
//! is exactly what a human clicks; what it asserts is read back out of
//! [`state_json`](DemoState::state_json).
//!
//! ## Every step asserts a VALUE (the eight LAWS)
//!
//! Nothing here is "the tab switched" alone. Every step reads a REAL value back out
//! of the running app (LAW 6): the loaded row counts, the `health.all_populated`
//! empty-guard (LAW 2 — RAGNARÖK: a blank data surface is RED), the syschart
//! selection swept across its outcome classes (LAW 3 — select-new / switch /
//! toggle-off / re-select), the iceberg pager across advance+retreat+lower-clamp,
//! the layer toggles, and the fx-glow / legend / palette acknowledgements. The
//! matching [`tests`] add the numeric `count > 0` asserts (which the exact-equality
//! [`Assert::State`] can't phrase) plus the LAW-2 **sensitivity proof** that the
//! same `health` oracle goes RED on an empty surface, and the AccessKit
//! **error-atom walk** (LAW 5) that no surface rendered a red error stub.
use nornir_robotui::{Action, Assert, Observable, RobotSession, Scenario, Step, Targets, TestResultRow};
use serde_json::{json, Value};
use crate::DemoState;
/// The robot canvas — a generous 1280×820 so the deck's WHOLE tab bar lays out and
/// every one of its ~70 tab headers is a clickable AccessKit node. The default
/// kittest canvas is too narrow and clips the later tabs (OSM 3D / icebergview),
/// which then can't be driven. Both the headless matrix and the watch window use it.
pub const CANVAS: [f32; 2] = [1280.0, 820.0];
/// Build a headless [`RobotSession`] over the real demo at the [`CANVAS`] size — the
/// shared constructor the `facett_robot run` bin and the tests both use, so the
/// SHIPPED headless matrix drives the exact deck the tests prove (LAW 1). Not the
/// default-sized `run_scenario_mode` harness, which clips the tab bar.
pub fn headless_session() -> RobotSession<'static, DemoState> {
let harness = nornir_robotui::egui_kittest::Harness::builder()
.with_size(egui::vec2(CANVAS[0], CANVAS[1]))
.build_ui_state(
move |ui: &mut egui::Ui, app: &mut DemoState| {
let mut t = Targets::default();
facett_robot_draw(ui, app, &mut t);
},
demo_app(),
);
RobotSession::new(harness, "facett")
}
/// Drive [`facett_scenario`] headless through the shipped `nornir-robotui` crate and
/// return one [`TestResultRow`] per step — the matrix the `facett_robot run` bin
/// prints and CI reconciles.
pub fn run_headless() -> Vec<TestResultRow> {
headless_session().run_scenario(&facett_scenario())
}
/// The **observe** seam: expose the demo's whole `state_json` (every tab, control
/// and the live sim, plus the `screen` on-screen roll-up and the `health`
/// empty/error verdicts) through [`nornir_robotui::Observable`] so the robot
/// asserts real values read back out of the running app — not "didn't panic".
impl Observable for DemoState {
fn state_json(&self) -> Value {
DemoState::state_json(self)
}
}
/// A [`DemoState`] configured for a **deterministic** robot drive: the auto-demo /
/// attract kiosk loop is OFF (so it never advances tabs under the scenario) and the
/// legend panel is closed (so it doesn't overlap the deck's tab bar in the headless
/// layout, and so the `legend` toggle step has a known starting state). The same
/// construction both modes run against.
pub fn demo_app() -> DemoState {
let mut demo = DemoState::new();
demo.attract_enabled = false;
demo.show_about = false;
demo
}
/// ONE `draw(ui, app, targets)` closure for BOTH modes: render the REAL facett demo
/// (`draw_app`) — the same path the eframe binary drives. Both modes resolve the
/// scenario's targets through the live AccessKit tree, so no `▶` rail and no
/// `Targets` recording is needed (the side-channel is left empty). See the
/// [module docs](self).
pub fn facett_robot_draw(ui: &mut egui::Ui, demo: &mut DemoState, _t: &mut Targets) {
demo.draw_app(ui);
}
// ── step builders (each carries a VALUE assertion, never a bare tab-switch) ──────
/// Click the real GROUP label holding tab `title` (the two-level bar, 2026-10-01: a tab
/// is only on screen while its group is) and assert the deck's active group became it.
fn open_group(title: &str) -> Step {
let group = crate::tab_group(title).unwrap_or(crate::TAB_GROUP_REST);
Step::new(format!("open group `{group}` (holds `{title}`) → menu.group == {group}"))
.action(Action::ClickLabel(group.into()))
.assert(Assert::State { pointer: "/deck/menu/group".into(), expected: json!(group) })
}
/// Click the real deck tab header `title` (after its group) and assert the deck's
/// `active` became it — asserted at BOTH `/deck/active` (the deck's own truth) and
/// `/screen/active` (the flat on-screen roll-up), so the roll-up is proven to track
/// navigation.
fn open_tab(title: &str) -> [Step; 2] {
[
open_group(title),
Step::new(format!("open `{title}` view → active == {title}"))
.action(Action::ClickLabel(title.into()))
.assert(Assert::State { pointer: "/screen/active".into(), expected: json!(title) }),
]
}
/// A tab-switch step whose oracle is a value read out of that facet's `state_json`
/// (RFC-6901 `pointer` under `/deck/facets/<title>`) — the "the pane really carries
/// this DATA" assertion (LAW 6), e.g. `grid → rows == 48`.
fn tab_value(title: &str, pointer: &str, expected: Value) -> [Step; 2] {
[
open_group(title),
Step::new(format!("`{title}` → {pointer} == {expected}"))
.action(Action::ClickLabel(title.into()))
.assert(Assert::State { pointer: pointer.into(), expected }),
]
}
/// Click a real control by its AccessKit label and assert an exact `state_json`
/// value landed (no tab switch — a toolbar / in-pane control).
fn click_value(label: &str, desc: &str, pointer: &str, expected: Value) -> Step {
Step::new(format!("`{label}` → {desc}"))
.action(Action::ClickLabel(label.into()))
.assert(Assert::State { pointer: pointer.into(), expected })
}
/// Click a real button (resolved by its text) and assert an exact `state_json` value.
fn button_value(text: &str, desc: &str, pointer: &str, expected: Value) -> Step {
Step::new(format!("`{text}` → {desc}"))
.action(Action::ClickButton(text.into()))
.assert(Assert::State { pointer: pointer.into(), expected })
}
/// The **canonical scenario** for the main demo window — every step drives a REAL
/// facett control and asserts a REAL value (LAW 6), sweeping the interactive
/// surfaces across their outcome classes (LAW 3) and guarding against blank data
/// (LAW 2). The SAME data drives the headless CI matrix and the visible watch
/// replay. Folds in the value-asserting coverage the standalone `tests/robot_demo.rs`
/// pioneered, now reusable through the shipped `nornir-robotui` crate.
pub fn facett_scenario() -> Scenario {
let scn = Scenario::new("facett-robot")
// 0. The whole demo drew at least one widget (not a blank frame).
.step(Step::new("facett-demo drew its UI").assert(Assert::Drew))
// 0b. RAGNARÖK GUARD (LAW 2) — every data-bearing surface is populated on
// first load: `health.all_populated == true` AND `health.empty == []`.
// If an OSM tab / grid / syschart ever loads blank, THESE go red (and the
// `health.diagnostics` map names why + what to run) — a blank pane is not
// a silent pass. Asserted before any interaction, on the shipped data.
.step(
Step::new("no data-bearing surface is empty (RAGNARÖK guard)")
.assert(Assert::State { pointer: "/health/all_populated".into(), expected: json!(true) }),
)
.step(
Step::new("health.empty names zero blank surfaces")
.assert(Assert::State { pointer: "/health/empty".into(), expected: json!([]) }),
);
// 1. VIEW / TAB SWITCHING with a per-pane VALUE oracle (not just deck.active):
// each tab proves it carries real DATA, and `/screen/active` tracks the nav.
let scn = scn
.steps(open_tab("graph"))
.steps(tab_value("graph", "/deck/facets/graph/nodes", json!(12)))
.steps(tab_value("grid", "/deck/facets/grid/rows", json!(48)))
// the flat roll-up mirrors the per-facet count (LAW 6 hoist).
.steps(open_tab("grid"))
.step(Step::new("grid rows mirrored into /screen").assert(Assert::State {
pointer: "/screen/grid_rows".into(),
expected: json!(48),
}))
.steps(tab_value("table", "/deck/facets/table/rows", json!(48)))
.steps(tab_value("metro", "/deck/facets/metro/lines", json!(2)))
.steps(tab_value("metro", "/deck/facets/metro/green_lines", json!(1)))
.steps(tab_value("console", "/deck/facets/console/lines", json!(8)));
// 2. THE OSM MAP VIEWS — the marquee primary functions. Assert the LOADED counts
// (via the health roll-up) + that the viewers advertise `navigable`, and that
// the flat roll-up carries the same loaded geometry the deck facet does.
let scn = scn
.steps(open_tab("OSM 2D"))
.step(Step::new("OSM 2D advertises the navigable slippy camera").assert(Assert::State {
pointer: "/deck/facets/OSM 2D/navigable".into(),
expected: json!(true),
}))
.step(Step::new("OSM 2D loaded points are surfaced (LAW 2 populated)").assert(Assert::State {
pointer: "/health/surfaces/osm_2d.points/populated".into(),
expected: json!(true),
}))
// The OVERPRINT census (lane TL, 2026-09-03). The demo's sources are all real
// OSM cuts with a `tags` column, so this is 0 and must STAY 0: a non-zero count
// here means `facett_osm::read_ways_with` decided a tagged cut was a tagless
// source and painted the whole basemap magenta. The number is only ever non-zero
// when the operator points `$FACETT_OSM_WAYS` at a plain WKB table (Trafikverket,
// NVDB) — the case the field exists to make visible, guarded in
// `osm_tabs::tests::a_tagless_ways_source_reaches_the_deck_as_overprint`.
.step(Step::new("OSM 2D: an OSM cut carries NO overprint ways").assert(Assert::State {
pointer: "/deck/facets/OSM 2D/overprint_ways".into(),
expected: json!(0),
}))
.steps(open_tab("OSM 3D"))
.step(Step::new("OSM 3D extruded a non-empty city mesh (LAW 2)").assert(Assert::State {
pointer: "/health/surfaces/osm_3d.triangles/populated".into(),
expected: json!(true),
}));
// 3. IN-MAP LAYER CONTROLS (the geomap `map` tab) — the layer toggles + camera
// viewport are surfaced as values; assert their initial state so a watch drive
// proves the controls are wired (the toggle EFFECT is swept in the test half,
// which flips them and re-reads `show_ways`).
let scn = scn
.steps(open_tab("map"))
.step(Step::new("map layer toggle `show_ways` is on").assert(Assert::State {
pointer: "/screen/map/show_ways".into(),
expected: json!(true),
}))
.step(Step::new("map layer toggle `show_points` is on").assert(Assert::State {
pointer: "/screen/map/show_points".into(),
expected: json!(true),
}));
// 4. SYSCHART SELECTION — sweep the SELECT function's outcome space (LAW 3) via
// the label-addressable node-select toolbar: select-new → switch → switch →
// toggle-off → re-select. Each class asserts the exact resulting selection at
// BOTH the facet pointer and the `/screen` roll-up.
let scn = scn
.steps(open_tab("syschart"))
.step(click_value("node:Ingest", "select-new → selected == Ingest",
"/screen/syschart_selected", json!("Ingest")))
.step(click_value("node:Decode", "switch → selected == Decode",
"/deck/facets/syschart/selected", json!("Decode")))
.step(click_value("node:Score", "switch → selected == Score",
"/screen/syschart_selected", json!("Score")))
.step(click_value("node:Score", "toggle-off (re-click) → selected == null",
"/screen/syschart_selected", Value::Null))
.step(click_value("node:Audit", "re-select after null → selected == Audit",
"/deck/facets/syschart/selected", json!("Audit")));
// 5. ICEBERG PAGER — sweep advance + retreat + the lower CLAMP boundary (LAW 3):
// page 0 → next → 1 → prev → 0 → prev (clamped) → 0. Time-travel over the live
// population table.
let scn = scn
.steps(open_tab("icebergview"))
.step(Step::new("icebergview browses the live `population` table").assert(Assert::State {
pointer: "/deck/facets/icebergview/table".into(),
expected: json!("population"),
}))
.step(button_value("next ▶", "page advances 0 → 1", "/screen/iceberg/page", json!(1)))
.step(button_value("◀ prev", "page retreats 1 → 0", "/screen/iceberg/page", json!(0)))
.step(button_value("◀ prev", "page clamps at the 0 boundary", "/screen/iceberg/page", json!(0)));
// 5b. A primary control stays visible/targetable (drew, addressable in the tree):
// back on the graph group, the `graph` tab header is on the bar. Before the
// toolbar steps, because the legend popup they open covers the group row.
let scn = scn
.step(open_group("graph"))
.step(Step::new("the `graph` tab control is visible").assert(Assert::LabelVisible("graph".into())));
// 6. TOOLBAR fx / palette / legend — the deck-wide controls. Assert the routed
// command AND the resulting on-screen value flip (glow, legend), swept both
// ways so the toggle is proven to move in BOTH directions (LAW 3 boundaries).
let scn = scn
.step(click_value("glow", "fx.glow routed", "/screen/last_command", json!("fx.glow")))
.step(Step::new("glow bloom is now ON").assert(Assert::State {
pointer: "/screen/glow".into(),
expected: json!(true),
}))
.step(click_value("glow", "fx.glow routed again", "/screen/last_command", json!("fx.glow")))
.step(Step::new("glow bloom toggled back OFF").assert(Assert::State {
pointer: "/screen/glow".into(),
expected: json!(false),
}))
.step(click_value("legend", "about.toggle routed", "/screen/last_command", json!("about.toggle")))
.step(Step::new("legend panel is now shown").assert(Assert::State {
pointer: "/screen/show_about".into(),
expected: json!(true),
}));
scn
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scenario_covers_the_primary_functions() {
let scn = facett_scenario();
assert_eq!(scn.suite, "facett-robot");
// A rich value-asserting deck: navigation + per-pane data + OSM populate
// guards + layer toggles + the syschart/iceberg/toolbar sweeps.
assert!(scn.steps.len() >= 30, "the scenario sweeps the deck ({} steps)", scn.steps.len());
}
#[test]
fn observable_reads_the_screen_rollup() {
// The Observable surface exposes the deck's active tab + the flat on-screen
// roll-up a scenario asserts on.
let demo = demo_app();
let state = Observable::state_json(&demo);
assert!(state.pointer("/deck/active").is_some(), "state_json exposes /deck/active");
assert!(state.pointer("/screen/active").is_some(), "state_json exposes /screen/active");
assert!(state.pointer("/health/all_populated").is_some(), "state_json exposes /health");
assert!(!demo.attract_enabled, "the robot app runs with the kiosk loop OFF");
}
/// Drive the WHOLE scenario headless through the SHIPPED `nornir-robotui` crate
/// and assert EVERY row PASSed — the matrix line the `facett_robot run` bin emits.
#[test]
fn robot_drives_the_deck_and_every_row_passes() {
let mut robot = headless_session();
let scn = facett_scenario();
let rows = robot.run_scenario(&scn);
let failed: Vec<_> = rows
.iter()
.filter(|r| r.status == nornir_robotui::status::FAIL)
.map(|r| format!("{} — {}", r.test_name, r.message))
.collect();
assert!(failed.is_empty(), "robot scenario rows FAILED:\n{}", failed.join("\n"));
assert_eq!(rows.len(), scn.steps.len(), "one matrix row per step");
}
/// LAW 2 numeric half — the `count > 0` asserts the exact-equality scenario
/// oracle can't phrase: the OSM tabs loaded REAL geometry (not a blank pane), the
/// tabular surfaces carry rows. Read straight off the running app's `state_json`.
#[test]
fn loaded_surfaces_carry_real_data_counts() {
let robot = headless_session();
let n = |ptr: &str| robot.state().pointer(ptr).and_then(Value::as_i64).unwrap_or(0);
assert!(n("/screen/osm_2d/points") > 0, "OSM 2D loaded real points");
assert!(n("/screen/osm_2d/ways") > 0, "OSM 2D loaded real ways");
assert!(n("/screen/osm_3d/triangles") > 0, "OSM 3D extruded a real mesh");
assert!(n("/screen/grid_rows") > 0, "grid carries rows");
assert!(n("/screen/table_rows") > 0, "table carries rows");
}
/// LAW 5 — walk the LIVE AccessKit tree of the rendered deck and assert NO node
/// rendered an error atom (`is not served` / `Failed to load` / `unavailable` /
/// `scan failed` …). A red error stub anywhere in the shipped surface is RED.
#[test]
fn no_surface_renders_an_error_atom() {
let mut robot = headless_session();
// Render a settle frame so every eager pane is in the tree.
robot.run(2);
let atoms = nornir_robotui::error_atoms(robot.harness_mut());
assert!(atoms.is_empty(), "the deck rendered error atoms: {atoms:?}");
}
/// LAW 3 — the in-map LAYER TOGGLE effect: flip the geomap `map` view's
/// `show_ways` off and back through its portable-state seam, asserting the
/// on-screen value moves in BOTH directions (the toggle is not a no-op).
#[test]
fn map_layer_toggle_moves_the_value() {
use facett::Facet;
let mut demo = demo_app();
let show = |d: &DemoState| {
d.state_json().pointer("/screen/map/show_ways").and_then(Value::as_bool)
};
assert_eq!(show(&demo), Some(true), "ways start visible");
// Drive the same layer-toggle path the map view's control flips (the FC-2
// `load_state` mutation seam routes through the view's `SetWays` message).
{
let m = demo.deck.facet_mut::<facett::map::MapView>("map").expect("map facet present");
assert!(m.load_state(&json!({ "show_ways": false })), "map accepted the layer toggle");
}
assert_eq!(show(&demo), Some(false), "ways toggled OFF (value moved)");
{
let m = demo.deck.facet_mut::<facett::map::MapView>("map").expect("map facet present");
assert!(m.load_state(&json!({ "show_ways": true })), "map accepted the re-toggle");
}
assert_eq!(show(&demo), Some(true), "ways toggled back ON (value moved both ways)");
}
/// LAW 2 SENSITIVITY PROOF — the SAME `health` oracle the scenario asserts green
/// must go RED on an empty surface. Feed `health_json` a synthetic deck where the
/// OSM 2D points went to zero and assert it reports the surface empty + names the
/// populate diagnostic (so `empty == []` is asserting something real).
#[test]
fn health_oracle_goes_red_on_an_empty_surface() {
let demo = demo_app();
// A populated deck → all_populated, empty == [].
let live = demo.state_json();
assert_eq!(live.pointer("/health/all_populated"), Some(&json!(true)));
assert_eq!(live.pointer("/health/empty"), Some(&json!([])));
// Now synthesise an EMPTY OSM 2D pane and re-run the oracle directly.
let empty_deck = json!({
"facets": {
crate::osm_tabs::TITLE_2D: { "points": 0, "ways": 0 },
crate::osm_tabs::TITLE_3D: { "triangles": 0 },
"grid": { "rows": 0 },
}
});
let health = demo.health_json(&empty_deck);
assert_eq!(health.pointer("/all_populated"), Some(&json!(false)), "empty surface must be RED");
let empty = health.pointer("/empty").and_then(Value::as_array).unwrap();
assert!(empty.iter().any(|v| v == "osm_2d.points"), "names the empty OSM 2D surface");
assert!(
health.pointer("/diagnostics/osm_2d.points").is_some(),
"the POPULATE-DIAGNOSTIC names why + what to run",
);
}
}