Skip to main content

mabi_modbus/testing/
report.rs

1//! Test reporting utilities.
2//!
3//! Provides structured test reports for performance validation,
4//! with support for various output formats.
5
6use std::collections::HashMap;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Test metrics collected during performance testing.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TestMetrics {
15    /// Total number of requests sent.
16    pub total_requests: u64,
17    /// Number of successful requests.
18    pub successful_requests: u64,
19    /// Number of failed requests.
20    pub failed_requests: u64,
21    /// Total number of connections established.
22    pub total_connections: u64,
23    /// Peak concurrent connections.
24    pub peak_connections: u64,
25    /// Average transactions per second.
26    pub avg_tps: u64,
27    /// Peak transactions per second.
28    pub peak_tps: u64,
29    /// P50 latency in milliseconds.
30    pub p50_latency_ms: f64,
31    /// P95 latency in milliseconds.
32    pub p95_latency_ms: f64,
33    /// P99 latency in milliseconds.
34    pub p99_latency_ms: f64,
35    /// Error rate (0.0 - 1.0).
36    pub error_rate: f64,
37    /// Peak memory usage in megabytes.
38    pub memory_peak_mb: f64,
39}
40
41impl TestMetrics {
42    /// Check if metrics meet basic quality thresholds.
43    pub fn is_healthy(&self) -> bool {
44        self.error_rate < 0.05 && self.p99_latency_ms < 100.0
45    }
46}
47
48/// Summary of a test run.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TestSummary {
51    /// Test name.
52    pub name: String,
53    /// Test description.
54    pub description: String,
55    /// Whether the test passed.
56    pub passed: bool,
57    /// Test duration.
58    #[serde(with = "humantime_serde")]
59    pub duration: Duration,
60    /// Start time.
61    pub start_time: DateTime<Utc>,
62    /// End time.
63    pub end_time: DateTime<Utc>,
64    /// Number of targets checked.
65    pub targets_checked: usize,
66    /// Number of targets passed.
67    pub targets_passed: usize,
68    /// Failure reasons (if any).
69    pub failure_reasons: Vec<String>,
70}
71
72/// Complete test report.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TestReport {
75    /// Report metadata.
76    pub metadata: ReportMetadata,
77    /// Test summary.
78    pub summary: TestSummary,
79    /// Collected metrics.
80    pub metrics: TestMetrics,
81    /// Individual target results.
82    pub target_results: Vec<TargetResultEntry>,
83    /// Timeline of events.
84    pub timeline: Vec<TimelineEvent>,
85    /// Additional notes.
86    pub notes: Vec<String>,
87}
88
89/// Report metadata.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ReportMetadata {
92    /// Report version.
93    pub version: String,
94    /// Generation timestamp.
95    pub generated_at: DateTime<Utc>,
96    /// Test environment.
97    pub environment: String,
98    /// Host information.
99    pub host_info: HostInfo,
100    /// Test configuration used.
101    pub config: HashMap<String, String>,
102}
103
104/// Host information.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HostInfo {
107    /// Operating system.
108    pub os: String,
109    /// CPU count.
110    pub cpu_count: usize,
111    /// Total memory in bytes.
112    pub total_memory_bytes: u64,
113    /// Hostname.
114    pub hostname: String,
115}
116
117impl HostInfo {
118    /// Collect host information.
119    pub fn collect() -> Self {
120        Self {
121            os: std::env::consts::OS.to_string(),
122            cpu_count: num_cpus::get(),
123            total_memory_bytes: Self::get_total_memory(),
124            hostname: hostname::get()
125                .map(|h| h.to_string_lossy().to_string())
126                .unwrap_or_else(|_| "unknown".to_string()),
127        }
128    }
129
130    #[cfg(target_os = "linux")]
131    fn get_total_memory() -> u64 {
132        std::fs::read_to_string("/proc/meminfo")
133            .ok()
134            .and_then(|s| {
135                s.lines()
136                    .find(|l| l.starts_with("MemTotal:"))
137                    .and_then(|l| {
138                        l.split_whitespace()
139                            .nth(1)
140                            .and_then(|v| v.parse::<u64>().ok())
141                            .map(|kb| kb * 1024)
142                    })
143            })
144            .unwrap_or(0)
145    }
146
147    #[cfg(not(target_os = "linux"))]
148    fn get_total_memory() -> u64 {
149        // Placeholder for other OSes
150        0
151    }
152}
153
154/// Individual target result entry.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TargetResultEntry {
157    /// Target name.
158    pub name: String,
159    /// Expected value/threshold.
160    pub expected: String,
161    /// Actual value.
162    pub actual: String,
163    /// Whether the target passed.
164    pub passed: bool,
165    /// Comparison operator used.
166    pub comparison: String,
167}
168
169/// Timeline event during test execution.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct TimelineEvent {
172    /// Event timestamp (relative to test start).
173    #[serde(with = "humantime_serde")]
174    pub timestamp: Duration,
175    /// Event type.
176    pub event_type: EventType,
177    /// Event message.
178    pub message: String,
179    /// Associated metrics at this point.
180    pub metrics: Option<HashMap<String, f64>>,
181}
182
183/// Event types in the timeline.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum EventType {
187    TestStart,
188    TestEnd,
189    RampUpComplete,
190    TargetReached,
191    TargetMissed,
192    Error,
193    Warning,
194    Milestone,
195    Snapshot,
196}
197
198impl TestReport {
199    /// Create a new test report builder.
200    pub fn builder(name: impl Into<String>) -> TestReportBuilder {
201        TestReportBuilder::new(name)
202    }
203
204    /// Export report as JSON.
205    pub fn to_json(&self) -> Result<String, serde_json::Error> {
206        serde_json::to_string_pretty(self)
207    }
208
209    /// Export report as YAML.
210    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
211        serde_yaml::to_string(self)
212    }
213
214    /// Export report as markdown.
215    pub fn to_markdown(&self) -> String {
216        let mut md = String::new();
217
218        md.push_str(&format!("# Test Report: {}\n\n", self.summary.name));
219        md.push_str(&format!("**Description:** {}\n\n", self.summary.description));
220        md.push_str(&format!("**Status:** {}\n\n", if self.summary.passed { "PASSED" } else { "FAILED" }));
221
222        md.push_str("## Summary\n\n");
223        md.push_str(&format!("| Metric | Value |\n"));
224        md.push_str(&format!("|--------|-------|\n"));
225        md.push_str(&format!("| Duration | {:?} |\n", self.summary.duration));
226        md.push_str(&format!("| Start Time | {} |\n", self.summary.start_time));
227        md.push_str(&format!("| End Time | {} |\n", self.summary.end_time));
228        md.push_str(&format!("| Targets Passed | {}/{} |\n", self.summary.targets_passed, self.summary.targets_checked));
229
230        md.push_str("\n## Metrics\n\n");
231        md.push_str(&format!("| Metric | Value |\n"));
232        md.push_str(&format!("|--------|-------|\n"));
233        md.push_str(&format!("| Total Requests | {} |\n", self.metrics.total_requests));
234        md.push_str(&format!("| Successful | {} |\n", self.metrics.successful_requests));
235        md.push_str(&format!("| Failed | {} |\n", self.metrics.failed_requests));
236        md.push_str(&format!("| Avg TPS | {} |\n", self.metrics.avg_tps));
237        md.push_str(&format!("| Peak TPS | {} |\n", self.metrics.peak_tps));
238        md.push_str(&format!("| P50 Latency | {:.2}ms |\n", self.metrics.p50_latency_ms));
239        md.push_str(&format!("| P95 Latency | {:.2}ms |\n", self.metrics.p95_latency_ms));
240        md.push_str(&format!("| P99 Latency | {:.2}ms |\n", self.metrics.p99_latency_ms));
241        md.push_str(&format!("| Error Rate | {:.2}% |\n", self.metrics.error_rate * 100.0));
242        md.push_str(&format!("| Peak Memory | {:.2}MB |\n", self.metrics.memory_peak_mb));
243
244        md.push_str("\n## Target Results\n\n");
245        md.push_str(&format!("| Target | Expected | Actual | Status |\n"));
246        md.push_str(&format!("|--------|----------|--------|--------|\n"));
247        for target in &self.target_results {
248            let status = if target.passed { "PASSED" } else { "FAILED" };
249            md.push_str(&format!("| {} | {} {} | {} | {} |\n",
250                target.name, target.comparison, target.expected, target.actual, status));
251        }
252
253        if !self.summary.failure_reasons.is_empty() {
254            md.push_str("\n## Failure Reasons\n\n");
255            for reason in &self.summary.failure_reasons {
256                md.push_str(&format!("- {}\n", reason));
257            }
258        }
259
260        md.push_str("\n## Environment\n\n");
261        md.push_str(&format!("- **OS:** {}\n", self.metadata.host_info.os));
262        md.push_str(&format!("- **CPUs:** {}\n", self.metadata.host_info.cpu_count));
263        md.push_str(&format!("- **Memory:** {:.2}GB\n",
264            self.metadata.host_info.total_memory_bytes as f64 / (1024.0 * 1024.0 * 1024.0)));
265        md.push_str(&format!("- **Host:** {}\n", self.metadata.host_info.hostname));
266
267        md
268    }
269
270    /// Save report to file.
271    pub fn save(&self, path: &str) -> std::io::Result<()> {
272        let content = if path.ends_with(".json") {
273            self.to_json().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
274        } else if path.ends_with(".yaml") || path.ends_with(".yml") {
275            self.to_yaml().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
276        } else if path.ends_with(".md") {
277            self.to_markdown()
278        } else {
279            return Err(std::io::Error::new(
280                std::io::ErrorKind::InvalidInput,
281                "Unsupported file format",
282            ));
283        };
284
285        std::fs::write(path, content)
286    }
287}
288
289/// Builder for TestReport.
290pub struct TestReportBuilder {
291    name: String,
292    description: String,
293    environment: String,
294    config: HashMap<String, String>,
295    start_time: DateTime<Utc>,
296    target_results: Vec<TargetResultEntry>,
297    timeline: Vec<TimelineEvent>,
298    notes: Vec<String>,
299}
300
301impl TestReportBuilder {
302    /// Create a new builder.
303    pub fn new(name: impl Into<String>) -> Self {
304        Self {
305            name: name.into(),
306            description: String::new(),
307            environment: "development".to_string(),
308            config: HashMap::new(),
309            start_time: Utc::now(),
310            target_results: Vec::new(),
311            timeline: Vec::new(),
312            notes: Vec::new(),
313        }
314    }
315
316    /// Set description.
317    pub fn description(mut self, desc: impl Into<String>) -> Self {
318        self.description = desc.into();
319        self
320    }
321
322    /// Set environment.
323    pub fn environment(mut self, env: impl Into<String>) -> Self {
324        self.environment = env.into();
325        self
326    }
327
328    /// Add configuration entry.
329    pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
330        self.config.insert(key.into(), value.into());
331        self
332    }
333
334    /// Add a target result.
335    pub fn add_target_result(
336        mut self,
337        name: impl Into<String>,
338        expected: impl Into<String>,
339        actual: impl Into<String>,
340        comparison: impl Into<String>,
341        passed: bool,
342    ) -> Self {
343        self.target_results.push(TargetResultEntry {
344            name: name.into(),
345            expected: expected.into(),
346            actual: actual.into(),
347            comparison: comparison.into(),
348            passed,
349        });
350        self
351    }
352
353    /// Add a timeline event.
354    pub fn add_event(
355        mut self,
356        timestamp: Duration,
357        event_type: EventType,
358        message: impl Into<String>,
359    ) -> Self {
360        self.timeline.push(TimelineEvent {
361            timestamp,
362            event_type,
363            message: message.into(),
364            metrics: None,
365        });
366        self
367    }
368
369    /// Add a note.
370    pub fn add_note(mut self, note: impl Into<String>) -> Self {
371        self.notes.push(note.into());
372        self
373    }
374
375    /// Build the report.
376    pub fn build(self, metrics: TestMetrics, passed: bool, duration: Duration) -> TestReport {
377        let end_time = Utc::now();
378        let targets_passed = self.target_results.iter().filter(|t| t.passed).count();
379
380        let failure_reasons: Vec<String> = self
381            .target_results
382            .iter()
383            .filter(|t| !t.passed)
384            .map(|t| format!("{}: expected {} {}, got {}", t.name, t.comparison, t.expected, t.actual))
385            .collect();
386
387        TestReport {
388            metadata: ReportMetadata {
389                version: "1.0.0".to_string(),
390                generated_at: end_time,
391                environment: self.environment,
392                host_info: HostInfo::collect(),
393                config: self.config,
394            },
395            summary: TestSummary {
396                name: self.name,
397                description: self.description,
398                passed,
399                duration,
400                start_time: self.start_time,
401                end_time,
402                targets_checked: self.target_results.len(),
403                targets_passed,
404                failure_reasons,
405            },
406            metrics,
407            target_results: self.target_results,
408            timeline: self.timeline,
409            notes: self.notes,
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_test_metrics_healthy() {
420        let healthy = TestMetrics {
421            total_requests: 10000,
422            successful_requests: 9900,
423            failed_requests: 100,
424            total_connections: 100,
425            peak_connections: 100,
426            avg_tps: 1000,
427            peak_tps: 1500,
428            p50_latency_ms: 5.0,
429            p95_latency_ms: 15.0,
430            p99_latency_ms: 30.0,
431            error_rate: 0.01,
432            memory_peak_mb: 256.0,
433        };
434        assert!(healthy.is_healthy());
435
436        let unhealthy = TestMetrics {
437            error_rate: 0.10,
438            ..healthy.clone()
439        };
440        assert!(!unhealthy.is_healthy());
441    }
442
443    #[test]
444    fn test_report_builder() {
445        let metrics = TestMetrics {
446            total_requests: 10000,
447            successful_requests: 9900,
448            failed_requests: 100,
449            total_connections: 100,
450            peak_connections: 100,
451            avg_tps: 1000,
452            peak_tps: 1500,
453            p50_latency_ms: 5.0,
454            p95_latency_ms: 15.0,
455            p99_latency_ms: 30.0,
456            error_rate: 0.01,
457            memory_peak_mb: 256.0,
458        };
459
460        let report = TestReport::builder("Test Run")
461            .description("Performance test")
462            .environment("test")
463            .config("connections", "100")
464            .add_target_result("Min TPS", "1000", "1000", ">=", true)
465            .add_note("Test completed successfully")
466            .build(metrics, true, Duration::from_secs(60));
467
468        assert!(report.summary.passed);
469        assert_eq!(report.summary.targets_passed, 1);
470    }
471
472    #[test]
473    fn test_report_to_markdown() {
474        let metrics = TestMetrics {
475            total_requests: 10000,
476            successful_requests: 9900,
477            failed_requests: 100,
478            total_connections: 100,
479            peak_connections: 100,
480            avg_tps: 1000,
481            peak_tps: 1500,
482            p50_latency_ms: 5.0,
483            p95_latency_ms: 15.0,
484            p99_latency_ms: 30.0,
485            error_rate: 0.01,
486            memory_peak_mb: 256.0,
487        };
488
489        let report = TestReport::builder("Test")
490            .build(metrics, true, Duration::from_secs(60));
491
492        let md = report.to_markdown();
493        assert!(md.contains("# Test Report: Test"));
494        assert!(md.contains("PASSED"));
495    }
496
497    #[test]
498    fn test_host_info_collect() {
499        let info = HostInfo::collect();
500        assert!(!info.os.is_empty());
501        assert!(info.cpu_count > 0);
502    }
503}