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

nordisk/holger

mannequin/src/robot.rs

//! # Robot-UI wiring for the mannequin GUI — ONE scenario, BOTH modes, REAL shell
//!
//! This is the mannequin's adoption of the shared `nornir-robotui` framework (edda
//! leaf, 0.2.6 — Watch resolves clicks via AccessKit), following its `demo.rs`
//! reference:
//!
//! * ONE [`Observable`] — the mannequin GUI exposes EVERY on-screen value as JSON so
//!   a robot asserts the value, not the pixels (delegates to
//!   [`MannequinGui::state_json`]);
//! * ONE `draw(ui, app, targets)` closure — [`mannequin_draw`] — that renders the
//!   **REAL shipped shell** ([`eframe::App::ui`]) into the robot frame. The primary
//!   controls a robot drives are therefore the ACTUAL widgets a user touches:
//!   - the facett `SystemChart`'s map nodes (PKI / OIDC / Nexus) — each a real
//!     AccessKit **button** the component registers (facett-syschart FC-4), labelled
//!     `"{system} · {badge} events"`, so the robot clicks the REAL on-map node;
//!   - the detail pane's `🔄 Regenerate` button;
//!   - the chrome bar's look-preset switcher (`selectable_label` per preset);
//!   - each pane's window-chrome fold control (`"fold <pane>"`).
//! * ONE [`Scenario`] — [`mannequin_scenario`] — the exact steps CI runs headless and
//!   `holger-mannequin robot --watch` replays visibly.
//!
//! ## Why the REAL shell, not a proxy control rail
//!
//! An earlier pass drew a bespoke button rail because the map nodes were believed to
//! be canvas-drawn and thus not click-addressable. That is no longer true: facett's
//! `SystemChart` paints each node AND registers an AccessKit button over it
//! (`facett_core::a11y::node`, label `"{label} · {badge} events"`). Driving a proxy
//! would have tested a surface the user never touches (LAW 1 — SHIPPED ≠ TESTED), so
//! the robot now drives the shipped shell directly and asserts values off its
//! `state_json`. `state_json["chart_nodes"][*]["a11y_label"]` publishes each node's
//! exact click label, keeping the drive target and the emit one truth.
//!
//! The choosable entry point lives in [`run`]: pass [`RunMode::Headless`] for the CI
//! matrix or [`RunMode::Watch`] for a real on-screen eframe window. `main.rs` picks
//! the mode from the `robot [--watch]` subcommand (or `ROBOTUI_WATCH=1`).

use std::sync::Arc;

use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{egui, RobotSession};
use nornir_robotui::{
    run_scenario_mode, Action, Assert, Observable, RunMode, RunOutcome, Scenario, Step, Targets,
    WatchConfig,
};
use serde_json::{json, Value};

use crate::event_log::{EventLog, SystemKind};
use crate::gui::{node_a11y_label, MannequinGui, MAP_PANE};
use crate::PkiService;

/// The stable look preset a robot switches TO first — not the running-OS default
/// (`windows-dark` on Linux), so the click provably CHANGES the observed look name.
pub const ROBOT_LOOK: &str = "macos-light";

/// A SECOND preset the scenario switches to, to prove the switcher maps EACH preset
/// to its own name (LAW 3 — sweep, not one value).
pub const ROBOT_LOOK_2: &str = "windows-light";

/// The `🔄 Regenerate` button's label (the detail pane's PKI action).
const REGEN: &str = "🔄 Regenerate";

/// Draw ONE frame of the mannequin robot surface — **the real shipped shell**. The
/// robot frame hands us a `&mut egui::Ui`; we render the exact `eframe::App::ui` a
/// user runs into it (a throwaway kittest `Frame`, which the shell ignores). Every
/// primary control — the map's AccessKit node buttons, the Regenerate button, the
/// look switcher, the pane fold chrome — is therefore the ACTUAL widget, addressable
/// by its real label in both the headless AccessKit path and the watch cursor.
///
/// `Targets` is unused: the shell's controls all carry AccessKit labels (facett's
/// FC-4 contract), so both modes resolve them from the live tree — no proxy rect to
/// record.
pub fn mannequin_draw(ui: &mut egui::Ui, app: &mut MannequinGui, _t: &mut Targets) {
    let mut frame = eframe::Frame::_new_kittest();
    eframe::App::ui(app, ui, &mut frame);
}

