415 lines
12 KiB
Rust
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 °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);
|
|
}
|
|
}
|
|
}
|