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

nordisk/edda

crates/nornir-robotui/src/lib.rs

//! # nornir-robotui
//!
//! A **reusable robot-UI testing framework** for egui apps. It does three things,
//! each with a confirmed nordisk seam:
//!
//! 1. **Drive** real egui interactions with [`egui_kittest`] (AccessKit-based):
//!    click a label or button, type into a field, press a key, scroll/drag, and
//!    step/run frames. Optionally render a wgpu PNG snapshot (behind the
//!    `snapshot` feature so the core stays light).
//! 2. **Observe / assert** via the nordisk pattern — every component exposes a
//!    readable [`state_json`](Observable::state_json) (the same blob nornir's viz
//!    dumps to `$NORNIR_VIZ_STATE` and facett's `Facet`s expose). The framework
//!    reads that state back to assert what the user *would see* — not pixels.
//! 3. **Emit** results as [`nornir_testmatrix::TestResultRow`]s — each
//!    `(action → expected outcome)` step becomes a matrix cell (pass/fail), so a
//!    robot run literally **fills the test matrix**.
//!
//! ## Self-emit exemption (functional-status roster)
//!
//! This crate is **EXEMPT** from carrying a `selftest::emit` functional-status
//! bridge (the `nornir-git` / `-jobs` / `-build-thing` pattern). It is the
//! test-harness lib itself: emitting `TestResultRow`s into the matrix IS its
//! entire product surface (point 3 above), so it emits by construction, not
//! through a consumer bridge. A separate bridge would be a self-referential
//! no-op. See `.nornir/self-emitters.md` (the per-crate roster) for the
//! allow-list entry recording this exemption.
//!
//! ## The shape
//!
//! ```no_run
//! use nornir_robotui::{RobotSession, Observable, Scenario, Step, Action, Assert};
//! use egui_kittest::Harness;
//! use serde_json::{json, Value};
//!
//! // 1. Your app implements `Observable` (one method — return what you render).
//! struct MyApp { clicks: u32 }
//! impl Observable for MyApp {
//!     fn state_json(&self) -> Value { json!({ "clicks": self.clicks }) }
//! }
//!
//! // 2. Wrap a kittest Harness over your app/ui in a RobotSession.
//! let harness = Harness::new_ui_state(
//!     |ui, app: &mut MyApp| { if ui.button("Bump").clicked() { app.clicks += 1; } },
//!     MyApp { clicks: 0 },
//! );
//! let mut robot = RobotSession::new(harness, "my-app");
//!
//! // 3. Script actions + assertions; run the scenario → matrix rows.
//! let scenario = Scenario::new("smoke").step(
//!     Step::new("bump button increments")
//!         .action(Action::ClickButton("Bump".into()))
//!         .assert(Assert::State { pointer: "/clicks".into(), expected: json!(1) }),
//! );
//! let rows = robot.run_scenario(&scenario);  // Vec<TestResultRow>, suite = "robot-ui"
//! ```
//!
//! A single `run_scenario` call drives every button and **fills the matrix**.
//!
//! ## Two modes over the SAME scenario (headless matrix + visible watch)
//!
//! A [`Scenario`] is plain data, so the SAME steps run two ways — pick the
//! backend with [`RunMode`]:
//!
//! * [`RunMode::Headless`] — the drive above: the off-screen `egui_kittest`
//!   harness emits [`TestResultRow`]s (fills the matrix, for CI). This is the
//!   default and the lean build.
//! * [`RunMode::Watch`] (feature `watch`) — a **real, visible eframe window** that
//!   replays the same scenario step-by-step with a pacing delay, moving a cursor
//!   and injecting synthetic input so you WATCH the robot click / type / scroll /
//!   drag through it in real time.
//!
//! One consumer seam gives both: write ONE `draw(ui, app, targets)` closure + ONE
//! [`Scenario`], then call [`run_scenario_mode`] with the mode (an API param,
//! `ROBOTUI_WATCH=1` via [`RunMode::from_env`], or the `robotui` CLI subcommand):
//!
//! ```no_run
//! use nornir_robotui::{run_scenario_mode, RunMode, WatchConfig, Targets};
//! # use nornir_robotui::{Observable, Scenario};
//! # use serde_json::{json, Value};
//! # struct MyApp { clicks: u32 }
//! # impl Observable for MyApp { fn state_json(&self) -> Value { json!({"clicks": self.clicks}) } }
//! # let scenario = Scenario::new("smoke");
//! // ONE closure. BOTH modes resolve labels via the AccessKit tree, so `Targets`
//! // is OPTIONAL — record a rect only for a bespoke non-AccessKit surface (it is
//! // the watch fallback); a plain labelled widget needs no `t.record` at all.
//! let draw = |ui: &mut egui::Ui, app: &mut MyApp, _t: &mut Targets| {
//!     if ui.button("Bump").clicked() { app.clicks += 1; }
//! };
//! let mode = RunMode::from_env();                 // ROBOTUI_WATCH=1 → Watch
//! run_scenario_mode(MyApp { clicks: 0 }, draw, &scenario, "my-repo", mode, WatchConfig::default())?;
//! # Ok::<(), anyhow::Error>(())
//! ```
//!
//! CLI (feature `watch`): `robotui run <scenario.toml>` (headless matrix) and
//! `robotui watch <scenario.toml>` (the paced, visible window). The existing
//! [`RobotSession::run_scenario`] API is unchanged — Watch is purely additive.

use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::Value;

/// The substrings that mark a rendered widget as an **error atom** — a node whose
/// text says the UI surfaced a failure (a gRPC NotFound, a load failure, an
/// "unavailable" stub). The atom-walk ([`error_atoms`]) scans the live AccessKit
/// tree for any node whose label/value contains one of these. EXTEND this list as
/// new error shapes appear; one list, one walk, every surface.
pub const ERROR_MARKERS: &[&str] = &[
    "is not served",
    "Failed to load",
    "unavailable",
    "RPC: status:",
    // SPECIFIC pane error renders (bug #22). NOT a blanket "failed" — that would
    // over-match a "✗ failed" verdict glyph or a `failed: <n>` count column. These
    // are the exact red labels the viz panes emit on a load/scan/read failure.
    "scan failed",
    "open warehouse failed",
    "read failed",
    "release_events read failed",
    "failed to read registry",
];

/// The text an AccessKit node carries: its **label** (where egui puts plain
/// `ui.label(..)` text) OR, for a `Role::Label` widget, its **value** (where
/// AccessKit stores a label-role node's text). Either may hold an error atom, so
/// the walk reads both and joins them.
fn node_text(node: &egui_kittest::kittest::AccessKitNode<'_>) -> String {
    let mut t = node.label().unwrap_or_default();
    if let Some(v) = node.value() {
        if !v.is_empty() {
            if !t.is_empty() {
                t.push(' ');
            }
            t.push_str(&v);
        }
    }
    t
}

/// Walk the **live AccessKit tree** of a kittest [`Harness`] and return the text of
/// every node that looks like an error atom — any node whose label/value contains
/// an [`ERROR_MARKERS`] substring. An **empty** result means the rendered surface
/// is clean (no error leaked into any atom); a non-empty result names exactly which
/// atoms went red, so a test can assert `error_atoms(&h).is_empty()` and, on
/// failure, print the offending text.
///
/// This is the **L0 atom oracle** of the marker-matrix design (`§2b`): the only
/// layer that sees *sub-panel* atoms (e.g. the nornir tab's CloneEvents/Jobs panels)
/// which no per-button/per-tab enumeration covers. The "walk" is a single
/// `query_all_by` filter over the tree — kittest already recurses for us.
pub fn error_atoms<A>(harness: &Harness<'_, A>) -> Vec<String> {
    // egui maps one `ui.label(text)` to *several* AccessKit nodes carrying the
    // same text (a wrapping node + the galley), so the same error string can match
    // more than once. The caller wants the DISTINCT error messages, so dedup while
    // preserving first-seen order.
    let mut seen = std::collections::BTreeSet::new();
    harness
        .query_all_by(|n: &egui_kittest::kittest::AccessKitNode<'_>| {
            let t = node_text(n);
            ERROR_MARKERS.iter().any(|m| t.contains(m))
        })
        .map(|node| node_text(&node.accesskit_node()))
        .filter(|t| seen.insert(t.clone()))
        .collect()
}

/// One **discovered atom** the [`all_atoms`] walk saw in the live AccessKit tree:
/// an interactive or labelled node with its visible text and its AccessKit role.
/// This is the per-tab *discovered surface* the atom-coverage producer turns into
/// `surface_coverage` rows and the drift cross-check compares against the
/// `button_registry` — every station the metro map would draw must correspond to
/// an atom the viz actually renders.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Atom {
    /// The node's visible text (label, or label+value for a label-role node) —
    /// the same text [`error_atoms`] reads, so error atoms and discovered atoms
    /// share one text source.
    pub text: String,
    /// The AccessKit role tag (e.g. `Button`, `Label`, `CheckBox`, `TextInput`),
    /// rendered via `Debug` so it's a stable string for rows + assertions.
    pub role: String,
}

/// The AccessKit roles [`all_atoms`] treats as an **atom** — an interactive or
/// labelled node a user can see or drive. This is the broader predicate the task
/// asks for: the `error_atoms` walk filters on *text markers*; this one filters on
/// *role* so it enumerates the whole discovered surface of a tab (buttons the
/// metro map stations, labels the user reads), not just the red nodes. EXTEND as
/// new interactive roles matter.
fn is_atom_role(role: egui::accesskit::Role) -> bool {
    use egui::accesskit::Role::*;
    matches!(
        role,
        Button
            | Label
            | Link
            | CheckBox
            | RadioButton
            | Switch
            | Tab
            | MenuItem
            | TextInput
            | MultilineTextInput
            | ComboBox
            | ListBoxOption
            | Slider
            | SpinButton
    )
}

