1use std::time::{Duration, Instant};
43
44pub type TuiLoadResult<T> = Result<T, TuiLoadError>;
46
47#[derive(Debug, Clone, PartialEq)]
49pub enum TuiLoadError {
50 FrameTimeout {
52 frame: usize,
54 timeout_ms: u64,
56 filter: String,
58 item_count: usize,
60 },
61 BudgetExceeded {
63 frame: usize,
65 actual_ms: f64,
67 budget_ms: f64,
69 },
70 DataGenerationFailed {
72 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#[derive(Debug, Clone, Default)]
114pub struct TuiFrameMetrics {
115 pub frame_count: usize,
117 pub total_time_us: u64,
119 pub min_frame_us: u64,
121 pub max_frame_us: u64,
123 pub frame_times_us: Vec<u64>,
125}
126
127impl TuiFrameMetrics {
128 #[must_use]
130 pub fn new() -> Self {
131 Self {
132 min_frame_us: u64::MAX,
133 ..Default::default()
134 }
135 }
136
137 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 #[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 #[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 #[must_use]
166 pub fn max_frame_ms(&self) -> f64 {
167 self.max_frame_us as f64 / 1000.0
168 }
169
170 #[must_use]
172 pub fn p50_frame_ms(&self) -> f64 {
173 self.percentile(50)
174 }
175
176 #[must_use]
178 pub fn p95_frame_ms(&self) -> f64 {
179 self.percentile(95)
180 }
181
182 #[must_use]
184 pub fn p99_frame_ms(&self) -> f64 {
185 self.percentile(99)
186 }
187
188 #[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 #[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#[derive(Debug, Clone)]
210pub struct SyntheticItem {
211 pub id: u32,
213 pub name: String,
215 pub description: String,
217 pub value1: f32,
219 pub value2: f32,
221 pub state: String,
223 pub owner: String,
225 pub count: u32,
227}
228
229impl SyntheticItem {
230 #[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 #[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#[derive(Debug, Clone)]
254pub struct DataGenerator {
255 seed: u64,
257 item_count: usize,
259 avg_description_len: usize,
261}
262
263impl DataGenerator {
264 #[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 #[must_use]
276 pub fn with_seed(mut self, seed: u64) -> Self {
277 self.seed = seed;
278 self
279 }
280
281 #[must_use]
283 pub fn with_description_len(mut self, len: usize) -> Self {
284 self.avg_description_len = len;
285 self
286 }
287
288 #[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 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 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, value2: ((r2 % 10000) as f32) / 100.0, state: states[state_idx].to_string(),
330 owner: users[user_idx].to_string(),
331 count: ((r3 % 64) + 1) as u32, });
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 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#[derive(Debug, Clone)]
378pub struct TuiLoadConfig {
379 pub item_count: usize,
381 pub frame_budget_ms: f64,
383 pub timeout_ms: u64,
385 pub frames_per_filter: usize,
387 pub filters: Vec<String>,
389 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, timeout_ms: 1000, 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#[derive(Debug)]
416pub struct TuiLoadTest {
417 config: TuiLoadConfig,
418 data: Vec<SyntheticItem>,
419}
420
421impl TuiLoadTest {
422 #[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 #[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 #[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 #[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 #[must_use]
454 pub fn with_filters(mut self, filters: Vec<String>) -> Self {
455 self.config.filters = filters;
456 self
457 }
458
459 #[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 #[must_use]
468 pub fn with_strict_budget(mut self, strict: bool) -> Self {
469 self.config.strict_budget = strict;
470 self
471 }
472
473 #[must_use]
475 pub fn data(&self) -> &[SyntheticItem] {
476 &self.data
477 }
478
479 #[must_use]
481 pub fn config(&self) -> &TuiLoadConfig {
482 &self.config
483 }
484
485 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 let frame_time_us = if let Some(reported_time) = render(&self.data, filter) {
513 reported_time
514 } else {
515 start.elapsed().as_micros() as u64
517 };
518
519 let elapsed = start.elapsed();
520
521 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 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 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 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 let _filtered = filter_fn(&self.data, filter);
588
589 let elapsed = start.elapsed();
590
591 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#[derive(Debug, Clone, Copy, Default)]
619pub struct TuiLoadAssertion;
620
621impl TuiLoadAssertion {
622 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 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 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#[derive(Debug, Clone)]
697pub struct IntegrationLoadTest {
698 frame_budget_ms: f64,
700 timeout_ms: u64,
702 frame_count: usize,
704 component_budgets: std::collections::HashMap<String, f64>,
706}
707
708impl IntegrationLoadTest {
709 #[must_use]
711 pub fn new() -> Self {
712 Self {
713 frame_budget_ms: 100.0, timeout_ms: 5000, frame_count: 5,
716 component_budgets: std::collections::HashMap::new(),
717 }
718 }
719
720 #[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 #[must_use]
729 pub fn with_timeout_ms(mut self, timeout: u64) -> Self {
730 self.timeout_ms = timeout;
731 self
732 }
733
734 #[must_use]
736 pub fn with_frame_count(mut self, count: usize) -> Self {
737 self.frame_count = count;
738 self
739 }
740
741 #[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 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 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 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#[derive(Debug, Clone, Default)]
808pub struct ComponentTimings(pub std::collections::HashMap<String, f64>);
809
810impl ComponentTimings {
811 #[must_use]
813 pub fn new() -> Self {
814 Self(std::collections::HashMap::new())
815 }
816
817 pub fn record(&mut self, name: &str, duration_ms: f64) {
819 self.0.insert(name.to_string(), duration_ms);
820 }
821
822 #[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")); 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); }
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 for _ in 0..100 {
917 metrics.record(10_000); }
919
920 assert!(metrics.meets_fps(60)); assert!(metrics.meets_fps(100)); assert!(!metrics.meets_fps(120)); }
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 Some(100) });
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) .with_frames_per_filter(1);
947
948 let result = test.run(|_items, _filter| {
949 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 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 });
977
978 assert!(result.is_ok(), "Should handle 5000 items without hang");
979 let metrics = result.unwrap();
980
981 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 assert!(!results.is_empty());
1010
1011 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 for item in &items {
1037 assert!(
1038 item.description.len() >= 100,
1039 "Description too short: {}",
1040 item.description.len()
1041 );
1042 }
1043 }
1044}