Skip to main content

ftui_harness/
input_storm.rs

1#![forbid(unsafe_code)]
2
3//! Input Storm Generator for Fault Injection Testing (bd-1pys5.1)
4//!
5//! Generates deterministic input event sequences for stress testing the event
6//! processing pipeline. Supports various burst patterns matching real-world
7//! adversarial scenarios.
8//!
9//! # Burst Patterns
10//!
11//! | Pattern | Description |
12//! |---------|-------------|
13//! | [`BurstPattern::KeyboardStorm`] | 1000+ keypresses in rapid succession |
14//! | [`BurstPattern::MouseFlood`] | High-frequency mouse-move events |
15//! | [`BurstPattern::MixedBurst`] | Interleaved keyboard + mouse + paste |
16//! | [`BurstPattern::LongPaste`] | Single large paste event (100KB+) |
17//! | [`BurstPattern::RapidResize`] | 100 resize events in rapid succession |
18//!
19//! # JSONL Schema
20//!
21//! ```json
22//! {"event":"storm_start","pattern":"keyboard_storm","event_count":1000}
23//! {"event":"storm_inject","idx":0,"event_type":"key","key":"a","elapsed_ns":0}
24//! {"event":"storm_complete","total_events":1000,"duration_ns":12345,"events_processed":1000}
25//! ```
26
27use std::time::Instant;
28
29use ftui_core::event::{
30    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseEvent, MouseEventKind, PasteEvent,
31};
32
33// ============================================================================
34// Configuration
35// ============================================================================
36
37/// Pattern type for input storm generation.
38#[derive(Debug, Clone, PartialEq)]
39pub enum BurstPattern {
40    /// Rapid keyboard events (simulates typing at impossible speed).
41    KeyboardStorm {
42        /// Number of key events to generate.
43        count: usize,
44    },
45    /// High-frequency mouse-move events.
46    MouseFlood {
47        /// Number of mouse-move events.
48        count: usize,
49        /// Terminal width for coordinate wrapping.
50        width: u16,
51        /// Terminal height for coordinate wrapping.
52        height: u16,
53    },
54    /// Interleaved keyboard + mouse + paste events.
55    MixedBurst {
56        /// Total number of events to generate.
57        count: usize,
58        /// Terminal width for mouse coordinates.
59        width: u16,
60        /// Terminal height for mouse coordinates.
61        height: u16,
62    },
63    /// Single large paste event.
64    LongPaste {
65        /// Size of paste content in bytes.
66        size_bytes: usize,
67    },
68    /// Rapid resize events.
69    RapidResize {
70        /// Number of resize events.
71        count: usize,
72    },
73}
74
75impl BurstPattern {
76    /// Human-readable pattern name for logging.
77    pub fn name(&self) -> &'static str {
78        match self {
79            Self::KeyboardStorm { .. } => "keyboard_storm",
80            Self::MouseFlood { .. } => "mouse_flood",
81            Self::MixedBurst { .. } => "mixed_burst",
82            Self::LongPaste { .. } => "long_paste",
83            Self::RapidResize { .. } => "rapid_resize",
84        }
85    }
86}
87
88/// Configuration for an input storm.
89#[derive(Debug, Clone)]
90pub struct InputStormConfig {
91    /// The burst pattern to generate.
92    pub pattern: BurstPattern,
93    /// Random seed for deterministic generation.
94    pub seed: u64,
95}
96
97impl InputStormConfig {
98    /// Create a new config with the given pattern and seed.
99    pub fn new(pattern: BurstPattern, seed: u64) -> Self {
100        Self { pattern, seed }
101    }
102}
103
104// ============================================================================
105// Event Generation
106// ============================================================================
107
108/// Simple deterministic PRNG (xorshift64) for reproducible event sequences.
109struct Rng {
110    state: u64,
111}
112
113impl Rng {
114    fn new(seed: u64) -> Self {
115        Self {
116            state: if seed == 0 { 1 } else { seed },
117        }
118    }
119
120    fn next(&mut self) -> u64 {
121        self.state ^= self.state << 13;
122        self.state ^= self.state >> 7;
123        self.state ^= self.state << 17;
124        self.state
125    }
126
127    fn next_u16(&mut self, max: u16) -> u16 {
128        if max == 0 {
129            return 0;
130        }
131        (self.next() % max as u64) as u16
132    }
133
134    fn next_char(&mut self) -> char {
135        // Lowercase ASCII letters.
136        let idx = (self.next() % 26) as u8;
137        (b'a' + idx) as char
138    }
139}
140
141/// Generated storm result with events and metadata.
142pub struct InputStorm {
143    /// The generated events in order.
144    pub events: Vec<Event>,
145    /// Pattern name for logging.
146    pub pattern_name: &'static str,
147    /// Seed used for generation.
148    pub seed: u64,
149}
150
151/// Generate a deterministic input storm from config.
152pub fn generate_storm(config: &InputStormConfig) -> InputStorm {
153    let mut rng = Rng::new(config.seed);
154    let events = match &config.pattern {
155        BurstPattern::KeyboardStorm { count } => generate_keyboard_storm(*count, &mut rng),
156        BurstPattern::MouseFlood {
157            count,
158            width,
159            height,
160        } => generate_mouse_flood(*count, *width, *height, &mut rng),
161        BurstPattern::MixedBurst {
162            count,
163            width,
164            height,
165        } => generate_mixed_burst(*count, *width, *height, &mut rng),
166        BurstPattern::LongPaste { size_bytes } => generate_long_paste(*size_bytes, &mut rng),
167        BurstPattern::RapidResize { count } => generate_rapid_resize(*count, &mut rng),
168    };
169
170    InputStorm {
171        events,
172        pattern_name: config.pattern.name(),
173        seed: config.seed,
174    }
175}
176
177fn generate_keyboard_storm(count: usize, rng: &mut Rng) -> Vec<Event> {
178    let mut events = Vec::with_capacity(count);
179    for _ in 0..count {
180        let ch = rng.next_char();
181        events.push(Event::Key(KeyEvent {
182            code: KeyCode::Char(ch),
183            modifiers: Modifiers::empty(),
184            kind: KeyEventKind::Press,
185        }));
186    }
187    events
188}
189
190fn generate_mouse_flood(count: usize, width: u16, height: u16, rng: &mut Rng) -> Vec<Event> {
191    let mut events = Vec::with_capacity(count);
192    let mut x = width / 2;
193    let mut y = height / 2;
194
195    for _ in 0..count {
196        // Random walk within bounds.
197        let dx = rng.next_u16(3) as i32 - 1; // -1, 0, or 1
198        let dy = rng.next_u16(3) as i32 - 1;
199        x = (x as i32 + dx).clamp(0, width.saturating_sub(1) as i32) as u16;
200        y = (y as i32 + dy).clamp(0, height.saturating_sub(1) as i32) as u16;
201
202        events.push(Event::Mouse(MouseEvent {
203            kind: MouseEventKind::Moved,
204            x,
205            y,
206            modifiers: Modifiers::empty(),
207        }));
208    }
209    events
210}
211
212fn generate_mixed_burst(count: usize, width: u16, height: u16, rng: &mut Rng) -> Vec<Event> {
213    let mut events = Vec::with_capacity(count);
214    let mut mouse_x = width / 2;
215    let mut mouse_y = height / 2;
216
217    for _ in 0..count {
218        let kind = rng.next() % 10;
219        let event = match kind {
220            0..=4 => {
221                // 50% keyboard
222                let ch = rng.next_char();
223                Event::Key(KeyEvent {
224                    code: KeyCode::Char(ch),
225                    modifiers: Modifiers::empty(),
226                    kind: KeyEventKind::Press,
227                })
228            }
229            5..=7 => {
230                // 30% mouse
231                let dx = rng.next_u16(3) as i32 - 1;
232                let dy = rng.next_u16(3) as i32 - 1;
233                mouse_x = (mouse_x as i32 + dx).clamp(0, width.saturating_sub(1) as i32) as u16;
234                mouse_y = (mouse_y as i32 + dy).clamp(0, height.saturating_sub(1) as i32) as u16;
235                Event::Mouse(MouseEvent {
236                    kind: MouseEventKind::Moved,
237                    x: mouse_x,
238                    y: mouse_y,
239                    modifiers: Modifiers::empty(),
240                })
241            }
242            8 => {
243                // 10% small paste
244                let len = (rng.next() % 50) as usize + 5;
245                let text: String = (0..len).map(|_| rng.next_char()).collect();
246                Event::Paste(PasteEvent {
247                    text,
248                    bracketed: true,
249                })
250            }
251            _ => {
252                // 10% resize
253                let w = rng.next_u16(120) + 20;
254                let h = rng.next_u16(50) + 10;
255                Event::Resize {
256                    width: w,
257                    height: h,
258                }
259            }
260        };
261        events.push(event);
262    }
263    events
264}
265
266fn generate_long_paste(size_bytes: usize, rng: &mut Rng) -> Vec<Event> {
267    let text: String = (0..size_bytes).map(|_| rng.next_char()).collect();
268    vec![Event::Paste(PasteEvent {
269        text,
270        bracketed: true,
271    })]
272}
273
274fn generate_rapid_resize(count: usize, rng: &mut Rng) -> Vec<Event> {
275    let mut events = Vec::with_capacity(count);
276    for _ in 0..count {
277        let w = rng.next_u16(120) + 20;
278        let h = rng.next_u16(50) + 10;
279        events.push(Event::Resize {
280            width: w,
281            height: h,
282        });
283    }
284    events
285}
286
287// ============================================================================
288// JSONL Logging
289// ============================================================================
290
291/// JSONL event for storm logging.
292pub struct StormLogEntry {
293    pub event: &'static str,
294    pub idx: Option<usize>,
295    pub event_type: Option<&'static str>,
296    pub detail: Option<String>,
297    pub elapsed_ns: Option<u64>,
298    pub pattern: Option<&'static str>,
299    pub event_count: Option<usize>,
300    pub total_events: Option<usize>,
301    pub duration_ns: Option<u64>,
302    pub events_processed: Option<usize>,
303    pub peak_queue_depth: Option<usize>,
304    pub memory_bytes: Option<usize>,
305}
306
307impl StormLogEntry {
308    pub fn to_jsonl(&self) -> String {
309        let mut parts = vec![format!(r#""event":"{}""#, self.event)];
310        if let Some(idx) = self.idx {
311            parts.push(format!(r#""idx":{idx}"#));
312        }
313        if let Some(et) = self.event_type {
314            parts.push(format!(r#""event_type":"{et}""#));
315        }
316        if let Some(ref d) = self.detail {
317            parts.push(format!(r#""detail":"{d}""#));
318        }
319        if let Some(ns) = self.elapsed_ns {
320            parts.push(format!(r#""elapsed_ns":{ns}"#));
321        }
322        if let Some(p) = self.pattern {
323            parts.push(format!(r#""pattern":"{p}""#));
324        }
325        if let Some(c) = self.event_count {
326            parts.push(format!(r#""event_count":{c}"#));
327        }
328        if let Some(t) = self.total_events {
329            parts.push(format!(r#""total_events":{t}"#));
330        }
331        if let Some(d) = self.duration_ns {
332            parts.push(format!(r#""duration_ns":{d}"#));
333        }
334        if let Some(p) = self.events_processed {
335            parts.push(format!(r#""events_processed":{p}"#));
336        }
337        if let Some(q) = self.peak_queue_depth {
338            parts.push(format!(r#""peak_queue_depth":{q}"#));
339        }
340        if let Some(m) = self.memory_bytes {
341            parts.push(format!(r#""memory_bytes":{m}"#));
342        }
343        format!("{{{}}}", parts.join(","))
344    }
345}
346
347/// Classify an event for logging.
348pub fn event_type_name(event: &Event) -> &'static str {
349    match event {
350        Event::Key(_) => "key",
351        Event::Mouse(_) => "mouse",
352        Event::Paste(_) => "paste",
353        Event::Resize { .. } => "resize",
354        Event::Focus(_) => "focus",
355        Event::Clipboard(_) => "clipboard",
356        Event::Tick => "tick",
357    }
358}
359
360/// Run a storm through the simulator and collect JSONL evidence.
361///
362/// Returns (events_processed, peak_queue_depth, jsonl_log).
363pub fn run_storm_with_logging(storm: &InputStorm) -> (usize, Vec<String>) {
364    let start = Instant::now();
365    let mut log_lines = Vec::new();
366
367    // Start entry
368    log_lines.push(
369        StormLogEntry {
370            event: "storm_start",
371            pattern: Some(storm.pattern_name),
372            event_count: Some(storm.events.len()),
373            idx: None,
374            event_type: None,
375            detail: None,
376            elapsed_ns: None,
377            total_events: None,
378            duration_ns: None,
379            events_processed: None,
380            peak_queue_depth: None,
381            memory_bytes: None,
382        }
383        .to_jsonl(),
384    );
385
386    // Log a sample of events (every 100th).
387    for (idx, event) in storm.events.iter().enumerate() {
388        if idx % 100 == 0 || idx == storm.events.len() - 1 {
389            let elapsed = start.elapsed().as_nanos() as u64;
390            log_lines.push(
391                StormLogEntry {
392                    event: "storm_inject",
393                    idx: Some(idx),
394                    event_type: Some(event_type_name(event)),
395                    elapsed_ns: Some(elapsed),
396                    detail: None,
397                    pattern: None,
398                    event_count: None,
399                    total_events: None,
400                    duration_ns: None,
401                    events_processed: None,
402                    peak_queue_depth: None,
403                    memory_bytes: None,
404                }
405                .to_jsonl(),
406            );
407        }
408    }
409
410    let duration = start.elapsed().as_nanos() as u64;
411    let events_processed = storm.events.len();
412
413    // Complete entry
414    log_lines.push(
415        StormLogEntry {
416            event: "storm_complete",
417            total_events: Some(events_processed),
418            duration_ns: Some(duration),
419            events_processed: Some(events_processed),
420            idx: None,
421            event_type: None,
422            detail: None,
423            elapsed_ns: None,
424            pattern: None,
425            event_count: None,
426            peak_queue_depth: None,
427            memory_bytes: None,
428        }
429        .to_jsonl(),
430    );
431
432    (events_processed, log_lines)
433}
434
435// ============================================================================
436// Tests
437// ============================================================================
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn keyboard_storm_generates_correct_count() {
445        let config = InputStormConfig::new(BurstPattern::KeyboardStorm { count: 1000 }, 42);
446        let storm = generate_storm(&config);
447        assert_eq!(storm.events.len(), 1000);
448        assert!(storm.events.iter().all(|e| matches!(e, Event::Key(_))));
449    }
450
451    #[test]
452    fn keyboard_storm_deterministic() {
453        let config = InputStormConfig::new(BurstPattern::KeyboardStorm { count: 100 }, 42);
454        let storm1 = generate_storm(&config);
455        let storm2 = generate_storm(&config);
456        assert_eq!(storm1.events.len(), storm2.events.len());
457        for (a, b) in storm1.events.iter().zip(storm2.events.iter()) {
458            assert_eq!(format!("{a:?}"), format!("{b:?}"));
459        }
460    }
461
462    #[test]
463    fn mouse_flood_generates_correct_count() {
464        let config = InputStormConfig::new(
465            BurstPattern::MouseFlood {
466                count: 1000,
467                width: 80,
468                height: 24,
469            },
470            42,
471        );
472        let storm = generate_storm(&config);
473        assert_eq!(storm.events.len(), 1000);
474        assert!(storm.events.iter().all(|e| matches!(e, Event::Mouse(_))));
475    }
476
477    #[test]
478    fn mouse_flood_stays_in_bounds() {
479        let config = InputStormConfig::new(
480            BurstPattern::MouseFlood {
481                count: 10000,
482                width: 80,
483                height: 24,
484            },
485            42,
486        );
487        let storm = generate_storm(&config);
488        for event in &storm.events {
489            if let Event::Mouse(me) = event {
490                assert!(me.x < 80, "mouse x={} out of bounds", me.x);
491                assert!(me.y < 24, "mouse y={} out of bounds", me.y);
492            }
493        }
494    }
495
496    #[test]
497    fn mixed_burst_generates_correct_count() {
498        let config = InputStormConfig::new(
499            BurstPattern::MixedBurst {
500                count: 1000,
501                width: 80,
502                height: 24,
503            },
504            42,
505        );
506        let storm = generate_storm(&config);
507        assert_eq!(storm.events.len(), 1000);
508
509        // Should contain a mix of event types.
510        let key_count = storm
511            .events
512            .iter()
513            .filter(|e| matches!(e, Event::Key(_)))
514            .count();
515        let mouse_count = storm
516            .events
517            .iter()
518            .filter(|e| matches!(e, Event::Mouse(_)))
519            .count();
520        assert!(key_count > 0, "expected some key events");
521        assert!(mouse_count > 0, "expected some mouse events");
522    }
523
524    #[test]
525    fn long_paste_generates_correct_size() {
526        let config = InputStormConfig::new(
527            BurstPattern::LongPaste {
528                size_bytes: 100_000,
529            },
530            42,
531        );
532        let storm = generate_storm(&config);
533        assert_eq!(storm.events.len(), 1);
534        if let Event::Paste(pe) = &storm.events[0] {
535            assert_eq!(pe.text.len(), 100_000);
536            assert!(pe.bracketed);
537        } else {
538            panic!("expected paste event");
539        }
540    }
541
542    #[test]
543    fn rapid_resize_generates_correct_count() {
544        let config = InputStormConfig::new(BurstPattern::RapidResize { count: 100 }, 42);
545        let storm = generate_storm(&config);
546        assert_eq!(storm.events.len(), 100);
547        assert!(
548            storm
549                .events
550                .iter()
551                .all(|e| matches!(e, Event::Resize { .. }))
552        );
553    }
554
555    #[test]
556    fn rapid_resize_bounds() {
557        let config = InputStormConfig::new(BurstPattern::RapidResize { count: 1000 }, 42);
558        let storm = generate_storm(&config);
559        for event in &storm.events {
560            if let Event::Resize { width, height } = event {
561                assert!(*width >= 20 && *width < 140, "width={width} out of bounds");
562                assert!(
563                    *height >= 10 && *height < 60,
564                    "height={height} out of bounds"
565                );
566            }
567        }
568    }
569
570    #[test]
571    fn jsonl_logging_produces_valid_entries() {
572        let config = InputStormConfig::new(BurstPattern::KeyboardStorm { count: 500 }, 42);
573        let storm = generate_storm(&config);
574        let (processed, log_lines) = run_storm_with_logging(&storm);
575
576        assert_eq!(processed, 500);
577        assert!(log_lines.len() >= 3); // start + at least 1 inject + complete
578
579        // All lines should be valid JSON.
580        for line in &log_lines {
581            assert!(
582                line.starts_with('{') && line.ends_with('}'),
583                "Malformed JSONL: {line}"
584            );
585            // Parse as JSON to verify structure.
586            let val: serde_json::Value = serde_json::from_str(line)
587                .unwrap_or_else(|e| panic!("Failed to parse JSONL: {e}\n{line}"));
588            assert!(val["event"].is_string(), "Missing event field");
589        }
590    }
591
592    #[test]
593    fn storm_pattern_names() {
594        assert_eq!(
595            BurstPattern::KeyboardStorm { count: 1 }.name(),
596            "keyboard_storm"
597        );
598        assert_eq!(
599            BurstPattern::MouseFlood {
600                count: 1,
601                width: 80,
602                height: 24
603            }
604            .name(),
605            "mouse_flood"
606        );
607        assert_eq!(
608            BurstPattern::MixedBurst {
609                count: 1,
610                width: 80,
611                height: 24
612            }
613            .name(),
614            "mixed_burst"
615        );
616        assert_eq!(
617            BurstPattern::LongPaste { size_bytes: 1 }.name(),
618            "long_paste"
619        );
620        assert_eq!(
621            BurstPattern::RapidResize { count: 1 }.name(),
622            "rapid_resize"
623        );
624    }
625}