/// Walk the **live AccessKit tree** of a kittest [`Harness`] and return every
/// **discovered atom** — each interactive/labelled node (role in [`is_atom_role`])
/// that carries some visible text — deduped by `(text, role)`, first-seen order.
///
/// Where [`error_atoms`] answers *"did any node go red?"*, this answers *"what
/// interactive/labelled surface did this tab actually render?"* — the per-tab atom
/// set the atom-coverage producer ([`nornir`]'s `atom_coverage_rows`) maps to
/// `surface_coverage` rows and the `button_registry` drift cross-check compares
/// against. It reuses the same `query_all_by` + `node_text` path as `error_atoms`,
/// only with a role predicate instead of a marker predicate (one walk shape, two
/// filters). Nodes with no text (decorative/structural) are dropped — an atom must
/// be something the user can read or address.
pub fn all_atoms<A>(harness: &Harness<'_, A>) -> Vec<Atom> {
    let mut seen = std::collections::BTreeSet::new();
    harness
        .query_all_by(|n: &egui_kittest::kittest::AccessKitNode<'_>| is_atom_role(n.role()))
        .filter_map(|node| {
            let ak = node.accesskit_node();
            let text = node_text(&ak);
            if text.is_empty() {
                return None;
            }
            let role = format!("{:?}", ak.role());
            if seen.insert((text.clone(), role.clone())) {
                Some(Atom { text, role })
            } else {
                None
            }
        })
        .collect()
}

/// Just the **labels** of the discovered atoms (`all_atoms(..).text`), deduped —
/// the convenience the drift cross-check uses to test set membership of a button
/// id against what a tab rendered.
pub fn atom_labels<A>(harness: &Harness<'_, A>) -> Vec<String> {
    let mut seen = std::collections::BTreeSet::new();
    all_atoms(harness)
        .into_iter()
        .filter_map(|a| seen.insert(a.text.clone()).then_some(a.text))
        .collect()
}

pub use nornir_testmatrix::{self, JsonFileSink, NullSink, TestResultRow, TestSink, status};

// Re-export the driving primitives so consumers don't need to also depend on
// egui_kittest / egui directly for the common case.
pub use egui;
pub use egui_kittest;

mod scenario;
pub use scenario::{Action, Assert, FieldValue, Scenario, Step, Target};

// The `cmd_json` WIRE channel: a transport-agnostic command/assertion protocol so
// a driver can drive a REMOTE egui process (separate process / container) the SAME
// way the in-process `RobotSession` drives a co-located one. It REUSES the
// `Action`/`Assert`/`Target`/`Step` vocabulary above (one command home) — the wire
// requests just carry those enums over a byte-stream transport. Behind the `wire`
// feature so the lean/CI matrix build pulls no serde-on-egui unless a consumer
// actually needs the remote channel.
#[cfg(feature = "wire")]
pub mod cmd_json;

// Mode selection (feature-free): choose Headless (matrix) or Watch (visible
// window) for the SAME scenario, plus the `Targets` rect side-channel and
// `WatchConfig`. Only the window-opening backend below is behind `watch`.
mod mode;
pub use mode::{RunMode, Targets, WatchConfig, WatchRenderer};

// WHICH display backend a drive ran on (Wayland / Xvfb / X11) + a screenshot that
// picks its tool accordingly (`spectacle` on Wayland, `import` on X11) and says
// which it used. Feature-free: a headless CI drive should be able to state its
// environment too, and a measurement that cannot name its backend is not evidence.
pub mod display;
pub use display::{DisplayBackend, Shot};

// The trivial demo component + scenario that proves the duality (used by the
// `robotui` bin and the headless duality test). Feature-free.
pub mod demo;

// The live WATCH backend: replay a scenario in a real eframe window, visibly
// paced. Behind `watch` so the lean/CI build pulls no windowing stack.
#[cfg(feature = "watch")]
pub mod watch;

// The WebP-in-warehouse snapshot harness (render-oracle reference store).
// Behind `snapshot` (it needs `image`); the warehouse coupling is held behind
// the `SnapshotStore` trait so this crate stays warehouse-free.
#[cfg(feature = "snapshot")]
pub mod snapshot;

// The egui REPLAY DRIVER: a `nornir-saga` `ReplayPlan` (a recorded user journey)
// → a robot `Scenario` this session drives against the real egui app. Re-export
// the saga replay types so a consumer needs only this crate to replay sessions.
pub mod replay;
pub use nornir_saga;
pub use nornir_saga::replay::{ReplayPlan, ReplayStep, StepKind};

// The `xsession` module is GONE (2026-08-07). It was the rank-5 real-window leg on a
// private Xvfb display, and robot UI is Wayland-only now, so it had no world to run in.
// Rank 5 is still reachable: the `watch` backend opens a real window and captures that
// window's own presented framebuffer, which is a real surface on a real adapter and needs
// no compositor permission — see [`display`] for why that beats an external capture tool.

/// The **observe** seam: any egui app/component the robot drives implements this
/// so the framework can read back *what it renders* and assert on it — the
/// nordisk "components expose readable data" LAW. This is the same `state_json()`
/// nornir's viz and facett's `Facet`s already expose; adopting the robot is just
/// `impl Observable for YourApp { fn state_json(&self) -> Value { … } }`.
pub trait Observable {
    /// A JSON blob describing the current rendered state — tab, selections,
    /// counts, the rows a table shows, etc. The robot asserts against this with
    /// JSON pointers (e.g. `/test/runs/0/run_id`).
    fn state_json(&self) -> Value;
}

/// A scripted egui session: a [`Harness`] over an app that implements
/// [`Observable`], plus high-level actions and assertions. `A` is your app type.
///
/// Construct with [`RobotSession::new`] (you build the `Harness`, picking
/// `Harness::new_ui_state` for a single-widget UI or `Harness::builder().…
/// build_state(draw_fn, app)` for a full app), then drive it with the action
/// methods or run a whole [`Scenario`].
pub struct RobotSession<'a, A: Observable> {
    harness: Harness<'a, A>,
    /// The repo/app name stamped onto every emitted matrix row's `repo`.
    repo: String,
}

impl<'a, A: Observable> RobotSession<'a, A> {
    /// Wrap a built [`Harness`] over an [`Observable`] app. `repo` is stamped on
    /// every matrix row this session emits (e.g. `"korp"`, `"nornir"`).
    pub fn new(harness: Harness<'a, A>, repo: impl Into<String>) -> Self {
        Self {
            harness,
            repo: repo.into(),
        }
    }

    /// Borrow the underlying app (read-only) — e.g. to call its own helpers.
    pub fn app(&self) -> &A {
        self.harness.state()
    }

    /// Borrow the underlying app mutably — e.g. to inject fixture data before a
    /// scenario so the panes have real rows to drive against.
    pub fn app_mut(&mut self) -> &mut A {
        self.harness.state_mut()
    }

