test: add 11 scenario tests for runtime-dashboard (from 0)
EventStore cursor semantics (streaming, overflow, future cursor), recording pipeline (JSON roundtrip, destructive drain), StatsCollector multi-worker aggregation, and event sequence monotonicity. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
parent
edd9558f47
commit
63fd6d0d5f
1 changed files with 293 additions and 0 deletions
293
crates/runtime-dashboard/tests/dashboard_core.rs
Normal file
293
crates/runtime-dashboard/tests/dashboard_core.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use runtime_dashboard::collector::StatsCollector;
|
||||||
|
use runtime_dashboard::layer::{DashboardEvent, EventStore};
|
||||||
|
use runtime_dashboard::trace::RuntimeTrace;
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::stats::{ActorSnapshot, StatsHook};
|
||||||
|
|
||||||
|
fn make_event(message: &str) -> DashboardEvent {
|
||||||
|
DashboardEvent {
|
||||||
|
seq: 0, // filled by EventStore::push
|
||||||
|
timestamp_ms: 1000,
|
||||||
|
level: "INFO".into(),
|
||||||
|
message: message.into(),
|
||||||
|
worker_id: None,
|
||||||
|
fields: serde_json::Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EventStore: Streaming cursor semantics ──────────────────────────────
|
||||||
|
|
||||||
|
/// Scenario: Two clients consume the same event stream at different rates.
|
||||||
|
/// A fast client reads every event; a slow client joins late and catches up.
|
||||||
|
/// Both eventually see the same final event.
|
||||||
|
#[test]
|
||||||
|
fn two_clients_consuming_at_different_rates() {
|
||||||
|
let store = EventStore::new(100, false, 0);
|
||||||
|
|
||||||
|
// Fast client starts at cursor 0
|
||||||
|
let mut fast_cursor: u64 = 0;
|
||||||
|
|
||||||
|
// Push 5 events
|
||||||
|
for i in 0..5 {
|
||||||
|
store.push(make_event(&format!("event-{i}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast client reads all 5
|
||||||
|
let (batch, new_cursor) = store.read_from(fast_cursor);
|
||||||
|
assert_eq!(batch.len(), 5);
|
||||||
|
assert_eq!(batch[0].message, "event-0");
|
||||||
|
assert_eq!(batch[4].message, "event-4");
|
||||||
|
fast_cursor = new_cursor;
|
||||||
|
|
||||||
|
// Push 3 more
|
||||||
|
for i in 5..8 {
|
||||||
|
store.push(make_event(&format!("event-{i}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast client sees only new 3
|
||||||
|
let (batch, new_cursor) = store.read_from(fast_cursor);
|
||||||
|
assert_eq!(batch.len(), 3);
|
||||||
|
assert_eq!(batch[0].message, "event-5");
|
||||||
|
fast_cursor = new_cursor;
|
||||||
|
|
||||||
|
// Slow client joins now at cursor 0 — sees all 8
|
||||||
|
let (slow_batch, slow_cursor) = store.read_from(0);
|
||||||
|
assert_eq!(slow_batch.len(), 8);
|
||||||
|
assert_eq!(slow_batch[7].message, "event-7");
|
||||||
|
|
||||||
|
// Both cursors now agree
|
||||||
|
assert_eq!(fast_cursor, slow_cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: The event stream overflows the ring buffer.
|
||||||
|
/// A client that fell behind loses old events but gets the most recent window.
|
||||||
|
#[test]
|
||||||
|
fn ring_buffer_overflow_caps_old_cursors() {
|
||||||
|
let store = EventStore::new(10, false, 0);
|
||||||
|
|
||||||
|
// Push 25 events into a 10-capacity ring
|
||||||
|
for i in 0..25 {
|
||||||
|
store.push(make_event(&format!("event-{i}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client at cursor 0 gets only the most recent 10
|
||||||
|
let (batch, cursor) = store.read_from(0);
|
||||||
|
assert_eq!(batch.len(), 10);
|
||||||
|
assert_eq!(batch[0].message, "event-15");
|
||||||
|
assert_eq!(batch[9].message, "event-24");
|
||||||
|
assert_eq!(cursor, 25);
|
||||||
|
|
||||||
|
// A client already caught up gets nothing
|
||||||
|
let (batch, _) = store.read_from(cursor);
|
||||||
|
assert!(batch.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: Client has a cursor beyond the latest event (future cursor).
|
||||||
|
/// This can happen if events were trimmed. The client should get nothing, not panic.
|
||||||
|
#[test]
|
||||||
|
fn future_cursor_returns_empty() {
|
||||||
|
let store = EventStore::new(10, false, 0);
|
||||||
|
store.push(make_event("only-one"));
|
||||||
|
|
||||||
|
let (batch, cursor) = store.read_from(999);
|
||||||
|
assert!(batch.is_empty());
|
||||||
|
assert_eq!(cursor, 999); // cursor unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: Empty store — no events ever pushed.
|
||||||
|
#[test]
|
||||||
|
fn empty_store_returns_nothing() {
|
||||||
|
let store = EventStore::new(10, false, 0);
|
||||||
|
let (batch, cursor) = store.read_from(0);
|
||||||
|
assert!(batch.is_empty());
|
||||||
|
assert_eq!(cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EventStore: Recording pipeline ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Scenario: A monitoring session records events and saves a valid trace file.
|
||||||
|
/// Given: recording enabled, events flowing through the store
|
||||||
|
/// When: all_events() is called
|
||||||
|
/// Then: every event is available and the data round-trips through JSON
|
||||||
|
#[test]
|
||||||
|
fn recording_session_produces_replayable_trace() {
|
||||||
|
let store = EventStore::new(5, true, 100);
|
||||||
|
|
||||||
|
// Simulate a burst of runtime events
|
||||||
|
for i in 0..20 {
|
||||||
|
let mut ev = make_event(&format!("tick-{i}"));
|
||||||
|
ev.worker_id = Some(i % 3);
|
||||||
|
store.push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the recording log
|
||||||
|
let events = store.all_events().expect("recording should be enabled");
|
||||||
|
assert_eq!(events.len(), 20, "all 20 events should be in the recording");
|
||||||
|
|
||||||
|
// Build a trace and round-trip through JSON
|
||||||
|
let trace = RuntimeTrace {
|
||||||
|
events,
|
||||||
|
stats_timeline: Vec::new(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&trace).unwrap();
|
||||||
|
let restored: RuntimeTrace = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restored.events.len(), 20);
|
||||||
|
assert_eq!(restored.events[0].message, "tick-0");
|
||||||
|
assert_eq!(restored.events[19].message, "tick-19");
|
||||||
|
assert_eq!(restored.events[1].worker_id, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: Recording disabled — all_events returns None.
|
||||||
|
#[test]
|
||||||
|
fn no_recording_means_no_full_log() {
|
||||||
|
let store = EventStore::new(10, false, 0);
|
||||||
|
store.push(make_event("hello"));
|
||||||
|
assert!(store.all_events().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: all_events() is destructive — second call gets an empty vec.
|
||||||
|
#[test]
|
||||||
|
fn recording_drain_is_destructive() {
|
||||||
|
let store = EventStore::new(5, true, 100);
|
||||||
|
store.push(make_event("one"));
|
||||||
|
store.push(make_event("two"));
|
||||||
|
|
||||||
|
let first = store.all_events().unwrap();
|
||||||
|
assert_eq!(first.len(), 2);
|
||||||
|
|
||||||
|
let second = store.all_events().unwrap();
|
||||||
|
assert!(second.is_empty(), "second drain should get nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StatsCollector: Multi-worker snapshot aggregation ───────────────────
|
||||||
|
|
||||||
|
/// Scenario: Three workers each report actor snapshots independently.
|
||||||
|
/// The dashboard merges all workers' data into a single view.
|
||||||
|
#[test]
|
||||||
|
fn three_workers_report_independently_merged_view_is_complete() {
|
||||||
|
let collector = StatsCollector::new(3);
|
||||||
|
|
||||||
|
let addr_a = ActorAddress::new_random();
|
||||||
|
let addr_b = ActorAddress::new_random();
|
||||||
|
let addr_c = ActorAddress::new_random();
|
||||||
|
|
||||||
|
// Worker 0 reports 1 actor
|
||||||
|
collector.on_tick(0, &[ActorSnapshot {
|
||||||
|
address: addr_a,
|
||||||
|
mailbox_depth: 5,
|
||||||
|
last_msg_type: Some("Ping"),
|
||||||
|
messages_processed: 100,
|
||||||
|
poisoned: false,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Worker 1 reports 1 actor
|
||||||
|
collector.on_tick(1, &[ActorSnapshot {
|
||||||
|
address: addr_b,
|
||||||
|
mailbox_depth: 0,
|
||||||
|
last_msg_type: None,
|
||||||
|
messages_processed: 50,
|
||||||
|
poisoned: false,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Worker 2 reports 1 actor (poisoned)
|
||||||
|
collector.on_tick(2, &[ActorSnapshot {
|
||||||
|
address: addr_c,
|
||||||
|
mailbox_depth: 3,
|
||||||
|
last_msg_type: Some("BadMsg"),
|
||||||
|
messages_processed: 10,
|
||||||
|
poisoned: true,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Dashboard reads merged view
|
||||||
|
let details = collector.actor_details();
|
||||||
|
assert_eq!(details.len(), 3, "all 3 actors from 3 workers");
|
||||||
|
|
||||||
|
let info_a = details.iter().find(|d| d.address == addr_a).unwrap();
|
||||||
|
assert_eq!(info_a.worker_id, 0);
|
||||||
|
assert_eq!(info_a.mailbox_depth, 5);
|
||||||
|
assert_eq!(info_a.messages_processed, 100);
|
||||||
|
|
||||||
|
let info_c = details.iter().find(|d| d.address == addr_c).unwrap();
|
||||||
|
assert!(info_c.poisoned);
|
||||||
|
assert_eq!(info_c.worker_id, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: A worker updates its snapshots — old data is replaced, not accumulated.
|
||||||
|
#[test]
|
||||||
|
fn worker_update_replaces_stale_snapshot() {
|
||||||
|
let collector = StatsCollector::new(1);
|
||||||
|
let addr = ActorAddress::new_random();
|
||||||
|
|
||||||
|
// First tick: 1 actor with 10 messages
|
||||||
|
collector.on_tick(0, &[ActorSnapshot {
|
||||||
|
address: addr,
|
||||||
|
mailbox_depth: 5,
|
||||||
|
last_msg_type: None,
|
||||||
|
messages_processed: 10,
|
||||||
|
poisoned: false,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assert_eq!(collector.actor_details().len(), 1);
|
||||||
|
assert_eq!(collector.actor_details()[0].messages_processed, 10);
|
||||||
|
|
||||||
|
// Second tick: same actor now has 25 messages
|
||||||
|
collector.on_tick(0, &[ActorSnapshot {
|
||||||
|
address: addr,
|
||||||
|
mailbox_depth: 2,
|
||||||
|
last_msg_type: Some("Update"),
|
||||||
|
messages_processed: 25,
|
||||||
|
poisoned: false,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let details = collector.actor_details();
|
||||||
|
assert_eq!(details.len(), 1, "still 1 actor, not 2");
|
||||||
|
assert_eq!(details[0].messages_processed, 25);
|
||||||
|
assert_eq!(details[0].mailbox_depth, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenario: A worker reports zero actors (all stopped). Dashboard reflects empty.
|
||||||
|
#[test]
|
||||||
|
fn worker_reports_empty_after_all_actors_stop() {
|
||||||
|
let collector = StatsCollector::new(2);
|
||||||
|
let addr = ActorAddress::new_random();
|
||||||
|
|
||||||
|
// Worker 0 has actors
|
||||||
|
collector.on_tick(0, &[ActorSnapshot {
|
||||||
|
address: addr,
|
||||||
|
mailbox_depth: 0,
|
||||||
|
last_msg_type: None,
|
||||||
|
messages_processed: 5,
|
||||||
|
poisoned: false,
|
||||||
|
}]);
|
||||||
|
assert_eq!(collector.actor_details().len(), 1);
|
||||||
|
|
||||||
|
// Worker 0 reports empty (all actors stopped)
|
||||||
|
collector.on_tick(0, &[]);
|
||||||
|
assert!(collector.actor_details().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration: EventStore sequences are monotonically increasing ──────
|
||||||
|
|
||||||
|
/// Scenario: Push events from "multiple sources" — sequences never have gaps or duplicates.
|
||||||
|
#[test]
|
||||||
|
fn event_sequences_are_gap_free_and_monotonic() {
|
||||||
|
let store = Arc::new(EventStore::new(50, false, 0));
|
||||||
|
|
||||||
|
// Simulate interleaved pushes
|
||||||
|
for i in 0..30 {
|
||||||
|
let mut ev = make_event(&format!("source-{}-event", i % 3));
|
||||||
|
ev.worker_id = Some(i % 3);
|
||||||
|
store.push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (batch, _) = store.read_from(0);
|
||||||
|
assert_eq!(batch.len(), 30);
|
||||||
|
|
||||||
|
// Verify monotonic sequences with no gaps
|
||||||
|
for (i, ev) in batch.iter().enumerate() {
|
||||||
|
assert_eq!(ev.seq, i as u64, "seq should be monotonically increasing");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue