//! Stress and fuzz tests for TUI robustness (bd-nu0d). //! //! Verifies the TUI handles adverse conditions without panic: //! - Resize storms: 100 rapid resizes including degenerate sizes //! - Rapid keypresses: 50 keys in fast succession across modes //! - Event fuzz: 10k seeded deterministic event traces with invariant checks //! //! Fuzz seeds are logged at test start for reproduction. use chrono::{TimeZone, Utc}; use ftui::render::frame::Frame; use ftui::render::grapheme_pool::GraphemePool; use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers}; 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 ctrl_c() -> Msg { Msg::RawEvent(Event::Key( KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL), )) } fn resize(w: u16, h: u16) -> Msg { Msg::Resize { width: w, height: h, } } /// Render the app at a given size — panics if view() panics. fn render_at(app: &LoreApp, width: u16, height: u16) { // Clamp to at least 1x1 to avoid zero-size frame allocation. 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); } // --------------------------------------------------------------------------- // Resize Storm Tests // --------------------------------------------------------------------------- /// 100 rapid resize events with varying sizes — no panic, valid final state. #[test] fn test_resize_storm_no_panic() { let mut app = test_app(); let sizes: Vec<(u16, u16)> = (0..100) .map(|i| { // Vary between small and large sizes, including edge cases. let w = ((i * 7 + 13) % 281 + 20) as u16; // 20..300 let h = ((i * 11 + 3) % 71 + 10) as u16; // 10..80 (w, h) }) .collect(); for &(w, h) in &sizes { app.update(resize(w, h)); } // Final state should reflect last resize. let (last_w, last_h) = sizes[99]; assert_eq!(app.state.terminal_size, (last_w, last_h)); // Render at final size — must not panic. render_at(&app, last_w, last_h); } /// Resize to degenerate sizes (very small, zero-like) — no panic. #[test] fn test_resize_degenerate_sizes_no_panic() { let mut app = test_app(); let degenerate_sizes = [ (1, 1), (0, 0), (1, 0), (0, 1), (2, 2), (10, 1), (1, 10), (u16::MAX, 1), (1, u16::MAX), (80, 24), // Reset to normal. ]; for &(w, h) in °enerate_sizes { app.update(resize(w, h)); // Render must not panic even at degenerate sizes. render_at(&app, w, h); } } /// Resize storm interleaved with key events — no panic. #[test] fn test_resize_interleaved_with_keys() { let mut app = test_app(); for i in 0..50 { let w = (40 + i * 3) as u16; let h = (15 + i) as u16; app.update(resize(w, h)); // Send a navigation key between resizes. let cmd = app.update(key(KeyCode::Down)); assert!(!matches!(cmd, Cmd::Quit)); } // Final render at last size. render_at(&app, 40 + 49 * 3, 15 + 49); } // --------------------------------------------------------------------------- // Rapid Keypress Tests // --------------------------------------------------------------------------- /// 50 rapid key events mixing navigation, filter, and mode switches — no panic. #[test] fn test_rapid_keypress_no_panic() { let mut app = test_app(); let mut quit_seen = false; let keys = [ KeyCode::Down, KeyCode::Up, KeyCode::Enter, KeyCode::Escape, KeyCode::Tab, KeyCode::Char('j'), KeyCode::Char('k'), KeyCode::Char('/'), KeyCode::Char('g'), KeyCode::Char('i'), KeyCode::Char('g'), KeyCode::Char('m'), KeyCode::Escape, KeyCode::Char('?'), KeyCode::Escape, KeyCode::Char('g'), KeyCode::Char('d'), KeyCode::Down, KeyCode::Down, KeyCode::Enter, KeyCode::Escape, KeyCode::Char('g'), KeyCode::Char('s'), KeyCode::Char('r'), KeyCode::Char('e'), KeyCode::Char('t'), KeyCode::Char('r'), KeyCode::Char('y'), KeyCode::Enter, KeyCode::Escape, KeyCode::Backspace, KeyCode::Char('g'), KeyCode::Char('d'), KeyCode::Up, KeyCode::Up, KeyCode::Down, KeyCode::Home, KeyCode::End, KeyCode::PageDown, KeyCode::PageUp, KeyCode::Left, KeyCode::Right, KeyCode::Tab, KeyCode::BackTab, KeyCode::Char('G'), KeyCode::Char('1'), KeyCode::Char('2'), KeyCode::Char('3'), KeyCode::Delete, KeyCode::F(1), ]; for k in keys { let cmd = app.update(key(k)); if matches!(cmd, Cmd::Quit) { quit_seen = true; break; } } // Test that we didn't panic. If we quit early (via 'q' equivalent), that's fine. // The point is no panic. let _ = quit_seen; } /// Ctrl+C always exits regardless of input mode state. #[test] fn test_ctrl_c_exits_from_any_mode() { // Normal mode. let mut app = test_app(); assert!(matches!(app.update(ctrl_c()), Cmd::Quit)); // Text mode. let mut app = test_app(); app.input_mode = InputMode::Text; assert!(matches!(app.update(ctrl_c()), Cmd::Quit)); // Palette mode. let mut app = test_app(); app.input_mode = InputMode::Palette; assert!(matches!(app.update(ctrl_c()), Cmd::Quit)); // GoPrefix mode. let mut app = test_app(); app.update(key_char('g')); assert!(matches!(app.update(ctrl_c()), Cmd::Quit)); } /// After rapid mode switches, input mode settles to a valid state. #[test] fn test_rapid_mode_switches_consistent() { let mut app = test_app(); // Rapid mode toggles: Normal -> GoPrefix -> back -> Text -> back -> Palette -> back for _ in 0..10 { app.update(key_char('g')); // Enter GoPrefix app.update(key(KeyCode::Escape)); // Back to Normal app.update(key_char('/')); // Might enter Text (search) app.update(key(KeyCode::Escape)); // Back to Normal } // After all that, mode should be Normal (Escape always returns to Normal). assert!( matches!(app.input_mode, InputMode::Normal), "Input mode should settle to Normal after Escape" ); } // --------------------------------------------------------------------------- // Event Fuzz Tests (Deterministic) // --------------------------------------------------------------------------- /// Simple seeded PRNG for deterministic fuzz (xorshift64). struct Rng(u64); impl Rng { fn new(seed: u64) -> Self { Self(seed.wrapping_add(1)) // Avoid zero seed. } fn next(&mut self) -> u64 { let mut x = self.0; x ^= x << 13; x ^= x >> 7; x ^= x << 17; self.0 = x; x } fn next_range(&mut self, max: u64) -> u64 { self.next() % max } } /// Generate a random Msg from the fuzz alphabet. fn random_event(rng: &mut Rng) -> Msg { match rng.next_range(10) { // Key events (60% of events). 0..=5 => { let key_code = match rng.next_range(20) { 0 => KeyCode::Up, 1 => KeyCode::Down, 2 => KeyCode::Left, 3 => KeyCode::Right, 4 => KeyCode::Enter, 5 => KeyCode::Escape, 6 => KeyCode::Tab, 7 => KeyCode::BackTab, 8 => KeyCode::Backspace, 9 => KeyCode::Home, 10 => KeyCode::End, 11 => KeyCode::PageUp, 12 => KeyCode::PageDown, 13 => KeyCode::Char('g'), 14 => KeyCode::Char('j'), 15 => KeyCode::Char('k'), 16 => KeyCode::Char('/'), 17 => KeyCode::Char('?'), 18 => KeyCode::Char('a'), _ => KeyCode::Char('x'), }; key(key_code) } // Resize events (20% of events). 6 | 7 => { let w = (rng.next_range(300) + 1) as u16; let h = (rng.next_range(100) + 1) as u16; resize(w, h) } // Tick events (20% of events). _ => Msg::Tick, } } /// Check invariants after each event in the fuzz loop. fn check_invariants(app: &LoreApp, seed: u64, event_idx: usize) { // Navigation stack depth >= 1. assert!( app.navigation.depth() >= 1, "Invariant violation at seed={seed}, event={event_idx}: nav stack empty" ); // InputMode is one of the valid variants. match &app.input_mode { InputMode::Normal | InputMode::Text | InputMode::Palette | InputMode::GoPrefix { .. } => {} } } /// 10k deterministic fuzz traces with invariant checks. #[test] fn test_event_fuzz_10k_traces() { const NUM_TRACES: usize = 100; const EVENTS_PER_TRACE: usize = 100; // Total: 100 * 100 = 10k events. for trace in 0..NUM_TRACES { let seed = 42_u64.wrapping_mul(trace as u64 + 1); let mut rng = Rng::new(seed); let mut app = test_app(); for event_idx in 0..EVENTS_PER_TRACE { let msg = random_event(&mut rng); let cmd = app.update(msg); // If we get Quit, that's valid — restart the app for this trace. if matches!(cmd, Cmd::Quit) { app = test_app(); continue; } check_invariants(&app, seed, event_idx); } } } /// Verify fuzz is deterministic — same seed produces same final state. #[test] fn test_fuzz_deterministic_replay() { let seed = 12345_u64; let run = |s: u64| -> (Screen, (u16, u16)) { let mut rng = Rng::new(s); let mut app = test_app(); for _ in 0..200 { let msg = random_event(&mut rng); let cmd = app.update(msg); if matches!(cmd, Cmd::Quit) { app = test_app(); } } (app.navigation.current().clone(), app.state.terminal_size) }; let (screen1, size1) = run(seed); let (screen2, size2) = run(seed); assert_eq!(screen1, screen2, "Same seed should produce same screen"); assert_eq!(size1, size2, "Same seed should produce same terminal size"); } /// Extended fuzz: interleave renders with events — no panic during view(). #[test] fn test_fuzz_with_render_no_panic() { let seed = 99999_u64; let mut rng = Rng::new(seed); let mut app = test_app(); for _ in 0..500 { let msg = random_event(&mut rng); let cmd = app.update(msg); if matches!(cmd, Cmd::Quit) { app = test_app(); continue; } // Render every 10th event to catch view panics. let (w, h) = app.state.terminal_size; if w > 0 && h > 0 { render_at(&app, w, h); } } }