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/synthetic.rs

//! **Synthetic sagas** — deterministic recorded sagas for demos + tests, so the
//! whole viewer / replay / web / driver stack is exercisable with **no live
//! Njord and no real browser**.
//!
//! [`demo_saga`] is a small, hand-authored "checkout" session: navigate in,
//! sweep the pointer along a curve, click a button, scroll, focus + type a
//! field, then navigate away — enough movement that the live view visibly moves
//! and the trail is non-trivial.

use crate::ui::capture::SagaMsg;
use crate::ui::emit::{Emitter, UiSink};
use crate::ui::msg::{Saga, UiMsg};
use crate::ui::viewer::Viewer;

/// A deterministic demo saga: a scripted checkout flow. Same output every call
/// (no clock, no rng), so it doubles as a test fixture.
pub fn demo_saga() -> Saga {
    let mut saga = Saga::new("synthetic · checkout flow");
    let mut t = 0u64;
    let mut at = |saga: &mut Saga, dt: u64, msg: UiMsg| {
        t += dt;
        saga.add(msg.at(t));
    };

    at(&mut saga, 0, UiMsg::nav("/"));
    at(&mut saga, 120, UiMsg::note("landing page shown"));

    // Sweep the pointer along a smooth arc toward the "Shop" nav (24 points).
    for i in 0..24u64 {
        let f = i as f64 / 23.0;
        let x = 60.0 + f * 640.0;
        let y = 80.0 + (f * std::f64::consts::PI).sin() * 220.0;
        at(&mut saga, 40, UiMsg::pointer_move(x, y));
    }

    at(&mut saga, 60, UiMsg::click(700.0, 96.0, "#nav-shop"));
    at(&mut saga, 150, UiMsg::nav("/shop"));
    at(&mut saga, 200, UiMsg::scroll(0.0, 320.0));

    // Move down to a product, click it, land on the product page.
    for i in 0..12u64 {
        let f = i as f64 / 11.0;
        at(&mut saga, 45, UiMsg::pointer_move(360.0 + f * 40.0, 300.0 + f * 260.0));
    }
    at(&mut saga, 60, UiMsg::click(390.0, 560.0, "#product-42"));
    at(&mut saga, 150, UiMsg::nav("/product/42"));

    // Add to cart, go to checkout, fill the form.
    at(&mut saga, 220, UiMsg::pointer_move(520.0, 420.0));
    at(&mut saga, 60, UiMsg::click(520.0, 420.0, "#add-to-cart"));
    at(&mut saga, 300, UiMsg::nav("/checkout"));
    at(&mut saga, 180, UiMsg::focus("#name"));
    at(&mut saga, 90, UiMsg::field_edit("#name", "Alice Example"));
    at(&mut saga, 120, UiMsg::key("Tab"));
    at(&mut saga, 60, UiMsg::focus("#card"));
    at(&mut saga, 140, UiMsg::field_edit("#card", "4111 1111 1111 1111"));
    at(&mut saga, 200, UiMsg::pointer_move(640.0, 640.0));
    at(&mut saga, 60, UiMsg::click(640.0, 640.0, "#pay"));
    at(&mut saga, 250, UiMsg::nav("/receipt"));
    at(&mut saga, 100, UiMsg::note("order confirmed"));

    saga
}

// ─── the wire-against fixture: a synthetic saga-enabled ELM app ──────────────

/// A message the [`SyntheticApp`] reacts to — a stand-in for a real host's Elm
/// `Msg`. Includes `Tick` (a non-movement) so the projection's filtering is
/// exercised end-to-end, exactly like a real app's internal messages.
#[derive(Debug, Clone, PartialEq)]
pub enum AppMsg {
    /// The user navigated to a route.
    Nav(String),
    /// The pointer moved.
    Move(f64, f64),
    /// A click at a position on a target element.
    Click(f64, f64, String),
    /// Focus moved to a target.
    Focus(String),
    /// A field was typed into.
    Edit(String, String),
    /// A key was pressed.
    Key(String),
    /// The viewport scrolled.
    Scroll(f64, f64),
    /// An internal tick — NOT a UI movement (must be filtered out of the saga).
    Tick,
}

/// **The one projection** a saga-enabled host implements: map the app's `Msg` to
/// a [`UiMsg`], returning `None` for anything that isn't a UI movement.
impl SagaMsg for AppMsg {
    fn to_ui_msg(&self) -> Option<UiMsg> {
        Some(match self {
            AppMsg::Nav(r) => UiMsg::nav(r),
            AppMsg::Move(x, y) => UiMsg::pointer_move(*x, *y),
            AppMsg::Click(x, y, t) => UiMsg::click(*x, *y, t),
            AppMsg::Focus(t) => UiMsg::focus(t),
            AppMsg::Edit(t, v) => UiMsg::field_edit(t, v),
            AppMsg::Key(k) => UiMsg::key(k),
            AppMsg::Scroll(x, y) => UiMsg::scroll(*x, *y),
            AppMsg::Tick => return None, // internal — not a movement
        })
    }
}

/// A minimal **saga-enabled ELM app** — Model + `Msg` + `update`, with the ONE
/// tap at the `update` choke point — that emits a scripted [`AppMsg`] stream
/// through the [`Emitter`]/[`UiSink`] seam. It proves live-view + replay end to
/// end with **no real Njord and no browser**: drive it into a [`VecSink`] to get
/// a [`Saga`], or into a [`ChannelSink`](crate::ui::ChannelSink) to stream LIVE.
///
/// The app keeps its own [`Viewer`]-shaped model (folding each movement) so it
/// can stamp each emitted event with the `state_json` it observed — giving the
/// inject-assert law a fixture: a replay's independently re-folded model must
/// match the snapshot the app emitted.
pub struct SyntheticApp<S: UiSink> {
    /// The app's observable model — the same fold the viewer/replay perform, so
    /// the emitted `state_json` snapshots are directly comparable.
    model: Viewer,
    /// The emit seam the app feeds its projected movements to.
    emitter: Emitter<S>,
    /// A logical clock the script advances (deterministic — no wall clock).
    t_ms: u64,
}

