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