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/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));
    }
}