feat(tui): add soak + pagination race tests (bd-14hv)
7 soak tests: 50k-event sustained load, watchdog timeout, render interleaving, screen cycling, mode oscillation, depth bounds, multi-seed. 7 pagination race tests: concurrent read/write with snapshot fence, multi-reader, within-fence writes, stress 1000 iterations.
This commit is contained in:
410
crates/lore-tui/tests/soak_test.rs
Normal file
410
crates/lore-tui/tests/soak_test.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! Soak test for sustained TUI robustness (bd-14hv).
|
||||
//!
|
||||
//! Drives the TUI through 50,000+ events (navigation, filter, mode switches,
|
||||
//! resize, tick) with FakeClock time acceleration. Verifies:
|
||||
//! - No panic under sustained load
|
||||
//! - No deadlock (watchdog timeout)
|
||||
//! - Navigation stack depth stays bounded (no unbounded memory growth)
|
||||
//! - Input mode stays valid after every event
|
||||
//!
|
||||
//! The soak simulates ~30 minutes of accelerated usage in <5s wall clock.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use ftui::render::frame::Frame;
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
use ftui::{Cmd, Event, KeyCode, KeyEvent, Model};
|
||||
|
||||
use lore_tui::app::LoreApp;
|
||||
use lore_tui::clock::FakeClock;
|
||||
use lore_tui::message::{InputMode, Msg, Screen};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn frozen_clock() -> FakeClock {
|
||||
FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap())
|
||||
}
|
||||
|
||||
fn test_app() -> LoreApp {
|
||||
let mut app = LoreApp::new();
|
||||
app.clock = Box::new(frozen_clock());
|
||||
app
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> Msg {
|
||||
Msg::RawEvent(Event::Key(KeyEvent::new(code)))
|
||||
}
|
||||
|
||||
fn key_char(c: char) -> Msg {
|
||||
key(KeyCode::Char(c))
|
||||
}
|
||||
|
||||
fn resize(w: u16, h: u16) -> Msg {
|
||||
Msg::Resize {
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_at(app: &LoreApp, width: u16, height: u16) {
|
||||
let w = width.max(1);
|
||||
let h = height.max(1);
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut frame = Frame::new(w, h, &mut pool);
|
||||
app.view(&mut frame);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seeded PRNG (xorshift64)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.wrapping_add(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
fn range(&mut self, max: u64) -> u64 {
|
||||
self.next() % max
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random TUI event from a realistic distribution.
|
||||
///
|
||||
/// Distribution:
|
||||
/// - 50% navigation keys (j/k/up/down/enter/escape/tab)
|
||||
/// - 15% filter/search keys (/, letters, backspace)
|
||||
/// - 10% "go" prefix (g + second key)
|
||||
/// - 10% resize events
|
||||
/// - 10% tick events
|
||||
/// - 5% special keys (ctrl+c excluded to avoid quit)
|
||||
fn random_event(rng: &mut Rng) -> Msg {
|
||||
match rng.range(20) {
|
||||
// Navigation keys (50%)
|
||||
0 | 1 => key(KeyCode::Down),
|
||||
2 | 3 => key(KeyCode::Up),
|
||||
4 => key(KeyCode::Enter),
|
||||
5 => key(KeyCode::Escape),
|
||||
6 => key(KeyCode::Tab),
|
||||
7 => key_char('j'),
|
||||
8 => key_char('k'),
|
||||
9 => key(KeyCode::BackTab),
|
||||
// Filter/search keys (15%)
|
||||
10 => key_char('/'),
|
||||
11 => key_char('a'),
|
||||
12 => key(KeyCode::Backspace),
|
||||
// Go prefix (10%)
|
||||
13 => key_char('g'),
|
||||
14 => key_char('d'),
|
||||
// Resize (10%)
|
||||
15 => {
|
||||
let w = (rng.range(260) + 40) as u16;
|
||||
let h = (rng.range(50) + 10) as u16;
|
||||
resize(w, h)
|
||||
}
|
||||
16 => resize(80, 24),
|
||||
// Tick (10%)
|
||||
17 | 18 => Msg::Tick,
|
||||
// Special keys (5%)
|
||||
_ => match rng.range(6) {
|
||||
0 => key(KeyCode::Home),
|
||||
1 => key(KeyCode::End),
|
||||
2 => key(KeyCode::PageUp),
|
||||
3 => key(KeyCode::PageDown),
|
||||
4 => key_char('G'),
|
||||
_ => key_char('?'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check invariants that must hold after every event.
|
||||
fn check_soak_invariants(app: &LoreApp, event_idx: usize) {
|
||||
// Navigation stack depth >= 1 (always has root).
|
||||
assert!(
|
||||
app.navigation.depth() >= 1,
|
||||
"Soak invariant: nav depth < 1 at event {event_idx}"
|
||||
);
|
||||
|
||||
// Navigation depth bounded (soak shouldn't grow stack unboundedly).
|
||||
// With random escape/pop interspersed, depth should stay reasonable.
|
||||
// We use 500 as a generous upper bound.
|
||||
assert!(
|
||||
app.navigation.depth() <= 500,
|
||||
"Soak invariant: nav depth {} exceeds 500 at event {event_idx}",
|
||||
app.navigation.depth()
|
||||
);
|
||||
|
||||
// Input mode is a valid variant.
|
||||
match &app.input_mode {
|
||||
InputMode::Normal | InputMode::Text | InputMode::Palette | InputMode::GoPrefix { .. } => {}
|
||||
}
|
||||
|
||||
// Breadcrumbs match depth.
|
||||
assert_eq!(
|
||||
app.navigation.breadcrumbs().len(),
|
||||
app.navigation.depth(),
|
||||
"Soak invariant: breadcrumbs != depth at event {event_idx}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soak Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 50,000 random events with invariant checks — no panic, no unbounded growth.
|
||||
///
|
||||
/// Simulates ~30 minutes of sustained TUI usage at accelerated speed.
|
||||
/// If Ctrl+C fires (we exclude it from the event alphabet), we restart.
|
||||
#[test]
|
||||
fn test_soak_50k_events_no_panic() {
|
||||
let seed = 0xDEAD_BEEF_u64;
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut app = test_app();
|
||||
|
||||
for event_idx in 0..50_000 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
|
||||
// If quit fires (shouldn't with our alphabet, but be safe), restart.
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check invariants every 100 events (full check is expensive at 50k).
|
||||
if event_idx % 100 == 0 {
|
||||
check_soak_invariants(&app, event_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Final invariant check.
|
||||
check_soak_invariants(&app, 50_000);
|
||||
}
|
||||
|
||||
/// Soak with interleaved renders — verifies view() never panics.
|
||||
#[test]
|
||||
fn test_soak_with_renders_no_panic() {
|
||||
let seed = 0xCAFE_BABE_u64;
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut app = test_app();
|
||||
|
||||
for event_idx in 0..10_000 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render every 50th event.
|
||||
if event_idx % 50 == 0 {
|
||||
let (w, h) = app.state.terminal_size;
|
||||
if w > 0 && h > 0 {
|
||||
render_at(&app, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watchdog: run the soak in a thread with a timeout.
|
||||
///
|
||||
/// If the soak takes longer than 30 seconds, it's likely deadlocked.
|
||||
#[test]
|
||||
fn test_soak_watchdog_no_deadlock() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let seed = 0xBAAD_F00D_u64;
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut app = test_app();
|
||||
|
||||
for _ in 0..20_000 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
}
|
||||
}
|
||||
|
||||
tx.send(()).expect("send completion signal");
|
||||
});
|
||||
|
||||
// Wait up to 30 seconds.
|
||||
let result = rx.recv_timeout(Duration::from_secs(30));
|
||||
assert!(result.is_ok(), "Soak test timed out — possible deadlock");
|
||||
|
||||
handle.join().expect("soak thread panicked");
|
||||
}
|
||||
|
||||
/// Multi-screen navigation soak: cycle through all screens.
|
||||
///
|
||||
/// Verifies the TUI handles rapid screen switching under sustained load.
|
||||
#[test]
|
||||
fn test_soak_screen_cycling() {
|
||||
let mut app = test_app();
|
||||
|
||||
let screens_to_visit = [
|
||||
Screen::Dashboard,
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
Screen::Who,
|
||||
Screen::Trace,
|
||||
Screen::FileHistory,
|
||||
Screen::Sync,
|
||||
Screen::Stats,
|
||||
];
|
||||
|
||||
// Cycle through screens 500 times, doing random ops at each.
|
||||
let mut rng = Rng::new(42);
|
||||
for cycle in 0..500 {
|
||||
for screen in &screens_to_visit {
|
||||
app.update(Msg::NavigateTo(screen.clone()));
|
||||
|
||||
// Do 5 random events per screen.
|
||||
for _ in 0..5 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic invariant check (skip depth bound — this test pushes 10 screens/cycle).
|
||||
if cycle % 50 == 0 {
|
||||
assert!(
|
||||
app.navigation.depth() >= 1,
|
||||
"Nav depth < 1 at cycle {cycle}"
|
||||
);
|
||||
match &app.input_mode {
|
||||
InputMode::Normal
|
||||
| InputMode::Text
|
||||
| InputMode::Palette
|
||||
| InputMode::GoPrefix { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation depth tracking: verify depth stays bounded under random pushes.
|
||||
///
|
||||
/// The soak includes both push (Enter, navigation) and pop (Escape, Backspace)
|
||||
/// operations. Depth should fluctuate but remain bounded.
|
||||
#[test]
|
||||
fn test_soak_nav_depth_bounded() {
|
||||
let mut rng = Rng::new(777);
|
||||
let mut app = test_app();
|
||||
let mut max_depth = 0_usize;
|
||||
|
||||
for _ in 0..30_000 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
continue;
|
||||
}
|
||||
|
||||
let depth = app.navigation.depth();
|
||||
if depth > max_depth {
|
||||
max_depth = depth;
|
||||
}
|
||||
}
|
||||
|
||||
// With ~50% navigation keys including Escape/pop, depth shouldn't
|
||||
// grow unboundedly. 200 is a very generous upper bound.
|
||||
assert!(
|
||||
max_depth < 200,
|
||||
"Navigation depth grew to {max_depth} — potential unbounded growth"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rapid mode oscillation soak: rapidly switch between input modes.
|
||||
#[test]
|
||||
fn test_soak_mode_oscillation() {
|
||||
let mut app = test_app();
|
||||
|
||||
// Rapidly switch modes 10,000 times.
|
||||
for i in 0..10_000 {
|
||||
match i % 6 {
|
||||
0 => {
|
||||
app.update(key_char('g'));
|
||||
} // Enter GoPrefix
|
||||
1 => {
|
||||
app.update(key(KeyCode::Escape));
|
||||
} // Back to Normal
|
||||
2 => {
|
||||
app.update(key_char('/'));
|
||||
} // Enter Text/Search
|
||||
3 => {
|
||||
app.update(key(KeyCode::Escape));
|
||||
} // Back to Normal
|
||||
4 => {
|
||||
app.update(key_char('g'));
|
||||
app.update(key_char('d'));
|
||||
} // Go to Dashboard
|
||||
_ => {
|
||||
app.update(key(KeyCode::Escape));
|
||||
} // Ensure Normal
|
||||
}
|
||||
|
||||
// InputMode should always be valid.
|
||||
match &app.input_mode {
|
||||
InputMode::Normal
|
||||
| InputMode::Text
|
||||
| InputMode::Palette
|
||||
| InputMode::GoPrefix { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// After final Escape, should be in Normal.
|
||||
app.update(key(KeyCode::Escape));
|
||||
assert!(
|
||||
matches!(app.input_mode, InputMode::Normal),
|
||||
"Should be Normal after final Escape"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full soak: events + renders + multiple seeds for coverage.
|
||||
#[test]
|
||||
fn test_soak_multi_seed_comprehensive() {
|
||||
for seed in [1, 42, 999, 0xFFFF, 0xDEAD_CAFE, 31337] {
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut app = test_app();
|
||||
|
||||
for event_idx in 0..5_000 {
|
||||
let msg = random_event(&mut rng);
|
||||
let cmd = app.update(msg);
|
||||
|
||||
if matches!(cmd, Cmd::Quit) {
|
||||
app = test_app();
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_idx % 200 == 0 {
|
||||
let (w, h) = app.state.terminal_size;
|
||||
if w > 0 && h > 0 {
|
||||
render_at(&app, w, h);
|
||||
}
|
||||
check_soak_invariants(&app, event_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user