crates/nornir-saga/src/ui/player.rs
//! **The replay engine** — re-dispatch a recorded saga's movements through the
//! [`Viewer`] Elm model, **time-scaled, seekable, and deterministic**.
//!
//! Determinism: [`Viewer::update`] is a pure function of `(msg, model)`, so a
//! given saga always folds to the same model sequence — [`Player::frames`]
//! returns that canonical sequence, and [`Player::seek`] reconstructs any point
//! by re-folding from the start (no hidden state).
//!
//! Time-scaling: [`Player::speed`] scales the *gaps* between successive `t_ms`
//! values. The player itself does not sleep (it is runtime-agnostic); a host —
//! the CLI loop or the web SSE task — reads [`Player::next_delay_ms`] and paces
//! accordingly. This keeps the engine pure and testable (delay math is asserted
//! without any real waiting).
use crate::ui::msg::{Saga, TimedUiMsg};
use crate::ui::viewer::{Viewer, ViewerMsg};
/// One replayed frame: the time it plays at, the movement applied, and the
/// resulting viewer `state_json`. The deterministic unit of a replay.
#[derive(Debug, Clone, PartialEq)]
pub struct Frame {
/// Index of the movement in the saga.
pub index: usize,
/// The saga time (ms) of the movement.
pub t_ms: u64,
/// The movement applied to reach this frame.
pub msg: crate::ui::msg::UiMsg,
/// The viewer `state_json` AFTER applying the movement.
pub state_json: String,
/// The source `state_json` snapshot the saga carried for this movement, if
/// any — replay can assert the folded model matches what the source saw.
pub expect_state: Option<String>,
}
/// A seekable, time-scaled, deterministic replay of a [`Saga`] through a
/// [`Viewer`].
#[derive(Debug, Clone)]
pub struct Player {
saga: Saga,
viewer: Viewer,
/// Index of the NEXT movement to apply (0..=len). `len` == finished.
pos: usize,
/// Playback speed multiplier (1.0 = real time, 2.0 = twice as fast). The
/// delay between frames is divided by this; must be > 0.
speed: f64,
}
impl Player {
/// A player positioned at the start of `saga` at real-time speed.
pub fn new(saga: Saga) -> Player {
Player { saga, viewer: Viewer::new(), pos: 0, speed: 1.0 }
}
/// Set the playback speed (clamped to a sane positive range). Builder-style.
pub fn with_speed(mut self, speed: f64) -> Player {
self.set_speed(speed);
self
}
/// Set the playback speed in place (clamped to `[0.05, 100.0]`).
pub fn set_speed(&mut self, speed: f64) {
self.speed = if speed.is_finite() { speed.clamp(0.05, 100.0) } else { 1.0 };
}
/// The current speed multiplier.
pub fn speed(&self) -> f64 {
self.speed
}
/// Number of movements in the saga.
pub fn len(&self) -> usize {
self.saga.len()
}
/// Is the saga empty?
pub fn is_empty(&self) -> bool {
self.saga.is_empty()
}
/// Total saga span (ms).
pub fn duration_ms(&self) -> u64 {
self.saga.duration_ms()
}
/// Index of the next movement to apply (`len()` when finished).
pub fn pos(&self) -> usize {
self.pos
}
/// Has the whole saga been played?
pub fn finished(&self) -> bool {
self.pos >= self.saga.len()
}
/// The viewer's current `state_json`.
pub fn state_json(&self) -> String {
self.viewer.state_json()
}
/// Borrow the viewer (for reading the model).
pub fn viewer(&self) -> &Viewer {
&self.viewer
}
/// Apply the next movement and return its [`Frame`], or `None` at end.
pub fn step(&mut self) -> Option<Frame> {
let ev = self.saga.events.get(self.pos)?.clone();
let idx = self.pos;
let _ = self.viewer.update(ViewerMsg::Apply { t_ms: ev.t_ms, msg: ev.msg.clone() });
self.pos += 1;
Some(Frame {
index: idx,
t_ms: ev.t_ms,
msg: ev.msg,
state_json: self.viewer.state_json(),
expect_state: ev.state_json,
})
}
/// **Seek** to `index` (0..=len): reset the viewer and re-fold movements
/// `[0, index)`. Deterministic — the model at `index` is identical whether
/// reached by stepping or by seeking. Returns the resulting `state_json`.
pub fn seek(&mut self, index: usize) -> String {
let target = index.min(self.saga.len());
let _ = self.viewer.update(ViewerMsg::Reset);
for ev in &self.saga.events[0..target] {
let _ = self.viewer.update(ViewerMsg::Apply { t_ms: ev.t_ms, msg: ev.msg.clone() });
}
self.pos = target;
self.viewer.state_json()
}
/// Seek to the position just after the last movement with `t_ms <= at_ms`
/// (time-based scrubbing). Returns the resulting `state_json`.
pub fn seek_ms(&mut self, at_ms: u64) -> String {
let index = self.saga.events.iter().take_while(|e| e.t_ms <= at_ms).count();
self.seek(index)
}
/// The scaled delay (ms) a host should wait BEFORE playing the movement at
/// the current `pos` — i.e. the gap from the previously-applied movement,
/// divided by [`speed`](Self::speed). `0` for the first movement or when
/// finished.
pub fn next_delay_ms(&self) -> u64 {
if self.pos == 0 || self.pos >= self.saga.len() {
return 0;
}
let prev = self.saga.events[self.pos - 1].t_ms;
let next = self.saga.events[self.pos].t_ms;
let gap = next.saturating_sub(prev) as f64;
(gap / self.speed).round() as u64
}
/// Rewind to the start (viewer reset, `pos = 0`).
pub fn rewind(&mut self) {
let _ = self.viewer.update(ViewerMsg::Reset);
self.pos = 0;
}
/// The full deterministic frame sequence for the saga, from a clean start.
/// Does not disturb the player's cursor (`pos`) — it folds a private viewer.
pub fn frames(&self) -> Vec<Frame> {
let mut v = Viewer::new();
let mut out = Vec::with_capacity(self.saga.len());
for (idx, ev) in self.saga.events.iter().enumerate() {
let _ = v.update(ViewerMsg::Apply { t_ms: ev.t_ms, msg: ev.msg.clone() });
out.push(Frame {
index: idx,
t_ms: ev.t_ms,
msg: ev.msg.clone(),
state_json: v.state_json(),
expect_state: ev.state_json.clone(),
});
}
out
}
/// Borrow the saga being replayed.
pub fn saga(&self) -> &Saga {
&self.saga
}
/// The `TimedUiMsg` at `index`, if any.
pub fn event(&self, index: usize) -> Option<&TimedUiMsg> {
self.saga.events.get(index)
}
/// **Verify** this replay against the `state_json` snapshots the source
/// captured — the inject-assert law. Re-fold every frame and compare it to
/// what the source observed; frames the source left un-snapshotted are
/// skipped (nothing to assert). Also checks the **rollback** path — a
/// reset-and-re-fold (what [`seek`](Self::seek) / [`rewind`](Self::rewind)
/// do, the engine's compensation/undo) must reproduce the forward replay
/// exactly.
///
/// Under the `testmatrix` feature each checked frame emits a
/// `functional_status` row (step-success / step-fail), the rollback check
/// emits one, and a session roll-up lands — so replaying a captured journey
/// FILLS the test matrix. With the feature OFF the verdict is still computed;
/// only the emits compile away.
pub fn verify(&self) -> ReplayVerdict {
let frames = self.frames();
let mut v = ReplayVerdict::default();
for f in &frames {
let Some(expect) = f.expect_state.as_deref() else {
continue;
};
v.checked += 1;
let ok = f.state_json == expect;
if ok {
v.matched += 1;
} else {
v.diverged += 1;
}
emit_status(
"replay_step",
ok,
&format!("frame {} · {} · t={}ms", f.index, f.msg.kind(), f.t_ms),
);
}
// Rollback / compensation: a fresh viewer re-folded from the start (what
// `seek(0)` → `seek(len)` performs) must reproduce the forward replay's
// final model — replay's undo is deterministic or it is nothing.
let forward_end = frames
.last()
.map(|f| f.state_json.clone())
.unwrap_or_else(|| Viewer::new().state_json());
let mut probe = self.clone();
let _ = probe.seek(0);
let rollback_end = probe.seek(probe.len());
v.rollback_ok = rollback_end == forward_end;
emit_status(
"replay_rollback",
v.rollback_ok,
&format!("{} frames re-fold deterministically", frames.len()),
);
emit_status(
"replay",
v.ok(),
&format!(
"{}/{} frames matched · rollback {}",
v.matched,
v.checked,
if v.rollback_ok { "ok" } else { "DIVERGED" }
),
);
v
}
}
/// The outcome of [`Player::verify`]: how many frames were checked against their
/// captured snapshot, how many re-folded to it, how many **diverged**, and
/// whether the rollback (reset + re-fold) reproduced the forward replay.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ReplayVerdict {
/// Frames that carried a source snapshot to assert against.
pub checked: usize,
/// Frames whose re-folded model matched the captured snapshot.
pub matched: usize,
/// Frames whose re-folded model DIVERGED from the captured snapshot.
pub diverged: usize,
/// Whether the rollback path (reset + re-fold) reproduced the forward replay.
pub rollback_ok: bool,
}
impl ReplayVerdict {
/// A clean verdict: nothing diverged and the rollback was deterministic.
pub fn ok(&self) -> bool {
self.diverged == 0 && self.rollback_ok
}
}
/// Emit one replay `functional_status` marker (feature `testmatrix`); a no-op
/// otherwise. The component is fixed to the crate so all replay rows cluster.
#[cfg(feature = "testmatrix")]
#[inline]
fn emit_status(check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status("nornir-saga", check, ok, detail);
}
/// No-op emit when `testmatrix` is OFF — the verdict logic still runs; only the
/// matrix row is compiled away.
#[cfg(not(feature = "testmatrix"))]
#[inline]
fn emit_status(_check: &str, _ok: bool, _detail: &str) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::msg::UiMsg;
fn demo() -> Saga {
Saga::new("determinism")
.push(UiMsg::nav("/a").at(0))
.push(UiMsg::pointer_move(10.0, 10.0).at(100))
.push(UiMsg::pointer_move(20.0, 20.0).at(200))
.push(UiMsg::click(20.0, 20.0, "#go").at(300))
.push(UiMsg::nav("/b").at(500))
}
#[test]
fn same_saga_yields_the_same_model_sequence() {
let p1 = Player::new(demo());
let p2 = Player::new(demo());
let f1: Vec<String> = p1.frames().into_iter().map(|f| f.state_json).collect();
let f2: Vec<String> = p2.frames().into_iter().map(|f| f.state_json).collect();
assert_eq!(f1, f2, "replay is deterministic: identical model sequence");
assert_eq!(f1.len(), 5);
}
#[test]
fn stepping_and_seeking_agree() {
// Step to the end, recording each frame's state.
let mut stepper = Player::new(demo());
let mut stepped = Vec::new();
while let Some(f) = stepper.step() {
stepped.push(f.state_json);
}
// Seeking to each index k must reproduce the state after k steps.
let mut seeker = Player::new(demo());
for k in 1..=demo().len() {
let s = seeker.seek(k);
assert_eq!(s, stepped[k - 1], "seek({k}) == state after {k} steps");
}
}
#[test]
fn seek_ms_scrubs_by_time() {
let mut p = Player::new(demo());
// at t=250ms, three movements (t=0,100,200) have played.
let s = p.seek_ms(250);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["applied"], 3);
assert_eq!(v["route"], "/a", "the /b nav at t=500 has not played yet");
}
#[test]
fn speed_scales_the_inter_frame_delay() {
let mut p = Player::new(demo()).with_speed(2.0);
// Move to pos=2 (next gap is 200 - 100 = 100ms of saga time).
p.step();
p.step();
assert_eq!(p.next_delay_ms(), 50, "100ms gap / 2x speed = 50ms");
// First frame has no preceding gap.
p.rewind();
assert_eq!(p.next_delay_ms(), 0);
}
#[test]
fn speed_is_clamped_to_positive() {
let p = Player::new(demo()).with_speed(0.0);
assert!(p.speed() >= 0.05, "zero/negative speed clamped positive");
}
#[test]
fn verify_matches_captured_snapshots_and_rollback() {
// The wired synthetic saga stamps the app's observed snapshot on every
// movement, so an independent replay must re-fold to each one and the
// rollback (reset + re-fold) must reproduce the forward replay.
let saga = crate::ui::wired_saga("verify");
let verdict = Player::new(saga).verify();
assert!(verdict.checked > 0, "every event carried a snapshot to check");
assert_eq!(verdict.diverged, 0, "replay re-folds to every captured snapshot");
assert!(verdict.rollback_ok, "seek rollback reproduces the forward replay");
assert!(verdict.ok());
}
#[test]
fn verify_flags_a_tampered_snapshot() {
// Corrupt one captured snapshot → verify must catch the divergence.
let mut saga = crate::ui::wired_saga("bad");
let ev = saga
.events
.iter_mut()
.find(|e| e.state_json.is_some())
.expect("a snapshotted event");
ev.state_json = Some(r#"{"lie":true}"#.into());
let verdict = Player::new(saga).verify();
assert!(verdict.diverged >= 1, "the tampered snapshot is caught");
assert!(!verdict.ok(), "a divergence is not a clean verdict");
}
#[test]
fn verify_of_an_empty_saga_is_clean() {
let verdict = Player::new(Saga::new("empty")).verify();
assert_eq!(verdict.checked, 0);
assert!(verdict.rollback_ok, "empty rollback is trivially deterministic");
assert!(verdict.ok());
}
}