Skip to main content

probador/
visualization.rs

1//! Enhanced TUI Visualization Module (PROBAR-SPEC-006 Section H)
2//!
3//! Real-time TUI dashboard and report viewing for load tests.
4//! NO HTML or JavaScript - all output is TUI or binary format.
5
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::missing_panics_doc)]
8#![allow(clippy::missing_errors_doc)]
9#![allow(clippy::module_name_repetitions)]
10#![allow(clippy::missing_const_for_fn)]
11#![allow(clippy::cast_possible_truncation)]
12#![allow(clippy::cast_precision_loss)]
13#![allow(clippy::cast_sign_loss)]
14#![allow(clippy::cast_lossless)]
15#![allow(clippy::format_push_string)]
16#![allow(clippy::uninlined_format_args)]
17#![allow(clippy::doc_markdown)]
18#![allow(unused_variables)]
19
20use serde::{Deserialize, Serialize};
21use std::collections::VecDeque;
22use std::path::PathBuf;
23
24// =============================================================================
25// H.4 Export Formats - NO HTML/JavaScript
26// =============================================================================
27
28/// Export formats for load test results
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
30pub enum ExportFormat {
31    /// MessagePack binary (compact, WASM-friendly)
32    #[default]
33    MessagePack,
34    /// JSON (human-readable, tooling integration)
35    Json,
36    /// NDJSON stream (newline-delimited, append-friendly)
37    NdJsonStream,
38    /// Binary stream (for real-time TUI consumption)
39    BinaryStream,
40}
41
42impl ExportFormat {
43    /// Get file extension for this format
44    pub fn extension(&self) -> &'static str {
45        match self {
46            Self::MessagePack => "msgpack",
47            Self::Json => "json",
48            Self::NdJsonStream => "ndjson",
49            Self::BinaryStream => "bin",
50        }
51    }
52
53    /// Parse from file extension
54    pub fn from_extension(ext: &str) -> Option<Self> {
55        match ext.to_lowercase().as_str() {
56            "msgpack" | "mp" => Some(Self::MessagePack),
57            "json" => Some(Self::Json),
58            "ndjson" | "jsonl" => Some(Self::NdJsonStream),
59            "bin" | "binary" => Some(Self::BinaryStream),
60            _ => None,
61        }
62    }
63}
64
65// =============================================================================
66// H.2 Real-Time Dashboard Types
67// =============================================================================
68
69/// Time series data point
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct DataPoint {
72    /// Timestamp in milliseconds since test start
73    pub timestamp_ms: u64,
74    /// Value at this timestamp
75    pub value: f64,
76}
77
78impl DataPoint {
79    /// Create a new data point
80    pub fn new(timestamp_ms: u64, value: f64) -> Self {
81        Self {
82            timestamp_ms,
83            value,
84        }
85    }
86}
87
88/// Time series for streaming metrics
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TimeSeries {
91    /// Series name
92    pub name: String,
93    /// Data points (limited buffer)
94    pub points: VecDeque<DataPoint>,
95    /// Maximum points to retain
96    pub max_points: usize,
97    /// Current value
98    pub current: f64,
99    /// Peak value
100    pub peak: f64,
101    /// Peak timestamp
102    pub peak_time_ms: u64,
103}
104
105impl TimeSeries {
106    /// Create a new time series
107    pub fn new(name: &str, max_points: usize) -> Self {
108        Self {
109            name: name.to_string(),
110            points: VecDeque::with_capacity(max_points),
111            max_points,
112            current: 0.0,
113            peak: 0.0,
114            peak_time_ms: 0,
115        }
116    }
117
118    /// Add a data point
119    pub fn push(&mut self, timestamp_ms: u64, value: f64) {
120        if self.points.len() >= self.max_points {
121            self.points.pop_front();
122        }
123        self.points.push_back(DataPoint::new(timestamp_ms, value));
124        self.current = value;
125        if value > self.peak {
126            self.peak = value;
127            self.peak_time_ms = timestamp_ms;
128        }
129    }
130
131    /// Get average value
132    pub fn average(&self) -> f64 {
133        if self.points.is_empty() {
134            return 0.0;
135        }
136        let sum: f64 = self.points.iter().map(|p| p.value).sum();
137        sum / self.points.len() as f64
138    }
139
140    /// Get min value
141    pub fn min(&self) -> f64 {
142        self.points
143            .iter()
144            .map(|p| p.value)
145            .fold(f64::INFINITY, f64::min)
146    }
147}
148
149/// Streaming histogram for latency percentiles
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct StreamingHistogram {
152    /// Bucket counts (index = latency_ms / bucket_size)
153    buckets: Vec<u64>,
154    /// Bucket size in milliseconds
155    bucket_size_ms: u64,
156    /// Total count
157    count: u64,
158    /// Sum for mean calculation
159    sum: u64,
160    /// Min value seen
161    min: u64,
162    /// Max value seen
163    max: u64,
164}
165
166impl StreamingHistogram {
167    /// Create a new histogram
168    pub fn new(bucket_size_ms: u64, num_buckets: usize) -> Self {
169        Self {
170            buckets: vec![0; num_buckets],
171            bucket_size_ms,
172            count: 0,
173            sum: 0,
174            min: u64::MAX,
175            max: 0,
176        }
177    }
178
179    /// Record a latency value
180    pub fn record(&mut self, latency_ms: u64) {
181        let bucket = (latency_ms / self.bucket_size_ms) as usize;
182        if bucket < self.buckets.len() {
183            self.buckets[bucket] += 1;
184        } else {
185            // Overflow to last bucket
186            if let Some(last) = self.buckets.last_mut() {
187                *last += 1;
188            }
189        }
190        self.count += 1;
191        self.sum += latency_ms;
192        self.min = self.min.min(latency_ms);
193        self.max = self.max.max(latency_ms);
194    }
195
196    /// Get percentile value
197    pub fn percentile(&self, p: u8) -> u64 {
198        if self.count == 0 {
199            return 0;
200        }
201        let target = ((p as f64 / 100.0) * self.count as f64) as u64;
202        let mut cumulative = 0u64;
203        for (i, &count) in self.buckets.iter().enumerate() {
204            cumulative += count;
205            if cumulative >= target {
206                return (i as u64 + 1) * self.bucket_size_ms;
207            }
208        }
209        self.max
210    }
211
212    /// Get mean latency
213    pub fn mean(&self) -> u64 {
214        if self.count == 0 {
215            0
216        } else {
217            self.sum / self.count
218        }
219    }
220
221    /// Get count
222    pub fn count(&self) -> u64 {
223        self.count
224    }
225
226    /// Get min
227    pub fn min(&self) -> u64 {
228        if self.count == 0 {
229            0
230        } else {
231            self.min
232        }
233    }
234
235    /// Get max
236    pub fn max(&self) -> u64 {
237        self.max
238    }
239
240    /// Reset histogram
241    pub fn reset(&mut self) {
242        self.buckets.fill(0);
243        self.count = 0;
244        self.sum = 0;
245        self.min = u64::MAX;
246        self.max = 0;
247    }
248}
249
250impl Default for StreamingHistogram {
251    fn default() -> Self {
252        Self::new(1, 10000) // 1ms buckets, up to 10 seconds
253    }
254}
255
256/// Metrics stream for real-time dashboard
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct MetricsStream {
259    /// Throughput time series (req/s)
260    pub throughput: TimeSeries,
261    /// Latency histogram
262    pub latency: StreamingHistogram,
263    /// Error rate time series (%)
264    pub error_rate: TimeSeries,
265    /// Active users time series
266    pub active_users: TimeSeries,
267}
268
269impl MetricsStream {
270    /// Create a new metrics stream
271    pub fn new() -> Self {
272        Self {
273            throughput: TimeSeries::new("throughput", 300), // 5 min at 1/s
274            latency: StreamingHistogram::default(),
275            error_rate: TimeSeries::new("error_rate", 300),
276            active_users: TimeSeries::new("active_users", 300),
277        }
278    }
279
280    /// Record a request
281    pub fn record_request(&mut self, timestamp_ms: u64, latency_ms: u64, success: bool) {
282        self.latency.record(latency_ms);
283        if !success {
284            // Error rate will be computed separately
285        }
286    }
287
288    /// Update throughput
289    pub fn update_throughput(&mut self, timestamp_ms: u64, requests_per_sec: f64) {
290        self.throughput.push(timestamp_ms, requests_per_sec);
291    }
292
293    /// Update error rate
294    pub fn update_error_rate(&mut self, timestamp_ms: u64, error_percent: f64) {
295        self.error_rate.push(timestamp_ms, error_percent);
296    }
297
298    /// Update active users
299    pub fn update_active_users(&mut self, timestamp_ms: u64, users: u32) {
300        self.active_users.push(timestamp_ms, users as f64);
301    }
302}
303
304impl Default for MetricsStream {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310// =============================================================================
311// H.2 Dashboard State
312// =============================================================================
313
314/// Load test stage info
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct StageInfo {
317    /// Stage name
318    pub name: String,
319    /// Elapsed seconds in stage
320    pub elapsed_secs: u64,
321    /// Total duration of stage
322    pub duration_secs: u64,
323    /// Target users for this stage
324    pub target_users: u32,
325}
326
327/// Dashboard state for TUI rendering
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct DashboardState {
330    /// Test name/scenario
331    pub test_name: String,
332    /// Current stage info
333    pub stage: StageInfo,
334    /// Metrics stream
335    pub metrics: MetricsStream,
336    /// Per-endpoint stats
337    pub endpoints: Vec<EndpointMetrics>,
338    /// Test running
339    pub running: bool,
340    /// Test paused
341    pub paused: bool,
342    /// Total elapsed milliseconds
343    pub elapsed_ms: u64,
344}
345
346impl DashboardState {
347    /// Create new dashboard state
348    pub fn new(test_name: &str) -> Self {
349        Self {
350            test_name: test_name.to_string(),
351            stage: StageInfo {
352                name: "init".to_string(),
353                elapsed_secs: 0,
354                duration_secs: 0,
355                target_users: 0,
356            },
357            metrics: MetricsStream::new(),
358            endpoints: Vec::new(),
359            running: false,
360            paused: false,
361            elapsed_ms: 0,
362        }
363    }
364
365    /// Start the test
366    pub fn start(&mut self) {
367        self.running = true;
368        self.paused = false;
369    }
370
371    /// Pause the test
372    pub fn pause(&mut self) {
373        self.paused = true;
374    }
375
376    /// Resume the test
377    pub fn resume(&mut self) {
378        self.paused = false;
379    }
380
381    /// Stop the test
382    pub fn stop(&mut self) {
383        self.running = false;
384    }
385}
386
387/// Per-endpoint metrics
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct EndpointMetrics {
390    /// Endpoint name
391    pub name: String,
392    /// Request count
393    pub count: u64,
394    /// p50 latency
395    pub p50_ms: u64,
396    /// p95 latency
397    pub p95_ms: u64,
398    /// p99 latency
399    pub p99_ms: u64,
400    /// Error count
401    pub errors: u64,
402}
403
404impl EndpointMetrics {
405    /// Create new endpoint metrics
406    pub fn new(name: &str) -> Self {
407        Self {
408            name: name.to_string(),
409            count: 0,
410            p50_ms: 0,
411            p95_ms: 0,
412            p99_ms: 0,
413            errors: 0,
414        }
415    }
416}
417
418// =============================================================================
419// H.3 TUI Report Viewer
420// =============================================================================
421
422/// Report viewer configuration
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ReportViewerConfig {
425    /// Report file path
426    pub path: PathBuf,
427    /// Show detailed breakdown
428    pub detailed: bool,
429    /// Compare with baseline
430    pub baseline: Option<PathBuf>,
431}
432
433/// Report comparison result
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct ReportComparison {
436    /// Current report name
437    pub current_name: String,
438    /// Baseline report name
439    pub baseline_name: String,
440    /// Throughput change (%)
441    pub throughput_change: f64,
442    /// p50 latency change (%)
443    pub p50_change: f64,
444    /// p95 latency change (%)
445    pub p95_change: f64,
446    /// p99 latency change (%)
447    pub p99_change: f64,
448    /// Error rate change (absolute)
449    pub error_rate_change: f64,
450    /// Overall verdict
451    pub verdict: ComparisonVerdict,
452}
453
454/// Comparison verdict
455#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
456pub enum ComparisonVerdict {
457    /// Performance improved
458    Improved,
459    /// Performance unchanged (within tolerance)
460    Unchanged,
461    /// Performance regressed
462    Regressed,
463}
464
465impl ComparisonVerdict {
466    /// Get symbol for display
467    pub fn symbol(&self) -> &'static str {
468        match self {
469            Self::Improved => "↑",
470            Self::Unchanged => "≈",
471            Self::Regressed => "↓",
472        }
473    }
474
475    /// Get color hint
476    pub fn color(&self) -> &'static str {
477        match self {
478            Self::Improved => "green",
479            Self::Unchanged => "yellow",
480            Self::Regressed => "red",
481        }
482    }
483}
484
485// =============================================================================
486// TUI Rendering
487// =============================================================================
488
489/// Render dashboard as TUI string
490pub fn render_dashboard(state: &DashboardState) -> String {
491    let mut out = String::new();
492
493    // Header
494    out.push_str("┌─────────────────────────────────────────────────────────────────────────┐\n");
495    out.push_str(&format!(
496        "│ LOAD TEST: {:<30} Stage: {} ({}/{}s) │\n",
497        truncate(&state.test_name, 30),
498        state.stage.name,
499        state.stage.elapsed_secs,
500        state.stage.duration_secs
501    ));
502    out.push_str("├─────────────────────────────────────────────────────────────────────────┤\n");
503
504    // Metrics summary
505    out.push_str(&format!(
506        "│  Throughput: {:>6.0} req/s (peak: {:.0} @ t={}s)                         │\n",
507        state.metrics.throughput.current,
508        state.metrics.throughput.peak,
509        state.metrics.throughput.peak_time_ms / 1000
510    ));
511    out.push_str(&format!(
512        "│  Latency:    p50={:>4}ms  p95={:>4}ms  p99={:>4}ms                       │\n",
513        state.metrics.latency.percentile(50),
514        state.metrics.latency.percentile(95),
515        state.metrics.latency.percentile(99)
516    ));
517    out.push_str(&format!(
518        "│  Users:      {:>4}  │  Error Rate: {:>5.2}%                              │\n",
519        state.metrics.active_users.current as u32, state.metrics.error_rate.current
520    ));
521
522    // Endpoints table
523    if !state.endpoints.is_empty() {
524        out.push_str(
525            "├─────────────────────────────────────────────────────────────────────────┤\n",
526        );
527        out.push_str("│  Endpoint        │ Count   │ p50    │ p95    │ p99    │ Errors │\n");
528        out.push_str("│──────────────────┼─────────┼────────┼────────┼────────┼────────│\n");
529        for ep in &state.endpoints {
530            out.push_str(&format!(
531                "│  {:<16} │ {:>7} │ {:>4}ms │ {:>4}ms │ {:>4}ms │ {:>6} │\n",
532                truncate(&ep.name, 16),
533                ep.count,
534                ep.p50_ms,
535                ep.p95_ms,
536                ep.p99_ms,
537                ep.errors
538            ));
539        }
540    }
541
542    // Footer
543    out.push_str("├─────────────────────────────────────────────────────────────────────────┤\n");
544    let status = if state.paused {
545        "PAUSED"
546    } else if state.running {
547        "RUNNING"
548    } else {
549        "STOPPED"
550    };
551    out.push_str(&format!(
552        "│  [q] Quit  [p] Pause  [r] Reset  [e] Export      Status: {:>8}     │\n",
553        status
554    ));
555    out.push_str("└─────────────────────────────────────────────────────────────────────────┘\n");
556
557    out
558}
559
560/// Render report comparison
561pub fn render_comparison(comp: &ReportComparison) -> String {
562    let mut out = String::new();
563
564    out.push_str("REPORT COMPARISON\n");
565    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
566    out.push_str(&format!("Baseline: {}\n", comp.baseline_name));
567    out.push_str(&format!("Current:  {}\n\n", comp.current_name));
568
569    out.push_str("┌────────────────┬────────────┬────────────┐\n");
570    out.push_str("│ Metric         │ Change     │ Verdict    │\n");
571    out.push_str("├────────────────┼────────────┼────────────┤\n");
572
573    let format_change = |change: f64| -> String {
574        if change > 0.0 {
575            format!("+{:.1}%", change)
576        } else {
577            format!("{:.1}%", change)
578        }
579    };
580
581    out.push_str(&format!(
582        "│ Throughput     │ {:>10} │ {:>10} │\n",
583        format_change(comp.throughput_change),
584        if comp.throughput_change > 5.0 {
585            "↑ Better"
586        } else if comp.throughput_change < -5.0 {
587            "↓ Worse"
588        } else {
589            "≈ Same"
590        }
591    ));
592    out.push_str(&format!(
593        "│ p50 Latency    │ {:>10} │ {:>10} │\n",
594        format_change(comp.p50_change),
595        if comp.p50_change < -5.0 {
596            "↑ Better"
597        } else if comp.p50_change > 5.0 {
598            "↓ Worse"
599        } else {
600            "≈ Same"
601        }
602    ));
603    out.push_str(&format!(
604        "│ p95 Latency    │ {:>10} │ {:>10} │\n",
605        format_change(comp.p95_change),
606        if comp.p95_change < -5.0 {
607            "↑ Better"
608        } else if comp.p95_change > 5.0 {
609            "↓ Worse"
610        } else {
611            "≈ Same"
612        }
613    ));
614    out.push_str(&format!(
615        "│ p99 Latency    │ {:>10} │ {:>10} │\n",
616        format_change(comp.p99_change),
617        if comp.p99_change < -5.0 {
618            "↑ Better"
619        } else if comp.p99_change > 5.0 {
620            "↓ Worse"
621        } else {
622            "≈ Same"
623        }
624    ));
625    out.push_str(&format!(
626        "│ Error Rate     │ {:>10} │ {:>10} │\n",
627        format_change(comp.error_rate_change),
628        if comp.error_rate_change < -0.1 {
629            "↑ Better"
630        } else if comp.error_rate_change > 0.1 {
631            "↓ Worse"
632        } else {
633            "≈ Same"
634        }
635    ));
636
637    out.push_str("└────────────────┴────────────┴────────────┘\n\n");
638
639    out.push_str(&format!(
640        "Overall: {} {}\n",
641        comp.verdict.symbol(),
642        match comp.verdict {
643            ComparisonVerdict::Improved => "IMPROVED",
644            ComparisonVerdict::Unchanged => "UNCHANGED",
645            ComparisonVerdict::Regressed => "REGRESSED",
646        }
647    ));
648
649    out
650}
651
652/// Truncate string to max length
653fn truncate(s: &str, max: usize) -> String {
654    if s.len() <= max {
655        s.to_string()
656    } else {
657        format!("{}…", &s[..max - 1])
658    }
659}
660
661// =============================================================================
662// Tests
663// =============================================================================
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn test_export_format_extension() {
672        assert_eq!(ExportFormat::MessagePack.extension(), "msgpack");
673        assert_eq!(ExportFormat::Json.extension(), "json");
674        assert_eq!(ExportFormat::NdJsonStream.extension(), "ndjson");
675    }
676
677    #[test]
678    fn test_export_format_from_extension() {
679        assert_eq!(
680            ExportFormat::from_extension("msgpack"),
681            Some(ExportFormat::MessagePack)
682        );
683        assert_eq!(
684            ExportFormat::from_extension("JSON"),
685            Some(ExportFormat::Json)
686        );
687        assert_eq!(ExportFormat::from_extension("unknown"), None);
688    }
689
690    #[test]
691    fn test_time_series() {
692        let mut ts = TimeSeries::new("test", 5);
693        ts.push(1000, 10.0);
694        ts.push(2000, 20.0);
695        ts.push(3000, 30.0);
696
697        assert_eq!(ts.current, 30.0);
698        assert_eq!(ts.peak, 30.0);
699        assert_eq!(ts.average(), 20.0);
700    }
701
702    #[test]
703    fn test_time_series_overflow() {
704        let mut ts = TimeSeries::new("test", 3);
705        for i in 0..5 {
706            ts.push(i * 1000, i as f64);
707        }
708        assert_eq!(ts.points.len(), 3);
709        assert_eq!(ts.points[0].value, 2.0);
710    }
711
712    #[test]
713    fn test_streaming_histogram() {
714        let mut hist = StreamingHistogram::new(10, 100);
715        for i in 0..100 {
716            hist.record(i);
717        }
718        assert_eq!(hist.count(), 100);
719        assert_eq!(hist.min(), 0);
720        assert_eq!(hist.max(), 99);
721    }
722
723    #[test]
724    fn test_streaming_histogram_percentile() {
725        let mut hist = StreamingHistogram::new(1, 1000);
726        for i in 1..=100 {
727            hist.record(i);
728        }
729        // Percentiles from histogram
730        assert!(hist.percentile(50) >= 45 && hist.percentile(50) <= 55);
731        assert!(hist.percentile(95) >= 90);
732    }
733
734    #[test]
735    fn test_metrics_stream() {
736        let mut metrics = MetricsStream::new();
737        metrics.record_request(1000, 50, true);
738        metrics.record_request(2000, 100, true);
739        metrics.update_throughput(1000, 100.0);
740        metrics.update_active_users(1000, 10);
741
742        assert_eq!(metrics.latency.count(), 2);
743        assert_eq!(metrics.throughput.current, 100.0);
744        assert_eq!(metrics.active_users.current, 10.0);
745    }
746
747    #[test]
748    fn test_dashboard_state() {
749        let mut state = DashboardState::new("Test Load");
750        assert!(!state.running);
751
752        state.start();
753        assert!(state.running);
754        assert!(!state.paused);
755
756        state.pause();
757        assert!(state.paused);
758
759        state.resume();
760        assert!(!state.paused);
761
762        state.stop();
763        assert!(!state.running);
764    }
765
766    #[test]
767    fn test_comparison_verdict() {
768        assert_eq!(ComparisonVerdict::Improved.symbol(), "↑");
769        assert_eq!(ComparisonVerdict::Unchanged.symbol(), "≈");
770        assert_eq!(ComparisonVerdict::Regressed.symbol(), "↓");
771    }
772
773    #[test]
774    fn test_render_dashboard() {
775        let state = DashboardState::new("WASM Boot Test");
776        let output = render_dashboard(&state);
777        assert!(output.contains("WASM Boot Test"));
778        assert!(output.contains("STOPPED"));
779    }
780
781    #[test]
782    fn test_render_comparison() {
783        let comp = ReportComparison {
784            current_name: "v2.0".to_string(),
785            baseline_name: "v1.0".to_string(),
786            throughput_change: 15.0,
787            p50_change: -10.0,
788            p95_change: -5.0,
789            p99_change: 2.0,
790            error_rate_change: -0.5,
791            verdict: ComparisonVerdict::Improved,
792        };
793        let output = render_comparison(&comp);
794        assert!(output.contains("v2.0"));
795        assert!(output.contains("IMPROVED"));
796    }
797
798    #[test]
799    fn test_truncate() {
800        assert_eq!(truncate("short", 10), "short");
801        assert_eq!(truncate("this is long", 8), "this is…");
802    }
803
804    #[test]
805    fn test_endpoint_metrics() {
806        let ep = EndpointMetrics::new("homepage");
807        assert_eq!(ep.name, "homepage");
808        assert_eq!(ep.count, 0);
809    }
810
811    #[test]
812    fn test_export_format_extension_all() {
813        // Cover all ExportFormat::extension() branches
814        assert_eq!(ExportFormat::MessagePack.extension(), "msgpack");
815        assert_eq!(ExportFormat::Json.extension(), "json");
816        assert_eq!(ExportFormat::NdJsonStream.extension(), "ndjson");
817        assert_eq!(ExportFormat::BinaryStream.extension(), "bin");
818    }
819
820    #[test]
821    fn test_export_format_from_extension_all_variants() {
822        assert_eq!(
823            ExportFormat::from_extension("msgpack"),
824            Some(ExportFormat::MessagePack)
825        );
826        assert_eq!(
827            ExportFormat::from_extension("mp"),
828            Some(ExportFormat::MessagePack)
829        );
830        assert_eq!(
831            ExportFormat::from_extension("json"),
832            Some(ExportFormat::Json)
833        );
834        assert_eq!(
835            ExportFormat::from_extension("ndjson"),
836            Some(ExportFormat::NdJsonStream)
837        );
838        assert_eq!(
839            ExportFormat::from_extension("jsonl"),
840            Some(ExportFormat::NdJsonStream)
841        );
842        assert_eq!(
843            ExportFormat::from_extension("bin"),
844            Some(ExportFormat::BinaryStream)
845        );
846        assert_eq!(
847            ExportFormat::from_extension("binary"),
848            Some(ExportFormat::BinaryStream)
849        );
850        assert_eq!(ExportFormat::from_extension("unknown"), None);
851    }
852
853    #[test]
854    fn test_data_point_creation() {
855        let point = DataPoint::new(1000, 42.5);
856        assert_eq!(point.timestamp_ms, 1000);
857        assert_eq!(point.value, 42.5);
858    }
859
860    #[test]
861    fn test_streaming_histogram_mean() {
862        let mut hist = StreamingHistogram::new(1, 100);
863        hist.record(10);
864        hist.record(20);
865        hist.record(30);
866        assert_eq!(hist.mean(), 20);
867    }
868
869    #[test]
870    fn test_streaming_histogram_mean_empty() {
871        let hist = StreamingHistogram::new(1, 100);
872        assert_eq!(hist.mean(), 0);
873    }
874
875    #[test]
876    fn test_streaming_histogram_min_empty() {
877        let hist = StreamingHistogram::new(1, 100);
878        assert_eq!(hist.min(), 0);
879    }
880
881    #[test]
882    fn test_streaming_histogram_percentile_empty() {
883        let hist = StreamingHistogram::new(1, 100);
884        assert_eq!(hist.percentile(50), 0);
885    }
886
887    #[test]
888    fn test_streaming_histogram_reset() {
889        let mut hist = StreamingHistogram::new(1, 100);
890        hist.record(50);
891        hist.record(100);
892        assert_eq!(hist.count(), 2);
893
894        hist.reset();
895        assert_eq!(hist.count(), 0);
896        assert_eq!(hist.max(), 0);
897    }
898
899    #[test]
900    fn test_streaming_histogram_default() {
901        let hist = StreamingHistogram::default();
902        assert_eq!(hist.count(), 0);
903    }
904
905    #[test]
906    fn test_streaming_histogram_overflow_bucket() {
907        let mut hist = StreamingHistogram::new(10, 10); // 10 buckets of 10ms each
908        hist.record(1000); // Way over the buckets
909        assert_eq!(hist.count(), 1);
910    }
911
912    #[test]
913    fn test_comparison_verdict_color() {
914        assert_eq!(ComparisonVerdict::Improved.color(), "green");
915        assert_eq!(ComparisonVerdict::Unchanged.color(), "yellow");
916        assert_eq!(ComparisonVerdict::Regressed.color(), "red");
917    }
918
919    #[test]
920    fn test_metrics_stream_default() {
921        let metrics = MetricsStream::default();
922        assert_eq!(metrics.latency.count(), 0);
923    }
924
925    #[test]
926    fn test_metrics_stream_update_error_rate() {
927        let mut metrics = MetricsStream::new();
928        metrics.update_error_rate(1000, 5.5);
929        assert_eq!(metrics.error_rate.current, 5.5);
930    }
931
932    #[test]
933    fn test_dashboard_state_with_endpoints() {
934        let mut state = DashboardState::new("Endpoint Test");
935        state.endpoints.push(EndpointMetrics {
936            name: "homepage".to_string(),
937            count: 100,
938            p50_ms: 50,
939            p95_ms: 100,
940            p99_ms: 200,
941            errors: 2,
942        });
943        state.start();
944
945        let output = render_dashboard(&state);
946        assert!(output.contains("homepage"));
947        assert!(output.contains("RUNNING"));
948    }
949
950    #[test]
951    fn test_dashboard_state_paused() {
952        let mut state = DashboardState::new("Pause Test");
953        state.start();
954        state.pause();
955
956        let output = render_dashboard(&state);
957        assert!(output.contains("PAUSED"));
958    }
959
960    #[test]
961    fn test_export_format_default() {
962        let format = ExportFormat::default();
963        assert!(matches!(format, ExportFormat::MessagePack));
964    }
965
966    #[test]
967    fn test_stage_info_creation() {
968        let stage = StageInfo {
969            name: "warmup".to_string(),
970            elapsed_secs: 30,
971            duration_secs: 60,
972            target_users: 100,
973        };
974        assert_eq!(stage.name, "warmup");
975        assert_eq!(stage.target_users, 100);
976    }
977
978    #[test]
979    fn test_report_viewer_config_creation() {
980        let config = ReportViewerConfig {
981            path: PathBuf::from("report.json"),
982            detailed: true,
983            baseline: Some(PathBuf::from("baseline.json")),
984        };
985        assert!(config.detailed);
986        assert!(config.baseline.is_some());
987    }
988
989    #[test]
990    fn test_report_comparison_creation() {
991        let comp = ReportComparison {
992            current_name: "current".to_string(),
993            baseline_name: "baseline".to_string(),
994            throughput_change: 10.0,
995            p50_change: -5.0,
996            p95_change: -3.0,
997            p99_change: -1.0,
998            error_rate_change: 0.0,
999            verdict: ComparisonVerdict::Unchanged,
1000        };
1001        let output = render_comparison(&comp);
1002        assert!(output.contains("current"));
1003        assert!(output.contains("baseline"));
1004    }
1005
1006    #[test]
1007    fn test_render_comparison_regressed() {
1008        let comp = ReportComparison {
1009            current_name: "new".to_string(),
1010            baseline_name: "old".to_string(),
1011            throughput_change: -20.0,
1012            p50_change: 50.0,
1013            p95_change: 80.0,
1014            p99_change: 100.0,
1015            error_rate_change: 5.0,
1016            verdict: ComparisonVerdict::Regressed,
1017        };
1018        let output = render_comparison(&comp);
1019        assert!(output.contains("REGRESSED"));
1020    }
1021
1022    #[test]
1023    fn test_time_series_empty_average() {
1024        let ts = TimeSeries::new("empty", 10);
1025        assert_eq!(ts.average(), 0.0);
1026    }
1027}