Skip to main content

presentar_test/
tui.rs

1//! TUI Testing Framework (SPEC-024 Section 12 & 13)
2//!
3//! This module enforces test-first development for TUI widgets.
4//! Tests DEFINE the interface - implementation follows.
5//!
6//! # Example: Test-First Interface Definition
7//!
8//! ```ignore
9//! use presentar_test::tui::{TuiTestBackend, expect_frame, FrameAssertion};
10//!
11//! #[test]
12//! fn test_cpu_exploded_receives_async_updates() {
13//!     let mut backend = TuiTestBackend::new(120, 40);
14//!     let mut app = App::test_instance();
15//!
16//!     // Frame 1: Initial render
17//!     app.apply_snapshot(snapshot1);
18//!     backend.render(|buf| ui::draw(&app, buf));
19//!     let freq1 = backend.extract_text_at(50, 5); // CPU frequency
20//!
21//!     // Frame 2: After async update
22//!     app.apply_snapshot(snapshot2);
23//!     backend.render(|buf| ui::draw(&app, buf));
24//!     let freq2 = backend.extract_text_at(50, 5);
25//!
26//!     // ASSERTION: Data must update
27//!     expect_frame(&backend)
28//!         .field("cpu_freq")
29//!         .changed_between(freq1, freq2);
30//! }
31//! ```
32
33use std::collections::HashMap;
34use std::time::{Duration, Instant};
35
36/// Cell in the TUI buffer.
37#[derive(Debug, Clone, PartialEq)]
38pub struct TuiCell {
39    /// Character at this position.
40    pub ch: char,
41    /// Foreground color (RGB).
42    pub fg: (u8, u8, u8),
43    /// Background color (RGB).
44    pub bg: (u8, u8, u8),
45    /// Bold attribute.
46    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/// In-memory TUI test backend.
61/// Renders widgets to a buffer for assertions.
62#[derive(Debug)]
63pub struct TuiTestBackend {
64    /// Width in columns.
65    pub width: u16,
66    /// Height in rows.
67    pub height: u16,
68    /// Cell buffer.
69    cells: Vec<TuiCell>,
70    /// Frame counter.
71    frame_count: u64,
72    /// Render metrics.
73    metrics: RenderMetrics,
74    /// Deterministic mode.
75    deterministic: bool,
76}
77
78impl TuiTestBackend {
79    /// Create a new test backend with dimensions.
80    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    /// Enable/disable deterministic mode.
93    pub fn with_deterministic(mut self, enabled: bool) -> Self {
94        self.deterministic = enabled;
95        self
96    }
97
98    /// Clear the buffer.
99    pub fn clear(&mut self) {
100        for cell in &mut self.cells {
101            *cell = TuiCell::default();
102        }
103    }
104
105    /// Get cell at position.
106    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    /// Set cell at position.
115    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    /// Draw text at position.
122    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    /// Render a frame using provided closure.
141    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    /// Extract text from a row.
151    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    /// Extract text at position (reads until whitespace or boundary).
161    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    /// Extract text in region.
179    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    /// Convert buffer to string representation.
194    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    /// Get current frame count.
204    pub fn frame_count(&self) -> u64 {
205        self.frame_count
206    }
207
208    /// Get render metrics.
209    pub fn metrics(&self) -> &RenderMetrics {
210        &self.metrics
211    }
212
213    /// Create a snapshot of current state.
214    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/// Snapshot of TUI state for comparison.
225#[derive(Debug, Clone)]
226pub struct TuiSnapshot {
227    /// Width.
228    pub width: u16,
229    /// Height.
230    pub height: u16,
231    /// Cell data.
232    pub cells: Vec<TuiCell>,
233    /// Metadata (data values used, timestamps, etc.).
234    pub metadata: HashMap<String, String>,
235}
236
237impl TuiSnapshot {
238    /// Load snapshot from file.
239    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    /// Save snapshot to file.
246    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    /// Parse snapshot from string.
252    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        // First line: dimensions
259        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        // Parse cell data
273        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    /// Serialize snapshot to string.
288    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    /// Get metadata value.
301    pub fn metadata(&self, key: &str) -> &str {
302        self.metadata.get(key).map(|s| s.as_str()).unwrap_or("")
303    }
304
305    /// Set metadata value.
306    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    /// Compare with another snapshot.
312    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/// Snapshot error.
353#[derive(Debug)]
354pub enum SnapshotError {
355    IoError(String),
356    ParseError(String),
357}
358
359/// Difference between two snapshots.
360#[derive(Debug)]
361pub struct SnapshotDiff {
362    /// Whether snapshots match.
363    pub matches: bool,
364    /// List of differences.
365    pub differences: Vec<DiffEntry>,
366    /// Total cells compared.
367    pub total_cells: usize,
368    /// Cells that matched.
369    pub matching_cells: usize,
370}
371
372impl SnapshotDiff {
373    /// Get match percentage.
374    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/// Single difference entry.
384#[derive(Debug)]
385pub struct DiffEntry {
386    pub x: u16,
387    pub y: u16,
388    pub expected: String,
389    pub actual: String,
390}
391
392/// Performance metrics collected during rendering.
393#[derive(Debug, Clone, Default)]
394pub struct RenderMetrics {
395    /// Total frames rendered.
396    pub frame_count: u64,
397    /// Frame time samples (microseconds).
398    samples: Vec<u64>,
399}
400
401impl RenderMetrics {
402    /// Create new metrics collector.
403    pub fn new() -> Self {
404        Self {
405            frame_count: 0,
406            samples: Vec::with_capacity(1000),
407        }
408    }
409
410    /// Record a frame's render time.
411    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    /// Get minimum frame time (microseconds).
417    pub fn min_us(&self) -> u64 {
418        self.samples.iter().min().copied().unwrap_or(0)
419    }
420
421    /// Get maximum frame time (microseconds).
422    pub fn max_us(&self) -> u64 {
423        self.samples.iter().max().copied().unwrap_or(0)
424    }
425
426    /// Get mean frame time (microseconds).
427    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    /// Get percentile (0-100).
436    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    /// Check if metrics meet performance targets.
447    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    /// Export to JSON.
452    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/// Performance targets for validation.
467#[derive(Debug, Clone)]
468pub struct PerformanceTargets {
469    /// Maximum frame time in microseconds.
470    pub max_frame_us: u64,
471    /// Target p99 frame time.
472    pub p99_frame_us: u64,
473    /// Maximum memory usage (bytes).
474    pub max_memory_bytes: usize,
475}
476
477impl Default for PerformanceTargets {
478    fn default() -> Self {
479        Self {
480            max_frame_us: 16_667,         // 60fps = 16.67ms
481            p99_frame_us: 1_000,          // 1ms for TUI
482            max_memory_bytes: 100 * 1024, // 100KB
483        }
484    }
485}
486
487/// Fluent frame assertion builder.
488pub 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    /// Set tolerance for differences (0 = exact match).
498    pub fn with_tolerance(mut self, tolerance: usize) -> Self {
499        self.tolerance = tolerance;
500        self
501    }
502
503    /// Ignore color differences.
504    pub fn ignore_color(mut self) -> Self {
505        self.ignore_color = true;
506        self
507    }
508
509    /// Ignore trailing whitespace.
510    pub fn ignore_whitespace_at_eol(mut self) -> Self {
511        self.ignore_trailing_whitespace = true;
512        self
513    }
514
515    /// Compare only a specific region.
516    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    /// Assert frame matches snapshot.
522    ///
523    /// # Panics
524    /// Panics if frame doesn't match within tolerance.
525    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    /// Assert frame contains text.
553    ///
554    /// # Panics
555    /// Panics if text is not found.
556    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    /// Assert frame does not contain text.
566    ///
567    /// # Panics
568    /// Panics if text is found.
569    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    /// Assert text at specific position.
579    ///
580    /// # Panics
581    /// Panics if text doesn't match.
582    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    /// Assert row content.
592    ///
593    /// # Panics
594    /// Panics if row doesn't match.
595    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
615/// Start building frame assertions.
616pub 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
626/// Benchmark harness for running widget benchmarks.
627pub struct BenchmarkHarness {
628    backend: TuiTestBackend,
629    warmup_frames: u32,
630    benchmark_frames: u32,
631}
632
633impl BenchmarkHarness {
634    /// Create new benchmark harness.
635    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    /// Set warmup and benchmark frame counts.
644    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    /// Run benchmark with provided render function.
651    pub fn benchmark<F: FnMut(&mut TuiTestBackend)>(&mut self, mut render: F) -> BenchmarkResult {
652        // Warmup phase
653        for _ in 0..self.warmup_frames {
654            self.backend.render(|b| render(b));
655        }
656
657        // Reset metrics
658        self.backend.metrics = RenderMetrics::new();
659
660        // Benchmark phase
661        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/// Benchmark result.
673#[derive(Debug)]
674pub struct BenchmarkResult {
675    /// Performance metrics.
676    pub metrics: RenderMetrics,
677    /// Final frame content.
678    pub final_frame: String,
679}
680
681impl BenchmarkResult {
682    /// Check if benchmark meets targets.
683    pub fn meets_targets(&self, targets: &PerformanceTargets) -> bool {
684        self.metrics.meets_targets(targets)
685    }
686}
687
688/// Assertion helper for async data updates.
689/// This is the CRITICAL test that defines interface requirements.
690pub struct AsyncUpdateAssertion {
691    /// Field name being tested.
692    field: String,
693    /// Initial value.
694    initial: Option<String>,
695    /// Values after each update.
696    values: Vec<String>,
697}
698
699impl AsyncUpdateAssertion {
700    /// Create new async update assertion.
701    pub fn new(field: &str) -> Self {
702        Self {
703            field: field.to_string(),
704            initial: None,
705            values: Vec::new(),
706        }
707    }
708
709    /// Record initial value.
710    pub fn record_initial(&mut self, value: &str) {
711        self.initial = Some(value.to_string());
712    }
713
714    /// Record updated value.
715    pub fn record_update(&mut self, value: &str) {
716        self.values.push(value.to_string());
717    }
718
719    /// Assert that value is present (non-empty).
720    ///
721    /// # Panics
722    /// Panics if value is empty or missing.
723    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    /// Assert that value changed between updates.
736    ///
737    /// # Panics
738    /// Panics if value never changed.
739    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    /// Assert that value is numeric and within range.
753    ///
754    /// # Panics
755    /// Panics if value is not numeric or out of range.
756    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        // Try to parse as number (strip % and other suffixes)
762        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); // Less than 1 second
894    }
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    // =========================================================================
921    // THIS IS THE CRITICAL TEST PATTERN
922    // This test DEFINES the interface for async updates in exploded mode
923    // =========================================================================
924
925    /// **TEST THAT DEFINES INTERFACE**
926    ///
927    /// This test specifies that exploded CPU panel MUST receive:
928    /// - per_core_freq: CPU frequency data that updates each frame
929    /// - per_core_temp: CPU temperature data that updates each frame
930    ///
931    /// The implementation MUST satisfy this interface.
932    #[test]
933    #[ignore] // Enable when implementing
934    fn test_exploded_cpu_receives_async_freq_temp_updates() {
935        // This test will fail until MetricsSnapshot includes freq/temp
936        // and apply_snapshot transfers them to App fields
937
938        // STEP 1: Create test backend
939        let mut backend = TuiTestBackend::new(140, 45);
940
941        // STEP 2: Create assertions for fields that MUST exist
942        let mut freq_assertion = AsyncUpdateAssertion::new("per_core_freq[0]");
943        let mut temp_assertion = AsyncUpdateAssertion::new("per_core_temp[0]");
944
945        // STEP 3: Simulate initial frame with deterministic test values
946        backend.render(|b| {
947            // Simulated initial render
948            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        // STEP 4: Simulate async update (new snapshot applied)
955        backend.render(|b| {
956            // Simulated updated render (values changed)
957            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        // STEP 5: Assertions that DEFINE the interface
964        freq_assertion.assert_present();
965        freq_assertion.assert_changed();
966        freq_assertion.assert_numeric_in_range(0.0, 10.0); // GHz
967
968        temp_assertion.assert_present();
969        temp_assertion.assert_changed();
970        temp_assertion.assert_numeric_in_range(0.0, 150.0); // °C
971    }
972}