    /// Borrow the underlying kittest [`Harness`] (for escape-hatch driving).
    pub fn harness_mut(&mut self) -> &mut Harness<'a, A> {
        &mut self.harness
    }

    /// The app's current [`Observable::state_json`] — what the robot asserts on.
    pub fn state(&self) -> Value {
        self.harness.state().state_json()
    }

    // ─── actions ────────────────────────────────────────────────────────────

    /// Run one frame so a queued action takes effect and the layout settles.
    /// Uses `run_ok` (not `run`) so an app that requests a repaint every frame
    /// — like nornir's viz auto-refresh — doesn't trip kittest's max-steps guard.
    pub fn step(&mut self) {
        let _ = self.harness.run_ok();
    }

    /// Run several frames (handy after an action that triggers async-ish work the
    /// next frame, e.g. a reload).
    pub fn run(&mut self, frames: usize) {
        for _ in 0..frames {
            self.step();
        }
    }

    /// Click the widget whose AccessKit **label** equals `label` (a button,
    /// selectable label, checkbox, tab header — anything egui labels). Then step
    /// a frame so the click lands.
    ///
    /// # An action that cannot fail is not an action (`korpui-31`)
    /// This used to be `query_by_label(label).map(Node::click)` — resolve, poke,
    /// return `Ok`. `Node::click` queues a synthetic `PointerButton` at the node's
    /// rect centre and returns `()`, so **every** click reported success, including
    /// the ones egui delivered to a different widget or to nothing. That is the
    /// hollow-ACTION twin of `Assert::Drew`: a step that is green by construction.
    ///
    /// So the click now RESOLVES through [`label_visibility`](Self::label_visibility)
    /// first: a widget that is not on the rendered surface is an `Err` naming its rect
    /// and the surface it missed, not a green step over a counter that never moved.
    ///
    /// # What this still does NOT prove, measured
    /// It does not prove the press reached *that* widget when the widget IS on the
    /// surface but something is painted over it. The obvious check — read the widget's
    /// own `Response` back after the frame and require `clicked()` — does not work, and
    /// this is measured rather than assumed: `Context::read_response` rebuilds the
    /// response from the interaction state of the pass that runs AFTER the click, which
    /// never carries a press, so it answers `clicked() == false` for clicks that
    /// demonstrably landed. Wired up, it turned **3 of 34** of this crate's own tests
    /// red, including `click_button_mutates_observable_state`, whose counter proves the
    /// click worked. A real landing check needs an egui API that reports the pass's own
    /// interaction; until then the honest bound is: the ACTION proves reachability, the
    /// step's `Assert` proves effect.
    pub fn click_label(&mut self, label: &str) -> anyhow::Result<()> {
        self.click_resolved(label, "widget labelled")
    }

    /// Click a button by its text. In egui a button's AccessKit label *is* its
    /// text, so this is [`click_label`](Self::click_label) with a button-flavoured
    /// error message.
    pub fn click_button(&mut self, text: &str) -> anyhow::Result<()> {
        self.click_resolved(text, "button")
    }

    /// The one click body [`click_label`] and [`click_button`] share (LAW 5 — the
    /// visibility gate and the landing check exist once, so the two spellings cannot
    /// drift into two different notions of "the click worked").
    ///
    /// [`click_label`]: Self::click_label
    /// [`click_button`]: Self::click_button
    fn click_resolved(&mut self, label: &str, what: &str) -> anyhow::Result<()> {
        // RESOLVE + SEE. `label_visibility` is the same oracle `Assert::LabelVisible`
        // uses, so "visible" and "clickable" cannot disagree — which is the whole
        // korpui-31 shape: the drive was told the user could see and press a control
        // that was nowhere a pointer could reach.
        self.label_visibility(label)
            .map_err(|e| anyhow::anyhow!("no {what} {label:?} to click: {e}"))?;
        let node = self
            .resolve_by_label(label)
            .expect("resolved one line above");
        node.click();
        self.step();
        Ok(())
    }

    /// Focus the field labelled `label` and type `text` into it, then step.
    pub fn type_into(&mut self, label: &str, text: &str) -> anyhow::Result<()> {
        // Focus first and let a frame land so egui marks the field active, then
        // type and step again so the edit is applied — egui needs the focus to be
        // in effect on the frame the text events arrive.
        match self.harness.query_by_label(label) {
            Some(node) => node.focus(),
            None => return Err(anyhow::anyhow!("no field {label:?} to type into")),
        }
        self.step();
        match self.harness.query_by_label(label) {
            Some(node) => node.type_text(text),
            None => return Err(anyhow::anyhow!("field {label:?} vanished after focus")),
        }
        self.step();
        Ok(())
    }

    /// Press a key (e.g. [`egui::Key::Enter`], `Tab`, `ArrowDown`), then step.
    pub fn key(&mut self, key: egui::Key) {
        self.harness.key_press(key);
        self.step();
    }

    /// Scroll by `delta` (positive y scrolls up, negative down — egui's
    /// convention), then step. Emits a raw scroll event the focused/hovered
    /// scroll area consumes.
    pub fn scroll(&mut self, delta: egui::Vec2) {
        self.harness.event(egui::Event::MouseWheel {
            unit: egui::MouseWheelUnit::Point,
            delta,
            // egui 0.34 added a `phase` field; "if unknown, use Move".
            phase: egui::TouchPhase::Move,
            modifiers: egui::Modifiers::default(),
        });
        self.step();
    }

    /// Drag from `from` to `to` in screen points (press, move, release), then
    /// step. Drives draggable widgets / pan gestures.
    pub fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) {
        self.harness.drag_at(from);
        self.step();
        self.harness.drop_at(to);
        self.step();
    }

    // ─── R6: richer live-drive (widget-level) ────────────────────────────────

    /// **R6** — click a widget addressed by its AccessKit **node id** (`u64`,
    /// i.e. `node.accesskit_node().id().0`) rather than its label. Use this when
    /// labels collide (two "Submit" buttons) or are empty (an icon-only button,
    /// a `TextEdit` with only `hint_text`). Returns `Err` if no node carries that
    /// id this frame. The id is the one [`Self::node_id`] /
    /// [`Self::find_id`] hand back, so a driver can discover ids then click them.
    pub fn click_by_id(&mut self, id: u64) -> anyhow::Result<()> {
        match self.query_by_id(id) {
            Some(node) => {
                node.click();
                self.step();
                Ok(())
            }
            None => Err(anyhow::anyhow!("no widget with AccessKit id {id} to click")),
        }
    }

    /// **R6** — set an arbitrary form field (addressed by label OR node id) to a
    /// value, choosing the gesture from the widget's AccessKit **role**:
    ///
    /// * a [`FieldValue::Text`] clears the field (focus → select-all → backspace)
    ///   and types the new text — a single/multi-line `TextEdit`;
    /// * a [`FieldValue::Bool`] reads the current toggle and **clicks only if it
    ///   differs**, so it sets (never just flips) a checkbox/switch;
    /// * a [`FieldValue::Number`] sends the AccessKit `SetValue` action egui
    ///   honours for numeric widgets (slider / `DragValue`).
    ///
    /// Returns `Err` if the target is absent (or, for `Number`, the widget does
    /// not accept `SetValue`). Steps a frame so the edit lands. The caller should
    /// then assert the change took — via `state_json` ([`Assert::State`]) or the
    /// widget value ([`Assert::FieldValue`] / [`Assert::FieldChecked`]).
    pub fn set_field(&mut self, target: &Target, value: &FieldValue) -> anyhow::Result<()> {
        match value {
            FieldValue::Text(text) => self.set_text_field(target, text),
            FieldValue::Bool(want) => self.set_bool_field(target, *want),
            FieldValue::Number(n) => self.set_number_field(target, *n),
        }
    }

    fn set_text_field(&mut self, target: &Target, text: &str) -> anyhow::Result<()> {
        // Resolve to a stable id first; the node handle is re-queried after each
        // step (the AccessKit tree is rebuilt every frame).
        let id = self
            .find_id(target)
            .ok_or_else(|| anyhow::anyhow!("no field {target:?} to set"))?;
        // Focus + let the frame land so egui marks the field active.
        self.node_by_id(id)
            .ok_or_else(|| anyhow::anyhow!("field {target:?} vanished"))?
            .focus();
        self.step();
        // Clear: select-all then Backspace removes whatever was there, so this is
        // a *set* (not an append) regardless of prior contents.
        self.harness
            .key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
        self.harness.key_press(egui::Key::Backspace);
        self.step();
        // Type the new value into the (still-focused) field.
        match self.node_by_id(id) {
            Some(node) => node.type_text(text),
            None => return Err(anyhow::anyhow!("field {target:?} vanished after clear")),
        }
        self.step();
        Ok(())
    }

    fn set_bool_field(&mut self, target: &Target, want: bool) -> anyhow::Result<()> {
        let id = self
            .find_id(target)
            .ok_or_else(|| anyhow::anyhow!("no checkbox {target:?} to set"))?;
        let current = self
            .node_by_id(id)
            .and_then(|n| n.accesskit_node().toggled())
            .map(|t| matches!(t, egui::accesskit::Toggled::True));
        // Only click if the state differs (or is unknown) — so this *sets*, never
        // blindly flips.
        if current != Some(want) {
            self.node_by_id(id)
                .ok_or_else(|| anyhow::anyhow!("checkbox {target:?} vanished"))?
                .click();
            self.step();
        }
        Ok(())
    }

    fn set_number_field(&mut self, target: &Target, n: f64) -> anyhow::Result<()> {
        let id = self
            .find_id(target)
            .ok_or_else(|| anyhow::anyhow!("no numeric widget {target:?} to set"))?;
        // egui honours the AccessKit SetValue action (NumericValue) for sliders /
        // DragValues — the same path a screen-reader uses to set a value.
        let target_node = egui::accesskit::NodeId(id);
        self.harness.event(egui::Event::AccessKitActionRequest(
            egui::accesskit::ActionRequest {
                action: egui::accesskit::Action::SetValue,
                target_node,
                target_tree: egui::accesskit::TreeId::ROOT,
                data: Some(egui::accesskit::ActionData::NumericValue(n)),
            },
        ));
        self.step();
        Ok(())
    }

    /// **R6** — feed an arbitrary synthetic [`egui::Event`] sequence into the
    /// harness for one frame, then step. The lowest-level escape hatch: pointer
    /// moves/clicks at an absolute position, key combos with modifiers, raw
    /// `Text` events — anything the higher-level verbs can't address. Events are
    /// delivered in order on the *same* frame, then a frame is run.
    pub fn inject_events(&mut self, events: impl IntoIterator<Item = egui::Event>) {
        for ev in events {
            self.harness.event(ev);
        }
        self.step();
    }

    // ─── R6: AccessKit-tree readers (discover + assert widgets) ───────────────

    /// Resolve a [`Target`] to a concrete AccessKit node id this frame, if the
    /// widget is present. Lets a driver discover the id of a label-addressed
    /// widget once, then drive it by id across frames.
    pub fn find_id(&self, target: &Target) -> Option<u64> {
        match target {
            Target::Label(label) => self
                .harness
                .query_by_label(label)
                .map(|n| egui_node_id(&n.accesskit_node())),
            Target::Id(id) => self
                .query_by_id(*id)
                .map(|n| egui_node_id(&n.accesskit_node())),
        }
    }

    /// The AccessKit node id of the widget labelled `label`, if present — the
    /// discover-then-drive-by-id helper.
    pub fn node_id(&self, label: &str) -> Option<u64> {
        self.harness
            .query_by_label(label)
            .map(|n| egui_node_id(&n.accesskit_node()))
    }

    /// The current AccessKit `value` string of the widget the `target` addresses
    /// (a `TextEdit`'s text, a slider's formatted value), if any. Reads it off
    /// the rendered widget — proof a [`set_field`](Self::set_field) text edit
    /// landed in the widget, independent of `state_json`.
    pub fn field_value(&self, target: &Target) -> Option<String> {
        // Resolve to an id (a `Copy` u64) first so no `&str` borrow of `target`
        // is tied into the AccessKit tree's lifetime.
        let id = self.find_id(target)?;
        self.query_by_id(id).and_then(|n| n.value())
    }

    /// Whether the checkbox/switch the `target` addresses is currently toggled
    /// on. `None` if the widget isn't a toggle (or is absent).
    pub fn field_checked(&self, target: &Target) -> Option<bool> {
        let id = self.find_id(target)?;
        self.query_by_id(id)
            .and_then(|n| n.accesskit_node().toggled())
            .map(|t| matches!(t, egui::accesskit::Toggled::True))
    }

    // Internal: query a node by raw AccessKit id (anywhere in the tree).
    fn query_by_id(&self, id: u64) -> Option<egui_kittest::Node<'_>> {
        self.harness.query_by(move |n| egui_node_id(n) == id)
    }

    // Internal: query a node by raw AccessKit id, mutating-frame variant kept
    // separate so the borrow checker is happy across the focus→type sequence.
    fn node_by_id(&self, id: u64) -> Option<egui_kittest::Node<'_>> {
        self.query_by_id(id)
    }

    // ─── assertions ─────────────────────────────────────────────────────────

    /// **Where is the widget labelled `label`, and can the user actually see it?**
    /// `Ok(rect)` = it is laid out AND its bounds meet the rendered surface;
    /// `Err(reason)` names which of the three ways it failed.
    ///
    /// # `korpui-31`: presence in the tree is not visibility
    /// This used to be `query_by_label(label).is_some()` and its doc said "the user
    /// would see it". Those are different claims, and the gap is not theoretical:
    /// `egui_kittest::Node::click` injects an `Event::PointerButton` at
    /// `rect().center()`, so a node whose rect is off the rendered surface is
    /// "clicked" at a coordinate egui hands to whatever is painted there — or to
    /// nothing at all. MEASURED (`clipped_probe`, a 200×100 surface with the button
    /// pushed 400 px down): the node IS in the tree, its bounding box is `y 411..429`
    /// against a `0..100` surface, the old `label_visible` answered **true**, the old
    /// `click_label` returned **Ok**, and the counter stayed at **0**. Same shape
    /// MEASURED on korp's real shell: a `LabelVisible "🎬 Demo"` PASS followed by a
    /// `ClickLabel "🎬 Demo"` whose action "succeeded" while `/tab` never left `tables`.
    ///
    /// So the question is asked of the RECT, not of the tree.
    pub fn label_visibility(&self, label: &str) -> Result<egui::Rect, String> {
        let node = self.resolve_by_label(label)?;
        let bb = node.accesskit_node().bounding_box().ok_or_else(|| {
            format!("widget {label:?} is in the tree but has NO bounds — it was never laid out")
        })?;
        let rect = egui::Rect::from_min_max(
            egui::pos2(bb.x0 as f32, bb.y0 as f32),
            egui::pos2(bb.x1 as f32, bb.y1 as f32),
        );
        let surface = self.harness.ctx.content_rect();
        let seen = rect.intersect(surface);
        if seen.width() <= 0.0 || seen.height() <= 0.0 {
            return Err(format!(
                "widget {label:?} is laid out at {rect:?}, which does not meet the rendered \
                 surface {surface:?} — the user cannot see it, and a click at its centre \
                 lands off-surface"
            ));
        }
        Ok(rect)
    }

    /// **The ONE label→node resolver** (LAW 5) every label-addressed call goes
    /// through, and the answer to a label that names MORE THAN ONE node.
    ///
    /// # Why this exists — MEASURED on nornir's viz, 2026-08-22
    ///
    /// `kittest`'s `query_by_label` is `get`-flavoured: two matches is a PANIC
    /// ("Found two or more nodes matching the query"), not a `None`. That is the
    /// right default for a query, and the wrong one for a DRIVE, because a real
    /// pane legitimately paints a name twice — once as the control and once as
    /// the heading of the thing the control selects:
    ///
    /// * `🎛 Settings` — the header TAB (`Button`) and the pane's own heading
    ///   (`Label` + `TextRun`);
    /// * every 🔨 Build variant row — `nornir-solo` as the selectable `Button`
    ///   AND as the card's title `Label`.
    ///
    /// Three of nornir's robot suites (`robot_settings_tab`, `robot_viz`,
    /// `viz_build_robot`) died in that panic the first time they were compiled
    /// after the egui realign — the drive never reached its assertion, so the
    /// suite could not report on the app at all. Neither UI is wrong: a heading
    /// SHOULD say what the button says.
    ///
    /// So: among nodes carrying the label, prefer the one a user could actually
    /// press. Ambiguity between a control and its caption is resolved toward the
    /// control; ambiguity between two CONTROLS is still ambiguous and still an
    /// error, because that one is a real defect in the app's labelling and
    /// silently picking one would hide it.
    ///
    /// This can only widen: before it, every one of these cases panicked, so no
    /// passing drive anywhere depended on the old behaviour.
    fn resolve_by_label<'q>(&'q self, label: &'q str) -> Result<egui_kittest::Node<'q>, String> {
        let all: Vec<_> = self.harness.query_all_by_label(label).collect();
        match all.len() {
            0 => Err(format!(
                "no widget labelled {label:?} in the AccessKit tree"
            )),
            1 => Ok(all.into_iter().next().expect("len == 1")),
            _ => {
                let mut pressable = all
                    .into_iter()
                    .filter(|n| is_pressable(n.accesskit_node().role()));
                match (pressable.next(), pressable.next()) {
                    (Some(one), None) => Ok(one),
                    (Some(_), Some(_)) => Err(format!(
                        "{label:?} names MORE THAN ONE pressable widget — that is an \
                         ambiguous label in the app, not something a drive may guess at; \
                         give the controls distinct labels (or address one by id)"
                    )),
                    (None, _) => Err(format!(
                        "{label:?} is in the AccessKit tree but NOTHING carrying it is \
                         pressable — it is a caption, not a control"
                    )),
                }
            }
        }
    }

    /// True iff a widget labelled `label` is laid out ON the rendered surface —
    /// i.e. the user would see it. Non-panicking; pair with [`Assert`] for the
    /// scenario path. The reason for a `false` is in
    /// [`label_visibility`](Self::label_visibility).
    pub fn label_visible(&self, label: &str) -> bool {
        self.label_visibility(label).is_ok()
    }

    /// Read a value out of the app's [`Observable::state_json`] by JSON pointer
    /// (RFC 6901, e.g. `/test/runs/0/run_id`). `None` if the path is absent.
    pub fn state_at(&self, pointer: &str) -> Option<Value> {
        self.state().pointer(pointer).cloned()
    }

    // ─── snapshot (PNG proof) ────────────────────────────────────────────────

    /// Render a wgpu PNG snapshot named `name` (golden-compared by egui_kittest,
    /// written under the consumer's snapshot dir). Only available with the
    /// `snapshot` feature so the core stays GPU-free.
    #[cfg(feature = "snapshot")]
    pub fn snapshot(&mut self, name: &str) {
        self.harness.snapshot(name);
    }

    /// Render the current frame to an [`image::RgbaImage`] (raw pixels) — for
    /// callers that want to save proof themselves. `snapshot` feature only.
    #[cfg(feature = "snapshot")]
    pub fn render(&mut self) -> Result<image::RgbaImage, String> {
        self.harness.render()
    }

    // ─── the scenario runner: drive every step → matrix rows ────────────────

    /// Run a whole [`Scenario`]: for each [`Step`], perform its [`Action`],
    /// evaluate its [`Assert`] against the post-action [`state`](Self::state),
    /// and record a [`TestResultRow`] (`suite = "robot-ui"`,
    /// `test_name = step.label`, `aspect = "robot-ui"`). All rows share one
    /// `run_id` so they roll up as a single matrix run.
    ///
    /// This is the "drive every button → the matrix fills" call: one invocation
    /// returns one row per step, ready to ship to any
    /// [`TestSink`](nornir_testmatrix::TestSink) (a `JsonFileSink`, or nornir's
    /// Iceberg warehouse).
    pub fn run_scenario(&mut self, scenario: &Scenario) -> Vec<TestResultRow> {
        let run_id = nornir_testmatrix::new_run_id();
        let ts = now_micros();
        let mut rows = Vec::with_capacity(scenario.steps.len());
        for step in &scenario.steps {
            let started = std::time::Instant::now();
            let (status, message) = self.run_step(step);
            let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
            rows.push(TestResultRow {
                run_id: run_id.clone(),
                repo: self.repo.clone(),
                suite: scenario.suite.clone(),
                test_name: step.label.clone(),
                status: status.to_string(),
                duration_ms,
                ts_micros: ts,
                message,
                aspect: "robot-ui".to_string(),
                metric: 0.0,
            });
        }
        rows
    }

    /// Perform one step's action then check its assertion. Returns
    /// `(status, message)` — a failed action *or* a failed assertion is `fail`
    /// with the reason; otherwise `pass`. `pub(crate)` so the `cmd_json` wire
    /// host ([`crate::cmd_json`]) drives a whole step over the channel through the
    /// SAME act→assert path the in-process runner uses (one vocabulary, one home).
    pub(crate) fn run_step(&mut self, step: &Step) -> (&'static str, String) {
        // 1. ACT — a failed action (e.g. missing button) fails the step.
        if let Err(e) = self.perform(&step.action) {
            return (status::FAIL, format!("action failed: {e}"));
        }
        // 2. ASSERT — against the post-action observable state.
        match self.check(&step.assert) {
            Ok(()) => (status::PASS, String::new()),
            Err(e) => (status::FAIL, e),
        }
    }

    /// Dispatch an [`Action`] to the matching driver method. `pub(crate)` so the
    /// `cmd_json` wire host applies a wire-decoded [`Action`] through the exact
    /// same in-process gesture the local runner uses.
    pub(crate) fn perform(&mut self, action: &Action) -> anyhow::Result<()> {
        match action {
            Action::ClickLabel(s) => self.click_label(s),
            Action::ClickButton(s) => self.click_button(s),
            Action::ClickById(id) => self.click_by_id(*id),
            Action::TypeInto { label, text } => self.type_into(label, text),
            Action::SetField { target, value } => self.set_field(target, value),
            Action::Key(k) => {
                self.key(*k);
                Ok(())
            }
            Action::Scroll(delta) => {
                self.scroll(*delta);
                Ok(())
            }
            Action::Drag { from, to } => {
                self.drag(*from, *to);
                Ok(())
            }
            Action::InjectEvents(events) => {
                self.inject_events(events.iter().cloned());
                Ok(())
            }
            Action::Step => {
                self.step();
                Ok(())
            }
            Action::None => Ok(()),
        }
    }

    /// Evaluate an [`Assert`] against the current observable state. `Ok` = pass.
    /// `pub(crate)` so the `cmd_json` wire host evaluates a wire-decoded
    /// [`Assert`] through the SAME oracle — a wire assertion FAILURE surfaces as a
    /// failed result exactly as the in-process one does.
    pub(crate) fn check(&self, assert: &Assert) -> Result<(), String> {
        match assert {
            Assert::None => Ok(()),
            // A chained `.assert(a).assert(b)` — EVERY clause must hold. The
            // message names which clause of how many failed, so a revived clause
            // is traceable to its line in the chain instead of just "the step".
            Assert::All(clauses) => {
                let n = clauses.len();
                for (i, c) in clauses.iter().enumerate() {
                    self.check(c)
                        .map_err(|e| format!("clause {} of {n}: {e}", i + 1))?;
                }
                Ok(())
            }
            Assert::LabelVisible(label) => {
                if self.label_visible(label) {
                    Ok(())
                } else {
                    Err(format!(
                        "expected a widget labelled {label:?} to be visible"
                    ))
                }
            }
            Assert::Drew => {
                // "Did it draw?" — the AccessKit tree has more than just the root
                // when a UI rendered something. A bare/blank frame has only root.
                let root = self.harness.root();
                if root.children().next().is_some() {
                    Ok(())
                } else {
                    Err("expected the UI to draw at least one widget, tree was empty".into())
                }
            }
            Assert::State { pointer, expected } => match self.state_at(pointer) {
                Some(actual) if &actual == expected => Ok(()),
                Some(actual) => Err(format!(
                    "state at {pointer:?}: expected {expected}, got {actual}"
                )),
                None => Err(format!("state at {pointer:?} is absent")),
            },
            Assert::StateContains { pointer, needle } => match self.state_at(pointer) {
                Some(actual) => {
                    let hay = match &actual {
                        Value::String(s) => s.clone(),
                        other => other.to_string(),
                    };
                    if hay.contains(needle) {
                        Ok(())
                    } else {
                        Err(format!(
                            "state at {pointer:?} ({hay}) does not contain {needle:?}"
                        ))
                    }
                }
                None => Err(format!("state at {pointer:?} is absent")),
            },
            Assert::FieldValue { target, value } => match self.field_value(target) {
                Some(actual) if &actual == value => Ok(()),
                Some(actual) => Err(format!(
                    "field {target:?} value: expected {value:?}, got {actual:?}"
                )),
                None => Err(format!(
                    "field {target:?} has no value (absent or not a value widget)"
                )),
            },
            Assert::FieldChecked { target, checked } => match self.field_checked(target) {
                Some(actual) if actual == *checked => Ok(()),
                Some(actual) => Err(format!(
                    "field {target:?} checked: expected {checked}, got {actual}"
                )),
                None => Err(format!(
                    "field {target:?} is not a toggle (absent or not a checkbox)"
                )),
            },
        }
    }
}

