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

nordisk/edda

crates/nornir-saga/src/replay.rs

//! The **replay plan** — turn an ordered stream of captured [`SagaEvent`]s into a
//! toolkit-neutral [`ReplayStep`] sequence the matrix drives as a robot test.
//!
//! A recorded session is a noisy stream: `nav`/`click` events interleaved with
//! `log`/`error`/`state` context. Replay keeps only the **actionable** events
//! ([`EventKind::is_actionable`] — `Nav` + `Click`), sorts them by capture `seq`
//! (so ordering is independent of wall-clock skew), and maps each to a
//! [`ReplayStep`]: a `Navigate` (a route change) or an `Activate` (a click on a
//! component). Each step also carries the `expect_state` JSON the original event
//! observed, so a driver can assert "after this step the app shows what the user
//! saw" — the inject-assert LAW applied to a replay.
//!
//! This module is **driver-agnostic**: it produces plain data. The egui driver
//! (kittest / `nornir-robotui`) and the web driver (WebDriver / fantoccini) each
//! consume the SAME [`ReplayPlan`]; that split lives outside this lean crate.

use serde::{Deserialize, Serialize};

use crate::event::{EventKind, SagaEvent};

/// One toolkit-neutral step of a replay. Built from an actionable [`SagaEvent`];
/// a driver maps it to a concrete gesture (egui AccessKit click / WebDriver
/// `goto`+`click`). Carries the route + component + action so the driver can
/// locate the target, plus the `expect_state` the original session observed so
/// the step can be asserted (not just performed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayStep {
    /// What gesture this step is.
    pub kind: StepKind,
    /// The route / view the step targets (e.g. `/offert/123` or `GraphView`).
    pub route: String,
    /// The component the step targets (e.g. `OffertForm`, `🧪 Test`).
    pub component: String,
    /// The action label (e.g. `submit`, `open`, `select_row`) — the verb a
    /// driver matches a widget/locator against.
    pub action: String,
    /// The readable state JSON the ORIGINAL session observed at this point. A
    /// driver asserts the replayed app reaches the same state (empty = nothing
    /// to assert, just perform).
    pub expect_state: String,
    /// The capture `seq` of the source event — preserves the original order and
    /// lets a failed step name the exact recorded moment it diverged from.
    pub source_seq: u64,
}

/// Which gesture a [`ReplayStep`] is — the toolkit-neutral verb set. `Nav` →
/// `Navigate`, `Click` → `Activate`; logs/errors/state are context and never
/// become steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepKind {
    /// Go to a route / view (the backbone of replay).
    Navigate,
    /// Activate (click) a component.
    Activate,
}

impl StepKind {
    /// Stable lowercase label.
    pub fn as_str(self) -> &'static str {
        match self {
            StepKind::Navigate => "navigate",
            StepKind::Activate => "activate",
        }
    }
}

impl ReplayStep {
    /// Build a step from an actionable event, or `None` if the event is context
    /// (`Log`/`Error`/`State`).
    pub fn from_event(e: &SagaEvent) -> Option<ReplayStep> {
        let kind = match e.kind {
            EventKind::Nav => StepKind::Navigate,
            EventKind::Click => StepKind::Activate,
            _ => return None,
        };
        Some(ReplayStep {
            kind,
            route: e.route.clone(),
            component: e.component.clone(),
            action: e.action.clone(),
            expect_state: e.state_json.clone(),
            source_seq: e.seq,
        })
    }
}

/// A whole session's replay: its `session_id` + the ordered actionable steps.
/// `nornir-saga`'s contribution stops here (plain data); a driver turns the
/// steps into a robot test, and a failed step is a RED matrix row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ReplayPlan {
    /// The session these steps were recorded under.
    pub session_id: String,
    /// The app the session was recorded against (e.g. `njord`, `facett`).
    pub app: String,
    /// The actionable steps, in capture order.
    pub steps: Vec<ReplayStep>,
}

impl ReplayPlan {
    /// Build the replay plan for ONE session out of a (possibly mixed-session,
    /// unordered) slice of events: filter to `session_id`, sort by capture
    /// `seq`, keep only actionable events, map each to a [`ReplayStep`].
    pub fn for_session(session_id: &str, events: &[SagaEvent]) -> ReplayPlan {
        let mut mine: Vec<&SagaEvent> =
            events.iter().filter(|e| e.session_id == session_id).collect();
        mine.sort_by_key(|e| e.seq);
        let app = mine.first().map(|e| e.app.clone()).unwrap_or_default();
        let steps = mine.iter().filter_map(|e| ReplayStep::from_event(e)).collect();
        ReplayPlan {
            session_id: session_id.to_string(),
            app,
            steps,
        }
    }

