1use std::collections::HashMap;
34use std::time::{Duration, Instant};
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct TuiCell {
39 pub ch: char,
41 pub fg: (u8, u8, u8),
43 pub bg: (u8, u8, u8),
45 pub bold: bool,
47}
48
49impl Default for TuiCell {
50 fn default() -> Self {
51 Self {
52 ch: ' ',
53 fg: (255, 255, 255),
54 bg: (0, 0, 0),
55 bold: false,
56 }
57 }
58}
59
60#[derive(Debug)]
63pub struct TuiTestBackend {
64 pub width: u16,
66 pub height: u16,
68 cells: Vec<TuiCell>,
70 frame_count: u64,
72 metrics: RenderMetrics,
74 deterministic: bool,
76}
77
78impl TuiTestBackend {
79 pub fn new(width: u16, height: u16) -> Self {
81 let size = width as usize * height as usize;
82 Self {
83 width,
84 height,
85 cells: vec![TuiCell::default(); size],
86 frame_count: 0,
87 metrics: RenderMetrics::new(),
88 deterministic: true,
89 }
90 }
91
92 pub fn with_deterministic(mut self, enabled: bool) -> Self {
94 self.deterministic = enabled;
95 self
96 }
97
98 pub fn clear(&mut self) {
100 for cell in &mut self.cells {
101 *cell = TuiCell::default();
102 }
103 }
104
105 pub fn get(&self, x: u16, y: u16) -> Option<&TuiCell> {
107 if x < self.width && y < self.height {
108 Some(&self.cells[y as usize * self.width as usize + x as usize])
109 } else {
110 None
111 }
112 }
113
114 pub fn set(&mut self, x: u16, y: u16, cell: TuiCell) {
116 if x < self.width && y < self.height {
117 self.cells[y as usize * self.width as usize + x as usize] = cell;
118 }
119 }
120
121 pub fn draw_text(&mut self, x: u16, y: u16, text: &str, fg: (u8, u8, u8)) {
123 for (i, ch) in text.chars().enumerate() {
124 let col = x + i as u16;
125 if col < self.width {
126 self.set(
127 col,
128 y,
129 TuiCell {
130 ch,
131 fg,
132 bg: (0, 0, 0),
133 bold: false,
134 },
135 );
136 }
137 }
138 }
139
140 pub fn render<F: FnOnce(&mut Self)>(&mut self, f: F) {
142 let start = Instant::now();
143 self.clear();
144 f(self);
145 let elapsed = start.elapsed();
146 self.metrics.record_frame(elapsed);
147 self.frame_count += 1;
148 }
149
150 pub fn extract_row(&self, y: u16) -> String {
152 if y >= self.height {
153 return String::new();
154 }
155 let start = y as usize * self.width as usize;
156 let end = start + self.width as usize;
157 self.cells[start..end].iter().map(|c| c.ch).collect()
158 }
159
160 pub fn extract_text_at(&self, x: u16, y: u16) -> String {
162 let mut result = String::new();
163 let mut col = x;
164 while col < self.width {
165 if let Some(cell) = self.get(col, y) {
166 if cell.ch == ' ' && !result.is_empty() {
167 break;
168 }
169 if cell.ch != ' ' {
170 result.push(cell.ch);
171 }
172 }
173 col += 1;
174 }
175 result
176 }
177
178 pub fn extract_region(&self, x: u16, y: u16, width: u16, height: u16) -> Vec<String> {
180 let mut lines = Vec::with_capacity(height as usize);
181 for row in y..(y + height).min(self.height) {
182 let mut line = String::with_capacity(width as usize);
183 for col in x..(x + width).min(self.width) {
184 if let Some(cell) = self.get(col, row) {
185 line.push(cell.ch);
186 }
187 }
188 lines.push(line);
189 }
190 lines
191 }
192
193 pub fn to_string_plain(&self) -> String {
195 let mut result = String::with_capacity((self.width as usize + 1) * self.height as usize);
196 for y in 0..self.height {
197 result.push_str(&self.extract_row(y));
198 result.push('\n');
199 }
200 result
201 }
202
203 pub fn frame_count(&self) -> u64 {
205 self.frame_count
206 }
207
208 pub fn metrics(&self) -> &RenderMetrics {
210 &self.metrics
211 }
212
213 pub fn snapshot(&self) -> TuiSnapshot {
215 TuiSnapshot {
216 width: self.width,
217 height: self.height,
218 cells: self.cells.clone(),
219 metadata: HashMap::new(),
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
226pub struct TuiSnapshot {
227 pub width: u16,
229 pub height: u16,
231 pub cells: Vec<TuiCell>,
233 pub metadata: HashMap<String, String>,
235}
236
237impl TuiSnapshot {
238 pub fn load(path: &str) -> Result<Self, SnapshotError> {
240 let content =
241 std::fs::read_to_string(path).map_err(|e| SnapshotError::IoError(e.to_string()))?;
242 Self::parse(&content)
243 }
244
245 pub fn save(&self, path: &str) -> Result<(), SnapshotError> {
247 let content = self.serialize();
248 std::fs::write(path, content).map_err(|e| SnapshotError::IoError(e.to_string()))
249 }
250
251 pub fn parse(content: &str) -> Result<Self, SnapshotError> {
253 let lines: Vec<&str> = content.lines().collect();
254 if lines.is_empty() {
255 return Err(SnapshotError::ParseError("Empty snapshot".into()));
256 }
257
258 let dims: Vec<u16> = lines[0]
260 .split('x')
261 .filter_map(|s| s.trim().parse().ok())
262 .collect();
263
264 if dims.len() != 2 {
265 return Err(SnapshotError::ParseError("Invalid dimensions".into()));
266 }
267
268 let width = dims[0];
269 let height = dims[1];
270 let mut cells = vec![TuiCell::default(); width as usize * height as usize];
271
272 for (y, line) in lines.iter().skip(1).take(height as usize).enumerate() {
274 for (x, ch) in line.chars().take(width as usize).enumerate() {
275 cells[y * width as usize + x].ch = ch;
276 }
277 }
278
279 Ok(Self {
280 width,
281 height,
282 cells,
283 metadata: HashMap::new(),
284 })
285 }
286
287 pub fn serialize(&self) -> String {
289 let mut result = format!("{}x{}\n", self.width, self.height);
290 for y in 0..self.height {
291 for x in 0..self.width {
292 let idx = y as usize * self.width as usize + x as usize;
293 result.push(self.cells[idx].ch);
294 }
295 result.push('\n');
296 }
297 result
298 }
299
300 pub fn metadata(&self, key: &str) -> &str {
302 self.metadata.get(key).map(|s| s.as_str()).unwrap_or("")
303 }
304
305 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
307 self.metadata.insert(key.to_string(), value.to_string());
308 self
309 }
310
311 pub fn diff(&self, other: &TuiSnapshot) -> SnapshotDiff {
313 let mut diff = SnapshotDiff {
314 matches: true,
315 differences: Vec::new(),
316 total_cells: self.width as usize * self.height as usize,
317 matching_cells: 0,
318 };
319
320 if self.width != other.width || self.height != other.height {
321 diff.matches = false;
322 diff.differences.push(DiffEntry {
323 x: 0,
324 y: 0,
325 expected: format!("{}x{}", self.width, self.height),
326 actual: format!("{}x{}", other.width, other.height),
327 });
328 return diff;
329 }
330
331 for y in 0..self.height {
332 for x in 0..self.width {
333 let idx = y as usize * self.width as usize + x as usize;
334 if self.cells[idx] == other.cells[idx] {
335 diff.matching_cells += 1;
336 } else {
337 diff.matches = false;
338 diff.differences.push(DiffEntry {
339 x,
340 y,
341 expected: self.cells[idx].ch.to_string(),
342 actual: other.cells[idx].ch.to_string(),
343 });
344 }
345 }
346 }
347
348 diff
349 }
350}
351
352#[derive(Debug)]
354pub enum SnapshotError {
355 IoError(String),
356 ParseError(String),
357}
358
359#[derive(Debug)]
361pub struct SnapshotDiff {
362 pub matches: bool,
364 pub differences: Vec<DiffEntry>,
366 pub total_cells: usize,
368 pub matching_cells: usize,
370}
371
372impl SnapshotDiff {
373 pub fn match_percentage(&self) -> f64 {
375 if self.total_cells == 0 {
376 100.0
377 } else {
378 self.matching_cells as f64 / self.total_cells as f64 * 100.0
379 }
380 }
381}
382
383#[derive(Debug)]
385pub struct DiffEntry {
386 pub x: u16,
387 pub y: u16,
388 pub expected: String,
389 pub actual: String,
390}
391
392#[derive(Debug, Clone, Default)]
394pub struct RenderMetrics {
395 pub frame_count: u64,
397 samples: Vec<u64>,
399}
400
401impl RenderMetrics {
402 pub fn new() -> Self {
404 Self {
405 frame_count: 0,
406 samples: Vec::with_capacity(1000),
407 }
408 }
409
410 pub fn record_frame(&mut self, duration: Duration) {
412 self.frame_count += 1;
413 self.samples.push(duration.as_micros() as u64);
414 }
415
416 pub fn min_us(&self) -> u64 {
418 self.samples.iter().min().copied().unwrap_or(0)
419 }
420
421 pub fn max_us(&self) -> u64 {
423 self.samples.iter().max().copied().unwrap_or(0)
424 }
425
426 pub fn mean_us(&self) -> f64 {
428 if self.samples.is_empty() {
429 0.0
430 } else {
431 self.samples.iter().sum::<u64>() as f64 / self.samples.len() as f64
432 }
433 }
434
435 pub fn percentile(&self, p: u8) -> u64 {
437 if self.samples.is_empty() {
438 return 0;
439 }
440 let mut sorted = self.samples.clone();
441 sorted.sort_unstable();
442 let idx = (sorted.len() as f64 * p as f64 / 100.0) as usize;
443 sorted[idx.min(sorted.len() - 1)]
444 }
445
446 pub fn meets_targets(&self, targets: &PerformanceTargets) -> bool {
448 self.max_us() <= targets.max_frame_us && self.percentile(99) <= targets.p99_frame_us
449 }
450
451 pub fn to_json(&self) -> String {
453 format!(
454 r#"{{"frame_count":{},"min_us":{},"max_us":{},"mean_us":{:.2},"p50_us":{},"p95_us":{},"p99_us":{}}}"#,
455 self.frame_count,
456 self.min_us(),
457 self.max_us(),
458 self.mean_us(),
459 self.percentile(50),
460 self.percentile(95),
461 self.percentile(99),
462 )
463 }
464}
465
466#[derive(Debug, Clone)]
468pub struct PerformanceTargets {
469 pub max_frame_us: u64,
471 pub p99_frame_us: u64,
473 pub max_memory_bytes: usize,
475}
476
477impl Default for PerformanceTargets {
478 fn default() -> Self {
479 Self {
480 max_frame_us: 16_667, p99_frame_us: 1_000, max_memory_bytes: 100 * 1024, }
484 }
485}
486
487pub struct FrameAssertion<'a> {
489 backend: &'a TuiTestBackend,
490 tolerance: usize,
491 ignore_color: bool,
492 ignore_trailing_whitespace: bool,
493 region: Option<(u16, u16, u16, u16)>,
494}
495
496impl FrameAssertion<'_> {
497 pub fn with_tolerance(mut self, tolerance: usize) -> Self {
499 self.tolerance = tolerance;
500 self
501 }
502
503 pub fn ignore_color(mut self) -> Self {
505 self.ignore_color = true;
506 self
507 }
508
509 pub fn ignore_whitespace_at_eol(mut self) -> Self {
511 self.ignore_trailing_whitespace = true;
512 self
513 }
514
515 pub fn with_region(mut self, x: u16, y: u16, width: u16, height: u16) -> Self {
517 self.region = Some((x, y, width, height));
518 self
519 }
520
521 pub fn to_match_snapshot(self, snapshot: &TuiSnapshot) {
526 let current = self.backend.snapshot();
527 let diff = current.diff(snapshot);
528
529 if !diff.matches && diff.differences.len() > self.tolerance {
530 panic!(
531 "Frame does not match snapshot:\n\
532 - {}/{} cells differ ({:.1}% match)\n\
533 - Tolerance: {}\n\
534 - First 5 differences:\n{}",
535 diff.differences.len(),
536 diff.total_cells,
537 diff.match_percentage(),
538 self.tolerance,
539 diff.differences
540 .iter()
541 .take(5)
542 .map(|d| format!(
543 " ({}, {}): expected '{}', got '{}'",
544 d.x, d.y, d.expected, d.actual
545 ))
546 .collect::<Vec<_>>()
547 .join("\n")
548 );
549 }
550 }
551
552 pub fn to_contain_text(self, text: &str) {
557 let content = self.backend.to_string_plain();
558 assert!(
559 content.contains(text),
560 "Frame does not contain text: '{}'",
561 text
562 );
563 }
564
565 pub fn to_not_contain_text(self, text: &str) {
570 let content = self.backend.to_string_plain();
571 assert!(
572 !content.contains(text),
573 "Frame should not contain text: '{}'",
574 text
575 );
576 }
577
578 pub fn text_at(self, x: u16, y: u16, expected: &str) {
583 let actual = self.backend.extract_text_at(x, y);
584 assert_eq!(
585 actual, expected,
586 "Text at ({}, {}) expected '{}', got '{}'",
587 x, y, expected, actual
588 );
589 }
590
591 pub fn row_equals(self, y: u16, expected: &str) {
596 let actual = self.backend.extract_row(y);
597 let actual_trimmed = if self.ignore_trailing_whitespace {
598 actual.trim_end()
599 } else {
600 &actual
601 };
602 let expected_trimmed = if self.ignore_trailing_whitespace {
603 expected.trim_end()
604 } else {
605 expected
606 };
607 assert_eq!(
608 actual_trimmed, expected_trimmed,
609 "Row {} expected:\n'{}'\ngot:\n'{}'",
610 y, expected_trimmed, actual_trimmed
611 );
612 }
613}
614
615pub fn expect_frame(backend: &TuiTestBackend) -> FrameAssertion<'_> {
617 FrameAssertion {
618 backend,
619 tolerance: 0,
620 ignore_color: false,
621 ignore_trailing_whitespace: false,
622 region: None,
623 }
624}
625
626pub struct BenchmarkHarness {
628 backend: TuiTestBackend,
629 warmup_frames: u32,
630 benchmark_frames: u32,
631}
632
633impl BenchmarkHarness {
634 pub fn new(width: u16, height: u16) -> Self {
636 Self {
637 backend: TuiTestBackend::new(width, height),
638 warmup_frames: 100,
639 benchmark_frames: 1000,
640 }
641 }
642
643 pub fn with_frames(mut self, warmup: u32, benchmark: u32) -> Self {
645 self.warmup_frames = warmup;
646 self.benchmark_frames = benchmark;
647 self
648 }
649
650 pub fn benchmark<F: FnMut(&mut TuiTestBackend)>(&mut self, mut render: F) -> BenchmarkResult {
652 for _ in 0..self.warmup_frames {
654 self.backend.render(|b| render(b));
655 }
656
657 self.backend.metrics = RenderMetrics::new();
659
660 for _ in 0..self.benchmark_frames {
662 self.backend.render(|b| render(b));
663 }
664
665 BenchmarkResult {
666 metrics: self.backend.metrics().clone(),
667 final_frame: self.backend.to_string_plain(),
668 }
669 }
670}
671
672#[derive(Debug)]
674pub struct BenchmarkResult {
675 pub metrics: RenderMetrics,
677 pub final_frame: String,
679}
680
681impl BenchmarkResult {
682 pub fn meets_targets(&self, targets: &PerformanceTargets) -> bool {
684 self.metrics.meets_targets(targets)
685 }
686}
687
688pub struct AsyncUpdateAssertion {
691 field: String,
693 initial: Option<String>,
695 values: Vec<String>,
697}
698
699impl AsyncUpdateAssertion {
700 pub fn new(field: &str) -> Self {
702 Self {
703 field: field.to_string(),
704 initial: None,
705 values: Vec::new(),
706 }
707 }
708
709 pub fn record_initial(&mut self, value: &str) {
711 self.initial = Some(value.to_string());
712 }
713
714 pub fn record_update(&mut self, value: &str) {
716 self.values.push(value.to_string());
717 }
718
719 pub fn assert_present(&self) {
724 if let Some(ref initial) = self.initial {
725 assert!(
726 !initial.is_empty(),
727 "Field '{}' initial value should be present, got empty",
728 self.field
729 );
730 } else {
731 panic!("Field '{}' has no initial value recorded", self.field);
732 }
733 }
734
735 pub fn assert_changed(&self) {
740 let Some(ref initial) = self.initial else {
741 panic!("Field '{}' has no initial value", self.field);
742 };
743
744 let changed = self.values.iter().any(|v| v != initial);
745 assert!(
746 changed,
747 "Field '{}' expected to change from '{}' but never did. Updates: {:?}",
748 self.field, initial, self.values
749 );
750 }
751
752 pub fn assert_numeric_in_range(&self, min: f64, max: f64) {
757 let Some(ref initial) = self.initial else {
758 panic!("Field '{}' has no initial value", self.field);
759 };
760
761 let num_str = initial
763 .trim_end_matches('%')
764 .trim_end_matches("MHz")
765 .trim_end_matches("GHz")
766 .trim_end_matches("°C")
767 .trim();
768
769 let value: f64 = num_str.parse().unwrap_or_else(|_| {
770 panic!(
771 "Field '{}' expected numeric value, got '{}'",
772 self.field, initial
773 )
774 });
775
776 assert!(
777 value >= min && value <= max,
778 "Field '{}' value {} not in range [{}, {}]",
779 self.field,
780 value,
781 min,
782 max
783 );
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn test_backend_basic() {
793 let mut backend = TuiTestBackend::new(80, 24);
794 assert_eq!(backend.width, 80);
795 assert_eq!(backend.height, 24);
796
797 backend.draw_text(0, 0, "Hello", (255, 255, 255));
798 assert_eq!(backend.extract_text_at(0, 0), "Hello");
799 }
800
801 #[test]
802 fn test_backend_render_metrics() {
803 let mut backend = TuiTestBackend::new(80, 24);
804
805 backend.render(|b| {
806 b.draw_text(0, 0, "Frame 1", (255, 255, 255));
807 });
808
809 backend.render(|b| {
810 b.draw_text(0, 0, "Frame 2", (255, 255, 255));
811 });
812
813 assert_eq!(backend.frame_count(), 2);
814 assert!(backend.metrics().mean_us() >= 0.0);
815 }
816
817 #[test]
818 fn test_snapshot_diff() {
819 let mut backend1 = TuiTestBackend::new(10, 2);
820 backend1.draw_text(0, 0, "Hello", (255, 255, 255));
821 let snap1 = backend1.snapshot();
822
823 let mut backend2 = TuiTestBackend::new(10, 2);
824 backend2.draw_text(0, 0, "Hello", (255, 255, 255));
825 let snap2 = backend2.snapshot();
826
827 let diff = snap1.diff(&snap2);
828 assert!(diff.matches);
829 assert_eq!(diff.match_percentage(), 100.0);
830 }
831
832 #[test]
833 fn test_snapshot_diff_mismatch() {
834 let mut backend1 = TuiTestBackend::new(10, 2);
835 backend1.draw_text(0, 0, "Hello", (255, 255, 255));
836 let snap1 = backend1.snapshot();
837
838 let mut backend2 = TuiTestBackend::new(10, 2);
839 backend2.draw_text(0, 0, "World", (255, 255, 255));
840 let snap2 = backend2.snapshot();
841
842 let diff = snap1.diff(&snap2);
843 assert!(!diff.matches);
844 assert!(diff.differences.len() > 0);
845 }
846
847 #[test]
848 fn test_expect_frame_contains_text() {
849 let mut backend = TuiTestBackend::new(80, 24);
850 backend.draw_text(10, 5, "CPU: 45%", (255, 255, 255));
851
852 expect_frame(&backend).to_contain_text("CPU: 45%");
853 }
854
855 #[test]
856 #[should_panic(expected = "does not contain")]
857 fn test_expect_frame_missing_text() {
858 let backend = TuiTestBackend::new(80, 24);
859 expect_frame(&backend).to_contain_text("Missing text");
860 }
861
862 #[test]
863 fn test_async_update_assertion() {
864 let mut assertion = AsyncUpdateAssertion::new("cpu_freq");
865 assertion.record_initial("4.5GHz");
866 assertion.record_update("4.6GHz");
867 assertion.record_update("4.7GHz");
868
869 assertion.assert_present();
870 assertion.assert_changed();
871 }
872
873 #[test]
874 #[should_panic(expected = "expected to change")]
875 fn test_async_update_no_change() {
876 let mut assertion = AsyncUpdateAssertion::new("stale_field");
877 assertion.record_initial("static");
878 assertion.record_update("static");
879 assertion.record_update("static");
880
881 assertion.assert_changed();
882 }
883
884 #[test]
885 fn test_benchmark_harness() {
886 let mut harness = BenchmarkHarness::new(80, 24).with_frames(10, 100);
887
888 let result = harness.benchmark(|backend| {
889 backend.draw_text(0, 0, "Test", (255, 255, 255));
890 });
891
892 assert_eq!(result.metrics.frame_count, 100);
893 assert!(result.metrics.mean_us() < 1_000_000.0); }
895
896 #[test]
897 fn test_render_metrics() {
898 let mut metrics = RenderMetrics::new();
899 metrics.record_frame(Duration::from_micros(100));
900 metrics.record_frame(Duration::from_micros(200));
901 metrics.record_frame(Duration::from_micros(150));
902
903 assert_eq!(metrics.frame_count, 3);
904 assert_eq!(metrics.min_us(), 100);
905 assert_eq!(metrics.max_us(), 200);
906 assert!((metrics.mean_us() - 150.0).abs() < 1.0);
907 }
908
909 #[test]
910 fn test_performance_targets() {
911 let mut metrics = RenderMetrics::new();
912 for _ in 0..100 {
913 metrics.record_frame(Duration::from_micros(500));
914 }
915
916 let targets = PerformanceTargets::default();
917 assert!(metrics.meets_targets(&targets));
918 }
919
920 #[test]
933 #[ignore] fn test_exploded_cpu_receives_async_freq_temp_updates() {
935 let mut backend = TuiTestBackend::new(140, 45);
940
941 let mut freq_assertion = AsyncUpdateAssertion::new("per_core_freq[0]");
943 let mut temp_assertion = AsyncUpdateAssertion::new("per_core_temp[0]");
944
945 backend.render(|b| {
947 b.draw_text(50, 3, "4.76GHz", (255, 255, 255));
949 b.draw_text(60, 3, "65°C", (255, 255, 255));
950 });
951 freq_assertion.record_initial(&backend.extract_text_at(50, 3));
952 temp_assertion.record_initial(&backend.extract_text_at(60, 3));
953
954 backend.render(|b| {
956 b.draw_text(50, 3, "4.82GHz", (255, 255, 255));
958 b.draw_text(60, 3, "67°C", (255, 255, 255));
959 });
960 freq_assertion.record_update(&backend.extract_text_at(50, 3));
961 temp_assertion.record_update(&backend.extract_text_at(60, 3));
962
963 freq_assertion.assert_present();
965 freq_assertion.assert_changed();
966 freq_assertion.assert_numeric_in_range(0.0, 10.0); temp_assertion.assert_present();
969 temp_assertion.assert_changed();
970 temp_assertion.assert_numeric_in_range(0.0, 150.0); }
972}