/// The egui-side AccessKit **node id** as a raw `u64` for an AccessKit node.
///
/// accesskit_consumer's own `Node::id()` is an *opaque* `NodeId(TreeIndex,
/// LocalNodeId)` (its `.0` is private), so we can't read a raw u64 off it any
/// more. But `Node::locate()` hands back `(LocalNodeId, TreeId)`, and the
/// `LocalNodeId` *is* the schema-level `egui::accesskit::NodeId(pub u64)` egui
/// assigned the widget (egui maps `egui::Id -> NodeId(id.value())`). So
/// `locate().0.0` is exactly the stable per-widget u64 the old `id().0` returned
/// — that keeps the public `Target::Id` / `node_id`/`find_id` u64 API working.
/// Roles a user can PRESS. Used only to break a label tie between a control and
/// its caption (see [`RobotSession::resolve_by_label`]) — deliberately a small,
/// explicit list rather than "not a Label", so a new AccessKit role defaults to
/// "not a control" and surfaces as an error instead of being silently clicked.
fn is_pressable(role: egui::accesskit::Role) -> bool {
    use egui::accesskit::Role;
    matches!(
        role,
        Role::Button
            | Role::CheckBox
            | Role::RadioButton
            | Role::Link
            | Role::Tab
            | Role::MenuItem
            | Role::MenuItemCheckBox
            | Role::MenuItemRadio
            | Role::ListBoxOption
            | Role::Switch
    )
}

