crates/nornir-robotui/src/replay.rs
//! **Replay driver (egui)** — turn a `nornir-saga` [`ReplayPlan`] (a recorded
//! user journey) into a robot [`Scenario`] this crate's [`RobotSession`] drives
//! against the real egui app. A recorded session → a deterministic robot test;
//! a step that can't be reproduced is a **RED matrix row** (the inject-assert
//! LAW applied to a captured journey).
//!
//! This is the **egui half** of the design's driver split (egui → kittest /
//! nornir-robotui; web → WebDriver). It's driver-*agnostic* input (a `ReplayPlan`
//! is plain data) → driver-*specific* output (a kittest-drivable `Scenario`).
//!
//! ## How a step maps
//!
//! * [`StepKind::Navigate`] → click the widget whose AccessKit label is the step
//! target (a nav button / tab header), then assert the pane drew.
//! * [`StepKind::Activate`] → click the target widget, then assert it drew.
//!
//! The target *label* defaults to the event's `component` (the AccessKit label
//! the egui adapter stamps), falling back to `action` then `route`. A consuming
//! app whose labels differ from the recorded component names passes a
//! [`label_map`](plan_to_scenario_with) closure to remap; the common case (the
//! egui adapter records the same label the widget renders) needs no map.
use nornir_saga::replay::{ReplayPlan, ReplayStep, StepKind};
use crate::scenario::{Action, Assert, Scenario, Step};
/// The widget label a step targets: the `component`, else `action`, else `route`
/// — the first non-empty of the three, which is what the egui capture adapter
/// stamps as the AccessKit label.
pub fn step_label(step: &ReplayStep) -> &str {
if !step.component.is_empty() {
&step.component
} else if !step.action.is_empty() {
&step.action
} else {
&step.route
}
}
/// Convert a recorded [`ReplayPlan`] into a robot [`Scenario`] (suite =
/// `saga-replay:<session_id>`). Each [`ReplayStep`] becomes a [`Step`] that
/// clicks the step's target label and asserts the pane drew — so the matrix row
/// is RED if the recorded widget is gone (a regression the journey caught).
pub fn plan_to_scenario(plan: &ReplayPlan) -> Scenario {
plan_to_scenario_with(plan, |s| step_label(s).to_string())
}
/// Like [`plan_to_scenario`] but remaps each step's target label through
/// `label_map` first — for an app whose rendered labels differ from the recorded
/// component names.
pub fn plan_to_scenario_with(
plan: &ReplayPlan,
mut label_map: impl FnMut(&ReplayStep) -> String,
) -> Scenario {
let mut scenario = Scenario::new(format!("saga-replay:{}", plan.session_id));
for step in &plan.steps {
let label = label_map(step);
let verb = match step.kind {
StepKind::Navigate => "navigate",
StepKind::Activate => "activate",
};
let name = format!("seq {} · {verb} {label}", step.source_seq);
scenario = scenario.step(
Step::new(name)
.action(Action::ClickLabel(label))
// The journey replayed only if the pane still draws after the
// click — a vanished widget already failed the ClickLabel action.
.assert(Assert::Drew),
);
}
scenario
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_saga::event::{SagaEvent, SessionCtx};
fn plan() -> ReplayPlan {
let ctx = SessionCtx::new("s1", "u", "facett", "1.0", "sha");
let mut nav = SagaEvent::nav("/home", "🧪 Test", "open", r#"{"tab":"Test"}"#)
.in_session(&ctx);
let mut click =
SagaEvent::click("/home", "Bump", "submit", r#"{"clicks":1}"#).in_session(&ctx);
nav.seq = 0;
click.seq = 1;
ReplayPlan::for_session("s1", &[nav, click])
}
#[test]
fn plan_becomes_a_scenario_one_step_each() {
let s = plan_to_scenario(&plan());
assert_eq!(s.suite, "saga-replay:s1");
assert_eq!(s.steps.len(), 2, "one robot step per replayable event");
// First step clicks the recorded nav target (the component label).
assert!(matches!(
s.steps[0].action,
Action::ClickLabel(ref l) if l == "🧪 Test"
));
assert!(matches!(s.steps[0].assert, Assert::Drew));
assert!(s.steps[0].label.contains("navigate"));
assert!(matches!(
s.steps[1].action,
Action::ClickLabel(ref l) if l == "Bump"
));
assert!(s.steps[1].label.contains("activate"));
}
#[test]
fn label_map_remaps_targets() {
let s = plan_to_scenario_with(&plan(), |step| format!("ui::{}", step.component));
assert!(matches!(
s.steps[0].action,
Action::ClickLabel(ref l) if l == "ui::🧪 Test"
));
}
#[test]
fn step_label_falls_back_through_action_then_route() {
let ctx = SessionCtx::new("s", "u", "a", "1", "x");
let mut e = SagaEvent::nav("/only-route", "", "", "{}").in_session(&ctx);
e.seq = 0;
let p = ReplayPlan::for_session("s", &[e]);
assert_eq!(step_label(&p.steps[0]), "/only-route", "falls back to route");
}
}