/// The mannequin robot [`Scenario`], driving the REAL shell. Every step that changes
/// state asserts the VALUE changed (not merely that something drew / a selection
/// moved): a Regenerate increments `/events/pki`, a look switch lands the exact
/// `/look_name`, a fold flips `/scene/folded/<pane>`. Primary functions are SWEPT
/// (LAW 3): Regenerate is proven to increment on EACH press (0→1→2, not a 0→1 flip),
/// the look switcher is proven to map TWO distinct presets to their own names, the
/// three map nodes are each selected, and the fold is exercised in BOTH directions.
/// Empty/error states are asserted as values (LAW 2 / 4): the detail placeholder,
/// the empty issued-cert list, and per-system "No activity yet".
pub fn mannequin_scenario() -> Scenario {
    // The map-node click labels at the moment each is clicked. PKI is clicked while
    // still at badge 0; OIDC / Nexus never receive events, so all three are "· 0".
    let pki_node = node_a11y_label(&SystemKind::Pki, 0);
    let oidc_node = node_a11y_label(&SystemKind::Oidc, 0);
    let nexus_node = node_a11y_label(&SystemKind::Nexus, 0);
    let fold_map = format!("fold {MAP_PANE}");
    let unfold_map = format!("unfold {MAP_PANE}");

    Scenario::new("mannequin-robot")
        // ── clean slate: nothing selected, detail empty, zero events ─────────────
        .step(
            Step::new("initial: no system selected")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/selected_system".into(),
                    expected: Value::Null,
                }),
        )
        .step(
            Step::new("initial: the detail pane shows its empty placeholder")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/detail/empty".into(),
                    expected: json!(true),
                }),
        )
        .step(
            Step::new("initial: PKI badge reads zero events")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/events/pki".into(),
                    expected: json!(0),
                }),
        )
        // ── PRIMARY 1: select PKI by clicking the REAL on-map node ────────────────
        .step(
            Step::new("click the PKI node on the map → it becomes the selection")
                .action(Action::ClickButton(pki_node))
                .assert(Assert::State {
                    pointer: "/selected_system".into(),
                    expected: json!("pki"),
                }),
        )
        .step(
            Step::new("selecting a system fills the detail pane (no longer empty)")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/detail/empty".into(),
                    expected: json!(false),
                }),
        )
        .step(
            Step::new("PKI has issued no child certs yet → the empty-cert line shows")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/detail/pki_certs_empty".into(),
                    expected: json!(true),
                }),
        )
        .step(
            Step::new("the Regenerate control is visible on the PKI detail")
                .action(Action::None)
                .assert(Assert::LabelVisible(REGEN.into())),
        )
        // ── PRIMARY 2: regenerate the PKI CA — SWEEP the increment (0→1→2) ────────
        .step(
            Step::new("regenerate PKI CA → the event count increments 0→1")
                .action(Action::ClickButton(REGEN.into()))
                .assert(Assert::State {
                    pointer: "/events/pki".into(),
                    expected: json!(1),
                }),
        )
        .step(
            Step::new("regenerate again → it increments 1→2 (not a 0↔1 flip)")
                .action(Action::ClickButton(REGEN.into()))
                .assert(Assert::State {
                    pointer: "/events/pki".into(),
                    expected: json!(2),
                }),
        )
        .step(
            Step::new("the map's PKI badge now reads the same 2 events")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/chart_nodes/0/badge".into(),
                    expected: json!(2),
                }),
        )
        .step(
            Step::new("OIDC is unaffected by PKI regenerate (isolation)")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/events/oidc".into(),
                    expected: json!(0),
                }),
        )
        // ── PRIMARY 1 swept: select the other two map nodes ──────────────────────
        .step(
            Step::new("click the OIDC node → the selection moves")
                .action(Action::ClickButton(oidc_node))
                .assert(Assert::State {
                    pointer: "/selected_system".into(),
                    expected: json!("oidc"),
                }),
        )
        .step(
            Step::new("the detail pane follows the map to OIDC's zero-event state")
                .action(Action::None)
                .assert(Assert::State {
                    pointer: "/detail/event_count".into(),
                    expected: json!(0),
                }),
        )
        .step(
            Step::new("click the Nexus node → the selection moves again")
                .action(Action::ClickButton(nexus_node))
                .assert(Assert::State {
                    pointer: "/selected_system".into(),
                    expected: json!("nexus"),
                }),
        )
        // ── PRIMARY 3: switch the look preset — SWEEP two distinct presets ────────
        .step(
            Step::new("switch look → the exact preset name lands")
                .action(Action::ClickLabel(ROBOT_LOOK.into()))
                .assert(Assert::State {
                    pointer: "/look_name".into(),
                    expected: json!(ROBOT_LOOK),
                }),
        )
        .step(
            Step::new("switch to a SECOND preset → its own name lands (sweep)")
                .action(Action::ClickLabel(ROBOT_LOOK_2.into()))
                .assert(Assert::State {
                    pointer: "/look_name".into(),
                    expected: json!(ROBOT_LOOK_2),
                }),
        )
        // ── PRIMARY 4: fold the map pane, then unfold — BOTH directions ──────────
        .step(
            Step::new("fold the map pane → it reports folded")
                .action(Action::ClickLabel(fold_map))
                .assert(Assert::State {
                    pointer: format!("/scene/folded/{MAP_PANE}"),
                    expected: json!(true),
                }),
        )
        .step(
            Step::new("unfold the map pane → it reports unfolded again (sweep)")
                .action(Action::ClickLabel(unfold_map))
                .assert(Assert::State {
                    pointer: format!("/scene/folded/{MAP_PANE}"),
                    expected: json!(false),
                }),
        )
}

