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