fn egui_node_id(node: &egui_kittest::kittest::AccessKitNode<'_>) -> u64 {
    let (local_id, _tree) = node.locate();
    local_id.0
}

/// Microseconds since the Unix epoch (UTC) — the `ts_micros` stamp on every row.
fn now_micros() -> i64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_micros() as i64)
        .unwrap_or(0)
}

/// The outcome of a [`run_scenario_mode`] call.
pub enum RunOutcome {
    /// Headless drive completed — one [`TestResultRow`] per step (fills the matrix).
    Headless(Vec<TestResultRow>),
    /// A watch window ran to completion and closed (no rows; it was for a human).
    Watched,
}

impl RunOutcome {
    /// The matrix rows, if this was a headless run (`None` for a watch run).
    pub fn rows(&self) -> Option<&[TestResultRow]> {
        match self {
            RunOutcome::Headless(r) => Some(r),
            RunOutcome::Watched => None,
        }
    }
}

/// **Run ONE scenario in the chosen mode** — the unified seam that lets a
/// consumer write ONE `draw(ui, app, targets)` closure + ONE [`Scenario`] and get
/// BOTH modes:
///
/// * [`RunMode::Headless`] builds an off-screen `egui_kittest` [`Harness`] over
///   `draw`/`app`, runs the scenario through [`RobotSession::run_scenario`], and
///   returns [`RunOutcome::Headless`] with the matrix rows (the CI path — the
///   `Targets` the closure records are ignored; kittest resolves via AccessKit).
/// * [`RunMode::Watch`] (feature `watch`) opens a real eframe window and replays
///   the scenario visibly, returning [`RunOutcome::Watched`]. Without the `watch`
///   feature this arm returns a clear error telling you to rebuild with it.
///
/// `mode` can come from anywhere — a literal, [`RunMode::from_env`]
/// (`ROBOTUI_WATCH=1`), or a CLI subcommand. The existing
/// [`RobotSession::run_scenario`] API is untouched; this is purely additive.
pub fn run_scenario_mode<A, F>(
    app: A,
    mut draw: F,
    scenario: &Scenario,
    repo: impl Into<String>,
    mode: RunMode,
    #[allow(unused_variables)] cfg: WatchConfig,
) -> anyhow::Result<RunOutcome>
where
    A: Observable + 'static,
    F: FnMut(&mut egui::Ui, &mut A, &mut Targets) + 'static,
{
    match mode {
        RunMode::Headless => {
            // Wrap the unified closure so kittest sees a plain `(ui, app)` UI; the
            // recorded `Targets` are throwaway in this mode.
            let harness = Harness::new_ui_state(
                move |ui: &mut egui::Ui, app: &mut A| {
                    let mut targets = Targets::default();
                    draw(ui, app, &mut targets);
                },
                app,
            );
            let mut robot = RobotSession::new(harness, repo);
            Ok(RunOutcome::Headless(robot.run_scenario(scenario)))
        }
        RunMode::Watch => {
            #[cfg(feature = "watch")]
            {
                watch::run(app, draw, scenario, repo, cfg)?;
                Ok(RunOutcome::Watched)
            }
            #[cfg(not(feature = "watch"))]
            {
                let _ = (app, draw, scenario, repo.into());
                Err(anyhow::anyhow!(
                    "watch mode needs the `watch` feature — rebuild with `--features watch` \
                     (or use the `robotui watch` bin)"
                ))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// A tiny self-contained egui app: a "Bump" button increments a counter, a
    /// text field, a checkbox and a slider whose values we read back — enough to
    /// exercise click + type + set_field (text/bool/number) + click_by_id +
    /// inject_events + state assertions without any nornir/viz deps.
    #[derive(Default)]
    struct Counter {
        clicks: u32,
        name: String,
        agree: bool,
        amount: f32,
    }

    impl Observable for Counter {
        fn state_json(&self) -> Value {
            json!({
                "clicks": self.clicks,
                "name": self.name,
                "agree": self.agree,
                "amount": self.amount,
            })
        }
    }

    fn session() -> RobotSession<'static, Counter> {
        let harness = Harness::new_ui_state(
            |ui, app: &mut Counter| {
                if ui.button("Bump").clicked() {
                    app.clicks += 1;
                }
                // Link a visible label to the field so it gets an AccessKit name
                // the robot can target — the egui idiom that makes a TextEdit
                // addressable by `type_into("Name", …)`: the *input* is
                // `.labelled_by(label.id)`, which puts "Name" on the TextInput.
                let lbl = ui.label("Name");
                ui.text_edit_singleline(&mut app.name).labelled_by(lbl.id);
                // A checkbox (its label is its text) and a slider linked to a
                // label — both addressable for set_field.
                ui.checkbox(&mut app.agree, "Agree");
                let slbl = ui.label("Amount");
                ui.add(egui::Slider::new(&mut app.amount, 0.0..=100.0))
                    .labelled_by(slbl.id);
            },
            Counter::default(),
        );
        RobotSession::new(harness, "robotui-selftest")
    }

    #[test]
    fn click_button_mutates_observable_state() {
        let mut robot = session();
        assert_eq!(robot.state_at("/clicks"), Some(json!(0)));
        robot.click_button("Bump").expect("Bump button exists");
        robot.click_button("Bump").expect("Bump button exists");
        assert_eq!(
            robot.state_at("/clicks"),
            Some(json!(2)),
            "two real clicks drove the counter to 2 via the AccessKit tree"
        );
    }

    #[test]
    fn missing_button_is_an_error_not_a_panic() {
        let mut robot = session();
        let err = robot.click_button("Nope").unwrap_err();
        assert!(
            err.to_string().contains("Nope"),
            "names the missing button: {err}"
        );
    }

    #[test]
    fn label_visible_reads_the_accesskit_tree() {
        let robot = session();
        assert!(
            robot.label_visible("Bump"),
            "the Bump button is in the tree"
        );
        assert!(
            !robot.label_visible("Ghost"),
            "a missing label is not visible"
        );
    }

    #[test]
    fn run_scenario_fills_the_matrix_with_one_row_per_step() {
        let mut robot = session();
        let scenario = Scenario::new("counter")
            .step(
                Step::new("bump once → clicks == 1")
                    .action(Action::ClickButton("Bump".into()))
                    .assert(Assert::State {
                        pointer: "/clicks".into(),
                        expected: json!(1),
                    }),
            )
            .step(
                Step::new("bump again → clicks == 2")
                    .action(Action::ClickButton("Bump".into()))
                    .assert(Assert::State {
                        pointer: "/clicks".into(),
                        expected: json!(2),
                    }),
            )
            .step(Step::new("bump button is visible").assert(Assert::LabelVisible("Bump".into())))
            .step(
                // This one is WRONG on purpose — proves a failing assertion lands
                // as a red matrix cell, not a panic (inject-assert LAW).
                Step::new("deliberately-wrong expectation → red cell").assert(Assert::State {
                    pointer: "/clicks".into(),
                    expected: json!(999),
                }),
            );

        let rows = robot.run_scenario(&scenario);

        assert_eq!(rows.len(), 4, "one matrix row per step");
        assert!(
            rows.iter().all(|r| r.suite == "counter"),
            "suite carried through"
        );
        assert!(
            rows.iter().all(|r| r.aspect == "robot-ui"),
            "aspect tags the robot run"
        );
        assert!(
            rows.iter().all(|r| r.repo == "robotui-selftest"),
            "repo stamped"
        );
        let run_id = &rows[0].run_id;
        assert!(
            rows.iter().all(|r| &r.run_id == run_id),
            "all rows share one run id"
        );

        assert_eq!(rows[0].status, status::PASS, "first bump passed");
        assert_eq!(rows[1].status, status::PASS, "second bump passed");
        assert_eq!(rows[2].status, status::PASS, "visibility passed");
        assert_eq!(
            rows[3].status,
            status::FAIL,
            "the wrong expectation is a red cell"
        );
        assert!(
            rows[3].message.contains("999"),
            "the red cell records why it failed: {}",
            rows[3].message
        );
    }

    #[test]
    fn type_into_drives_a_text_field_and_state_reads_it_back() {
        let mut robot = session();
        robot
            .type_into("Name", "Heimdall")
            .expect("Name field exists");
        assert_eq!(
            robot.state_at("/name"),
            Some(json!("Heimdall")),
            "typed text round-trips through the real egui TextEdit into state_json"
        );
    }

    #[test]
    fn assert_drew_passes_for_a_rendered_ui() {
        let mut robot = session();
        robot.step();
        // Drew is checked via the scenario path so it goes through `check`.
        let scenario =
            Scenario::new("drew").step(Step::new("ui drew widgets").assert(Assert::Drew));
        let rows = robot.run_scenario(&scenario);
        assert_eq!(
            rows[0].status,
            status::PASS,
            "a rendered UI counts as drawn"
        );
    }

    /// **The chained-assert regression guard.** `Step::assert` used to be a
    /// SETTER (`self.assert = assert`), so a chained `.assert(a).assert(b)`
    /// silently threw `a` away and checked only `b`. Every earlier clause in
    /// every chained step in the fleet was dead code reporting green.
    ///
    /// This step chains a FALSE clause first and a TRUE clause second. Under the
    /// old setter the step reported `pass` (the false clause was gone). It must
    /// now report `fail` and name the discarded clause's subject.
    #[test]
    fn a_chained_assert_does_not_overwrite_the_one_before_it() {
        let mut robot = session();
        let scenario = Scenario::new("chain").step(
            Step::new("first clause is FALSE, second is TRUE")
                .assert(Assert::LabelVisible("Ghost".into())) // false: no such widget
                .assert(Assert::Drew), // true: the UI drew
        );
        let rows = robot.run_scenario(&scenario);
        assert_eq!(
            rows[0].status,
            status::FAIL,
            "the FIRST clause is false, so the step must fail; got message {:?}",
            rows[0].message
        );
        assert!(
            rows[0].message.contains("Ghost"),
            "the failure must name the clause that failed: {:?}",
            rows[0].message
        );
        assert!(
            rows[0].message.contains("clause 1 of 2"),
            "the failure must say WHICH clause of how many: {:?}",
            rows[0].message
        );
    }

    /// The mirror of the guard above: a chain where EVERY clause holds still
    /// passes — so the fix cannot be satisfied by a step that always fails.
    #[test]
    fn a_chain_of_true_clauses_still_passes() {
        let mut robot = session();
        let scenario = Scenario::new("chain-ok").step(
            Step::new("bump once, then check three separate things")
                .action(Action::ClickButton("Bump".into()))
                .assert(Assert::State {
                    pointer: "/clicks".into(),
                    expected: json!(1),
                })
                .assert(Assert::LabelVisible("Bump".into()))
                .assert(Assert::Drew),
        );
        let rows = robot.run_scenario(&scenario);
        assert_eq!(
            rows[0].status,
            status::PASS,
            "all three clauses hold: {:?}",
            rows[0].message
        );
    }

    /// A chain whose LAST clause is the false one must still fail — proves the
    /// accumulator did not simply invert into "check only the first".
    #[test]
    fn the_last_clause_of_a_chain_is_checked_too() {
        let mut robot = session();
        let scenario = Scenario::new("chain-last").step(
            Step::new("true then false")
                .assert(Assert::Drew)
                .assert(Assert::LabelVisible("Ghost".into())),
        );
        let rows = robot.run_scenario(&scenario);
        assert_eq!(rows[0].status, status::FAIL, "{:?}", rows[0].message);
        assert!(
            rows[0].message.contains("clause 2 of 2"),
            "names the second clause: {:?}",
            rows[0].message
        );
    }

    // ─── R6: richer live-drive (widget-level) ────────────────────────────────

    /// R6 set_field(Text): clears then sets a TextEdit by label, and the AccessKit
    /// value reads it back (Assert::FieldValue path) AND state_json reflects it.
    #[test]
    fn set_field_text_sets_a_textedit_and_value_reads_back() {
        let mut robot = session();
        // Pre-seed via type_into so we prove set_field *replaces*, not appends.
        robot.type_into("Name", "OLD").expect("Name field");
        robot
            .set_field(&Target::label("Name"), &FieldValue::Text("Heimdall".into()))
            .expect("set the Name field");
        assert_eq!(
            robot.state_at("/name"),
            Some(json!("Heimdall")),
            "set_field replaced (not appended) the text → state_json reflects it"
        );
        assert_eq!(
            robot.field_value(&Target::label("Name")).as_deref(),
            Some("Heimdall"),
            "the AccessKit widget value reads back the set text"
        );
    }

    /// R6 set_field(Bool): sets a checkbox to a state (idempotent — calling with
    /// the same desired state again is a no-op, never a flip).
    #[test]
    fn set_field_bool_sets_a_checkbox_idempotently() {
        let mut robot = session();
        assert_eq!(
            robot.state_at("/agree"),
            Some(json!(false)),
            "starts unchecked"
        );
        robot
            .set_field(&Target::label("Agree"), &FieldValue::Bool(true))
            .expect("set Agree");
        assert_eq!(robot.state_at("/agree"), Some(json!(true)), "now checked");
        assert_eq!(robot.field_checked(&Target::label("Agree")), Some(true));
        // Idempotent: setting true again leaves it true (does NOT flip back).
        robot
            .set_field(&Target::label("Agree"), &FieldValue::Bool(true))
            .expect("re-set Agree");
        assert_eq!(
            robot.state_at("/agree"),
            Some(json!(true)),
            "set_field(Bool(true)) is idempotent — it sets, never blindly flips"
        );
        // And setting false clears it.
        robot
            .set_field(&Target::label("Agree"), &FieldValue::Bool(false))
            .expect("clear Agree");
        assert_eq!(robot.state_at("/agree"), Some(json!(false)), "set to false");
    }

    /// R6 set_field(Number): the AccessKit SetValue action drives a slider to a
    /// known value, and state_json reflects the new amount.
    #[test]
    fn set_field_number_drives_a_slider_via_accesskit_setvalue() {
        let mut robot = session();
        robot.step();
        assert_eq!(
            robot.state_at("/amount"),
            Some(json!(0.0)),
            "slider starts at 0"
        );
        robot
            .set_field(&Target::label("Amount"), &FieldValue::Number(42.0))
            .expect("set the slider");
        assert_eq!(
            robot.state_at("/amount"),
            Some(json!(42.0)),
            "the SetValue action moved the slider to 42 → state_json reflects it"
        );
    }

    /// R6 click_by_id: discover a button's AccessKit id, then click it BY ID
    /// (not label) and the counter increments — the label-collision escape hatch.
    #[test]
    fn click_by_id_clicks_a_widget_by_accesskit_node_id() {
        let mut robot = session();
        robot.step();
        let id = robot.node_id("Bump").expect("Bump has an AccessKit id");
        assert_eq!(robot.state_at("/clicks"), Some(json!(0)));
        robot.click_by_id(id).expect("click Bump by id");
        assert_eq!(
            robot.state_at("/clicks"),
            Some(json!(1)),
            "clicking by AccessKit node id drove the same widget as the label would"
        );
        // A bogus id is an error, not a panic.
        let err = robot.click_by_id(0).unwrap_err();
        assert!(err.to_string().contains('0'), "names the missing id: {err}");
    }

    /// R6 inject_events: feed a raw synthetic egui event sequence (focus the
    /// field by id via an AccessKit Focus request, then a Text event) and the
    /// field receives it — the lowest-level escape hatch.
    #[test]
    fn inject_events_feeds_raw_synthetic_events() {
        let mut robot = session();
        robot.step();
        let id = robot.node_id("Name").expect("Name field id");
        // Focus the field via a raw AccessKit action request, then type via a raw
        // Text event — both injected as arbitrary egui::Events.
        robot.inject_events([egui::Event::AccessKitActionRequest(
            egui::accesskit::ActionRequest {
                action: egui::accesskit::Action::Focus,
                target_tree: egui::accesskit::TreeId::ROOT,
                target_node: egui::accesskit::NodeId(id),
                data: None,
            },
        )]);
        robot.inject_events([egui::Event::Text("Odin".into())]);
        assert_eq!(
            robot.state_at("/name"),
            Some(json!("Odin")),
            "a raw injected Focus + Text event sequence landed in the real TextEdit"
        );
    }

    /// R6 end-to-end through the scenario runner: every new verb fills a matrix
    /// row, including a deliberately-wrong FieldValue assertion → a red cell.
    #[test]
    fn r6_scenario_drives_every_new_verb_and_fills_the_matrix() {
        let mut robot = session();
        robot.step();
        let bump_id = robot.node_id("Bump").expect("Bump id");
        let scenario = Scenario::new("r6-selftest")
            .step(
                Step::new("set Name by label → value reads back")
                    .action(Action::SetField {
                        target: Target::label("Name"),
                        value: FieldValue::Text("Frigg".into()),
                    })
                    .assert(Assert::FieldValue {
                        target: Target::label("Name"),
                        value: "Frigg".into(),
                    }),
            )
            .step(
                Step::new("check Agree → checked")
                    .action(Action::SetField {
                        target: Target::label("Agree"),
                        value: FieldValue::Bool(true),
                    })
                    .assert(Assert::FieldChecked {
                        target: Target::label("Agree"),
                        checked: true,
                    }),
            )
            .step(
                Step::new("click Bump by id → clicks == 1")
                    .action(Action::ClickById(bump_id))
                    .assert(Assert::State {
                        pointer: "/clicks".into(),
                        expected: json!(1),
                    }),
            )
            .step(
                Step::new("inject Text into focused field")
                    .action(Action::InjectEvents(vec![
                        egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
                            action: egui::accesskit::Action::Focus,
                            target_tree: egui::accesskit::TreeId::ROOT,
                            target_node: egui::accesskit::NodeId(
                                robot.node_id("Name").unwrap_or(0),
                            ),
                            data: None,
                        }),
                    ]))
                    .assert(Assert::None),
            )
            .step(
                // WRONG on purpose → a red cell, proving a failed FieldValue is a
                // recorded failure, not a panic.
                Step::new("wrong field value → red cell").assert(Assert::FieldValue {
                    target: Target::label("Name"),
                    value: "NOPE".into(),
                }),
            );

        let rows = robot.run_scenario(&scenario);
        assert_eq!(rows.len(), 5, "one row per step");
        assert_eq!(
            rows[0].status,
            status::PASS,
            "set text: {}",
            rows[0].message
        );
        assert_eq!(
            rows[1].status,
            status::PASS,
            "set bool: {}",
            rows[1].message
        );
        assert_eq!(
            rows[2].status,
            status::PASS,
            "click by id: {}",
            rows[2].message
        );
        assert_eq!(rows[3].status, status::PASS, "inject: {}", rows[3].message);
        assert_eq!(
            rows[4].status,
            status::FAIL,
            "wrong field value is a red cell"
        );
        assert!(
            rows[4].message.contains("NOPE"),
            "records why: {}",
            rows[4].message
        );
    }

    // ─── L0 atom-walk: error_atoms() scans the live tree for error atoms ───────

    /// A two-label egui UI: one label carries an error-marker substring
    /// (`workspace `` is not served`), the other is clean. `error_atoms` walks the
    /// AccessKit tree and returns EXACTLY the one error atom's text. This is the
    /// reusable scan the viz atom-walk uses to catch sub-panel errors mechanically.
    #[test]
    fn error_atoms_returns_only_the_error_marker_node() {
        let mut harness = Harness::new_ui_state(
            |ui, _app: &mut Counter| {
                ui.label("all good here");
                ui.label("NotFound: workspace `` is not served");
            },
            Counter::default(),
        );
        let _ = harness.run_ok();
        let atoms = error_atoms(&harness);
        assert_eq!(
            atoms.len(),
            1,
            "exactly one error atom found, got {atoms:?}"
        );
        assert!(
            atoms[0].contains("is not served"),
            "the returned atom is the `is not served` error text: {atoms:?}"
        );
    }

    /// Bug #22: a lowercase pane error render (`open warehouse failed: …`) is now
    /// caught by `error_atoms`, while a benign `failed: <n>` count column is NOT
    /// (no blanket "failed" marker).
    #[test]
    fn error_atoms_catches_lowercase_pane_failures_not_count_columns() {
        let mut harness = Harness::new_ui_state(
            |ui, _app: &mut Counter| {
                ui.label("open warehouse failed: catalog.redb locked");
                ui.label("passed: 12   failed: 0"); // a count column, NOT an error
            },
            Counter::default(),
        );
        let _ = harness.run_ok();
        let atoms = error_atoms(&harness);
        assert_eq!(atoms.len(), 1, "only the real failure atom, got {atoms:?}");
        assert!(atoms[0].contains("open warehouse failed"), "{atoms:?}");
    }

    /// `all_atoms` walks the live tree and returns the interactive/labelled atoms
    /// with their roles — a button, a plain label, a checkbox — deduped, dropping
    /// text-less structural nodes. This is the per-tab discovered surface the
    /// atom-coverage producer maps to rows.
    #[test]
    fn all_atoms_enumerates_interactive_and_labelled_nodes() {
        let mut harness = Harness::new_ui_state(
            |ui, app: &mut Counter| {
                ui.label("Status: idle");
                if ui.button("Run").clicked() {}
                ui.checkbox(&mut app.agree, "Agree");
            },
            Counter::default(),
        );
        let _ = harness.run_ok();
        let atoms = all_atoms(&harness);
        // The button is discovered as a Button-role atom with its text.
        assert!(
            atoms
                .iter()
                .any(|a| a.text == "Run" && a.role.contains("Button")),
            "Run button discovered with role: {atoms:?}"
        );
        // The plain label is discovered as a Label-role atom.
        assert!(
            atoms
                .iter()
                .any(|a| a.text.contains("Status") && a.role.contains("Label")),
            "status label discovered: {atoms:?}"
        );
        // The checkbox is discovered (CheckBox role).
        assert!(
            atoms.iter().any(|a| a.text == "Agree"),
            "Agree checkbox discovered: {atoms:?}"
        );
        // `atom_labels` is the deduped text view used by the drift check.
        let labels = atom_labels(&harness);
        assert!(labels.contains(&"Run".to_string()));
        assert!(labels.contains(&"Agree".to_string()));
    }

    /// A clean UI (no error-marker substrings anywhere) → `error_atoms` is empty.
    /// This is the green-state proof: the empty Vec is what an error-free surface
    /// returns, so `error_atoms(&h).is_empty()` is a meaningful pass.
    #[test]
    fn error_atoms_is_empty_for_a_clean_ui() {
        let mut harness = Harness::new_ui_state(
            |ui, _app: &mut Counter| {
                ui.label("all good here");
                if ui.button("Run").clicked() {}
            },
            Counter::default(),
        );
        let _ = harness.run_ok();
        let atoms = error_atoms(&harness);
        assert!(
            atoms.is_empty(),
            "a clean UI has no error atoms, got {atoms:?}"
        );
    }
}

