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