    /// Build replay plans for EVERY session present in `events`, one plan per
    /// distinct `session_id`, ordered by each session's first capture `seq` (so
    /// the oldest session is first). Empty plans (a session with no actionable
    /// events) are kept — a session that only logged is still a real session.
    pub fn all_sessions(events: &[SagaEvent]) -> Vec<ReplayPlan> {
        // First-seen order of session ids by minimum seq.
        let mut order: Vec<(u64, String)> = Vec::new();
        for e in events {
            if !order.iter().any(|(_, id)| id == &e.session_id) {
                let min_seq = events
                    .iter()
                    .filter(|x| x.session_id == e.session_id)
                    .map(|x| x.seq)
                    .min()
                    .unwrap_or(e.seq);
                order.push((min_seq, e.session_id.clone()));
            }
        }
        order.sort_by_key(|(seq, _)| *seq);
        order
            .into_iter()
            .map(|(_, id)| ReplayPlan::for_session(&id, events))
            .collect()
    }

    /// Is this plan replayable (has at least one actionable step)?
    pub fn is_replayable(&self) -> bool {
        !self.steps.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::SessionCtx;

    fn seq(mut e: SagaEvent, n: u64) -> SagaEvent {
        e.seq = n;
        e
    }

    #[test]
    fn only_nav_and_click_become_steps() {
        let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", r#"{"v":1}"#).in_session(&ctx), 0),
            seq(SagaEvent::log("info", "noise").in_session(&ctx), 1),
            seq(SagaEvent::click("/a", "Btn", "submit", r#"{"v":2}"#).in_session(&ctx), 2),
            seq(SagaEvent::error("A", "boom").in_session(&ctx), 3),
            seq(SagaEvent::state("A", r#"{"v":3}"#).in_session(&ctx), 4),
        ];
        let plan = ReplayPlan::for_session("s1", &events);
        assert_eq!(plan.steps.len(), 2, "only the nav + click are steps");
        assert_eq!(plan.app, "njord");
        assert_eq!(plan.steps[0].kind, StepKind::Navigate);
        assert_eq!(plan.steps[0].route, "/a");
        assert_eq!(plan.steps[0].expect_state, r#"{"v":1}"#);
        assert_eq!(plan.steps[1].kind, StepKind::Activate);
        assert_eq!(plan.steps[1].component, "Btn");
        assert_eq!(plan.steps[1].action, "submit");
        assert!(plan.is_replayable());
    }

    #[test]
    fn steps_are_sorted_by_capture_seq_not_input_order() {
        let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
        // Fed out of order; replay must re-sort by seq.
        let events = vec![
            seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&ctx), 2),
            seq(SagaEvent::nav("/a", "Zero", "open", "{}").in_session(&ctx), 0),
            seq(SagaEvent::click("/a", "One", "y", "{}").in_session(&ctx), 1),
        ];
        let plan = ReplayPlan::for_session("s1", &events);
        let comps: Vec<&str> = plan.steps.iter().map(|s| s.component.as_str()).collect();
        assert_eq!(comps, vec!["Zero", "One", "Two"], "re-sorted by seq");
        // source_seq preserved so a failed step names the recorded moment.
        assert_eq!(plan.steps[0].source_seq, 0);
        assert_eq!(plan.steps[2].source_seq, 2);
    }

    #[test]
    fn for_session_scopes_to_one_session() {
        let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
        let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&a), 0),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&b), 1),
            seq(SagaEvent::click("/a", "A2", "go", "{}").in_session(&a), 2),
        ];
        let plan = ReplayPlan::for_session("sA", &events);
        assert_eq!(plan.steps.len(), 2, "only session A's events");
        assert!(plan.steps.iter().all(|s| s.route == "/a"));
    }

    #[test]
    fn all_sessions_splits_and_orders_by_first_seq() {
        let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
        let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
        let events = vec![
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&b), 5),
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&a), 1),
            seq(SagaEvent::click("/b", "B2", "x", "{}").in_session(&b), 6),
        ];
        let plans = ReplayPlan::all_sessions(&events);
        assert_eq!(plans.len(), 2);
        // sA started at seq 1, sB at seq 5 → sA first.
        assert_eq!(plans[0].session_id, "sA");
        assert_eq!(plans[1].session_id, "sB");
        assert_eq!(plans[1].steps.len(), 2, "sB has two actionable events");
    }

    #[test]
    fn plan_json_roundtrips() {
        let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
        let events = vec![seq(
            SagaEvent::nav("/a", "A", "open", r#"{"v":1}"#).in_session(&ctx),
            0,
        )];
        let plan = ReplayPlan::for_session("s1", &events);
        let json = serde_json::to_string(&plan).expect("serialize");
        let back: ReplayPlan = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, plan, "ReplayPlan survives a JSON roundtrip");
    }
}