#[cfg(test)]
mod clipped_probe {
    //! **`korpui-31` — the red proof for the visibility + landing gates.**
    //!
    //! MEASURED before the fix, verbatim from this same fixture: node present `true`,
    //! bounding box `y 411..429`, surface `[[0 0] - [200 100]]`, `label_visible` →
    //! **true**, `click_label` → **Ok**, `/clicks` → **0**. The framework told the
    //! drive the user could see and press a control that is 311 px below the window
    //! and then reported the press a success. Both halves are asserted red here.
    use super::*;
    use serde_json::{Value, json};

    #[derive(Default)]
    struct Counter {
        clicks: u32,
    }
    impl Observable for Counter {
        fn state_json(&self) -> Value {
            json!({ "clicks": self.clicks })
        }
    }

    /// `offscreen = true` pushes the button 400 px down a 200x100 surface, so it is
    /// laid out and in the AccessKit tree but nowhere the user or a pointer can reach.
    fn session(offscreen: bool) -> RobotSession<'static, Counter> {
        let pad = if offscreen { 400.0 } else { 0.0 };
        let harness = egui_kittest::Harness::builder()
            .with_size(egui::vec2(200.0, 100.0))
            .build_ui_state(
                move |ui, app: &mut Counter| {
                    ui.allocate_space(egui::vec2(1.0, pad));
                    if ui.button("Bump").clicked() {
                        app.clicks += 1;
                    }
                },
                Counter::default(),
            );
        RobotSession::new(harness, "robotui-selftest")
    }

    /// The control case: the SAME fixture with the button on the surface must stay
    /// green, or the gate above is just a way of failing.
    #[test]
    fn an_onscreen_widget_is_visible_and_its_click_lands() {
        let mut robot = session(false);
        robot.step();
        assert!(robot.label_visible("Bump"), "the button is on the surface");
        robot.click_label("Bump").expect("an on-surface click lands");
        assert_eq!(
            robot.state_at("/clicks"),
            Some(json!(1)),
            "the click reached the widget"
        );
    }

    /// RED: an off-surface widget is NOT visible, and clicking it is an ERROR — not
    /// a silent success over a counter that never moved.
    #[test]
    fn an_offscreen_widget_is_not_visible_and_cannot_be_clicked() {
        let mut robot = session(true);
        robot.step();

        // The node IS in the tree — this is exactly why the old `is_some()` answered
        // true. Assert that, so the test still describes the trap it closes.
        assert!(
            robot.harness.query_by_label("Bump").is_some(),
            "the widget is in the AccessKit tree — presence was never the question"
        );
        let why = robot
            .label_visibility("Bump")
            .expect_err("an off-surface widget is not visible");
        assert!(
            why.contains("does not meet the rendered surface"),
            "the reason names the surface it missed: {why}"
        );
        assert!(!robot.label_visible("Bump"));

        let err = robot
            .click_label("Bump")
            .expect_err("clicking an off-surface widget must be an error");
        assert!(err.to_string().contains("Bump"), "names the widget: {err}");
        assert_eq!(
            robot.state_at("/clicks"),
            Some(json!(0)),
            "and the counter proves the click really could not have landed"
        );
    }

    /// The step-level shape: the same miss as a `Scenario` must produce a RED matrix
    /// row, not a pass. This is what `korpui-31` said the fleet never got.
    #[test]
    fn an_offscreen_click_is_a_red_matrix_row() {
        let mut robot = session(true);
        let rows = robot.run_scenario(
            &Scenario::new("offscreen")
                .step(
                    Step::new("the button is visible")
                        .assert(Assert::LabelVisible("Bump".into())),
                )
                .step(Step::new("click it").action(Action::ClickLabel("Bump".into()))),
        );
        assert_eq!(rows.len(), 2);
        assert!(
            rows.iter().all(|r| r.status == status::FAIL),
            "both the assertion and the action must be red: {:?}",
            rows.iter().map(|r| (&r.test_name, &r.status, &r.message)).collect::<Vec<_>>()
        );
    }
}

