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/ui/msg.rs

//! **The UI-movement saga contract** — model-is-the-contract (the nornir/facett
//! idiom). This is exactly what a *saga-enabled* Njord would emit as its Elm
//! `Msg` stream and what the viewer + replay engine here consume.
//!
//! A live UI produces a stream of movements: the pointer travels, clicks fire,
//! the page scrolls, focus jumps, the route changes, fields are typed into. If
//! Njord's web UI is Elm-architected then **the saga IS that ordered `Msg`
//! stream** (± Model/`state_json` snapshots). [`UiMsg`] is that message; a
//! [`TimedUiMsg`] stamps it with the time it happened; a [`Saga`] is the ordered
//! recording. All three consumers — LIVE view, REPLAY, and the real-page
//! [`WebReplayDriver`](crate::ui::WebReplayDriver) — fall out of this one model.

use serde::{Deserialize, Serialize};

/// One UI-movement event — the atomic unit of a saga, i.e. one Elm `Msg` a
/// saga-enabled UI reacted to.
///
/// Serializes tagged (`{"kind":"pointer_move","x":..,"y":..}`) so the wire form
/// is self-describing and forward-compatible: an unknown consumer can route on
/// `kind` and a new variant is an additive change.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiMsg {
    /// The pointer moved to an absolute viewport position (CSS px from top-left).
    PointerMove { x: f64, y: f64 },
    /// A click / activation at a position, optionally naming the target element
    /// (a CSS selector, element id, or component name).
    Click { x: f64, y: f64, target: String },
    /// The viewport (or an element) scrolled to an offset.
    Scroll { x: f64, y: f64 },
    /// Focus moved to a target (element id / selector / component name).
    Focus { target: String },
    /// A route / navigation change — the backbone of a session's structure.
    Nav { route: String },
    /// A field edit (typing): the target and the field's new value.
    FieldEdit { target: String, value: String },
    /// A key press (target-less; e.g. `Enter`, `Escape`, `a`).
    Key { key: String },
    /// A free-text marker carried inline in the stream (log line / annotation).
    Note { text: String },
}

impl UiMsg {
    /// Stable lowercase label matching the serde `kind` tag — for logs / UIs.
    pub fn kind(&self) -> &'static str {
        match self {
            UiMsg::PointerMove { .. } => "pointer_move",
            UiMsg::Click { .. } => "click",
            UiMsg::Scroll { .. } => "scroll",
            UiMsg::Focus { .. } => "focus",
            UiMsg::Nav { .. } => "nav",
            UiMsg::FieldEdit { .. } => "field_edit",
            UiMsg::Key { .. } => "key",
            UiMsg::Note { .. } => "note",
        }
    }

    /// Does this movement actually drive a real page (vs. pure annotation)?
    /// `Note` is context; everything else maps to a
    /// [`DriveAction`](crate::ui::DriveAction).
    pub fn is_drivable(&self) -> bool {
        !matches!(self, UiMsg::Note { .. })
    }

    // ── terse constructors (keep call sites + synthetic sagas readable) ──
    pub fn pointer_move(x: f64, y: f64) -> UiMsg {
        UiMsg::PointerMove { x, y }
    }
    pub fn click(x: f64, y: f64, target: &str) -> UiMsg {
        UiMsg::Click { x, y, target: target.to_string() }
    }
    pub fn scroll(x: f64, y: f64) -> UiMsg {
        UiMsg::Scroll { x, y }
    }
    pub fn focus(target: &str) -> UiMsg {
        UiMsg::Focus { target: target.to_string() }
    }
    pub fn nav(route: &str) -> UiMsg {
        UiMsg::Nav { route: route.to_string() }
    }
    pub fn field_edit(target: &str, value: &str) -> UiMsg {
        UiMsg::FieldEdit { target: target.to_string(), value: value.to_string() }
    }
    pub fn key(key: &str) -> UiMsg {
        UiMsg::Key { key: key.to_string() }
    }
    pub fn note(text: &str) -> UiMsg {
        UiMsg::Note { text: text.to_string() }
    }

    /// Stamp this movement at `t_ms` (ms since saga start) → a [`TimedUiMsg`].
    pub fn at(self, t_ms: u64) -> TimedUiMsg {
        TimedUiMsg { t_ms, msg: self, state_json: None }
    }
}

