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