crates/nornir-saga/src/analysis.rs
//! **Frustration / stuck detection** — the "SEE where the user got stuck" half of
//! Saga (roadmap §R.d, folding in *PostHog frustration signals*).
//!
//! A recorded session is an ordered [`SagaEvent`] stream. This module reads that
//! stream (never a live app, never a backend — plain data, like the rest of the
//! lean crate) and derives a per-session [`SessionFrustration`]: how many
//! **rage-click** clusters it contains (a user hammering one component with no
//! navigation making progress) and which **routes the user got stuck on** (a route
//! that errored repeatedly). Both are the classic signals that a real user hit a
//! wall — exactly the sessions a dev should triage first.
//!
//! The output is deliberately a **monotone [`SessionFrustration::score`]** plus an
//! [`SessionFrustration::is_stuck`] flag so a caller can *float the painful
//! sessions to the top* of the Sessions list. [`rank_by_frustration`] does that
//! ordering. Detection is by **capture order** (`seq`), not wall-clock, so it is
//! deterministic and independent of timing skew — the same discipline as
//! [`crate::replay`].
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::event::{EventKind, SagaEvent};
/// A run of this many **consecutive** clicks on the SAME component with no
/// intervening navigation counts as one rage-click cluster. Three is the smallest
/// run that reads as "hammering, not just double-clicking".
pub const RAGE_CLICK_RUN: usize = 3;
/// A route that produces this many `error` events (within one session) is flagged
/// as a route the user got **stuck** on.
pub const REPEATED_ERROR_RUN: usize = 2;
/// The frustration signals derived for ONE session. Ordering by
/// [`Self::score`] (desc) floats the painful sessions to the top of the list.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionFrustration {
/// The session these signals were derived for.
pub session_id: String,
/// Number of rage-click clusters — runs of `>= RAGE_CLICK_RUN` consecutive
/// clicks on one component with no navigation in between.
pub rage_clusters: usize,
/// The longest run of consecutive same-component clicks seen (context for the
/// scrubber / hover; `0` if there were no clicks).
pub max_click_run: usize,
/// Routes the user got stuck on — each errored `>= REPEATED_ERROR_RUN` times.
/// Sorted + de-duplicated so the output is stable.
pub stuck_routes: Vec<String>,
/// Total `error` events in the session (all routes).
pub error_count: usize,
/// A monotone frustration score for ranking (higher = more painful). Not a
/// unit — only its ordering is meaningful.
pub score: u32,
/// Whether the session is flagged **stuck**: it has at least one rage-click
/// cluster OR at least one stuck route.
pub is_stuck: bool,
}
impl SessionFrustration {
/// Derive the frustration signals for `session_id` out of a (possibly
/// mixed-session, unordered) slice of events: scope to the session, sort by
/// capture `seq`, then walk once detecting rage clusters + counting errors
/// per route.
pub fn for_session(session_id: &str, events: &[SagaEvent]) -> SessionFrustration {
let mut mine: Vec<&SagaEvent> =
events.iter().filter(|e| e.session_id == session_id).collect();
mine.sort_by_key(|e| e.seq);
// Rage-click clusters: walk in capture order. A click extends the current
// run when it targets the same (route, component) as the previous click;
// a NAV resets the run (the user made progress). Logs/errors/state are
// context and do NOT break a run.
let mut cur_key: Option<(&str, &str)> = None;
let mut run: usize = 0;
let mut max_click_run: usize = 0;
let mut rage_clusters: usize = 0;
let mut counted_this_cluster = false;
// Errors per route (fall back to component when the adapter left route
// blank, e.g. a bare `SagaEvent::error(component, ...)`).
let mut errors_by_route: BTreeMap<&str, usize> = BTreeMap::new();
let mut error_count: usize = 0;
for e in &mine {
match e.kind {
EventKind::Click => {
let key = (e.route.as_str(), e.component.as_str());
if cur_key == Some(key) {
run += 1;
} else {
cur_key = Some(key);
run = 1;
counted_this_cluster = false;
}
if run > max_click_run {
max_click_run = run;
}
if run >= RAGE_CLICK_RUN && !counted_this_cluster {
rage_clusters += 1;
counted_this_cluster = true;
}
}
EventKind::Nav => {
// Navigation = progress → the click run resets.
cur_key = None;
run = 0;
counted_this_cluster = false;
}
EventKind::Error => {
error_count += 1;
let key = if e.route.is_empty() {
e.component.as_str()
} else {
e.route.as_str()
};
*errors_by_route.entry(key).or_insert(0) += 1;
}
EventKind::Log | EventKind::State => {}
}
}
let mut stuck_routes: Vec<String> = errors_by_route
.into_iter()
.filter(|(_, n)| *n >= REPEATED_ERROR_RUN)
.map(|(route, _)| route.to_string())
.collect();
stuck_routes.sort();
stuck_routes.dedup();
let is_stuck = rage_clusters > 0 || !stuck_routes.is_empty();
// Rage clusters and stuck routes each dominate raw error volume; error
// volume is the tie-breaker so a noisier painful session still outranks a
// quieter one with the same structure.
let score = (rage_clusters as u32) * 100
+ (stuck_routes.len() as u32) * 100
+ (error_count as u32);
SessionFrustration {
session_id: session_id.to_string(),
rage_clusters,
max_click_run,
stuck_routes,
error_count,
score,
is_stuck,
}
}
}
/// Derive [`SessionFrustration`] for EVERY session present in `events`, one per
/// distinct `session_id`.
pub fn frustration_by_session(events: &[SagaEvent]) -> Vec<SessionFrustration> {
let mut seen: Vec<&str> = Vec::new();
for e in events {
if !seen.iter().any(|id| *id == e.session_id) {
seen.push(&e.session_id);
}
}
seen.into_iter()
.map(|id| SessionFrustration::for_session(id, events))
.collect()
}
/// Order a set of frustration signals **painful-first**: highest [`score`] first,
/// ties broken by `session_id` for a stable result. This is the ordering that
/// floats the stuck sessions to the top of the Sessions list.
///
/// [`score`]: SessionFrustration::score
pub fn rank_by_frustration(mut fr: Vec<SessionFrustration>) -> Vec<SessionFrustration> {
fr.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.session_id.cmp(&b.session_id))
});
fr
}
// ─── session roll-up (the Sessions-list row model, painful-first) ────────────
/// One row of the Saga **Sessions list**: a recorded session rolled up — its id,
/// app, user, git sha, event + step + error counts, whether it is replayable, when
/// it started, and its frustration signals ([`stuck`](Self::stuck) /
/// [`frustration_score`](Self::frustration_score)) so painful sessions float to the
/// top. Pure (plain data over `&[SagaEvent]`) so BOTH the nornir viz Sessions pane
/// and the dwarves tape-recorder pane render the SAME rows — the warehouse-coupled
/// `list_saga_sessions` in nornir simply reads the events back and calls
/// [`summarize_sessions`] (REUSE — one home, consumed by both).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaSessionSummary {
pub session_id: String,
pub app: String,
pub user: String,
pub git_sha: String,
/// Total captured events (all kinds).
pub event_count: usize,
/// Actionable (replayable) steps — `nav` + `click`.
pub step_count: usize,
/// Distinct error events captured in the session.
pub error_count: usize,
/// Earliest capture timestamp (micros) — when the session started.
pub started_micros: u64,
/// Whether the session has at least one replayable step.
pub replayable: bool,
/// Whether the session is flagged **stuck** (a rage-click cluster or a route the
/// user hit repeated errors on — see [`SessionFrustration`]). Painful sessions
/// float to the top so a dev triages them first.
pub stuck: bool,
/// A monotone frustration score for ranking (higher = more painful). Only its
/// ordering is meaningful; drives the "float to top" sort.
pub frustration_score: u32,
}
/// Roll a flat (possibly mixed-session, unordered) event slice up into one
/// [`SagaSessionSummary`] per session, ordered **painful-first**: highest
/// frustration score, then most-recent (`started_micros` desc), then `session_id`
/// for a stable order — so a dev triages the stuck sessions before the calm ones.
///
/// Folds in the [`frustration_by_session`] signals (rage clusters + stuck routes) in
/// one extra pass over the same events. This is the exact roll-up nornir's
/// warehouse-backed `list_saga_sessions` performs after reading `saga_events` back;
/// keeping it here (the lean crate) lets a warehouse-free consumer (a `.jsonl` tape,
/// the dwarves pane) build the identical list without any backend.
pub fn summarize_sessions(events: &[SagaEvent]) -> Vec<SagaSessionSummary> {
let mut by_session: BTreeMap<String, SagaSessionSummary> = BTreeMap::new();
for e in events {
let s = by_session
.entry(e.session_id.clone())
.or_insert_with(|| SagaSessionSummary {
session_id: e.session_id.clone(),
app: e.app.clone(),
user: e.user.clone(),
git_sha: e.git_sha.clone(),
event_count: 0,
step_count: 0,
error_count: 0,
started_micros: e.ts_micros,
replayable: false,
stuck: false,
frustration_score: 0,
});
s.event_count += 1;
if e.kind.is_actionable() {
s.step_count += 1;
s.replayable = true;
}
if e.kind == EventKind::Error {
s.error_count += 1;
}
if e.ts_micros > 0 && (s.started_micros == 0 || e.ts_micros < s.started_micros) {
s.started_micros = e.ts_micros;
}
// Keep the first non-empty identity fields seen.
if s.app.is_empty() {
s.app = e.app.clone();
}
if s.user.is_empty() {
s.user = e.user.clone();
}
}
// Fold in the frustration signals (rage-click clusters + stuck routes).
for fr in frustration_by_session(events) {
if let Some(s) = by_session.get_mut(&fr.session_id) {
s.stuck = fr.is_stuck;
s.frustration_score = fr.score;
}
}
let mut out: Vec<SagaSessionSummary> = by_session.into_values().collect();
out.sort_by(|a, b| {
b.frustration_score
.cmp(&a.frustration_score)
.then(b.started_micros.cmp(&a.started_micros))
.then(a.session_id.cmp(&b.session_id))
});
out
}
// ─── nav-path / funnel aggregation (dev-cockpit `saga path`) ─────────────────
/// One transition out of a funnel step: where sessions went NEXT, and how many.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavTransition {
/// The next route sessions moved to from this step.
pub to: String,
/// How many sessions took this transition.
pub count: usize,
}
/// One ordered step of a [`NavFunnel`]: the modal route at this position, how many
/// sessions reached it, how many stopped here (drop-off), and where the rest went.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FunnelStep {
/// Zero-based position in the ordered route funnel.
pub position: usize,
/// The dominant (most common, ties broken lexicographically) route at this
/// position across the session set.
pub route: String,
/// Sessions whose nav path was at least this long (reached this step).
pub reached: usize,
/// Sessions that reached this step but did NOT proceed to the next one — the
/// funnel drop-off at this step.
pub dropped: usize,
/// Where sessions went next, most-taken first.
pub next: Vec<NavTransition>,
}
/// An ordered **navigation funnel** folded from the `Nav` events of a session set:
/// route → sessions-reached, per-step drop-off, and next-route transitions. Pure
/// (plain data, no backend) so both the `saga path` CLI verb and the dev-cockpit
/// tab render the same aggregation. Detection is by capture `seq`, deterministic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct NavFunnel {
/// The ordered funnel steps (position 0 = entry).
pub steps: Vec<FunnelStep>,
/// Number of distinct sessions with at least one nav event.
pub session_count: usize,
}
impl NavFunnel {
/// Fold the `Nav` events of `events` (any number of sessions) into an ordered
/// route funnel. Each session contributes its ordered nav route sequence
/// (consecutive duplicates collapsed — a re-render on the same route is not a
/// step). An empty / nav-less stream yields an empty funnel.
pub fn from_events(events: &[SagaEvent]) -> NavFunnel {
// Per-session ordered nav route paths (by capture seq), consecutive dups
// collapsed. BTreeMap keeps session iteration deterministic.
let mut by_session: BTreeMap<&str, Vec<&SagaEvent>> = BTreeMap::new();
for e in events {
if e.kind == EventKind::Nav {
by_session.entry(e.session_id.as_str()).or_default().push(e);
}
}
let mut paths: Vec<Vec<&str>> = Vec::new();
for (_sid, mut evs) in by_session {
evs.sort_by_key(|e| e.seq);
let mut path: Vec<&str> = Vec::new();
for e in evs {
if path.last().copied() != Some(e.route.as_str()) {
path.push(e.route.as_str());
}
}
if !path.is_empty() {
paths.push(path);
}
}
let session_count = paths.len();
let max_len = paths.iter().map(|p| p.len()).max().unwrap_or(0);
let mut steps = Vec::with_capacity(max_len);
for i in 0..max_len {
// reached = sessions whose path is at least this long.
let reached = paths.iter().filter(|p| p.len() > i).count();
// Modal route at this position (ties broken lexicographically for a
// stable, deterministic label).
let mut tally: BTreeMap<&str, usize> = BTreeMap::new();
for p in &paths {
if let Some(r) = p.get(i) {
*tally.entry(*r).or_insert(0) += 1;
}
}
let route = tally
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(r, _)| r.to_string())
.unwrap_or_default();
// Next-route transitions from this position.
let mut nexts: BTreeMap<&str, usize> = BTreeMap::new();
for p in &paths {
if p.len() > i {
if let Some(next) = p.get(i + 1) {
*nexts.entry(*next).or_insert(0) += 1;
}
}
}
let proceeded: usize = nexts.values().sum();
let dropped = reached - proceeded;
let mut next: Vec<NavTransition> = nexts
.into_iter()
.map(|(to, count)| NavTransition { to: to.to_string(), count })
.collect();
// Most-taken first, ties broken by route for stability.
next.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.to.cmp(&b.to)));
steps.push(FunnelStep { position: i, route, reached, dropped, next });
}
NavFunnel { steps, session_count }
}
/// Serialize the funnel to pretty JSON (the `saga path --json` payload).
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::SessionCtx;
fn seq(mut e: SagaEvent, n: u64) -> SagaEvent {
e.seq = n;
e
}
fn ctx() -> SessionCtx {
SessionCtx::new("s1", "bob", "njord", "1.0", "sha")
}
#[test]
fn three_consecutive_clicks_on_one_component_is_a_rage_cluster() {
let c = ctx();
let events = vec![
seq(SagaEvent::nav("/offert/7", "OffertForm", "open", "{}").in_session(&c), 0),
seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 1),
seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 2),
seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 3),
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.rage_clusters, 1, "three same-component clicks = one cluster");
assert_eq!(fr.max_click_run, 3);
assert!(fr.is_stuck, "a rage cluster flags the session stuck");
assert!(fr.score > 0);
}
#[test]
fn navigation_between_clicks_breaks_the_run() {
let c = ctx();
// Two clicks, a nav (progress!), then two more — no run reaches 3.
let events = vec![
seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0),
seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 1),
seq(SagaEvent::nav("/b", "Page", "open", "{}").in_session(&c), 2),
seq(SagaEvent::click("/b", "Btn", "x", "{}").in_session(&c), 3),
seq(SagaEvent::click("/b", "Btn", "x", "{}").in_session(&c), 4),
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.rage_clusters, 0, "navigation reset the click run");
assert_eq!(fr.max_click_run, 2);
assert!(!fr.is_stuck);
}
#[test]
fn context_events_do_not_break_a_rage_run() {
let c = ctx();
// A log + an error interleave the clicks but must NOT reset the run.
let events = vec![
seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0),
seq(SagaEvent::log("info", "noise").in_session(&c), 1),
seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 2),
seq(SagaEvent::state("Btn", "{}").in_session(&c), 3),
seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 4),
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.rage_clusters, 1, "log/state between clicks are context, not progress");
}
#[test]
fn switching_component_resets_the_run() {
let c = ctx();
let events = vec![
seq(SagaEvent::click("/a", "One", "x", "{}").in_session(&c), 0),
seq(SagaEvent::click("/a", "One", "x", "{}").in_session(&c), 1),
seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&c), 2),
seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&c), 3),
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.rage_clusters, 0, "no single component hit 3 in a row");
assert_eq!(fr.max_click_run, 2);
}
#[test]
fn repeated_error_on_a_route_is_a_stuck_route() {
let c = ctx();
let mut e1 = SagaEvent::error("OffertForm", "500").in_session(&c);
e1.route = "/offert/7".into();
e1.seq = 1;
let mut e2 = SagaEvent::error("OffertForm", "500 again").in_session(&c);
e2.route = "/offert/7".into();
e2.seq = 2;
let events = vec![
seq(SagaEvent::nav("/offert/7", "OffertForm", "open", "{}").in_session(&c), 0),
e1,
e2,
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.error_count, 2);
assert_eq!(fr.stuck_routes, vec!["/offert/7".to_string()]);
assert!(fr.is_stuck, "two errors on one route = stuck");
}
#[test]
fn single_error_is_not_stuck() {
let c = ctx();
let mut e1 = SagaEvent::error("Form", "oops").in_session(&c);
e1.route = "/a".into();
e1.seq = 1;
let events = vec![
seq(SagaEvent::nav("/a", "Form", "open", "{}").in_session(&c), 0),
e1,
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.error_count, 1);
assert!(fr.stuck_routes.is_empty());
assert!(!fr.is_stuck, "one error is not being stuck");
}
#[test]
fn clean_session_scores_zero() {
let c = ctx();
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
seq(SagaEvent::click("/a", "Go", "submit", "{}").in_session(&c), 1),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 2),
];
let fr = SessionFrustration::for_session("s1", &events);
assert_eq!(fr.score, 0);
assert!(!fr.is_stuck);
assert_eq!(fr.rage_clusters, 0);
}
#[test]
fn ranking_floats_the_painful_session_to_the_top() {
let clean = SessionCtx::new("clean", "u", "njord", "1.0", "sha");
let rage = SessionCtx::new("rage", "u", "njord", "1.0", "sha");
let mut events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&clean), 0),
seq(SagaEvent::click("/a", "Go", "x", "{}").in_session(&clean), 1),
];
for s in 2..6 {
events.push(seq(
SagaEvent::click("/x", "Stuck", "retry", "{}").in_session(&rage),
s,
));
}
let ranked = rank_by_frustration(frustration_by_session(&events));
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].session_id, "rage", "the stuck session floats to the top");
assert!(ranked[0].is_stuck);
assert!(!ranked[1].is_stuck);
}
#[test]
fn frustration_is_scoped_per_session() {
let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
// sA rage-clicks; sB is clean. Detection must not bleed across sessions.
let events = vec![
seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 0),
seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&b), 1),
seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 2),
seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&b), 3),
seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 4),
];
let fa = SessionFrustration::for_session("sA", &events);
let fb = SessionFrustration::for_session("sB", &events);
assert_eq!(fa.rage_clusters, 1, "sA's 3 clicks are consecutive within its own stream");
assert_eq!(fb.rage_clusters, 0, "sB only clicked twice");
}
// ── session roll-up (painful-first Sessions list) ────────────────────────
#[test]
fn summarize_rolls_up_counts_and_replayable() {
let c = ctx();
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
seq(SagaEvent::log("info", "noise").in_session(&c), 1),
seq(SagaEvent::click("/a", "Go", "submit", "{}").in_session(&c), 2),
];
let sums = summarize_sessions(&events);
assert_eq!(sums.len(), 1);
let s = &sums[0];
assert_eq!(s.session_id, "s1");
assert_eq!(s.app, "njord");
assert_eq!(s.event_count, 3, "all kinds counted");
assert_eq!(s.step_count, 2, "nav + click are replayable steps");
assert!(s.replayable);
assert_eq!(s.error_count, 0);
}
#[test]
fn summarize_floats_the_painful_session_to_the_top() {
// A calm session started LATER, a rage-click session EARLIER: recency alone
// would rank the calm one first; the frustration score must override that.
let calm = SessionCtx::new("calm", "u", "njord", "1.0", "sha");
let rage = SessionCtx::new("rage", "u", "njord", "1.0", "sha");
let mut events = Vec::new();
for s in 0..3u64 {
let mut e = SagaEvent::click("/x", "Retry", "retry", "{}").in_session(&rage);
e.seq = s;
e.ts_micros = 100 + s; // earliest
events.push(e);
}
let mut e = SagaEvent::nav("/home", "Home", "open", "{}").in_session(&calm);
e.seq = 10;
e.ts_micros = 9000; // most recent
events.push(e);
let sums = summarize_sessions(&events);
assert_eq!(sums.len(), 2);
assert_eq!(sums[0].session_id, "rage", "the stuck session floats above the calmer, more-recent one");
assert!(sums[0].stuck);
assert!(sums[0].frustration_score > 0);
assert!(!sums[1].stuck);
assert_eq!(sums[1].frustration_score, 0);
}
// ── nav-path / funnel aggregation ────────────────────────────────────────
#[test]
fn nav_funnel_aggregates_ordered_routes_with_dropoff() {
// Two sessions: full /a→/b→/c, and a drop at /b (/a→/b only).
let full = SessionCtx::new("full", "u", "njord", "1.0", "sha");
let drop = SessionCtx::new("drop", "u", "njord", "1.0", "sha");
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&full), 0),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&full), 1),
seq(SagaEvent::nav("/c", "C", "open", "{}").in_session(&full), 2),
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&drop), 3),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&drop), 4),
];
let funnel = NavFunnel::from_events(&events);
assert_eq!(funnel.session_count, 2);
assert_eq!(funnel.steps.len(), 3, "ordered /a → /b → /c");
assert_eq!(funnel.steps[0].route, "/a");
assert_eq!(funnel.steps[0].reached, 2);
assert_eq!(funnel.steps[0].dropped, 0, "both proceed past /a");
assert_eq!(funnel.steps[0].next, vec![NavTransition { to: "/b".into(), count: 2 }]);
assert_eq!(funnel.steps[1].route, "/b");
assert_eq!(funnel.steps[1].reached, 2);
assert_eq!(funnel.steps[1].dropped, 1, "one session dropped at /b");
assert_eq!(funnel.steps[1].next, vec![NavTransition { to: "/c".into(), count: 1 }]);
assert_eq!(funnel.steps[2].route, "/c");
assert_eq!(funnel.steps[2].reached, 1);
assert_eq!(funnel.steps[2].dropped, 1, "terminal step is a drop-off");
assert!(funnel.steps[2].next.is_empty());
}
#[test]
fn nav_funnel_empty_stream_is_empty() {
let funnel = NavFunnel::from_events(&[]);
assert_eq!(funnel.session_count, 0);
assert!(funnel.steps.is_empty());
// A stream with only non-nav events is also an empty funnel.
let c = ctx();
let only_clicks =
vec![seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0)];
assert!(NavFunnel::from_events(&only_clicks).steps.is_empty());
}
#[test]
fn nav_funnel_collapses_consecutive_duplicate_routes() {
// A re-render on the same route is not a new funnel step.
let c = ctx();
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
seq(SagaEvent::nav("/a", "A", "rerender", "{}").in_session(&c), 1),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 2),
];
let funnel = NavFunnel::from_events(&events);
assert_eq!(funnel.session_count, 1);
assert_eq!(funnel.steps.len(), 2, "the duplicate /a collapses");
assert_eq!(funnel.steps[0].route, "/a");
assert_eq!(funnel.steps[1].route, "/b");
}
#[test]
fn nav_funnel_json_roundtrips() {
let c = ctx();
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 1),
];
let funnel = NavFunnel::from_events(&events);
let json = funnel.to_json();
let back: NavFunnel = serde_json::from_str(&json).unwrap();
assert_eq!(back, funnel, "funnel survives a JSON roundtrip");
assert!(json.contains("\"route\""));
}
}