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