#[cfg(test)]
mod label_tie {
    //! **The control-vs-caption label tie** — a name painted BOTH as the control
    //! and as the heading of what it selects.
    //!
    //! MEASURED on nornir's viz 2026-08-22, which is where this came from: the 🎛
    //! Settings header tab and the Settings pane heading both carry `🎛 Settings`;
    //! every 🔨 Build row carries its variant name as the selectable `Button` AND
    //! as the card title `Label`. `kittest::query_by_label` is `get`-flavoured, so
    //! it PANICS on two matches — and `robot_settings_tab`, `robot_viz` and
    //! `viz_build_robot` all died in that panic the first time they compiled after
    //! the egui realign, never reaching the assertion they exist for.
    //!
    //! RED PROOF: with `resolve_by_label` reverted to `harness.query_by_label`,
    //! `a_control_wins_a_tie_with_its_own_caption` panics inside kittest with
    //! "Found two or more nodes matching the query" instead of clicking.
    use super::*;
    use serde_json::{Value, json};

    #[derive(Default)]
    struct Twice {
        clicks: u32,
    }
    impl Observable for Twice {
        fn state_json(&self) -> Value {
            json!({ "clicks": self.clicks })
        }
    }

    /// `Mode::Tie` paints `Save` as a caption AND as the button under it — the
    /// real shape. `Mode::TwoButtons` paints it as two BUTTONS, which is a genuine
    /// app-side ambiguity a drive must refuse rather than guess at.
    enum Mode {
        Tie,
        TwoButtons,
        CaptionOnly,
    }

