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-terminal, 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        // Warmup + best-of-N to kill cold-start and shared-runner jitter.
1007        // Falsifier preserved: if MIN p95 across 3 warmed runs exceeds 100ms,
1008        // filtering really regressed — not just a noisy neighbor.
1009        let run_once = || {
1010            TuiLoadTest::new()
1011                .with_item_count(5000)
1012                .with_timeout_ms(5000)
1013                .with_frames_per_filter(3)
1014                .run(|items, filter| {
1015                    let filter_lower = filter.to_lowercase();
1016                    let _filtered: Vec<_> = items
1017                        .iter()
1018                        .filter(|item| item.matches_filter_precomputed(&filter_lower))
1019                        .collect();
1020                    None
1021                })
1022        };
1023
1024        let _warmup = run_once();
1025
1026        let mut best_p95 = f64::INFINITY;
1027        for _ in 0..3 {
1028            let result = run_once();
1029            assert!(result.is_ok(), "Should handle 5000 items without hang");
1030            let p95 = result.unwrap().p95_frame_ms();
1031            if p95 < best_p95 {
1032                best_p95 = p95;
1033            }
1034        }
1035
1036        assert!(
1037            best_p95 < 100.0,
1038            "p95 (min of 3) = {best_p95:.2}ms, should be < 100ms"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_filter_stress_test() {
1044        let test = TuiLoadTest::new()
1045            .with_item_count(1000)
1046            .with_timeout_ms(2000)
1047            .with_frames_per_filter(5);
1048
1049        let result = test.run_filter_stress(|items, filter| {
1050            let filter_lower = filter.to_lowercase();
1051            items
1052                .iter()
1053                .filter(|item| item.matches_filter_precomputed(&filter_lower))
1054                .cloned()
1055                .collect()
1056        });
1057
1058        assert!(result.is_ok(), "filter stress test must not error");
1059        let results = result.expect("checked above");
1060
1061        // Correctness: all filters were tested
1062        assert!(!results.is_empty(), "must produce at least one result");
1063
1064        // Correctness: no filter returned an error
1065        // (Performance scaling belongs in cargo bench, not cargo test — MUDA)
1066    }
1067
1068    #[test]
1069    fn test_tui_load_error_display() {
1070        let err = TuiLoadError::FrameTimeout {
1071            frame: 5,
1072            timeout_ms: 1000,
1073            filter: "test".to_string(),
1074            item_count: 5000,
1075        };
1076        let msg = err.to_string();
1077        assert!(msg.contains('5'));
1078        assert!(msg.contains("1000"));
1079        assert!(msg.contains("test"));
1080        assert!(msg.contains("5000"));
1081    }
1082
1083    #[test]
1084    fn test_data_generator_with_long_descriptions() {
1085        let gen = DataGenerator::new(10).with_description_len(200);
1086        let items = gen.generate();
1087
1088        // All descriptions should be at least close to target length
1089        for item in &items {
1090            assert!(
1091                item.description.len() >= 100,
1092                "Description too short: {}",
1093                item.description.len()
1094            );
1095        }
1096    }
1097
1098    #[test]
1099    fn test_data_generator_default() {
1100        let gen = DataGenerator::default();
1101        let items = gen.generate();
1102        assert_eq!(items.len(), 1000);
1103    }
1104
1105    #[test]
1106    fn test_integration_load_test_run_success() {
1107        let test = IntegrationLoadTest::new()
1108            .with_frame_budget_ms(500.0)
1109            .with_timeout_ms(2000)
1110            .with_frame_count(3);
1111
1112        let mut call_count = 0;
1113        let result = test.run(|| {
1114            call_count += 1;
1115            let mut timings = ComponentTimings::new();
1116            timings.record("render", 1.0);
1117            timings
1118        });
1119
1120        assert!(result.is_ok());
1121        let metrics = result.unwrap();
1122        assert_eq!(metrics.frame_count, 3);
1123        assert_eq!(call_count, 3);
1124    }
1125
1126    #[test]
1127    fn test_integration_load_test_default() {
1128        let test = IntegrationLoadTest::default();
1129        let result = test.run(|| ComponentTimings::new());
1130        assert!(result.is_ok());
1131    }
1132
1133    #[test]
1134    fn test_integration_load_test_budget_exceeded() {
1135        let test = IntegrationLoadTest::new()
1136            .with_frame_count(2)
1137            .with_timeout_ms(5000)
1138            .with_component_budget("slow_component", 0.001); // impossibly tight
1139
1140        let result = test.run(|| {
1141            let mut timings = ComponentTimings::new();
1142            timings.record("slow_component", 10.0); // 10ms, way over 0.001ms
1143            timings
1144        });
1145
1146        assert!(result.is_err());
1147        match result {
1148            Err(TuiLoadError::BudgetExceeded {
1149                actual_ms,
1150                budget_ms,
1151                ..
1152            }) => {
1153                assert!((actual_ms - 10.0).abs() < f64::EPSILON);
1154                assert!((budget_ms - 0.001).abs() < f64::EPSILON);
1155            }
1156            _ => panic!("Expected BudgetExceeded error"),
1157        }
1158    }
1159
1160    #[test]
1161    fn test_integration_load_test_component_within_budget() {
1162        let test = IntegrationLoadTest::new()
1163            .with_frame_count(2)
1164            .with_timeout_ms(5000)
1165            .with_component_budget("fast", 100.0);
1166
1167        let result = test.run(|| {
1168            let mut timings = ComponentTimings::new();
1169            timings.record("fast", 1.0);
1170            timings
1171        });
1172
1173        assert!(result.is_ok());
1174    }
1175
1176    #[test]
1177    fn test_component_timings() {
1178        let mut t = ComponentTimings::new();
1179        assert!(t.get("render").is_none());
1180
1181        t.record("render", 5.5);
1182        assert!((t.get("render").unwrap() - 5.5).abs() < f64::EPSILON);
1183
1184        t.record("layout", 2.0);
1185        assert!((t.get("layout").unwrap() - 2.0).abs() < f64::EPSILON);
1186    }
1187
1188    #[test]
1189    fn test_tui_load_config_builder() {
1190        let config = TuiLoadConfig {
1191            item_count: 500,
1192            frame_budget_ms: 32.0,
1193            timeout_ms: 3000,
1194            frames_per_filter: 10,
1195            filters: vec!["sys".to_string(), "usr".to_string()],
1196            strict_budget: true,
1197        };
1198        assert_eq!(config.item_count, 500);
1199        assert!((config.frame_budget_ms - 32.0).abs() < f64::EPSILON);
1200        assert_eq!(config.timeout_ms, 3000);
1201        assert_eq!(config.frames_per_filter, 10);
1202        assert_eq!(config.filters.len(), 2);
1203        assert!(config.strict_budget);
1204    }
1205
1206    #[test]
1207    fn test_tui_load_error_budget_exceeded_display() {
1208        let err = TuiLoadError::BudgetExceeded {
1209            frame: 3,
1210            actual_ms: 150.0,
1211            budget_ms: 16.6,
1212        };
1213        let msg = err.to_string();
1214        assert!(msg.contains("150"));
1215        assert!(msg.contains("16.6"));
1216    }
1217
1218    #[test]
1219    fn test_frame_metrics_empty() {
1220        let metrics = TuiFrameMetrics::new();
1221        assert_eq!(metrics.frame_count, 0);
1222        assert_eq!(metrics.p50_frame_ms(), 0.0);
1223        assert_eq!(metrics.p95_frame_ms(), 0.0);
1224    }
1225}