/// A [`UiMsg`] stamped with the time it occurred — the ordered unit of a saga.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimedUiMsg {
    /// Milliseconds since the start of the saga. Non-decreasing across a saga;
    /// replay pacing scales the *gaps* between successive values.
    pub t_ms: u64,
    /// The UI movement.
    pub msg: UiMsg,
    /// OPTIONAL Elm Model snapshot (`state_json`) the emitter observed right
    /// after applying this msg. When present, replay can assert the viewer's
    /// folded model matches what the source UI saw (the inject-assert law).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state_json: Option<String>,
}

impl TimedUiMsg {
    /// A stamped movement carrying a source `state_json` snapshot to assert against.
    pub fn with_state(mut self, state_json: impl Into<String>) -> TimedUiMsg {
        self.state_json = Some(state_json.into());
        self
    }
}

/// An ordered recording of UI movements — **the saga**. This is the durable,
/// serde contract: what a saga-enabled Njord emits, what nornir-saga persists,
/// views live, replays, and (via a driver) re-injects into a real page.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Saga {
    /// Schema version so an older consumer can reject / adapt a newer saga.
    pub version: u32,
    /// Human label (e.g. `njord@0.9.0 · checkout flow`).
    pub title: String,
    /// The ordered movements. Invariant: `t_ms` is non-decreasing.
    pub events: Vec<TimedUiMsg>,
}

impl Default for Saga {
    fn default() -> Self {
        Saga { version: Self::VERSION, title: String::new(), events: Vec::new() }
    }
}

impl Saga {
    /// The current saga schema version.
    pub const VERSION: u32 = 1;

    /// A new, empty saga with the given title.
    pub fn new(title: &str) -> Saga {
        Saga { version: Self::VERSION, title: title.to_string(), events: Vec::new() }
    }

    /// Append a stamped movement (builder-style).
    pub fn push(mut self, ev: TimedUiMsg) -> Saga {
        self.events.push(ev);
        self
    }

    /// Append a stamped movement in place.
    pub fn add(&mut self, ev: TimedUiMsg) {
        self.events.push(ev);
    }

    /// Number of movements.
    pub fn len(&self) -> usize {
        self.events.len()
    }

    /// Is the saga empty?
    pub fn is_empty(&self) -> bool {
        self.events.is_empty()
    }

    /// Total wall-clock span of the saga (last `t_ms`; `0` if empty).
    pub fn duration_ms(&self) -> u64 {
        self.events.last().map(|e| e.t_ms).unwrap_or(0)
    }

    /// Parse a saga from its JSON form.
    pub fn from_json(s: &str) -> anyhow::Result<Saga> {
        Ok(serde_json::from_str(s)?)
    }

    /// Render the saga as pretty JSON (the persisted / wire form).
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_default()
    }

    /// Bridge this UI-movement recording into the warehouse
    /// [`SagaEvent`](crate::event::SagaEvent) model: `UiMsg::Nav` → a `nav`
    /// event, `UiMsg::Click` → a `click` event. The other movement kinds
    /// (pointer/scroll/focus/key/field/note) carry no replayable route/component,
    /// so they are dropped — the result is exactly the actionable (Nav/Click)
    /// events the replay planner consumes. Each event's `state_json` carries the
    /// movement's captured `state_json` (empty if none). An inherent method
    /// rather than `impl From<&Saga> for Vec<SagaEvent>` because the latter is an
    /// orphan-rule violation (both `From` and `Vec` are foreign).
    pub fn to_saga_events(&self) -> Vec<crate::event::SagaEvent> {
        use crate::event::SagaEvent;
        self.events
            .iter()
            .filter_map(|ev| {
                let state = ev.state_json.clone().unwrap_or_default();
                match &ev.msg {
                    UiMsg::Nav { route } => Some(SagaEvent::nav(route, "", "open", &state)),
                    UiMsg::Click { target, .. } => {
                        Some(SagaEvent::click("", target, "click", &state))
                    }
                    _ => None,
                }
            })
            .collect()
    }
}