impl<S: UiSink> SyntheticApp<S> {
    /// A new app feeding `sink`.
    pub fn new(sink: S) -> SyntheticApp<S> {
        SyntheticApp { model: Viewer::new(), emitter: Emitter::new(sink), t_ms: 0 }
    }

    /// **The Elm `update`** — the choke point. Every `Msg` flows through here;
    /// the ONE tap projects + stamps + emits the movement (carrying the model
    /// snapshot the app observed), then the app folds it into its own model.
    /// `dt_ms` is the logical time since the previous message.
    pub fn update(&mut self, dt_ms: u64, msg: AppMsg) {
        self.t_ms += dt_ms;
        if let Some(ui) = msg.to_ui_msg() {
            // Fold first so the snapshot reflects the state AFTER applying it —
            // exactly what a saga-enabled host observes right after `update`.
            self.model.apply(self.t_ms, &ui);
            self.emitter
                .emit_at_with_state(self.t_ms, ui, self.model.state_json());
        }
        // (a real app would also mutate its business model here)
    }

    /// The app's current observable model as `state_json`.
    pub fn state_json(&self) -> String {
        self.model.state_json()
    }

    /// Finish the session: close the sink (ends a LIVE stream) and return it.
    pub fn finish(mut self) -> S {
        self.emitter.close();
        self.emitter.into_sink()
    }
}

/// The deterministic scripted `(dt_ms, AppMsg)` stream the [`SyntheticApp`]
/// plays. Mirrors [`demo_saga`]'s checkout flow but as *app* messages (including
/// filtered `Tick`s), so driving it through the emit seam reproduces the saga.
pub fn synthetic_script() -> Vec<(u64, AppMsg)> {
    let mut s = Vec::new();
    s.push((0, AppMsg::Nav("/".into())));
    s.push((60, AppMsg::Tick)); // internal — filtered out of the saga
    for i in 0..12u64 {
        let f = i as f64 / 11.0;
        s.push((30, AppMsg::Move(60.0 + f * 640.0, 80.0 + f * 200.0)));
    }
    s.push((60, AppMsg::Click(700.0, 96.0, "#nav-shop".into())));
    s.push((150, AppMsg::Nav("/shop".into())));
    s.push((200, AppMsg::Scroll(0.0, 320.0)));
    s.push((90, AppMsg::Tick)); // internal — filtered
    s.push((60, AppMsg::Click(390.0, 560.0, "#product-42".into())));
    s.push((150, AppMsg::Nav("/product/42".into())));
    s.push((300, AppMsg::Nav("/checkout".into())));
    s.push((180, AppMsg::Focus("#name".into())));
    s.push((90, AppMsg::Edit("#name".into(), "Alice Example".into())));
    s.push((120, AppMsg::Key("Tab".into())));
    s.push((60, AppMsg::Focus("#card".into())));
    s.push((140, AppMsg::Edit("#card".into(), "4111 1111 1111 1111".into())));
    s.push((60, AppMsg::Click(640.0, 640.0, "#pay".into())));
    s.push((250, AppMsg::Nav("/receipt".into())));
    s
}

/// Drive the [`synthetic_script`] through a [`SyntheticApp`] into `sink`,
/// returning the sink. `VecSink` → a saga you can persist/replay; `ChannelSink`
/// → a LIVE stream a viewer thread folds. This is the one call the wire-against
/// fixture (and the round-trip tests) use to prove capture end to end without a
/// real host.
pub fn wire_synthetic<S: UiSink>(sink: S) -> S {
    let mut app = SyntheticApp::new(sink);
    for (dt, msg) in synthetic_script() {
        app.update(dt, msg);
    }
    app.finish()
}

/// A convenience: the wired synthetic saga as a titled [`Saga`], with each event
/// carrying the app's observed `state_json` snapshot (for inject-assert replay).
/// Equivalent to `wire_synthetic(VecSink::new()).into_saga(title)`.
pub fn wired_saga(title: &str) -> Saga {
    use crate::ui::emit::VecSink;
    wire_synthetic(VecSink::new()).into_saga(title)
}

/// The `UiMsg` movements the [`synthetic_script`] emits, in order (drops the
/// filtered `Tick`s) — the expected capture, for asserting against a saga.
pub fn synthetic_movements() -> Vec<UiMsg> {
    synthetic_script().iter().filter_map(|(_, m)| m.to_ui_msg()).collect()
}

/// The final movement count a wired synthetic saga should contain (movements
/// only; `Tick`s filtered).
pub fn synthetic_movement_count() -> usize {
    synthetic_movements().len()
}

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

    #[test]
    fn demo_saga_is_deterministic_and_well_formed() {
        let a = demo_saga();
        let b = demo_saga();
        assert_eq!(a, b, "synthetic saga is deterministic");
        assert!(a.len() > 40, "enough movement to animate");
        // t_ms is strictly non-decreasing.
        assert!(a.events.windows(2).all(|w| w[1].t_ms >= w[0].t_ms), "time non-decreasing");
        // It touches every interesting movement kind.
        let kinds: std::collections::HashSet<_> = a.events.iter().map(|e| e.msg.kind()).collect();
        for k in ["nav", "pointer_move", "click", "scroll", "focus", "field_edit", "key", "note"] {
            assert!(kinds.contains(k), "demo saga exercises {k}");
        }
    }
}