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

nordisk/facett

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