tests/saga_roundtrip.rs
//! **EPIC SAGA / task #25** — end-to-end round-trip of the record/replay
//! foundation through nornir's REAL warehouse + the egui replay driver:
//!
//! synthesize SagaEvents → `IcebergSagaSink` (a tempdir warehouse) → read back
//! → `ReplayPlan` → assert the `ReplayStep`s match the recorded journey → turn
//! the plan into a robot `Scenario` and drive a tiny egui app → assert each
//! replay step filled a matrix row (a recorded session became a robot test).
//!
//! This proves the seam the design promises: a recorded user session replays as a
//! deterministic robot UI test whose failures are RED matrix rows.
use egui_kittest::Harness;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::saga_events::{
EventKind, IcebergSagaSink, SagaEvent, SagaSelector, SagaSink, SessionCtx, StepKind,
append_saga_events, query_saga_events, session_replay_plan,
};
use nornir_robotui::replay::plan_to_scenario;
use nornir_robotui::{Observable, RobotSession, status};
use serde_json::{Value, json};
/// A tiny egui app whose two buttons carry the SAME AccessKit labels the recorded
/// session's components use ("OffertForm", "Submit"), so the replayed scenario's
/// ClickLabel steps drive real widgets. `state_json` exposes the click count.
#[derive(Default)]
struct Journey {
opened: u32,
submitted: u32,
}
impl Observable for Journey {
fn state_json(&self) -> Value {
json!({ "opened": self.opened, "submitted": self.submitted })
}
}
fn ctx() -> SessionCtx {
SessionCtx::new("sess-rt", "alice", "njord", "0.9.0", "abc123")
}
/// The recorded journey: open the form (nav), a log line (noise), submit (click).
fn recorded_session() -> Vec<SagaEvent> {
let c = ctx();
let mut nav = SagaEvent::nav("/offert/7", "OffertForm", "open", r#"{"id":7}"#).in_session(&c);
let mut log = SagaEvent::log("info", "loaded form").in_session(&c);
let mut click =
SagaEvent::click("/offert/7", "Submit", "submit", r#"{"ok":true}"#).in_session(&c);
nav.seq = 0;
nav.ts_micros = 100;
log.seq = 1;
log.ts_micros = 101;
click.seq = 2;
click.ts_micros = 102;
vec![nav, log, click]
}
#[test]
fn record_sink_readback_replay_match_round_trip() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
// 1. SINK the synthesized events through the warehouse-backed SagaSink trait.
let events = recorded_session();
let sink = IcebergSagaSink::new(&wh);
sink.write(&events).expect("sink the recorded session");
// 2. READ BACK — same events, sorted by (session, seq), payload intact.
let back = wh
.block_on(query_saga_events(
&wh,
&SagaSelector::Session("sess-rt".into()),
))
.expect("read the session back");
assert_eq!(back.len(), 3, "all three captured events round-tripped");
assert!(
back.windows(2).all(|w| w[0].seq < w[1].seq),
"sorted by seq"
);
let click = back.iter().find(|e| e.component == "Submit").unwrap();
assert_eq!(click.kind, EventKind::Click);
assert_eq!(
click.state_json, r#"{"ok":true}"#,
"PII-free state round-trips exactly"
);
// 3. REPLAY PLAN — only the actionable (nav/click) events become steps.
let plan = wh
.block_on(session_replay_plan(&wh, "sess-rt"))
.expect("build the replay plan");
assert_eq!(plan.steps.len(), 2, "the log line is context, not a step");
assert_eq!(plan.app, "njord");
assert_eq!(plan.steps[0].kind, StepKind::Navigate);
assert_eq!(plan.steps[0].component, "OffertForm");
assert_eq!(plan.steps[1].kind, StepKind::Activate);
assert_eq!(plan.steps[1].component, "Submit");
assert_eq!(
plan.steps[1].source_seq, 2,
"the step names its recorded moment"
);
}
#[test]
fn replay_plan_drives_egui_and_fills_the_matrix() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
wh.block_on(append_saga_events(&wh, &recorded_session()))
.unwrap();
let plan = wh.block_on(session_replay_plan(&wh, "sess-rt")).unwrap();
// The replay plan → a robot Scenario (the egui driver, nornir-robotui).
let scenario = plan_to_scenario(&plan);
assert_eq!(scenario.suite, "saga-replay:sess-rt");
assert_eq!(
scenario.steps.len(),
2,
"one robot step per replayable event"
);
// Drive the SAME-labelled egui app and fill the matrix.
let harness = Harness::new_ui_state(
|ui, app: &mut Journey| {
if ui.button("OffertForm").clicked() {
app.opened += 1;
}
if ui.button("Submit").clicked() {
app.submitted += 1;
}
},
Journey::default(),
);
let mut robot = RobotSession::new(harness, "saga-replay");
let rows = robot.run_scenario(&scenario);
// Each replay step became a matrix row, all green (the journey reproduced).
assert_eq!(
rows.len(),
2,
"the recorded session is now a 2-row matrix run"
);
assert!(rows.iter().all(|r| r.suite == "saga-replay:sess-rt"));
assert!(rows.iter().all(|r| r.aspect == "robot-ui"));
let run_id = &rows[0].run_id;
assert!(rows.iter().all(|r| &r.run_id == run_id), "one matrix run");
for r in &rows {
assert_eq!(
r.status,
status::PASS,
"step {:?} replayed: {}",
r.test_name,
r.message
);
}
// The replay actually drove the real widgets.
assert_eq!(
robot.state_at("/opened"),
Some(json!(1)),
"the nav step clicked OffertForm"
);
assert_eq!(
robot.state_at("/submitted"),
Some(json!(1)),
"the click step clicked Submit"
);
}
/// A replay step whose target widget is GONE → a RED matrix row (the regression a
/// recorded session catches). Drive the same plan against an app missing the
/// Submit button: the activate step fails, proving a broken replay is red, not a panic.
#[test]
fn replay_against_a_regressed_app_yields_a_red_matrix_row() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
wh.block_on(append_saga_events(&wh, &recorded_session()))
.unwrap();
let plan = wh.block_on(session_replay_plan(&wh, "sess-rt")).unwrap();
let scenario = plan_to_scenario(&plan);
// The app LOST the "Submit" button (a regression) — only OffertForm remains.
let harness = Harness::new_ui_state(
|ui, app: &mut Journey| {
if ui.button("OffertForm").clicked() {
app.opened += 1;
}
},
Journey::default(),
);
let mut robot = RobotSession::new(harness, "saga-replay");
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].status, status::PASS, "the nav step still works");
assert_eq!(
rows[1].status,
status::FAIL,
"the activate step is RED — the recorded journey caught the missing Submit button"
);
assert!(
rows[1].message.contains("Submit"),
"the red row names the vanished widget: {}",
rows[1].message
);
}