    fn session(mode: Mode) -> RobotSession<'static, Twice> {
        let harness = egui_kittest::Harness::builder()
            .with_size(egui::vec2(300.0, 200.0))
            .build_ui_state(
                move |ui, app: &mut Twice| match mode {
                    Mode::Tie => {
                        ui.label("Save");
                        if ui.button("Save").clicked() {
                            app.clicks += 1;
                        }
                    }
                    Mode::TwoButtons => {
                        if ui.button("Save").clicked() {
                            app.clicks += 1;
                        }
                        if ui.button("Save").clicked() {
                            app.clicks += 1;
                        }
                    }
                    Mode::CaptionOnly => {
                        ui.label("Save");
                    }
                },
                Twice::default(),
            );
        RobotSession::new(harness, "robotui-selftest")
    }

    /// The case the three nornir suites hit: caption + control share a name. The
    /// drive must press the CONTROL — and the applied proof is the counter, not
    /// that `click_label` returned `Ok`.
    #[test]
    fn a_control_wins_a_tie_with_its_own_caption() {
        let mut robot = session(Mode::Tie);
        robot.run(2);
        assert_eq!(robot.state()["clicks"], json!(0), "starts unclicked");
        robot.click_label("Save").expect("the control is pressable");
        assert_eq!(
            robot.state()["clicks"],
            json!(1),
            "the BUTTON was pressed, not the caption — a caption click leaves this 0"
        );
        assert!(
            robot.label_visible("Save"),
            "the same tie must resolve for the visibility oracle too, or `LabelVisible` \
             and `ClickLabel` disagree about the same widget"
        );
    }

    /// Two real CONTROLS sharing a label is an app defect. Resolving it silently
    /// would hide exactly the kind of duplicate-button bug a robot suite is for,
    /// so it stays an error — and an error, not a panic.
    #[test]
    fn two_pressable_widgets_with_one_label_stay_an_error() {
        let mut robot = session(Mode::TwoButtons);
        robot.run(2);
        let err = robot
            .click_label("Save")
            .expect_err("two buttons with one label must not resolve");
        assert!(
            err.to_string().contains("MORE THAN ONE pressable"),
            "the error must name the ambiguity: {err}"
        );
        assert_eq!(
            robot.state()["clicks"],
            json!(0),
            "and nothing may have been pressed while refusing"
        );
    }

    /// **The asymmetry, asserted rather than assumed.** A label carried by exactly
    /// ONE node resolves whatever its role is — that is the pre-existing behaviour
    /// of every label-addressed call and this change deliberately did not touch it,
    /// because tightening "a lone node must be pressable" would newly refuse clicks
    /// that korp/holger/dwarves drives make today and pass. So `click_label` on a
    /// lone caption still returns `Ok` — and PRESSES NOTHING, which is the part
    /// that matters and the part asserted here (the counter is the oracle, never
    /// the `Ok`).
    ///
    /// Only the ≥2 case refuses a caption, because there the alternative is a
    /// coin-flip between nodes. Named so the next reader sees the seam instead of
    /// discovering it: if the lone case should refuse too, that is a separate,
    /// deliberate widening across five consumer apps, not a tie-break detail.
    #[test]
    fn a_lone_caption_resolves_but_presses_nothing() {
        let mut robot = session(Mode::CaptionOnly);
        robot.run(2);
        assert!(
            robot.click_label("Save").is_ok(),
            "a lone node resolves regardless of role — the long-standing contract"
        );
        assert_eq!(
            robot.state()["clicks"],
            json!(0),
            "but nothing was pressed: a caption is not a control, and the APPLIED \
             outcome says so even where the return value does not"
        );
    }
}