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