Skip to main content

trueno_gpu/testing/
tui.rs

1//! TUI Monitoring Mode for Stress Testing
2//!
3//! Real-time terminal UI for monitoring stress test progress.
4//! Uses presentar/crossterm (via simular) for rendering.
5//!
6//! # Feature Flag
7//!
8//! Requires `tui-monitor` feature:
9//! ```toml
10//! trueno-gpu = { version = "0.1", features = ["tui-monitor"] }
11//! ```
12
13use super::stress::{PerformanceResult, StressReport};
14
15/// TUI configuration
16#[derive(Debug, Clone)]
17pub struct TuiConfig {
18    /// Refresh rate in milliseconds
19    pub refresh_rate_ms: u64,
20    /// Show frame time sparkline
21    pub show_frame_times: bool,
22    /// Show memory usage
23    pub show_memory_usage: bool,
24    /// Show anomaly alerts
25    pub show_anomaly_alerts: bool,
26    /// Title for the TUI window
27    pub title: String,
28}
29
30impl Default for TuiConfig {
31    fn default() -> Self {
32        Self {
33            refresh_rate_ms: 100,
34            show_frame_times: true,
35            show_memory_usage: true,
36            show_anomaly_alerts: true,
37            title: "trueno-gpu Stress Test Monitor".to_string(),
38        }
39    }
40}
41
42/// TUI state for rendering
43#[derive(Debug, Clone, Default)]
44pub struct TuiState {
45    /// Current cycle
46    pub current_cycle: u32,
47    /// Total cycles to run
48    pub total_cycles: u32,
49    /// Current FPS
50    pub current_fps: f64,
51    /// Memory usage in bytes
52    pub memory_bytes: usize,
53    /// Recent frame times (for sparkline)
54    pub frame_times: Vec<u64>,
55    /// Test results per test name
56    pub test_results: Vec<(String, u64, bool)>, // (name, duration_ms, passed)
57    /// Number of anomalies
58    pub anomaly_count: usize,
59    /// Number of regressions
60    pub regression_count: usize,
61    /// Pass rate (0.0 to 1.0)
62    pub pass_rate: f64,
63    /// Is running
64    pub running: bool,
65    /// Is paused
66    pub paused: bool,
67}
68
69impl TuiState {
70    /// Create new TUI state
71    #[must_use]
72    pub fn new(total_cycles: u32) -> Self {
73        Self {
74            total_cycles,
75            running: true,
76            ..Default::default()
77        }
78    }
79
80    /// Update state from stress report
81    pub fn update_from_report(&mut self, report: &StressReport) {
82        self.current_cycle = report.cycles_completed;
83        self.anomaly_count = report.anomalies.len();
84        self.pass_rate = report.pass_rate();
85
86        // Update frame times (keep last 50)
87        self.frame_times = report
88            .frames
89            .iter()
90            .rev()
91            .take(50)
92            .map(|f| f.duration_ms)
93            .collect();
94        self.frame_times.reverse();
95
96        // Calculate FPS from mean frame time
97        let mean_ms = report.mean_frame_time_ms();
98        self.current_fps = if mean_ms > 0.0 { 1000.0 / mean_ms } else { 0.0 };
99
100        // Memory from last frame
101        if let Some(last) = report.frames.last() {
102            self.memory_bytes = last.memory_bytes;
103        }
104    }
105
106    /// Format memory as human-readable string
107    #[must_use]
108    pub fn format_memory(&self) -> String {
109        let bytes = self.memory_bytes as f64;
110        if bytes < 1024.0 {
111            format!("{:.0} B", bytes)
112        } else if bytes < 1024.0 * 1024.0 {
113            format!("{:.1} KB", bytes / 1024.0)
114        } else {
115            format!("{:.1} MB", bytes / (1024.0 * 1024.0))
116        }
117    }
118
119    /// Generate sparkline data (normalized 0-7 for block characters)
120    #[must_use]
121    pub fn sparkline_data(&self) -> Vec<u8> {
122        if self.frame_times.is_empty() {
123            return vec![];
124        }
125
126        let max = *self.frame_times.iter().max().unwrap_or(&1) as f64;
127        let min = *self.frame_times.iter().min().unwrap_or(&0) as f64;
128        let range = (max - min).max(1.0);
129
130        self.frame_times
131            .iter()
132            .map(|&t| {
133                let normalized = (t as f64 - min) / range;
134                // normalized is in [0, 1], so result is in [0, 7]
135                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
136                let level = (normalized * 7.0).round() as u8;
137                level
138            })
139            .collect()
140    }
141
142    /// Generate sparkline string using Unicode block characters
143    #[must_use]
144    pub fn sparkline_string(&self) -> String {
145        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
146        self.sparkline_data()
147            .iter()
148            .map(|&v| BLOCKS[v.min(7) as usize])
149            .collect()
150    }
151}
152
153/// Render TUI state to string (for non-interactive output)
154#[must_use]
155pub fn render_to_string(
156    state: &TuiState,
157    report: &StressReport,
158    perf: &PerformanceResult,
159) -> String {
160    let mut output = String::new();
161
162    // Header
163    output.push_str("╔══════════════════════════════════════════════════════════════╗\n");
164    output.push_str("║  trueno-gpu Stress Test Monitor (simular TUI)                ║\n");
165    output.push_str("╠══════════════════════════════════════════════════════════════╣\n");
166
167    // Status line
168    output.push_str(&format!(
169        "║  Cycle: {}/{}    FPS: {:.1}    Memory: {:<10}       ║\n",
170        state.current_cycle,
171        state.total_cycles,
172        state.current_fps,
173        state.format_memory()
174    ));
175    output.push_str("║                                                               ║\n");
176
177    // Sparkline
178    let sparkline = state.sparkline_string();
179    if !sparkline.is_empty() {
180        output.push_str(&format!("║  Frame Times (ms):  {:<40} ║\n", sparkline));
181    }
182
183    // Stats
184    output.push_str(&format!(
185        "║  Mean: {:.0}ms  Max: {}ms  Variance: {:.2}                     ║\n",
186        perf.mean_frame_ms, perf.max_frame_ms, perf.variance
187    ));
188    output.push_str("║                                                               ║\n");
189
190    // Test results
191    output.push_str("║  Test Results:                                                ║\n");
192    let passed = report.total_passed;
193    let failed = report.total_failed;
194    output.push_str(&format!(
195        "║  ✓ Passed: {:<6}  ✗ Failed: {:<6}                         ║\n",
196        passed, failed
197    ));
198    output.push_str("║                                                               ║\n");
199
200    // Summary
201    let status = if perf.passed { "PASS" } else { "FAIL" };
202    output.push_str(&format!(
203        "║  Anomalies: {}    Regressions: {}    Status: {:<4}            ║\n",
204        state.anomaly_count, state.regression_count, status
205    ));
206
207    // Footer
208    output.push_str("╠══════════════════════════════════════════════════════════════╣\n");
209    output.push_str("║  [q] Quit  [p] Pause  [r] Reset  [s] Save Report             ║\n");
210    output.push_str("╚══════════════════════════════════════════════════════════════╝\n");
211
212    output
213}
214
215/// Simple ASCII progress bar
216#[must_use]
217pub fn progress_bar(current: u32, total: u32, width: usize) -> String {
218    if total == 0 {
219        return format!("[{}]", " ".repeat(width));
220    }
221
222    let progress = (current as f64 / total as f64).min(1.0);
223    // progress is in [0, 1], so filled is in [0, width]
224    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
225    let filled = (progress * width as f64).round() as usize;
226    let empty = width - filled;
227
228    format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
229}
230
231#[cfg(feature = "tui-monitor")]
232pub mod interactive {
233    //! Interactive TUI using presentar/crossterm
234    //!
235    //! Only available with `tui-monitor` feature.
236
237    #[allow(clippy::wildcard_imports)]
238    use super::*;
239
240    /// Run interactive TUI (requires tui-monitor feature)
241    ///
242    /// # Errors
243    ///
244    /// Returns error if terminal initialization fails
245    pub fn run_interactive(
246        _config: TuiConfig,
247        _state: &mut TuiState,
248    ) -> Result<(), Box<dyn std::error::Error>> {
249        // Full implementation requires presentar/crossterm
250        // This is a placeholder for the feature-gated implementation
251        Err("Interactive TUI requires tui-monitor feature with presentar".into())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_tui_config_default() {
261        let config = TuiConfig::default();
262        assert_eq!(config.refresh_rate_ms, 100);
263        assert!(config.show_frame_times);
264        assert!(config.show_memory_usage);
265        assert!(config.show_anomaly_alerts);
266    }
267
268    #[test]
269    fn test_tui_state_new() {
270        let state = TuiState::new(100);
271        assert_eq!(state.total_cycles, 100);
272        assert!(state.running);
273        assert!(!state.paused);
274    }
275
276    #[test]
277    fn test_format_memory() {
278        let mut state = TuiState::default();
279
280        state.memory_bytes = 512;
281        assert_eq!(state.format_memory(), "512 B");
282
283        state.memory_bytes = 2048;
284        assert_eq!(state.format_memory(), "2.0 KB");
285
286        state.memory_bytes = 5 * 1024 * 1024;
287        assert_eq!(state.format_memory(), "5.0 MB");
288    }
289
290    #[test]
291    fn test_sparkline_data() {
292        let mut state = TuiState::default();
293        state.frame_times = vec![10, 20, 30, 40, 50];
294
295        let data = state.sparkline_data();
296        assert_eq!(data.len(), 5);
297        assert_eq!(data[0], 0); // min
298        assert_eq!(data[4], 7); // max
299    }
300
301    #[test]
302    fn test_sparkline_string() {
303        let mut state = TuiState::default();
304        state.frame_times = vec![10, 20, 30, 40, 50];
305
306        let sparkline = state.sparkline_string();
307        assert_eq!(sparkline.chars().count(), 5);
308        assert!(sparkline.starts_with('▁'));
309        assert!(sparkline.ends_with('█'));
310    }
311
312    #[test]
313    fn test_progress_bar() {
314        assert_eq!(progress_bar(0, 100, 10), "[░░░░░░░░░░]");
315        assert_eq!(progress_bar(50, 100, 10), "[█████░░░░░]");
316        assert_eq!(progress_bar(100, 100, 10), "[██████████]");
317        assert_eq!(progress_bar(0, 0, 10), "[          ]"); // Edge case
318    }
319
320    #[test]
321    fn test_render_to_string() {
322        let state = TuiState::new(100);
323        let report = StressReport::default();
324        let perf = PerformanceResult {
325            passed: true,
326            max_frame_ms: 50,
327            mean_frame_ms: 40.0,
328            variance: 0.1,
329            pass_rate: 1.0,
330            violations: vec![],
331        };
332
333        let output = render_to_string(&state, &report, &perf);
334        assert!(output.contains("trueno-gpu Stress Test Monitor"));
335        assert!(output.contains("Cycle: 0/100"));
336        assert!(output.contains("PASS"));
337    }
338
339    #[test]
340    fn test_update_from_report() {
341        use super::super::stress::{FrameProfile, StressReport};
342
343        let mut state = TuiState::new(10);
344        let mut report = StressReport::default();
345
346        for i in 0..5 {
347            report.add_frame(FrameProfile {
348                cycle: i,
349                duration_ms: 50 + i as u64 * 10,
350                memory_bytes: 1024,
351                tests_passed: 5,
352                tests_failed: 0,
353                ..Default::default()
354            });
355        }
356
357        state.update_from_report(&report);
358
359        assert_eq!(state.current_cycle, 5);
360        assert_eq!(state.frame_times.len(), 5);
361        assert!(state.current_fps > 0.0);
362        assert!((state.pass_rate - 1.0).abs() < 0.001);
363    }
364}