1#![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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
30pub enum ExportFormat {
31 #[default]
33 MessagePack,
34 Json,
36 NdJsonStream,
38 BinaryStream,
40}
41
42impl ExportFormat {
43 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct DataPoint {
72 pub timestamp_ms: u64,
74 pub value: f64,
76}
77
78impl DataPoint {
79 pub fn new(timestamp_ms: u64, value: f64) -> Self {
81 Self {
82 timestamp_ms,
83 value,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TimeSeries {
91 pub name: String,
93 pub points: VecDeque<DataPoint>,
95 pub max_points: usize,
97 pub current: f64,
99 pub peak: f64,
101 pub peak_time_ms: u64,
103}
104
105impl TimeSeries {
106 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct StreamingHistogram {
152 buckets: Vec<u64>,
154 bucket_size_ms: u64,
156 count: u64,
158 sum: u64,
160 min: u64,
162 max: u64,
164}
165
166impl StreamingHistogram {
167 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 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 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 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 pub fn mean(&self) -> u64 {
214 if self.count == 0 {
215 0
216 } else {
217 self.sum / self.count
218 }
219 }
220
221 pub fn count(&self) -> u64 {
223 self.count
224 }
225
226 pub fn min(&self) -> u64 {
228 if self.count == 0 {
229 0
230 } else {
231 self.min
232 }
233 }
234
235 pub fn max(&self) -> u64 {
237 self.max
238 }
239
240 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) }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct MetricsStream {
259 pub throughput: TimeSeries,
261 pub latency: StreamingHistogram,
263 pub error_rate: TimeSeries,
265 pub active_users: TimeSeries,
267}
268
269impl MetricsStream {
270 pub fn new() -> Self {
272 Self {
273 throughput: TimeSeries::new("throughput", 300), latency: StreamingHistogram::default(),
275 error_rate: TimeSeries::new("error_rate", 300),
276 active_users: TimeSeries::new("active_users", 300),
277 }
278 }
279
280 pub fn record_request(&mut self, timestamp_ms: u64, latency_ms: u64, success: bool) {
282 self.latency.record(latency_ms);
283 if !success {
284 }
286 }
287
288 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct StageInfo {
317 pub name: String,
319 pub elapsed_secs: u64,
321 pub duration_secs: u64,
323 pub target_users: u32,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct DashboardState {
330 pub test_name: String,
332 pub stage: StageInfo,
334 pub metrics: MetricsStream,
336 pub endpoints: Vec<EndpointMetrics>,
338 pub running: bool,
340 pub paused: bool,
342 pub elapsed_ms: u64,
344}
345
346impl DashboardState {
347 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 pub fn start(&mut self) {
367 self.running = true;
368 self.paused = false;
369 }
370
371 pub fn pause(&mut self) {
373 self.paused = true;
374 }
375
376 pub fn resume(&mut self) {
378 self.paused = false;
379 }
380
381 pub fn stop(&mut self) {
383 self.running = false;
384 }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct EndpointMetrics {
390 pub name: String,
392 pub count: u64,
394 pub p50_ms: u64,
396 pub p95_ms: u64,
398 pub p99_ms: u64,
400 pub errors: u64,
402}
403
404impl EndpointMetrics {
405 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#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ReportViewerConfig {
425 pub path: PathBuf,
427 pub detailed: bool,
429 pub baseline: Option<PathBuf>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct ReportComparison {
436 pub current_name: String,
438 pub baseline_name: String,
440 pub throughput_change: f64,
442 pub p50_change: f64,
444 pub p95_change: f64,
446 pub p99_change: f64,
448 pub error_rate_change: f64,
450 pub verdict: ComparisonVerdict,
452}
453
454#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
456pub enum ComparisonVerdict {
457 Improved,
459 Unchanged,
461 Regressed,
463}
464
465impl ComparisonVerdict {
466 pub fn symbol(&self) -> &'static str {
468 match self {
469 Self::Improved => "↑",
470 Self::Unchanged => "≈",
471 Self::Regressed => "↓",
472 }
473 }
474
475 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
485pub fn render_dashboard(state: &DashboardState) -> String {
491 let mut out = String::new();
492
493 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 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 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 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
560pub 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
652fn 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#[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 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 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); hist.record(1000); 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}