/// The mannequin GUI as an [`Observable`]: the robot reads exactly the JSON the GUI
/// publishes for an operator. Local type + foreign trait — allowed.
impl Observable for MannequinGui {
    fn state_json(&self) -> Value {
        // Inherent method wins method resolution, so this is NOT the trait method.
        MannequinGui::state_json(self)
    }
}

/// A fresh mannequin GUI over an in-memory event log + a real PKI service — no daemon,
/// no network. Starts on a CLEAN slate (no seeded events) so the scenario's
/// clean-slate → drive → verify assertions (`events == 0` then increments) hold.
fn fresh_gui() -> anyhow::Result<MannequinGui> {
    let log = EventLog::new();
    let pki = Arc::new(PkiService::new()?);
    Ok(MannequinGui::new(log, pki))
}

/// The headless harness size. The shell is a vertical master/detail split whose map
/// pane must be tall enough to show the WHOLE 320pt system-map canvas — the Nexus
/// node sits at 0.9 of the canvas height, so a short pane would scroll it under the
/// clip and a real click would miss it. 1000×900 leaves the full map visible.
const HEADLESS_SIZE: (f32, f32) = (1000.0, 900.0);

/// **The choosable entry point.** Run the mannequin robot scenario in `mode`:
/// [`RunMode::Headless`] fills the CI matrix (off-screen egui_kittest), [`RunMode::Watch`]
/// opens a REAL eframe window and replays it visibly. `cfg` paces the watch run.
///
/// Headless builds its own sized [`Harness`] (rather than `run_scenario_mode`'s
/// default-size one) so the whole shipped map canvas is on-screen and every on-map
/// node is a reachable click target; Watch delegates to the shared runner, which owns
/// the real eframe window.
pub fn run(mode: RunMode, cfg: WatchConfig) -> anyhow::Result<RunOutcome> {
    match mode {
        RunMode::Headless => {
            let harness = Harness::builder()
                .with_size(egui::Vec2::new(HEADLESS_SIZE.0, HEADLESS_SIZE.1))
                .build_ui_state(
                    |ui: &mut egui::Ui, app: &mut MannequinGui| {
                        let mut targets = Targets::default();
                        mannequin_draw(ui, app, &mut targets);
                    },
                    fresh_gui()?,
                );
            let mut session = RobotSession::new(harness, "holger");
            Ok(RunOutcome::Headless(
                session.run_scenario(&mannequin_scenario()),
            ))
        }
        RunMode::Watch => run_scenario_mode(
            fresh_gui()?,
            mannequin_draw,
            &mannequin_scenario(),
            "holger",
            mode,
            cfg,
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_robotui::status;

    /// The scenario drives the REAL mannequin shell headless and every step passes —
    /// the CI matrix cell. Every value-assert step proves a primary function landed a
    /// VALUE change, so a regressed handler (a node click that does not select, a
    /// Regenerate that does not log, a fold that does not fold) turns the row RED.
    #[test]
    fn mannequin_robot_scenario_passes_headless() {
        let outcome = run(RunMode::Headless, WatchConfig::default()).expect("headless run");
        let rows = outcome.rows().expect("headless emits rows");
        assert_eq!(
            rows.len(),
            mannequin_scenario().steps.len(),
            "one row per step"
        );
        let failed: Vec<_> = rows.iter().filter(|r| r.status == status::FAIL).collect();
        assert!(failed.is_empty(), "all steps pass: {failed:#?}");
    }
}