Skip to main content

jugar_probar/
tui_load.rs

1//! TUI Load Testing Module
2//!
3//! Framework-agnostic load testing for terminal user interfaces.
4//! Works with any TUI framework (presentar, ratatui, crossterm, etc.).
5//!
6//! ## Key Features
7//!
8//! - **Framework Agnostic**: Uses callbacks/closures, not tied to any TUI library
9//! - **Data Generation**: Generate realistic data volumes (1000+ items)
10//! - **Frame Timing**: Measure render time per frame
11//! - **Hang Detection**: Timeout-based hang detection with detailed diagnostics
12//! - **Filter/Search Testing**: Test filter performance with large datasets
13//!
14//! ## Toyota Way Application
15//!
16//! - **Jidoka**: Automatic hang detection stops tests before infinite loops
17//! - **Muda**: Eliminate wasted time from slow filter implementations
18//! - **Genchi Genbutsu**: Measure actual render times, not theoretical
19//!
20//! ## Example
21//!
22//! ```ignore
23//! use jugar_probar::tui_load::{TuiLoadTest, DataGenerator, TuiFrameMetrics};
24//!
25//! // Create load test with 5000 synthetic processes
26//! let mut load_test = TuiLoadTest::new()
27//!     .with_item_count(5000)
28//!     .with_frame_budget_ms(16)  // 60 FPS target
29//!     .with_timeout_ms(1000);     // 1 second hang detection
30//!
31//! // Run test with your render function
32//! let result = load_test.run(|items, filter| {
33//!     // Your TUI render logic here
34//!     // Returns frame time in microseconds
35//!     render_process_list(items, filter)
36//! });
37//!
38//! assert!(result.is_ok(), "TUI should not hang");
39//! assert!(result.unwrap().p95_frame_ms < 16.0, "Should maintain 60 FPS");
40//! ```
41
42use std::time::{Duration, Instant};
43
44/// Result type for TUI load tests
45pub type TuiLoadResult<T> = Result<T, TuiLoadError>;
46
47/// Errors that can occur during TUI load testing
48#[derive(Debug, Clone, PartialEq)]
49pub enum TuiLoadError {
50    /// Frame render exceeded timeout (likely hang)
51    FrameTimeout {
52        /// Frame number that timed out
53        frame: usize,
54        /// Timeout in milliseconds
55        timeout_ms: u64,
56        /// Filter text being used (if any)
57        filter: String,
58        /// Number of items being rendered
59        item_count: usize,
60    },
61    /// Frame budget exceeded (too slow, but not a hang)
62    BudgetExceeded {
63        /// Frame number
64        frame: usize,
65        /// Actual frame time in milliseconds
66        actual_ms: f64,
67        /// Budget in milliseconds
68        budget_ms: f64,
69    },
70    /// Data generation failed
71    DataGenerationFailed {
72        /// Error message
73        message: String,
74    },
75}
76
77impl std::fmt::Display for TuiLoadError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::FrameTimeout {
81                frame,
82                timeout_ms,
83                filter,
84                item_count,
85            } => {
86                write!(
87                    f,
88                    "Frame {} timed out after {}ms (filter='{}', items={})",
89                    frame, timeout_ms, filter, item_count
90                )
91            }
92            Self::BudgetExceeded {
93                frame,
94                actual_ms,
95                budget_ms,
96            } => {
97                write!(
98                    f,
99                    "Frame {} exceeded budget: {:.2}ms > {:.2}ms",
100                    frame, actual_ms, budget_ms
101                )
102            }
103            Self::DataGenerationFailed { message } => {
104                write!(f, "Data generation failed: {}", message)
105            }
106        }
107    }
108}
109
110impl std::error::Error for TuiLoadError {}
111
112/// Metrics collected during TUI load testing
113#[derive(Debug, Clone, Default)]
114pub struct TuiFrameMetrics {
115    /// Total frames rendered
116    pub frame_count: usize,
117    /// Total render time in microseconds
118    pub total_time_us: u64,
119    /// Minimum frame time in microseconds
120    pub min_frame_us: u64,
121    /// Maximum frame time in microseconds
122    pub max_frame_us: u64,
123    /// Frame times for percentile calculation
124    pub frame_times_us: Vec<u64>,
125}
126
127impl TuiFrameMetrics {
128    /// Create new empty metrics
129    #[must_use]
130    pub fn new() -> Self {
131        Self {
132            min_frame_us: u64::MAX,
133            ..Default::default()
134        }
135    }
136
137    /// Record a frame time
138    pub fn record(&mut self, frame_time_us: u64) {
139        self.frame_count += 1;
140        self.total_time_us += frame_time_us;
141        self.min_frame_us = self.min_frame_us.min(frame_time_us);
142        self.max_frame_us = self.max_frame_us.max(frame_time_us);
143        self.frame_times_us.push(frame_time_us);
144    }
145
146    /// Get average frame time in milliseconds
147    #[must_use]
148    pub fn avg_frame_ms(&self) -> f64 {
149        if self.frame_count == 0 {
150            return 0.0;
151        }
152        (self.total_time_us as f64 / self.frame_count as f64) / 1000.0
153    }
154
155    /// Get minimum frame time in milliseconds
156    #[must_use]
157    pub fn min_frame_ms(&self) -> f64 {
158        if self.frame_count == 0 {
159            return 0.0;
160        }
161        self.min_frame_us as f64 / 1000.0
162    }
163
164    /// Get maximum frame time in milliseconds
165    #[must_use]
166    pub fn max_frame_ms(&self) -> f64 {
167        self.max_frame_us as f64 / 1000.0
168    }
169
170    /// Get p50 (median) frame time in milliseconds
171    #[must_use]
172    pub fn p50_frame_ms(&self) -> f64 {
173        self.percentile(50)
174    }
175
176    /// Get p95 frame time in milliseconds
177    #[must_use]
178    pub fn p95_frame_ms(&self) -> f64 {
179        self.percentile(95)
180    }
181
182    /// Get p99 frame time in milliseconds
183    #[must_use]
184    pub fn p99_frame_ms(&self) -> f64 {
185        self.percentile(99)
186    }
187
188    /// Get percentile frame time in milliseconds
189    #[must_use]
190    pub fn percentile(&self, p: u8) -> f64 {
191        if self.frame_times_us.is_empty() {
192            return 0.0;
193        }
194        let mut sorted = self.frame_times_us.clone();
195        sorted.sort_unstable();
196        let idx = ((p as f64 / 100.0) * (sorted.len() - 1) as f64) as usize;
197        sorted[idx.min(sorted.len() - 1)] as f64 / 1000.0
198    }
199
200    /// Check if frame times meet target FPS
201    #[must_use]
202    pub fn meets_fps(&self, target_fps: u32) -> bool {
203        let budget_ms = 1000.0 / target_fps as f64;
204        self.p95_frame_ms() <= budget_ms
205    }
206}
207
208/// A synthetic item for load testing (framework-agnostic)
209#[derive(Debug, Clone)]
210pub struct SyntheticItem {
211    /// Unique identifier
212    pub id: u32,
213    /// Name (searchable)
214    pub name: String,
215    /// Description/command line (searchable, can be long)
216    pub description: String,
217    /// Numeric value 1 (e.g., CPU %)
218    pub value1: f32,
219    /// Numeric value 2 (e.g., memory %)
220    pub value2: f32,
221    /// State string
222    pub state: String,
223    /// Owner/user
224    pub owner: String,
225    /// Additional count (e.g., threads)
226    pub count: u32,
227}
228
229impl SyntheticItem {
230    /// Check if item matches filter (case-insensitive)
231    #[must_use]
232    pub fn matches_filter(&self, filter: &str) -> bool {
233        if filter.is_empty() {
234            return true;
235        }
236        let filter_lower = filter.to_lowercase();
237        self.name.to_lowercase().contains(&filter_lower)
238            || self.description.to_lowercase().contains(&filter_lower)
239    }
240
241    /// Optimized filter matching with pre-lowercased filter
242    #[must_use]
243    pub fn matches_filter_precomputed(&self, filter_lower: &str) -> bool {
244        if filter_lower.is_empty() {
245            return true;
246        }
247        self.name.to_lowercase().contains(filter_lower)
248            || self.description.to_lowercase().contains(filter_lower)
249    }
250}
251
252/// Data generator for synthetic load test data
253#[derive(Debug, Clone)]
254pub struct DataGenerator {
255    /// Random seed for reproducibility
256    seed: u64,
257    /// Number of items to generate
258    item_count: usize,
259    /// Average description length
260    avg_description_len: usize,
261}
262
263impl DataGenerator {
264    /// Create a new data generator
265    #[must_use]
266    pub fn new(item_count: usize) -> Self {
267        Self {
268            seed: 42,
269            item_count,
270            avg_description_len: 100,
271        }
272    }
273
274    /// Set random seed
275    #[must_use]
276    pub fn with_seed(mut self, seed: u64) -> Self {
277        self.seed = seed;
278        self
279    }
280
281    /// Set average description length
282    #[must_use]
283    pub fn with_description_len(mut self, len: usize) -> Self {
284        self.avg_description_len = len;
285        self
286    }
287
288    /// Generate synthetic items
289    #[must_use]
290    pub fn generate(&self) -> Vec<SyntheticItem> {
291        let mut items = Vec::with_capacity(self.item_count);
292        let mut rng_state = self.seed;
293
294        let names = [
295            "systemd", "kworker", "chrome", "firefox", "code", "rust-analyzer",
296            "node", "python", "java", "postgres", "nginx", "docker", "containerd",
297            "ssh", "bash", "zsh", "fish", "vim", "nvim", "emacs", "tmux",
298            "htop", "top", "ps", "grep", "find", "cargo", "rustc", "gcc",
299            "clang", "llvm", "git", "make", "cmake", "webpack", "vite",
300        ];
301
302        let states = ["R", "S", "D", "Z", "T", "I"];
303        let users = ["root", "noah", "www-data", "postgres", "nobody", "daemon"];
304
305        for i in 0..self.item_count {
306            // Simple LCG PRNG
307            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
308            let r1 = rng_state;
309            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
310            let r2 = rng_state;
311            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
312            let r3 = rng_state;
313
314            let name_idx = (r1 as usize) % names.len();
315            let state_idx = (r2 as usize) % states.len();
316            let user_idx = (r3 as usize) % users.len();
317
318            // Generate a realistic command line
319            let base_name = names[name_idx];
320            let pid = 1000 + i as u32;
321            let description = self.generate_cmdline(base_name, r1);
322
323            items.push(SyntheticItem {
324                id: pid,
325                name: format!("{}-{}", base_name, i % 100),
326                description,
327                value1: ((r1 % 10000) as f32) / 100.0, // CPU 0-100%
328                value2: ((r2 % 10000) as f32) / 100.0, // Mem 0-100%
329                state: states[state_idx].to_string(),
330                owner: users[user_idx].to_string(),
331                count: ((r3 % 64) + 1) as u32, // threads 1-64
332            });
333        }
334
335        items
336    }
337
338    fn generate_cmdline(&self, base_name: &str, seed: u64) -> String {
339        let args = [
340            "--config", "/etc/config.yaml",
341            "--port", "8080",
342            "--workers", "4",
343            "--log-level", "debug",
344            "--data-dir", "/var/lib/data",
345            "--cache-size", "1024",
346            "--timeout", "30",
347            "--max-connections", "1000",
348            "--enable-metrics",
349            "--prometheus-port", "9090",
350        ];
351
352        let mut cmdline = format!("/usr/bin/{}", base_name);
353        let arg_count = ((seed % 6) + 2) as usize;
354
355        for i in 0..arg_count {
356            let arg_idx = ((seed.wrapping_add(i as u64 * 7)) % (args.len() as u64)) as usize;
357            cmdline.push(' ');
358            cmdline.push_str(args[arg_idx]);
359        }
360
361        // Pad to approximate target length
362        while cmdline.len() < self.avg_description_len {
363            cmdline.push_str(" --extra-arg");
364        }
365
366        cmdline
367    }
368}
369
370impl Default for DataGenerator {
371    fn default() -> Self {
372        Self::new(1000)
373    }
374}
375
376/// Configuration for TUI load testing
377#[derive(Debug, Clone)]
378pub struct TuiLoadConfig {
379    /// Number of items to generate
380    pub item_count: usize,
381    /// Frame budget in milliseconds (e.g., 16ms for 60 FPS)
382    pub frame_budget_ms: f64,
383    /// Timeout for hang detection in milliseconds
384    pub timeout_ms: u64,
385    /// Number of frames to render per filter
386    pub frames_per_filter: usize,
387    /// Filter strings to test
388    pub filters: Vec<String>,
389    /// Fail on budget exceeded (vs just warn)
390    pub strict_budget: bool,
391}
392
393impl Default for TuiLoadConfig {
394    fn default() -> Self {
395        Self {
396            item_count: 1000,
397            frame_budget_ms: 16.67, // 60 FPS
398            timeout_ms: 1000,       // 1 second
399            frames_per_filter: 10,
400            filters: vec![
401                String::new(),
402                "a".to_string(),
403                "sys".to_string(),
404                "chrome".to_string(),
405                "nonexistent_filter_that_matches_nothing".to_string(),
406            ],
407            strict_budget: false,
408        }
409    }
410}
411
412/// TUI Load Test Runner
413///
414/// Framework-agnostic load testing for TUI applications.
415#[derive(Debug)]
416pub struct TuiLoadTest {
417    config: TuiLoadConfig,
418    data: Vec<SyntheticItem>,
419}
420
421impl TuiLoadTest {
422    /// Create a new TUI load test with default configuration
423    #[must_use]
424    pub fn new() -> Self {
425        let config = TuiLoadConfig::default();
426        let data = DataGenerator::new(config.item_count).generate();
427        Self { config, data }
428    }
429
430    /// Create with specific item count
431    #[must_use]
432    pub fn with_item_count(mut self, count: usize) -> Self {
433        self.config.item_count = count;
434        self.data = DataGenerator::new(count).generate();
435        self
436    }
437
438    /// Set frame budget in milliseconds
439    #[must_use]
440    pub fn with_frame_budget_ms(mut self, budget_ms: f64) -> Self {
441        self.config.frame_budget_ms = budget_ms;
442        self
443    }
444
445    /// Set timeout for hang detection
446    #[must_use]
447    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
448        self.config.timeout_ms = timeout_ms;
449        self
450    }
451
452    /// Set filters to test
453    #[must_use]
454    pub fn with_filters(mut self, filters: Vec<String>) -> Self {
455        self.config.filters = filters;
456        self
457    }
458
459    /// Set number of frames per filter
460    #[must_use]
461    pub fn with_frames_per_filter(mut self, count: usize) -> Self {
462        self.config.frames_per_filter = count;
463        self
464    }
465
466    /// Enable strict budget enforcement
467    #[must_use]
468    pub fn with_strict_budget(mut self, strict: bool) -> Self {
469        self.config.strict_budget = strict;
470        self
471    }
472
473    /// Get the generated test data
474    #[must_use]
475    pub fn data(&self) -> &[SyntheticItem] {
476        &self.data
477    }
478
479    /// Get configuration
480    #[must_use]
481    pub fn config(&self) -> &TuiLoadConfig {
482        &self.config
483    }
484
485    /// Run load test with a render callback
486    ///
487    /// The callback receives:
488    /// - `items`: Slice of synthetic items to render
489    /// - `filter`: Current filter string
490    ///
491    /// The callback should perform the actual TUI rendering and return
492    /// the frame time in microseconds, or None if it wants the test
493    /// harness to measure time.
494    ///
495    /// # Errors
496    ///
497    /// Returns error if a frame times out (hang detected) or exceeds
498    /// budget in strict mode.
499    pub fn run<F>(&self, mut render: F) -> TuiLoadResult<TuiFrameMetrics>
500    where
501        F: FnMut(&[SyntheticItem], &str) -> Option<u64>,
502    {
503        let mut metrics = TuiFrameMetrics::new();
504        let timeout = Duration::from_millis(self.config.timeout_ms);
505        let mut frame_num = 0;
506
507        for filter in &self.config.filters {
508            for _ in 0..self.config.frames_per_filter {
509                let start = Instant::now();
510
511                // Call render function
512                let frame_time_us = if let Some(reported_time) = render(&self.data, filter) {
513                    reported_time
514                } else {
515                    // Measure time ourselves
516                    start.elapsed().as_micros() as u64
517                };
518
519                let elapsed = start.elapsed();
520
521                // Check for timeout (hang detection)
522                if elapsed > timeout {
523                    return Err(TuiLoadError::FrameTimeout {
524                        frame: frame_num,
525                        timeout_ms: self.config.timeout_ms,
526                        filter: filter.clone(),
527                        item_count: self.data.len(),
528                    });
529                }
530
531                // Check budget
532                let frame_ms = frame_time_us as f64 / 1000.0;
533                if self.config.strict_budget && frame_ms > self.config.frame_budget_ms {
534                    return Err(TuiLoadError::BudgetExceeded {
535                        frame: frame_num,
536                        actual_ms: frame_ms,
537                        budget_ms: self.config.frame_budget_ms,
538                    });
539                }
540
541                metrics.record(frame_time_us);
542                frame_num += 1;
543            }
544        }
545
546        Ok(metrics)
547    }
548
549    /// Run filter-specific performance test
550    ///
551    /// Tests filtering with increasing filter lengths to detect
552    /// O(n²) or worse complexity in filter implementations.
553    ///
554    /// # Errors
555    ///
556    /// Returns error if hang detected or budget exceeded.
557    pub fn run_filter_stress<F>(&self, mut filter_fn: F) -> TuiLoadResult<Vec<(String, TuiFrameMetrics)>>
558    where
559        F: FnMut(&[SyntheticItem], &str) -> Vec<SyntheticItem>,
560    {
561        let timeout = Duration::from_millis(self.config.timeout_ms);
562        let mut results = Vec::new();
563
564        // Test filters of increasing length/complexity
565        let stress_filters = [
566            "",
567            "a",
568            "ab",
569            "abc",
570            "sys",
571            "syst",
572            "syste",
573            "system",
574            "systemd",
575            "chrome",
576            "rust-analyzer",
577            "this_filter_will_match_nothing_at_all",
578        ];
579
580        for filter in stress_filters {
581            let mut metrics = TuiFrameMetrics::new();
582
583            for frame in 0..self.config.frames_per_filter {
584                let start = Instant::now();
585
586                // Call filter function
587                let _filtered = filter_fn(&self.data, filter);
588
589                let elapsed = start.elapsed();
590
591                // Check for timeout
592                if elapsed > timeout {
593                    return Err(TuiLoadError::FrameTimeout {
594                        frame,
595                        timeout_ms: self.config.timeout_ms,
596                        filter: filter.to_string(),
597                        item_count: self.data.len(),
598                    });
599                }
600
601                metrics.record(elapsed.as_micros() as u64);
602            }
603
604            results.push((filter.to_string(), metrics));
605        }
606
607        Ok(results)
608    }
609}
610
611impl Default for TuiLoadTest {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617/// Assertion helpers for TUI load test results
618#[derive(Debug, Clone, Copy, Default)]
619pub struct TuiLoadAssertion;
620
621impl TuiLoadAssertion {
622    /// Assert that p95 frame time meets target FPS
623    pub fn assert_meets_fps(metrics: &TuiFrameMetrics, target_fps: u32) {
624        let budget_ms = 1000.0 / target_fps as f64;
625        assert!(
626            metrics.p95_frame_ms() <= budget_ms,
627            "p95 frame time {:.2}ms exceeds {:.2}ms budget for {} FPS",
628            metrics.p95_frame_ms(),
629            budget_ms,
630            target_fps
631        );
632    }
633
634    /// Assert that no frame exceeded timeout
635    pub fn assert_no_hang(result: &TuiLoadResult<TuiFrameMetrics>) {
636        assert!(
637            result.is_ok(),
638            "TUI hang detected: {:?}",
639            result.as_ref().err()
640        );
641    }
642
643    /// Assert filter performance doesn't degrade with item count
644    pub fn assert_filter_scales_linearly(
645        results: &[(String, TuiFrameMetrics)],
646        max_degradation_factor: f64,
647    ) {
648        if results.len() < 2 {
649            return;
650        }
651
652        let baseline = results[0].1.avg_frame_ms();
653        if baseline == 0.0 {
654            return;
655        }
656
657        for (filter, metrics) in results.iter().skip(1) {
658            let factor = metrics.avg_frame_ms() / baseline;
659            assert!(
660                factor <= max_degradation_factor,
661                "Filter '{}' degraded by {:.1}x (max allowed: {:.1}x)",
662                filter,
663                factor,
664                max_degradation_factor
665            );
666        }
667    }
668}
669
670/// Integration load test that measures real application frame times.
671///
672/// Unlike synthetic load tests, this tests the ACTUAL application with
673/// real collectors, real system calls, and real data. It catches issues
674/// like blocking I/O, slow system calls, and expensive operations.
675///
676/// ## Example
677///
678/// ```ignore
679/// use jugar_probar::tui_load::IntegrationLoadTest;
680///
681/// // Test that real app renders frames under 100ms
682/// let test = IntegrationLoadTest::new()
683///     .with_frame_budget_ms(100.0)
684///     .with_timeout_ms(5000)
685///     .with_frame_count(10);
686///
687/// let result = test.run(|| {
688///     // Your real app initialization and render
689///     let mut app = App::new();
690///     app.collect_metrics();
691///     // Simulate frame render...
692/// });
693///
694/// assert!(result.is_ok(), "Real app should not hang");
695/// ```
696#[derive(Debug, Clone)]
697pub struct IntegrationLoadTest {
698    /// Frame budget in milliseconds
699    frame_budget_ms: f64,
700    /// Timeout for hang detection
701    timeout_ms: u64,
702    /// Number of frames to test
703    frame_count: usize,
704    /// Component-level timing thresholds (name -> max_ms)
705    component_budgets: std::collections::HashMap<String, f64>,
706}
707
708impl IntegrationLoadTest {
709    /// Create new integration load test
710    #[must_use]
711    pub fn new() -> Self {
712        Self {
713            frame_budget_ms: 100.0,  // 10 FPS minimum
714            timeout_ms: 5000,         // 5 second hang detection
715            frame_count: 5,
716            component_budgets: std::collections::HashMap::new(),
717        }
718    }
719
720    /// Set frame budget
721    #[must_use]
722    pub fn with_frame_budget_ms(mut self, budget: f64) -> Self {
723        self.frame_budget_ms = budget;
724        self
725    }
726
727    /// Set timeout for hang detection
728    #[must_use]
729    pub fn with_timeout_ms(mut self, timeout: u64) -> Self {
730        self.timeout_ms = timeout;
731        self
732    }
733
734    /// Set number of frames to test
735    #[must_use]
736    pub fn with_frame_count(mut self, count: usize) -> Self {
737        self.frame_count = count;
738        self
739    }
740
741    /// Add component budget (e.g., "container_analyzer" -> 100ms max)
742    #[must_use]
743    pub fn with_component_budget(mut self, name: &str, max_ms: f64) -> Self {
744        self.component_budgets.insert(name.to_string(), max_ms);
745        self
746    }
747
748    /// Run integration test with a closure that performs one frame
749    ///
750    /// The closure should:
751    /// 1. Initialize app (first call only)
752    /// 2. Collect metrics
753    /// 3. Render frame
754    ///
755    /// Returns timing for each frame.
756    pub fn run<F>(&self, mut frame_fn: F) -> TuiLoadResult<TuiFrameMetrics>
757    where
758        F: FnMut() -> ComponentTimings,
759    {
760        let mut metrics = TuiFrameMetrics::new();
761        let timeout = Duration::from_millis(self.timeout_ms);
762
763        for frame in 0..self.frame_count {
764            let start = Instant::now();
765
766            let timings = frame_fn();
767
768            let elapsed = start.elapsed();
769
770            // Check for hang
771            if elapsed > timeout {
772                return Err(TuiLoadError::FrameTimeout {
773                    frame,
774                    timeout_ms: self.timeout_ms,
775                    filter: format!("frame {}", frame),
776                    item_count: 0,
777                });
778            }
779
780            // Check component budgets
781            for (name, &max_ms) in &self.component_budgets {
782                if let Some(&actual_ms) = timings.0.get(name) {
783                    if actual_ms > max_ms {
784                        return Err(TuiLoadError::BudgetExceeded {
785                            frame,
786                            actual_ms,
787                            budget_ms: max_ms,
788                        });
789                    }
790                }
791            }
792
793            metrics.record(elapsed.as_micros() as u64);
794        }
795
796        Ok(metrics)
797    }
798}
799
800impl Default for IntegrationLoadTest {
801    fn default() -> Self {
802        Self::new()
803    }
804}
805
806/// Component timing results from a frame render
807#[derive(Debug, Clone, Default)]
808pub struct ComponentTimings(pub std::collections::HashMap<String, f64>);
809
810impl ComponentTimings {
811    /// Create empty timings
812    #[must_use]
813    pub fn new() -> Self {
814        Self(std::collections::HashMap::new())
815    }
816
817    /// Record a component timing
818    pub fn record(&mut self, name: &str, duration_ms: f64) {
819        self.0.insert(name.to_string(), duration_ms);
820    }
821
822    /// Get timing for a component
823    #[must_use]
824    pub fn get(&self, name: &str) -> Option<f64> {
825        self.0.get(name).copied()
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn test_data_generator_creates_items() {
835        let gen = DataGenerator::new(100);
836        let items = gen.generate();
837        assert_eq!(items.len(), 100);
838    }
839
840    #[test]
841    fn test_data_generator_deterministic() {
842        let gen1 = DataGenerator::new(50).with_seed(12345);
843        let gen2 = DataGenerator::new(50).with_seed(12345);
844        let items1 = gen1.generate();
845        let items2 = gen2.generate();
846
847        for (a, b) in items1.iter().zip(items2.iter()) {
848            assert_eq!(a.id, b.id);
849            assert_eq!(a.name, b.name);
850        }
851    }
852
853    #[test]
854    fn test_synthetic_item_filter_empty() {
855        let item = SyntheticItem {
856            id: 1,
857            name: "test".to_string(),
858            description: "desc".to_string(),
859            value1: 0.0,
860            value2: 0.0,
861            state: "R".to_string(),
862            owner: "root".to_string(),
863            count: 1,
864        };
865        assert!(item.matches_filter(""));
866    }
867
868    #[test]
869    fn test_synthetic_item_filter_name() {
870        let item = SyntheticItem {
871            id: 1,
872            name: "systemd".to_string(),
873            description: "init system".to_string(),
874            value1: 0.0,
875            value2: 0.0,
876            state: "S".to_string(),
877            owner: "root".to_string(),
878            count: 1,
879        };
880        assert!(item.matches_filter("sys"));
881        assert!(item.matches_filter("SYS")); // case insensitive
882        assert!(!item.matches_filter("chrome"));
883    }
884
885    #[test]
886    fn test_synthetic_item_filter_description() {
887        let item = SyntheticItem {
888            id: 1,
889            name: "init".to_string(),
890            description: "/usr/lib/systemd/systemd".to_string(),
891            value1: 0.0,
892            value2: 0.0,
893            state: "S".to_string(),
894            owner: "root".to_string(),
895            count: 1,
896        };
897        assert!(item.matches_filter("systemd"));
898    }
899
900    #[test]
901    fn test_frame_metrics_percentiles() {
902        let mut metrics = TuiFrameMetrics::new();
903        for i in 1..=100 {
904            metrics.record(i * 1000); // 1-100ms
905        }
906
907        assert_eq!(metrics.frame_count, 100);
908        assert!((metrics.p50_frame_ms() - 50.0).abs() < 2.0);
909        assert!(metrics.p95_frame_ms() >= 95.0);
910    }
911
912    #[test]
913    fn test_frame_metrics_meets_fps() {
914        let mut metrics = TuiFrameMetrics::new();
915        // All frames at 10ms = 100 FPS
916        for _ in 0..100 {
917            metrics.record(10_000); // 10ms in microseconds
918        }
919
920        assert!(metrics.meets_fps(60)); // Should meet 60 FPS
921        assert!(metrics.meets_fps(100)); // Should meet 100 FPS exactly
922        assert!(!metrics.meets_fps(120)); // Should NOT meet 120 FPS
923    }
924
925    #[test]
926    fn test_tui_load_test_no_hang() {
927        let test = TuiLoadTest::new()
928            .with_item_count(100)
929            .with_timeout_ms(1000);
930
931        let result = test.run(|_items, _filter| {
932            // Fast render - just return immediately
933            Some(100) // 0.1ms
934        });
935
936        assert!(result.is_ok());
937        let metrics = result.unwrap();
938        assert!(metrics.frame_count > 0);
939    }
940
941    #[test]
942    fn test_tui_load_test_detects_hang() {
943        let test = TuiLoadTest::new()
944            .with_item_count(10)
945            .with_timeout_ms(50) // Very short timeout
946            .with_frames_per_filter(1);
947
948        let result = test.run(|_items, _filter| {
949            // Simulate hang
950            std::thread::sleep(Duration::from_millis(100));
951            None
952        });
953
954        assert!(result.is_err());
955        match result {
956            Err(TuiLoadError::FrameTimeout { .. }) => {}
957            _ => panic!("Expected FrameTimeout error"),
958        }
959    }
960
961    #[test]
962    fn test_tui_load_test_large_dataset() {
963        let test = TuiLoadTest::new()
964            .with_item_count(5000)
965            .with_timeout_ms(5000)
966            .with_frames_per_filter(3);
967
968        // Simulate realistic filtering
969        let result = test.run(|items, filter| {
970            let filter_lower = filter.to_lowercase();
971            let _filtered: Vec<_> = items
972                .iter()
973                .filter(|item| item.matches_filter_precomputed(&filter_lower))
974                .collect();
975            None // Let harness measure time
976        });
977
978        assert!(result.is_ok(), "Should handle 5000 items without hang");
979        let metrics = result.unwrap();
980
981        // Should complete reasonably fast (p95 under 100ms)
982        assert!(
983            metrics.p95_frame_ms() < 100.0,
984            "p95 = {:.2}ms, should be < 100ms",
985            metrics.p95_frame_ms()
986        );
987    }
988
989    #[test]
990    fn test_filter_stress_test() {
991        let test = TuiLoadTest::new()
992            .with_item_count(1000)
993            .with_timeout_ms(2000)
994            .with_frames_per_filter(5);
995
996        let result = test.run_filter_stress(|items, filter| {
997            let filter_lower = filter.to_lowercase();
998            items
999                .iter()
1000                .filter(|item| item.matches_filter_precomputed(&filter_lower))
1001                .cloned()
1002                .collect()
1003        });
1004
1005        assert!(result.is_ok());
1006        let results = result.unwrap();
1007
1008        // Check that all filters were tested
1009        assert!(!results.is_empty());
1010
1011        // Check that performance doesn't degrade too much
1012        TuiLoadAssertion::assert_filter_scales_linearly(&results, 5.0);
1013    }
1014
1015    #[test]
1016    fn test_tui_load_error_display() {
1017        let err = TuiLoadError::FrameTimeout {
1018            frame: 5,
1019            timeout_ms: 1000,
1020            filter: "test".to_string(),
1021            item_count: 5000,
1022        };
1023        let msg = err.to_string();
1024        assert!(msg.contains("5"));
1025        assert!(msg.contains("1000"));
1026        assert!(msg.contains("test"));
1027        assert!(msg.contains("5000"));
1028    }
1029
1030    #[test]
1031    fn test_data_generator_with_long_descriptions() {
1032        let gen = DataGenerator::new(10).with_description_len(200);
1033        let items = gen.generate();
1034
1035        // All descriptions should be at least close to target length
1036        for item in &items {
1037            assert!(
1038                item.description.len() >= 100,
1039                "Description too short: {}",
1040                item.description.len()
1041            );
1042        }
1043    }
1044}