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",
296            "kworker",
297            "chrome",
298            "firefox",
299            "code",
300            "rust-analyzer",
301            "node",
302            "python",
303            "java",
304            "postgres",
305            "nginx",
306            "docker",
307            "containerd",
308            "ssh",
309            "bash",
310            "zsh",
311            "fish",
312            "vim",
313            "nvim",
314            "emacs",
315            "tmux",
316            "htop",
317            "top",
318            "ps",
319            "grep",
320            "find",
321            "cargo",
322            "rustc",
323            "gcc",
324            "clang",
325            "llvm",
326            "git",
327            "make",
328            "cmake",
329            "webpack",
330            "vite",
331        ];
332
333        let states = ["R", "S", "D", "Z", "T", "I"];
334        let users = ["root", "noah", "www-data", "postgres", "nobody", "daemon"];
335
336        for i in 0..self.item_count {
337            // Simple LCG PRNG
338            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
339            let r1 = rng_state;
340            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
341            let r2 = rng_state;
342            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
343            let r3 = rng_state;
344
345            let name_idx = (r1 as usize) % names.len();
346            let state_idx = (r2 as usize) % states.len();
347            let user_idx = (r3 as usize) % users.len();
348
349            // Generate a realistic command line
350            let base_name = names[name_idx];
351            let pid = 1000 + i as u32;
352            let description = self.generate_cmdline(base_name, r1);
353
354            items.push(SyntheticItem {
355                id: pid,
356                name: format!("{}-{}", base_name, i % 100),
357                description,
358                value1: ((r1 % 10000) as f32) / 100.0, // CPU 0-100%
359                value2: ((r2 % 10000) as f32) / 100.0, // Mem 0-100%
360                state: states[state_idx].to_string(),
361                owner: users[user_idx].to_string(),
362                count: ((r3 % 64) + 1) as u32, // threads 1-64
363            });
364        }
365
366        items
367    }
368
369    fn generate_cmdline(&self, base_name: &str, seed: u64) -> String {
370        let args = [
371            "--config",
372            "/etc/config.yaml",
373            "--port",
374            "8080",
375            "--workers",
376            "4",
377            "--log-level",
378            "debug",
379            "--data-dir",
380            "/var/lib/data",
381            "--cache-size",
382            "1024",
383            "--timeout",
384            "30",
385            "--max-connections",
386            "1000",
387            "--enable-metrics",
388            "--prometheus-port",
389            "9090",
390        ];
391
392        let mut cmdline = format!("/usr/bin/{}", base_name);
393        let arg_count = ((seed % 6) + 2) as usize;
394
395        for i in 0..arg_count {
396            let arg_idx = ((seed.wrapping_add(i as u64 * 7)) % (args.len() as u64)) as usize;
397            cmdline.push(' ');
398            cmdline.push_str(args[arg_idx]);
399        }
400
401        // Pad to approximate target length
402        while cmdline.len() < self.avg_description_len {
403            cmdline.push_str(" --extra-arg");
404        }
405
406        cmdline
407    }
408}
409
410impl Default for DataGenerator {
411    fn default() -> Self {
412        Self::new(1000)
413    }
414}
415
416/// Configuration for TUI load testing
417#[derive(Debug, Clone)]
418pub struct TuiLoadConfig {
419    /// Number of items to generate
420    pub item_count: usize,
421    /// Frame budget in milliseconds (e.g., 16ms for 60 FPS)
422    pub frame_budget_ms: f64,
423    /// Timeout for hang detection in milliseconds
424    pub timeout_ms: u64,
425    /// Number of frames to render per filter
426    pub frames_per_filter: usize,
427    /// Filter strings to test
428    pub filters: Vec<String>,
429    /// Fail on budget exceeded (vs just warn)
430    pub strict_budget: bool,
431}
432
433impl Default for TuiLoadConfig {
434    fn default() -> Self {
435        Self {
436            item_count: 1000,
437            frame_budget_ms: 16.67, // 60 FPS
438            timeout_ms: 1000,       // 1 second
439            frames_per_filter: 10,
440            filters: vec![
441                String::new(),
442                "a".to_string(),
443                "sys".to_string(),
444                "chrome".to_string(),
445                "nonexistent_filter_that_matches_nothing".to_string(),
446            ],
447            strict_budget: false,
448        }
449    }
450}
451
452/// TUI Load Test Runner
453///
454/// Framework-agnostic load testing for TUI applications.
455#[derive(Debug)]
456pub struct TuiLoadTest {
457    config: TuiLoadConfig,
458    data: Vec<SyntheticItem>,
459}
460
461impl TuiLoadTest {
462    /// Create a new TUI load test with default configuration
463    #[must_use]
464    pub fn new() -> Self {
465        let config = TuiLoadConfig::default();
466        let data = DataGenerator::new(config.item_count).generate();
467        Self { config, data }
468    }
469
470    /// Create with specific item count
471    #[must_use]
472    pub fn with_item_count(mut self, count: usize) -> Self {
473        self.config.item_count = count;
474        self.data = DataGenerator::new(count).generate();
475        self
476    }
477
478    /// Set frame budget in milliseconds
479    #[must_use]
480    pub fn with_frame_budget_ms(mut self, budget_ms: f64) -> Self {
481        self.config.frame_budget_ms = budget_ms;
482        self
483    }
484
485    /// Set timeout for hang detection
486    #[must_use]
487    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
488        self.config.timeout_ms = timeout_ms;
489        self
490    }
491
492    /// Set filters to test
493    #[must_use]
494    pub fn with_filters(mut self, filters: Vec<String>) -> Self {
495        self.config.filters = filters;
496        self
497    }
498
499    /// Set number of frames per filter
500    #[must_use]
501    pub fn with_frames_per_filter(mut self, count: usize) -> Self {
502        self.config.frames_per_filter = count;
503        self
504    }
505
506    /// Enable strict budget enforcement
507    #[must_use]
508    pub fn with_strict_budget(mut self, strict: bool) -> Self {
509        self.config.strict_budget = strict;
510        self
511    }
512
513    /// Get the generated test data
514    #[must_use]
515    pub fn data(&self) -> &[SyntheticItem] {
516        &self.data
517    }
518
519    /// Get configuration
520    #[must_use]
521    pub fn config(&self) -> &TuiLoadConfig {
522        &self.config
523    }
524
525    /// Run load test with a render callback
526    ///
527    /// The callback receives:
528    /// - `items`: Slice of synthetic items to render
529    /// - `filter`: Current filter string
530    ///
531    /// The callback should perform the actual TUI rendering and return
532    /// the frame time in microseconds, or None if it wants the test
533    /// harness to measure time.
534    ///
535    /// # Errors
536    ///
537    /// Returns error if a frame times out (hang detected) or exceeds
538    /// budget in strict mode.
539    pub fn run<F>(&self, mut render: F) -> TuiLoadResult<TuiFrameMetrics>
540    where
541        F: FnMut(&[SyntheticItem], &str) -> Option<u64>,
542    {
543        let mut metrics = TuiFrameMetrics::new();
544        let timeout = Duration::from_millis(self.config.timeout_ms);
545        let mut frame_num = 0;
546
547        for filter in &self.config.filters {
548            for _ in 0..self.config.frames_per_filter {
549                let start = Instant::now();
550
551                // Call render function
552                let frame_time_us = if let Some(reported_time) = render(&self.data, filter) {
553                    reported_time
554                } else {
555                    // Measure time ourselves
556                    start.elapsed().as_micros() as u64
557                };
558
559                let elapsed = start.elapsed();
560
561                // Check for timeout (hang detection)
562                if elapsed > timeout {
563                    return Err(TuiLoadError::FrameTimeout {
564                        frame: frame_num,
565                        timeout_ms: self.config.timeout_ms,
566                        filter: filter.clone(),
567                        item_count: self.data.len(),
568                    });
569                }
570
571                // Check budget
572                let frame_ms = frame_time_us as f64 / 1000.0;
573                if self.config.strict_budget && frame_ms > self.config.frame_budget_ms {
574                    return Err(TuiLoadError::BudgetExceeded {
575                        frame: frame_num,
576                        actual_ms: frame_ms,
577                        budget_ms: self.config.frame_budget_ms,
578                    });
579                }
580
581                metrics.record(frame_time_us);
582                frame_num += 1;
583            }
584        }
585
586        Ok(metrics)
587    }
588
589    /// Run filter-specific performance test
590    ///
591    /// Tests filtering with increasing filter lengths to detect
592    /// O(n²) or worse complexity in filter implementations.
593    ///
594    /// # Errors
595    ///
596    /// Returns error if hang detected or budget exceeded.
597    pub fn run_filter_stress<F>(
598        &self,
599        mut filter_fn: F,
600    ) -> TuiLoadResult<Vec<(String, TuiFrameMetrics)>>
601    where
602        F: FnMut(&[SyntheticItem], &str) -> Vec<SyntheticItem>,
603    {
604        let timeout = Duration::from_millis(self.config.timeout_ms);
605        let mut results = Vec::new();
606
607        // Test filters of increasing length/complexity
608        let stress_filters = [
609            "",
610            "a",
611            "ab",
612            "abc",
613            "sys",
614            "syst",
615            "syste",
616            "system",
617            "systemd",
618            "chrome",
619            "rust-analyzer",
620            "this_filter_will_match_nothing_at_all",
621        ];
622
623        for filter in stress_filters {
624            let mut metrics = TuiFrameMetrics::new();
625
626            for frame in 0..self.config.frames_per_filter {
627                let start = Instant::now();
628
629                // Call filter function
630                let _filtered = filter_fn(&self.data, filter);
631
632                let elapsed = start.elapsed();
633
634                // Check for timeout
635                if elapsed > timeout {
636                    return Err(TuiLoadError::FrameTimeout {
637                        frame,
638                        timeout_ms: self.config.timeout_ms,
639                        filter: filter.to_string(),
640                        item_count: self.data.len(),
641                    });
642                }
643
644                metrics.record(elapsed.as_micros() as u64);
645            }
646
647            results.push((filter.to_string(), metrics));
648        }
649
650        Ok(results)
651    }
652}
653
654impl Default for TuiLoadTest {
655    fn default() -> Self {
656        Self::new()
657    }
658}
659
660/// Assertion helpers for TUI load test results
661#[derive(Debug, Clone, Copy, Default)]
662pub struct TuiLoadAssertion;
663
664impl TuiLoadAssertion {
665    /// Assert that p95 frame time meets target FPS
666    pub fn assert_meets_fps(metrics: &TuiFrameMetrics, target_fps: u32) {
667        let budget_ms = 1000.0 / target_fps as f64;
668        assert!(
669            metrics.p95_frame_ms() <= budget_ms,
670            "p95 frame time {:.2}ms exceeds {:.2}ms budget for {} FPS",
671            metrics.p95_frame_ms(),
672            budget_ms,
673            target_fps
674        );
675    }
676
677    /// Assert that no frame exceeded timeout
678    pub fn assert_no_hang(result: &TuiLoadResult<TuiFrameMetrics>) {
679        assert!(
680            result.is_ok(),
681            "TUI hang detected: {:?}",
682            result.as_ref().err()
683        );
684    }
685
686    /// Assert filter performance doesn't degrade with item count
687    pub fn assert_filter_scales_linearly(
688        results: &[(String, TuiFrameMetrics)],
689        max_degradation_factor: f64,
690    ) {
691        if results.len() < 2 {
692            return;
693        }
694
695        let baseline = results[0].1.avg_frame_ms();
696        if baseline == 0.0 {
697            return;
698        }
699
700        for (filter, metrics) in results.iter().skip(1) {
701            let factor = metrics.avg_frame_ms() / baseline;
702            assert!(
703                factor <= max_degradation_factor,
704                "Filter '{}' degraded by {:.1}x (max allowed: {:.1}x)",
705                filter,
706                factor,
707                max_degradation_factor
708            );
709        }
710    }
711}
712
713/// Integration load test that measures real application frame times.
714///
715/// Unlike synthetic load tests, this tests the ACTUAL application with
716/// real collectors, real system calls, and real data. It catches issues
717/// like blocking I/O, slow system calls, and expensive operations.
718///
719/// ## Example
720///
721/// ```ignore
722/// use jugar_probar::tui_load::IntegrationLoadTest;
723///
724/// // Test that real app renders frames under 100ms
725/// let test = IntegrationLoadTest::new()
726///     .with_frame_budget_ms(100.0)
727///     .with_timeout_ms(5000)
728///     .with_frame_count(10);
729///
730/// let result = test.run(|| {
731///     // Your real app initialization and render
732///     let mut app = App::new();
733///     app.collect_metrics();
734///     // Simulate frame render...
735/// });
736///
737/// assert!(result.is_ok(), "Real app should not hang");
738/// ```
739#[derive(Debug, Clone)]
740pub struct IntegrationLoadTest {
741    /// Frame budget in milliseconds
742    frame_budget_ms: f64,
743    /// Timeout for hang detection
744    timeout_ms: u64,
745    /// Number of frames to test
746    frame_count: usize,
747    /// Component-level timing thresholds (name -> max_ms)
748    component_budgets: std::collections::HashMap<String, f64>,
749}
750
751impl IntegrationLoadTest {
752    /// Create new integration load test
753    #[must_use]
754    pub fn new() -> Self {
755        Self {
756            frame_budget_ms: 100.0, // 10 FPS minimum
757            timeout_ms: 5000,       // 5 second hang detection
758            frame_count: 5,
759            component_budgets: std::collections::HashMap::new(),
760        }
761    }
762
763    /// Set frame budget
764    #[must_use]
765    pub fn with_frame_budget_ms(mut self, budget: f64) -> Self {
766        self.frame_budget_ms = budget;
767        self
768    }
769
770    /// Set timeout for hang detection
771    #[must_use]
772    pub fn with_timeout_ms(mut self, timeout: u64) -> Self {
773        self.timeout_ms = timeout;
774        self
775    }
776
777    /// Set number of frames to test
778    #[must_use]
779    pub fn with_frame_count(mut self, count: usize) -> Self {
780        self.frame_count = count;
781        self
782    }
783
784    /// Add component budget (e.g., "container_analyzer" -> 100ms max)
785    #[must_use]
786    pub fn with_component_budget(mut self, name: &str, max_ms: f64) -> Self {
787        self.component_budgets.insert(name.to_string(), max_ms);
788        self
789    }
790
791    /// Run integration test with a closure that performs one frame
792    ///
793    /// The closure should:
794    /// 1. Initialize app (first call only)
795    /// 2. Collect metrics
796    /// 3. Render frame
797    ///
798    /// Returns timing for each frame.
799    pub fn run<F>(&self, mut frame_fn: F) -> TuiLoadResult<TuiFrameMetrics>
800    where
801        F: FnMut() -> ComponentTimings,
802    {
803        let mut metrics = TuiFrameMetrics::new();
804        let timeout = Duration::from_millis(self.timeout_ms);
805
806        for frame in 0..self.frame_count {
807            let start = Instant::now();
808
809            let timings = frame_fn();
810
811            let elapsed = start.elapsed();
812
813            // Check for hang
814            if elapsed > timeout {
815                return Err(TuiLoadError::FrameTimeout {
816                    frame,
817                    timeout_ms: self.timeout_ms,
818                    filter: format!("frame {}", frame),
819                    item_count: 0,
820                });
821            }
822
823            // Check component budgets
824            for (name, &max_ms) in &self.component_budgets {
825                if let Some(&actual_ms) = timings.0.get(name) {
826                    if actual_ms > max_ms {
827                        return Err(TuiLoadError::BudgetExceeded {
828                            frame,
829                            actual_ms,
830                            budget_ms: max_ms,
831                        });
832                    }
833                }
834            }
835
836            metrics.record(elapsed.as_micros() as u64);
837        }
838
839        Ok(metrics)
840    }
841}
842
843impl Default for IntegrationLoadTest {
844    fn default() -> Self {
845        Self::new()
846    }
847}
848
849/// Component timing results from a frame render
850#[derive(Debug, Clone, Default)]
851pub struct ComponentTimings(pub std::collections::HashMap<String, f64>);
852
853impl ComponentTimings {
854    /// Create empty timings
855    #[must_use]
856    pub fn new() -> Self {
857        Self(std::collections::HashMap::new())
858    }
859
860    /// Record a component timing
861    pub fn record(&mut self, name: &str, duration_ms: f64) {
862        self.0.insert(name.to_string(), duration_ms);
863    }
864
865    /// Get timing for a component
866    #[must_use]
867    pub fn get(&self, name: &str) -> Option<f64> {
868        self.0.get(name).copied()
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    #[test]
877    fn test_data_generator_creates_items() {
878        let gen = DataGenerator::new(100);
879        let items = gen.generate();
880        assert_eq!(items.len(), 100);
881    }
882
883    #[test]
884    fn test_data_generator_deterministic() {
885        let gen1 = DataGenerator::new(50).with_seed(12345);
886        let gen2 = DataGenerator::new(50).with_seed(12345);
887        let items1 = gen1.generate();
888        let items2 = gen2.generate();
889
890        for (a, b) in items1.iter().zip(items2.iter()) {
891            assert_eq!(a.id, b.id);
892            assert_eq!(a.name, b.name);
893        }
894    }
895
896    #[test]
897    fn test_synthetic_item_filter_empty() {
898        let item = SyntheticItem {
899            id: 1,
900            name: "test".to_string(),
901            description: "desc".to_string(),
902            value1: 0.0,
903            value2: 0.0,
904            state: "R".to_string(),
905            owner: "root".to_string(),
906            count: 1,
907        };
908        assert!(item.matches_filter(""));
909    }
910
911    #[test]
912    fn test_synthetic_item_filter_name() {
913        let item = SyntheticItem {
914            id: 1,
915            name: "systemd".to_string(),
916            description: "init system".to_string(),
917            value1: 0.0,
918            value2: 0.0,
919            state: "S".to_string(),
920            owner: "root".to_string(),
921            count: 1,
922        };
923        assert!(item.matches_filter("sys"));
924        assert!(item.matches_filter("SYS")); // case insensitive
925        assert!(!item.matches_filter("chrome"));
926    }
927
928    #[test]
929    fn test_synthetic_item_filter_description() {
930        let item = SyntheticItem {
931            id: 1,
932            name: "init".to_string(),
933            description: "/usr/lib/systemd/systemd".to_string(),
934            value1: 0.0,
935            value2: 0.0,
936            state: "S".to_string(),
937            owner: "root".to_string(),
938            count: 1,
939        };
940        assert!(item.matches_filter("systemd"));
941    }
942
943    #[test]
944    fn test_frame_metrics_percentiles() {
945        let mut metrics = TuiFrameMetrics::new();
946        for i in 1..=100 {
947            metrics.record(i * 1000); // 1-100ms
948        }
949
950        assert_eq!(metrics.frame_count, 100);
951        assert!((metrics.p50_frame_ms() - 50.0).abs() < 2.0);
952        assert!(metrics.p95_frame_ms() >= 95.0);
953    }
954
955    #[test]
956    fn test_frame_metrics_meets_fps() {
957        let mut metrics = TuiFrameMetrics::new();
958        // All frames at 10ms = 100 FPS
959        for _ in 0..100 {
960            metrics.record(10_000); // 10ms in microseconds
961        }
962
963        assert!(metrics.meets_fps(60)); // Should meet 60 FPS
964        assert!(metrics.meets_fps(100)); // Should meet 100 FPS exactly
965        assert!(!metrics.meets_fps(120)); // Should NOT meet 120 FPS
966    }
967
968    #[test]
969    fn test_tui_load_test_no_hang() {
970        let test = TuiLoadTest::new()
971            .with_item_count(100)
972            .with_timeout_ms(1000);
973
974        let result = test.run(|_items, _filter| {
975            // Fast render - just return immediately
976            Some(100) // 0.1ms
977        });
978
979        assert!(result.is_ok());
980        let metrics = result.unwrap();
981        assert!(metrics.frame_count > 0);
982    }
983
984    #[test]
985    fn test_tui_load_test_detects_hang() {
986        let test = TuiLoadTest::new()
987            .with_item_count(10)
988            .with_timeout_ms(50) // Very short timeout
989            .with_frames_per_filter(1);
990
991        let result = test.run(|_items, _filter| {
992            // Simulate hang
993            std::thread::sleep(Duration::from_millis(100));
994            None
995        });
996
997        assert!(result.is_err());
998        match result {
999            Err(TuiLoadError::FrameTimeout { .. }) => {}
1000            _ => panic!("Expected FrameTimeout error"),
1001        }
1002    }
1003
1004    #[test]
1005    fn test_tui_load_test_large_dataset() {
1006        let test = TuiLoadTest::new()
1007            .with_item_count(5000)
1008            .with_timeout_ms(5000)
1009            .with_frames_per_filter(3);
1010
1011        // Simulate realistic filtering
1012        let result = test.run(|items, filter| {
1013            let filter_lower = filter.to_lowercase();
1014            let _filtered: Vec<_> = items
1015                .iter()
1016                .filter(|item| item.matches_filter_precomputed(&filter_lower))
1017                .collect();
1018            None // Let harness measure time
1019        });
1020
1021        assert!(result.is_ok(), "Should handle 5000 items without hang");
1022        let metrics = result.unwrap();
1023
1024        // Should complete reasonably fast (p95 under 100ms)
1025        assert!(
1026            metrics.p95_frame_ms() < 100.0,
1027            "p95 = {:.2}ms, should be < 100ms",
1028            metrics.p95_frame_ms()
1029        );
1030    }
1031
1032    #[test]
1033    fn test_filter_stress_test() {
1034        let test = TuiLoadTest::new()
1035            .with_item_count(1000)
1036            .with_timeout_ms(2000)
1037            .with_frames_per_filter(5);
1038
1039        let result = test.run_filter_stress(|items, filter| {
1040            let filter_lower = filter.to_lowercase();
1041            items
1042                .iter()
1043                .filter(|item| item.matches_filter_precomputed(&filter_lower))
1044                .cloned()
1045                .collect()
1046        });
1047
1048        assert!(result.is_ok());
1049        let results = result.unwrap();
1050
1051        // Check that all filters were tested
1052        assert!(!results.is_empty());
1053
1054        // Check that performance doesn't degrade too much
1055        TuiLoadAssertion::assert_filter_scales_linearly(&results, 5.0);
1056    }
1057
1058    #[test]
1059    fn test_tui_load_error_display() {
1060        let err = TuiLoadError::FrameTimeout {
1061            frame: 5,
1062            timeout_ms: 1000,
1063            filter: "test".to_string(),
1064            item_count: 5000,
1065        };
1066        let msg = err.to_string();
1067        assert!(msg.contains('5'));
1068        assert!(msg.contains("1000"));
1069        assert!(msg.contains("test"));
1070        assert!(msg.contains("5000"));
1071    }
1072
1073    #[test]
1074    fn test_data_generator_with_long_descriptions() {
1075        let gen = DataGenerator::new(10).with_description_len(200);
1076        let items = gen.generate();
1077
1078        // All descriptions should be at least close to target length
1079        for item in &items {
1080            assert!(
1081                item.description.len() >= 100,
1082                "Description too short: {}",
1083                item.description.len()
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn test_data_generator_default() {
1090        let gen = DataGenerator::default();
1091        let items = gen.generate();
1092        assert_eq!(items.len(), 1000);
1093    }
1094
1095    #[test]
1096    fn test_integration_load_test_run_success() {
1097        let test = IntegrationLoadTest::new()
1098            .with_frame_budget_ms(500.0)
1099            .with_timeout_ms(2000)
1100            .with_frame_count(3);
1101
1102        let mut call_count = 0;
1103        let result = test.run(|| {
1104            call_count += 1;
1105            let mut timings = ComponentTimings::new();
1106            timings.record("render", 1.0);
1107            timings
1108        });
1109
1110        assert!(result.is_ok());
1111        let metrics = result.unwrap();
1112        assert_eq!(metrics.frame_count, 3);
1113        assert_eq!(call_count, 3);
1114    }
1115
1116    #[test]
1117    fn test_integration_load_test_default() {
1118        let test = IntegrationLoadTest::default();
1119        let result = test.run(|| ComponentTimings::new());
1120        assert!(result.is_ok());
1121    }
1122
1123    #[test]
1124    fn test_integration_load_test_budget_exceeded() {
1125        let test = IntegrationLoadTest::new()
1126            .with_frame_count(2)
1127            .with_timeout_ms(5000)
1128            .with_component_budget("slow_component", 0.001); // impossibly tight
1129
1130        let result = test.run(|| {
1131            let mut timings = ComponentTimings::new();
1132            timings.record("slow_component", 10.0); // 10ms, way over 0.001ms
1133            timings
1134        });
1135
1136        assert!(result.is_err());
1137        match result {
1138            Err(TuiLoadError::BudgetExceeded {
1139                actual_ms,
1140                budget_ms,
1141                ..
1142            }) => {
1143                assert!((actual_ms - 10.0).abs() < f64::EPSILON);
1144                assert!((budget_ms - 0.001).abs() < f64::EPSILON);
1145            }
1146            _ => panic!("Expected BudgetExceeded error"),
1147        }
1148    }
1149
1150    #[test]
1151    fn test_integration_load_test_component_within_budget() {
1152        let test = IntegrationLoadTest::new()
1153            .with_frame_count(2)
1154            .with_timeout_ms(5000)
1155            .with_component_budget("fast", 100.0);
1156
1157        let result = test.run(|| {
1158            let mut timings = ComponentTimings::new();
1159            timings.record("fast", 1.0);
1160            timings
1161        });
1162
1163        assert!(result.is_ok());
1164    }
1165
1166    #[test]
1167    fn test_component_timings() {
1168        let mut t = ComponentTimings::new();
1169        assert!(t.get("render").is_none());
1170
1171        t.record("render", 5.5);
1172        assert!((t.get("render").unwrap() - 5.5).abs() < f64::EPSILON);
1173
1174        t.record("layout", 2.0);
1175        assert!((t.get("layout").unwrap() - 2.0).abs() < f64::EPSILON);
1176    }
1177
1178    #[test]
1179    fn test_tui_load_config_builder() {
1180        let config = TuiLoadConfig {
1181            item_count: 500,
1182            frame_budget_ms: 32.0,
1183            timeout_ms: 3000,
1184            frames_per_filter: 10,
1185            filters: vec!["sys".to_string(), "usr".to_string()],
1186            strict_budget: true,
1187        };
1188        assert_eq!(config.item_count, 500);
1189        assert!((config.frame_budget_ms - 32.0).abs() < f64::EPSILON);
1190        assert_eq!(config.timeout_ms, 3000);
1191        assert_eq!(config.frames_per_filter, 10);
1192        assert_eq!(config.filters.len(), 2);
1193        assert!(config.strict_budget);
1194    }
1195
1196    #[test]
1197    fn test_tui_load_error_budget_exceeded_display() {
1198        let err = TuiLoadError::BudgetExceeded {
1199            frame: 3,
1200            actual_ms: 150.0,
1201            budget_ms: 16.6,
1202        };
1203        let msg = err.to_string();
1204        assert!(msg.contains("150"));
1205        assert!(msg.contains("16.6"));
1206    }
1207
1208    #[test]
1209    fn test_frame_metrics_empty() {
1210        let metrics = TuiFrameMetrics::new();
1211        assert_eq!(metrics.frame_count, 0);
1212        assert_eq!(metrics.p50_frame_ms(), 0.0);
1213        assert_eq!(metrics.p95_frame_ms(), 0.0);
1214    }
1215}