/// **The event-model → UI-movement bridge** — the inverse of
/// [`Saga::to_saga_events`] (and of nornir's `saga_from_ui`). Turn a stored
/// [`SagaEvent`](crate::event::SagaEvent) stream (the warehouse / `.jsonl`-tape
/// model) into a [`Saga`] the deterministic [`Player`](crate::ui::Player) can
/// replay: `nav` → [`UiMsg::Nav`], `click` → [`UiMsg::Click`] (targeting the
/// event's `component`). Only the **actionable** events (`nav`/`click`) become
/// movements — exactly the set [`Saga::to_saga_events`] emits — so a round-trip
/// through both bridges preserves the movement sequence, and the resulting
/// `Player` frame count matches the [`ReplayPlan`](crate::replay::ReplayPlan)
/// step count (so a diverged step lines up with its frame).
///
/// Events are taken in capture `seq` order (sorted defensively). Each movement's
/// `t_ms` is derived from `ts_micros` relative to the first event and clamped
/// **non-decreasing** (the [`Saga`] invariant), so a scrubber's time axis is
/// monotonic even if the source timestamps are equal or unstamped. An event's
/// `state_json` (when non-empty) rides along as the movement's snapshot, so
/// replay can assert the folded model matches what the user saw.
///
/// This is the ONE load-bearing new primitive for the dwarves tape-recorder
/// pane (see `.nornir/saga-design.md` §3.3); it lives here in the lean crate so
/// both the nornir viz tab and the dwarves pane consume it (REUSE — one home).
pub fn saga_to_ui(events: &[crate::event::SagaEvent]) -> Saga {
    use crate::event::EventKind;

    // Capture order, independent of wall-clock skew (same discipline as replay).
    let mut ordered: Vec<&crate::event::SagaEvent> = events.iter().collect();
    ordered.sort_by_key(|e| e.seq);

    // A readable title from the first event's app identity (falls back cleanly).
    let title = ordered
        .first()
        .map(|e| {
            let app = if e.app.is_empty() { "saga" } else { e.app.as_str() };
            match (e.app_version.is_empty(), e.session_id.is_empty()) {
                (true, true) => app.to_string(),
                (true, false) => format!("{app} · {}", e.session_id),
                (false, true) => format!("{app}@{}", e.app_version),
                (false, false) => format!("{app}@{} · {}", e.app_version, e.session_id),
            }
        })
        .unwrap_or_else(|| "saga".to_string());

    // Base timestamp = the first actionable event's `ts_micros` (0 when unstamped).
    let base_ts = ordered
        .iter()
        .find(|e| e.kind.is_actionable())
        .map(|e| e.ts_micros)
        .unwrap_or(0);

    let mut saga = Saga::new(&title);
    let mut last_t: u64 = 0;
    for e in ordered {
        let msg = match e.kind {
            EventKind::Nav => UiMsg::nav(&e.route),
            EventKind::Click => UiMsg::click(0.0, 0.0, &e.component),
            // logs/errors/state carry no replayable movement (see `to_saga_events`).
            _ => continue,
        };
        // Relative ms, clamped non-decreasing so `Saga`'s invariant holds.
        let raw = e.ts_micros.saturating_sub(base_ts) / 1000;
        let t_ms = raw.max(last_t);
        last_t = t_ms;
        let mut timed = msg.at(t_ms);
        if !e.state_json.is_empty() {
            timed = timed.with_state(e.state_json.clone());
        }
        saga.add(timed);
    }
    saga
}

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

    #[test]
    fn uimsg_kind_labels_match_serde_tag() {
        let m = UiMsg::click(10.0, 20.0, "#submit");
        assert_eq!(m.kind(), "click");
        let json = serde_json::to_string(&m).unwrap();
        assert!(json.contains(r#""kind":"click""#), "tagged repr: {json}");
        assert!(json.contains(r#""target":"#));
    }

    #[test]
    fn saga_bridges_to_actionable_saga_events() {
        use crate::event::EventKind;
        let saga = Saga::new("checkout")
            .push(UiMsg::nav("/checkout").at(0).with_state(r#"{"step":1}"#))
            .push(UiMsg::pointer_move(1.0, 2.0).at(10)) // dropped (not actionable)
            .push(UiMsg::click(3.0, 4.0, "💳 Pay").at(20))
            .push(UiMsg::key("Enter").at(30)); // dropped
        let events = saga.to_saga_events();
        assert_eq!(events.len(), 2, "only Nav + Click bridge across");
        assert_eq!(events[0].kind, EventKind::Nav);
        assert_eq!(events[0].route, "/checkout");
        assert_eq!(events[0].state_json, r#"{"step":1}"#);
        assert_eq!(events[1].kind, EventKind::Click);
        assert_eq!(events[1].component, "💳 Pay");
    }

    #[test]
    fn uimsg_roundtrips_through_json() {
        for m in [
            UiMsg::pointer_move(1.0, 2.0),
            UiMsg::click(3.0, 4.0, "#a"),
            UiMsg::scroll(0.0, 120.0),
            UiMsg::focus("#name"),
            UiMsg::nav("/checkout"),
            UiMsg::field_edit("#name", "Alice"),
            UiMsg::key("Enter"),
            UiMsg::note("hello"),
        ] {
            let j = serde_json::to_string(&m).unwrap();
            let back: UiMsg = serde_json::from_str(&j).unwrap();
            assert_eq!(back, m, "UiMsg survives JSON roundtrip");
        }
    }

    #[test]
    fn saga_to_ui_folds_events_to_movements_with_monotonic_time() {
        use crate::event::{SagaEvent, SessionCtx};
        let ctx = SessionCtx::new("s-1", "alice", "njord", "0.9.0", "abc123");
        // A 3-event nav / click / nav session, stamped with real (increasing) ts.
        let mut evs = vec![
            SagaEvent::nav("/kund", "KundList", "open", r#"{"route":"/kund"}"#).in_session(&ctx),
            SagaEvent::click("/kund", "OffertForm", "submit", r#"{"submitted":true}"#).in_session(&ctx),
            SagaEvent::nav("/offert/7", "OffertForm", "open", r#"{"route":"/offert/7"}"#).in_session(&ctx),
        ];
        for (i, e) in evs.iter_mut().enumerate() {
            e.seq = i as u64;
            e.ts_micros = 1_000_000 + (i as u64) * 250_000; // +250ms apart
        }

        let saga = saga_to_ui(&evs);
        assert_eq!(saga.len(), 3, "nav/click/nav → 3 movements");
        // Monotonic, relative-to-first t_ms (0, 250, 500 ms).
        assert!(
            saga.events.windows(2).all(|w| w[0].t_ms <= w[1].t_ms),
            "t_ms is non-decreasing"
        );
        assert_eq!(saga.events[0].t_ms, 0, "first movement is t=0");
        assert_eq!(saga.events[1].t_ms, 250);
        assert_eq!(saga.events[2].t_ms, 500);
        // The event kinds mapped to the right movements + carried state.
        assert_eq!(saga.events[0].msg, UiMsg::nav("/kund"));
        assert_eq!(saga.events[1].msg, UiMsg::click(0.0, 0.0, "OffertForm"));
        assert_eq!(saga.events[2].msg, UiMsg::nav("/offert/7"));
        assert_eq!(saga.events[1].state_json.as_deref(), Some(r#"{"submitted":true}"#));
        // Title carries the recorded app identity.
        assert!(saga.title.contains("njord"), "title names the app: {}", saga.title);
    }

    #[test]
    fn saga_to_ui_is_the_inverse_of_to_saga_events() {
        // A round-trip through BOTH bridges preserves the actionable movement
        // sequence (Nav route + Click target + snapshot) — RED if either bridge
        // drops or reorders the actionable events.
        let saga = Saga::new("checkout")
            .push(UiMsg::nav("/checkout").at(0).with_state(r#"{"step":1}"#))
            .push(UiMsg::pointer_move(1.0, 2.0).at(10)) // non-actionable, dropped
            .push(UiMsg::click(3.0, 4.0, "💳 Pay").at(20).with_state(r#"{"paid":true}"#))
            .push(UiMsg::nav("/done").at(30));
        let events = saga.to_saga_events();
        let back = saga_to_ui(&events);
        assert_eq!(back.len(), 3, "only the 2 nav + 1 click survive the round-trip");
        assert_eq!(back.events[0].msg, UiMsg::nav("/checkout"));
        assert_eq!(back.events[1].msg, UiMsg::click(0.0, 0.0, "💳 Pay"));
        assert_eq!(back.events[2].msg, UiMsg::nav("/done"));
        assert_eq!(back.events[0].state_json.as_deref(), Some(r#"{"step":1}"#));
        assert_eq!(back.events[1].state_json.as_deref(), Some(r#"{"paid":true}"#));
    }

    #[test]
    fn saga_to_ui_orders_by_capture_seq_not_input_order() {
        use crate::event::{SagaEvent, SessionCtx};
        let ctx = SessionCtx::new("s", "u", "njord", "1.0", "sha");
        // Fed out of order; the bridge must re-sort by seq.
        let mk = |seq: u64, route: &str| {
            let mut e = SagaEvent::nav(route, "P", "open", "{}").in_session(&ctx);
            e.seq = seq;
            e
        };
        let evs = vec![mk(2, "/c"), mk(0, "/a"), mk(1, "/b")];
        let saga = saga_to_ui(&evs);
        let routes: Vec<&UiMsg> = saga.events.iter().map(|e| &e.msg).collect();
        assert_eq!(routes[0], &UiMsg::nav("/a"));
        assert_eq!(routes[1], &UiMsg::nav("/b"));
        assert_eq!(routes[2], &UiMsg::nav("/c"));
    }

    #[test]
    fn note_is_the_only_non_drivable() {
        assert!(!UiMsg::note("x").is_drivable());
        assert!(UiMsg::pointer_move(0.0, 0.0).is_drivable());
        assert!(UiMsg::nav("/").is_drivable());
    }

    #[test]
    fn saga_roundtrips_and_reports_duration() {
        let saga = Saga::new("demo")
            .push(UiMsg::nav("/").at(0))
            .push(UiMsg::pointer_move(10.0, 10.0).at(100))
            .push(UiMsg::click(10.0, 10.0, "#go").at(250).with_state(r#"{"route":"/"}"#));
        assert_eq!(saga.len(), 3);
        assert_eq!(saga.duration_ms(), 250);
        let json = saga.to_json();
        let back = Saga::from_json(&json).unwrap();
        assert_eq!(back, saga, "Saga survives a JSON roundtrip");
        // the state snapshot on the last event is preserved.
        assert_eq!(back.events[2].state_json.as_deref(), Some(r#"{"route":"/"}"#));
        // events with no snapshot omit the field entirely.
        assert!(!json.contains(r#""state_json":null"#), "None snapshot is skipped, not null");
    }
}