Files
gitlore/crates/lore-tui/tests/stress_tests.rs

415 lines
12 KiB
Rust

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