crates/nornir-saga/tests/wire_against.rs
//! **Wire-against fixture** — prove live-view + replay end to end with a
//! synthetic saga-enabled ELM app, NO real Njord and NO browser.
//!
//! These are integration tests (they see only the crate's public surface), so
//! they double as executable documentation of the tap/emit → capture → persist →
//! source → player → viewer pipeline.
use std::sync::mpsc::channel;
use std::thread;
use nornir_saga::ui::{
synthetic_movement_count, synthetic_movements, wire_synthetic, wired_saga, ChannelSink,
ChannelSource, Emitter, Player, RecordedSource, Saga, SagaSource, SyntheticApp, VecSink, Viewer,
};
/// The whole point: drive the synthetic app through the EMIT seam into a
/// `VecSink`, persist the saga to JSON, reload it, replay it through the Player,
/// and assert the replayed final `state_json` equals a viewer folded directly
/// over the captured movements. Capture and replay must agree exactly.
#[test]
fn capture_persist_source_replay_roundtrip() {
// 1. CAPTURE — the app emits its scripted Msg stream through the emit seam.
let saga = wired_saga("njord@synthetic · checkout");
assert_eq!(
saga.len(),
synthetic_movement_count(),
"the two internal Tick msgs were filtered out of the saga"
);
// t_ms is non-decreasing (the Saga invariant).
assert!(
saga.events.windows(2).all(|w| w[1].t_ms >= w[0].t_ms),
"captured t_ms is non-decreasing"
);
// 2. PERSIST + reload (serde contract round-trip through the wire form).
let json = saga.to_json();
let reloaded = Saga::from_json(&json).expect("saga parses back");
assert_eq!(reloaded, saga, "saga survives a JSON round-trip byte-for-byte");
// 3. SOURCE — pull the reloaded saga through the source seam.
let mut src = RecordedSource::new(reloaded.clone());
let mut folded = Viewer::new();
while let Some(ev) = src.next() {
folded.apply(ev.t_ms, &ev.msg);
}
// 4. REPLAY — the Player folds the same saga independently.
let replayed = Player::new(reloaded.clone());
let last = replayed.frames().pop().expect("non-empty replay");
// 5. ASSERT — replay's final state_json == the independently-folded state.
assert_eq!(
last.state_json,
folded.state_json(),
"replayed final state matches the source-folded state"
);
// And it matches a viewer folded straight from the raw movement list.
let mut direct = Viewer::new();
for (i, m) in synthetic_movements().iter().enumerate() {
direct.apply(saga.events[i].t_ms, m);
}
assert_eq!(direct.state_json(), last.state_json, "raw-fold agrees too");
// The captured session actually went somewhere (ends on the receipt route).
let model: serde_json::Value = serde_json::from_str(&last.state_json).unwrap();
assert_eq!(model["route"], "/receipt");
assert!(model["counts"]["navs"].as_u64().unwrap() >= 4);
}
/// **Inject-assert law.** Each event the app emitted carries the `state_json` it
/// observed after that movement. An independent replay must re-fold to exactly
/// that snapshot at every step — the assertion a saga-enabled host relies on to
/// prove replay reproduces what it saw.
#[test]
fn replay_matches_every_captured_state_snapshot() {
let saga = wired_saga("inject-assert");
// every event carries a source snapshot (the app stamped one per movement).
assert!(
saga.events.iter().all(|e| e.state_json.is_some()),
"the fixture stamps a state snapshot on every movement"
);
let frames = Player::new(saga).frames();
for f in &frames {
let expected = f.expect_state.as_deref().expect("event carried a snapshot");
assert_eq!(
f.state_json, expected,
"replay frame {} re-folds to the captured snapshot",
f.index
);
}
}
/// **LIVE path** — the app emits through a `ChannelSink` on one thread while a
/// viewer folds a `ChannelSource` on another, exactly like a saga-enabled Njord
/// streaming to a live viewer. The live-folded model must equal the recorded
/// replay's final model.
#[test]
fn live_channel_stream_folds_to_the_same_model() {
let (tx, rx) = channel();
// Emitter thread: drive the synthetic app into a ChannelSink.
let producer = thread::spawn(move || {
wire_synthetic(ChannelSink::new(tx));
// sink (and its Sender) dropped here → the stream closes.
});
// Viewer thread: fold the live stream through the ChannelSource seam.
let mut src = ChannelSource::new(rx);
let mut live = Viewer::new();
let mut n = 0;
while let Some(ev) = src.next() {
live.apply(ev.t_ms, &ev.msg);
n += 1;
}
producer.join().unwrap();
assert_eq!(n, synthetic_movement_count(), "live viewer saw every movement");
// The live-folded model equals the recorded replay's final model.
let recorded = wired_saga("cmp");
let last = Player::new(recorded).frames().pop().unwrap();
assert_eq!(
live.state_json(),
last.state_json,
"LIVE fold == recorded replay fold (same Elm update, same result)"
);
}
/// The emit seam is transport-agnostic: the SAME app driven into different sinks
/// yields the same movement sequence. Proves `Emitter`/`UiSink` don't perturb the
/// stream.
#[test]
fn sink_choice_does_not_change_the_captured_stream() {
// VecSink capture.
let via_vec = wire_synthetic(VecSink::new()).into_saga("v");
// Manual Emitter over a VecSink, driving the app's projected msgs by hand.
let mut manual = Emitter::new(VecSink::new());
let mut app_model = Viewer::new();
let mut t = 0u64;
for (dt, msg) in nornir_saga::ui::synthetic_script() {
t += dt;
if let Some(ui) = <nornir_saga::ui::AppMsg as nornir_saga::ui::SagaMsg>::to_ui_msg(&msg) {
app_model.apply(t, &ui);
manual.emit_at_with_state(t, ui, app_model.state_json());
}
}
let via_manual = manual.into_sink().into_saga("v");
assert_eq!(via_vec.events, via_manual.events, "same movements regardless of driver");
}
/// A `SyntheticApp` used directly (not via the helper) still captures correctly —
/// exercises the public `SyntheticApp::update` / `state_json` / `finish` surface.
#[test]
fn synthetic_app_direct_use() {
let mut app = SyntheticApp::new(VecSink::new());
app.update(0, nornir_saga::ui::AppMsg::Nav("/home".into()));
app.update(50, nornir_saga::ui::AppMsg::Tick); // filtered
app.update(30, nornir_saga::ui::AppMsg::Click(10.0, 20.0, "#buy".into()));
let after_click = app.state_json();
let saga = app.finish().into_saga("direct");
assert_eq!(saga.len(), 2, "Tick filtered");
// the app's live model matches the last emitted snapshot.
assert_eq!(saga.events.last().unwrap().state_json.as_deref(), Some(after_click.as_str()));
}