crates/nornir-robotui/src/scenario.rs
//! The **scripted** layer: declarative [`Action`]s + [`Assert`]s composed into
//! [`Step`]s and a [`Scenario`]. A [`Scenario`] is the unit the
//! [`RobotSession::run_scenario`](crate::RobotSession::run_scenario) runner
//! drives — one step → one [`TestResultRow`](crate::TestResultRow), so a whole
//! scenario is one matrix run.
//!
//! Steps are plain data (no closures) so a scenario can be built in a `.toml`/
//! JSON file by a leaf repo and round-trip — `Action`/`Assert` carry only
//! `String`/`Value`/`egui::Key`/`egui::Vec2`, all `Clone`.
//!
//! ## Serde (feature `watch`)
//!
//! Under the `watch` feature every type here also derives
//! `serde::Serialize`/`Deserialize` (and `egui`'s own `serde` is turned on, so
//! `egui::Key`/`Vec2`/`Pos2`/`Event` serialize too). That is what makes the
//! `robotui watch <scenario.toml>` / `robotui run <scenario.toml>` CLI able to
//! LOAD a scenario from disk and run the SAME data in either mode. The lean
//! default build carries no serde-on-egui — the derives vanish with the feature.
use serde_json::Value;
/// How to address a widget in the AccessKit tree: by its (linked) **label** text,
/// or by its raw AccessKit **node id** (a `u64`). The label path is the common
/// case; the id path is the R6 escape hatch for widgets whose labels collide or
/// are empty (a `TextEdit` with only `hint_text`, an icon button, etc.).
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
/// The widget whose AccessKit label equals this string.
Label(String),
/// The widget whose AccessKit node id equals this `u64` (`node.id().0`).
Id(u64),
}
impl Target {
/// Construct a label target (sugar so scenarios read `Target::label("Name")`).
pub fn label(s: impl Into<String>) -> Self {
Target::Label(s.into())
}
/// Construct an id target.
pub fn id(id: u64) -> Self {
Target::Id(id)
}
}
/// How to set a form field's value in [`Action::SetField`] — the robot picks the
/// right gesture from the widget's AccessKit role, but the *intent* is one of:
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub enum FieldValue {
/// Clear the field and type this text (a `TextEdit` single/multi-line).
Text(String),
/// Set a checkbox/switch to this state (toggles only if it differs).
Bool(bool),
/// Set a slider / `DragValue` to this numeric value (via the AccessKit
/// `SetValue` action egui honours for numeric widgets).
Number(f64),
}
/// One thing the robot does to the UI. Plain data so scenarios are declarative
/// and reusable across korp / facett-demo / mannequin / viz.
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub enum Action {
/// Click the widget whose AccessKit label equals this string.
ClickLabel(String),
/// Click a button by its text (egui buttons label themselves with their text).
ClickButton(String),
/// **R6** — click a widget addressed by its AccessKit node id (`u64`), for the
/// cases [`ClickLabel`](Action::ClickLabel) can't reach (colliding/empty
/// labels, icon-only buttons).
ClickById(u64),
/// Focus the field labelled `label` and type `text` into it.
TypeInto { label: String, text: String },
/// **R6** — set an arbitrary form field (text / checkbox / slider) addressed by
/// label OR node id to a value, picking the gesture from the widget's role.
SetField { target: Target, value: FieldValue },
/// Press a key.
Key(egui::Key),
/// Scroll by a delta (egui convention: +y up, -y down).
Scroll(egui::Vec2),
/// Drag from one screen point to another (press → move → release).
Drag { from: egui::Pos2, to: egui::Pos2 },
/// **R6** — feed an arbitrary synthetic [`egui::Event`] sequence into the
/// harness for one frame, then step. The lowest-level escape hatch for
/// widgets the higher-level verbs can't address (pointer moves/clicks at a
/// position, key combos with modifiers, raw text events).
InjectEvents(Vec<egui::Event>),
/// Just run a frame (e.g. let a reload settle) without input.
Step,
/// Do nothing — for an assertion-only step (e.g. assert the initial state).
None,
}
/// What the robot expects to be true *after* the step's action, checked against
/// the app's [`state_json`](crate::Observable::state_json) (or the AccessKit
/// tree for visibility / drew).
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub enum Assert {
/// No assertion — the step only acts (e.g. a navigation step).
None,
/// A widget with this label must be present (the user would see it).
LabelVisible(String),
/// The UI drew at least one widget this frame (not a blank pane).
Drew,
/// The value at `pointer` (RFC 6901 JSON pointer into `state_json`) must
/// equal `expected` exactly.
State { pointer: String, expected: Value },
/// The value at `pointer`, stringified, must contain `needle` — for "the
/// rendered list mentions X" style checks without pinning exact shape.
StateContains { pointer: String, needle: String },
/// **R6** — the AccessKit `value` of the targeted widget must equal `value`.
/// Reads the value straight off the rendered widget (not `state_json`), so it
/// proves a [`SetField`](Action::SetField) text edit landed in the *widget*.
FieldValue { target: Target, value: String },
/// **R6** — the targeted checkbox/switch must be toggled to `checked`.
FieldChecked { target: Target, checked: bool },
/// **Every** clause must hold — the accumulator behind a chained
/// `.assert(a).assert(b)`. Evaluated left to right; the first clause that
/// fails fails the step and the message says which clause of how many.
///
/// You rarely write this by hand: [`Step::assert`] builds it for you, and
/// [`Step::asserts`] takes the list directly. It exists as a variant (rather
/// than the step's field becoming a `Vec`) so the whole scenario vocabulary —
/// the TOML/JSON wire shape, the `cmd_json` channel, the watch overlay —
/// keeps ONE `Assert` type with ONE evaluator.
All(Vec<Assert>),
}
impl Assert {
/// Conjoin two assertions into one that holds only if BOTH hold. The single
/// writer behind chained [`Step::assert`] calls.
///
/// Kept flat and identity-aware so a chain of n clauses is one
/// `All` of n, never a right-leaning nest of `All`s and never padded with
/// `None`s that would make the "clause k of n" message lie:
/// - `None.and(x) == x` and `x.and(None) == x` (identity)
/// - `All(v).and(x) == All(v ++ [x])` (flatten left)
/// - `x.and(All(w)) == All([x] ++ w)` (flatten right)
pub fn and(self, other: Assert) -> Assert {
match (self, other) {
(Assert::None, b) => b,
(a, Assert::None) => a,
(Assert::All(mut v), Assert::All(w)) => {
v.extend(w);
Assert::All(v)
}
(Assert::All(mut v), b) => {
v.push(b);
Assert::All(v)
}
(a, Assert::All(w)) => {
let mut v = vec![a];
v.extend(w);
Assert::All(v)
}
(a, b) => Assert::All(vec![a, b]),
}
}
/// Conjoin a whole list. An empty list is [`Assert::None`]; a single clause
/// stays that clause (no pointless `All` wrapper).
pub fn all(asserts: impl IntoIterator<Item = Assert>) -> Assert {
asserts
.into_iter()
.fold(Assert::None, |acc, a| acc.and(a))
}
/// The clauses this assertion carries, flattened — 0 for [`Assert::None`],
/// 1 for a leaf, n for an [`All`](Assert::All). What a consumer counts to see
/// how many things a step actually checks.
pub fn clauses(&self) -> Vec<&Assert> {
match self {
Assert::None => Vec::new(),
Assert::All(v) => v.iter().collect(),
leaf => vec![leaf],
}
}
}
/// One `(label, action, assert)` triple — the unit that becomes a matrix cell.
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct Step {
/// Human name → the matrix row's `test_name` (and what shows in the grid).
pub label: String,
/// What the robot does.
pub action: Action,
/// What must hold afterwards.
pub assert: Assert,
}
impl Step {
/// A new step with the given label, no action and no assertion yet (build it
/// up with [`action`](Self::action) / [`assert`](Self::assert)).
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
action: Action::None,
assert: Assert::None,
}
}
/// Set this step's action (builder style).
pub fn action(mut self, action: Action) -> Self {
self.action = action;
self
}
/// **Add** an assertion to this step (builder style). Chaining ACCUMULATES:
/// `.assert(a).assert(b)` means "a AND b", both checked, in that order.
///
/// It used to be a plain setter (`self.assert = assert`), which made a chain
/// silently keep only the LAST clause — every earlier clause was dead code
/// that ran nothing and reported green. Measured 2026-09-01: 68 chained call
/// sites across the fleet were discarding 136 clauses that way, 129 of them
/// in korp.
///
/// The fix is an append rather than a refusal to chain (a `#[deprecated]`
/// second call, say) because the chained call sites already SAY the right
/// thing — they were simply not being run. Accumulating makes 68 existing
/// sites correct with no edit, and keeps one writer for the semantics instead
/// of a rule every leaf repo has to remember.
///
/// `Assert::None` is the identity: chaining it adds nothing, so a
/// `Step::new(..)` that never asserts stays `Assert::None` and an explicit
/// `.assert(Assert::None)` cannot mask a real clause beside it.
pub fn assert(mut self, assert: Assert) -> Self {
self.assert = self.assert.and(assert);
self
}
/// Set this step's assertions from a list, replacing whatever is there — the
/// explicit form of a chain, for scenarios built from data.
pub fn asserts(mut self, asserts: impl IntoIterator<Item = Assert>) -> Self {
self.assert = Assert::all(asserts);
self
}
}
/// An ordered list of [`Step`]s that the runner executes against a
/// [`RobotSession`](crate::RobotSession). `suite` becomes each row's `suite`.
#[cfg_attr(any(feature = "watch", feature = "wire"), derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct Scenario {
/// The matrix `suite` for every row this scenario produces.
pub suite: String,
/// The steps, run in order.
pub steps: Vec<Step>,
}
impl Scenario {
/// A new, empty scenario whose rows carry `suite`.
pub fn new(suite: impl Into<String>) -> Self {
Self {
suite: suite.into(),
steps: Vec::new(),
}
}
/// Append a step (builder style).
pub fn step(mut self, step: Step) -> Self {
self.steps.push(step);
self
}
/// Append many steps at once.
pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
self.steps.extend(steps);
self
}
/// Parse a scenario from a TOML string (feature `watch`). This is what makes
/// the SAME scenario data loadable from disk and run in either mode — the
/// `robotui run`/`robotui watch` CLI both go through here.
#[cfg(feature = "watch")]
pub fn from_toml_str(s: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(s)?)
}
/// Serialize this scenario to a pretty TOML string (feature `watch`) — the
/// round-trip partner of [`from_toml_str`](Self::from_toml_str), used to emit
/// a canonical `.toml` for a scenario built in Rust.
#[cfg(feature = "watch")]
pub fn to_toml_string(&self) -> anyhow::Result<String> {
Ok(toml::to_string_pretty(self)?)
}
/// Load a scenario from a `.toml` file on disk (feature `watch`).
#[cfg(feature = "watch")]
pub fn load_toml(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path.as_ref())
.map_err(|e| anyhow::anyhow!("reading scenario {}: {e}", path.as_ref().display()))?;
Self::from_toml_str(&text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn builder_composes_a_scenario() {
let s = Scenario::new("nav")
.step(
Step::new("go to Test tab")
.action(Action::ClickLabel("🧪 Test".into()))
.assert(Assert::State {
pointer: "/tab".into(),
expected: json!("Test"),
}),
)
.step(Step::new("test pane drew").assert(Assert::Drew));
assert_eq!(s.suite, "nav");
assert_eq!(s.steps.len(), 2);
assert_eq!(s.steps[0].label, "go to Test tab");
assert!(matches!(s.steps[0].action, Action::ClickLabel(ref l) if l == "🧪 Test"));
assert!(matches!(s.steps[1].assert, Assert::Drew));
}
/// The whole point of the 2026-09-01 fix: chaining ACCUMULATES. Before it,
/// `.assert(a).assert(b)` left `b` alone in the field and `a` was never run.
#[test]
fn chained_asserts_accumulate_they_do_not_overwrite() {
let step = Step::new("three things")
.assert(Assert::Drew)
.assert(Assert::LabelVisible("Bump".into()))
.assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(1),
});
let clauses = step.assert.clauses();
assert_eq!(clauses.len(), 3, "all three clauses survive the chain");
assert!(matches!(clauses[0], Assert::Drew), "order is source order");
assert!(matches!(clauses[1], Assert::LabelVisible(l) if l == "Bump"));
assert!(matches!(clauses[2], Assert::State { .. }));
// Flat, not a right-leaning nest of Alls.
assert!(
clauses.iter().all(|c| !matches!(c, Assert::All(_))),
"the accumulator flattens"
);
}
/// `Assert::None` is the identity of `and`, so an un-asserted step stays
/// un-asserted and an explicit `None` in a chain cannot pad the clause count
/// (which would make the "clause k of n" message wrong).
#[test]
fn none_is_the_identity_of_the_accumulator() {
assert_eq!(Step::new("bare").assert.clauses().len(), 0);
let only = Step::new("one").assert(Assert::None).assert(Assert::Drew);
assert!(matches!(only.assert, Assert::Drew), "None adds nothing");
let trailing = Step::new("one").assert(Assert::Drew).assert(Assert::None);
assert!(matches!(trailing.assert, Assert::Drew));
assert_eq!(Assert::all([]).clauses().len(), 0);
assert!(matches!(Assert::all([Assert::Drew]), Assert::Drew));
}
/// `asserts([..])` is the explicit form of the same conjunction.
#[test]
fn asserts_list_matches_the_chain() {
let chained = Step::new("x")
.assert(Assert::Drew)
.assert(Assert::LabelVisible("Bump".into()));
let listed = Step::new("x").asserts([
Assert::Drew,
Assert::LabelVisible("Bump".into()),
]);
// Pin the ABSOLUTE count first. Comparing the two sides alone would pass
// vacuously if both collapsed to `Assert::None` — a guard that compares
// two things it also built cannot go red on its own.
assert_eq!(chained.assert.clauses().len(), 2, "the chain kept both");
assert_eq!(listed.assert.clauses().len(), 2, "the list kept both");
assert_eq!(
format!("{:?}", chained.assert),
format!("{:?}", listed.assert),
"the chain and the list build the same assertion"
);
}
#[test]
fn steps_bulk_append() {
let s = Scenario::new("bulk").steps([
Step::new("a"),
Step::new("b"),
Step::new("c"),
]);
assert_eq!(s.steps.len(), 3);
}
/// R6 — the new richer-live-drive verbs compose as plain data (so they can
/// live in a `.toml` scenario and round-trip).
#[test]
fn r6_verbs_compose_as_plain_data() {
let s = Scenario::new("r6")
.step(
Step::new("set the paste box by label")
.action(Action::SetField {
target: Target::label("paste"),
value: FieldValue::Text("hello".into()),
})
.assert(Assert::FieldValue {
target: Target::label("paste"),
value: "hello".into(),
}),
)
.step(
Step::new("toggle a checkbox by id")
.action(Action::SetField {
target: Target::id(42),
value: FieldValue::Bool(true),
})
.assert(Assert::FieldChecked {
target: Target::id(42),
checked: true,
}),
)
.step(
Step::new("click a widget by id")
.action(Action::ClickById(7)),
)
.step(
Step::new("inject a synthetic event")
.action(Action::InjectEvents(vec![egui::Event::Text("x".into())])),
);
assert_eq!(s.steps.len(), 4);
assert!(matches!(
s.steps[0].action,
Action::SetField { target: Target::Label(ref l), value: FieldValue::Text(ref t) }
if l == "paste" && t == "hello"
));
assert!(matches!(
s.steps[1].action,
Action::SetField { target: Target::Id(42), value: FieldValue::Bool(true) }
));
assert!(matches!(s.steps[2].action, Action::ClickById(7)));
assert!(matches!(s.steps[3].action, Action::InjectEvents(ref v) if v.len() == 1));
}
}