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