Skip to main content

jugar_probar/
reporter.rs

1//! Reporter - Test Reporting with Andon Cord Support
2//!
3//! Per spec Section 3.5: Test reporting with fail-fast mode (Andon Cord pattern).
4//!
5//! # Architecture
6//!
7//! ```text
8//! ┌────────────────────────────────────────────────────────────────────────┐
9//! │  Reporter with Andon Cord                                              │
10//! │  ─────────────────────────────                                         │
11//! │                                                                        │
12//! │  Toyota Principle: ANDON CORD                                          │
13//! │  "In Toyota factories, any worker can pull the cord to stop           │
14//! │   production when a defect is detected"                               │
15//! │                                                                        │
16//! │  ┌────────────────────┐     ┌──────────────────────┐                  │
17//! │  │  FailureMode::     │     │  FailureMode::       │                  │
18//! │  │  AndonCord         │     │  CollectAll          │                  │
19//! │  │                    │     │                      │                  │
20//! │  │  STOP on first     │     │  Collect ALL         │                  │
21//! │  │  failure (default) │     │  failures for        │                  │
22//! │  │                    │     │  exploratory testing │                  │
23//! │  └────────────────────┘     └──────────────────────┘                  │
24//! └────────────────────────────────────────────────────────────────────────┘
25//! ```
26//!
27//! # Toyota Principles Applied
28//!
29//! - **Andon Cord**: Stop immediately on critical failure
30//! - **Jidoka**: Build quality in by failing fast
31
32use crate::bridge::VisualDiff;
33use crate::driver::Screenshot;
34use crate::result::{ProbarError, ProbarResult};
35use serde::{Deserialize, Serialize};
36use std::path::Path;
37use std::time::{Duration, SystemTime};
38
39/// Failure mode for test execution
40///
41/// Andon Cord: Stop the line on first failure
42/// CollectAll: Gather all failures (for exploratory testing)
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum FailureMode {
45    /// Stop on first failure (Toyota Andon Cord)
46    #[default]
47    AndonCord,
48    /// Collect all failures (exploratory mode)
49    CollectAll,
50}
51
52/// Test result status
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum TestStatus {
55    /// Test passed
56    Passed,
57    /// Test failed
58    Failed,
59    /// Test was skipped
60    Skipped,
61    /// Test is pending
62    Pending,
63}
64
65impl TestStatus {
66    /// Check if status is passing
67    #[must_use]
68    pub const fn is_passed(&self) -> bool {
69        matches!(self, Self::Passed)
70    }
71
72    /// Check if status is failing
73    #[must_use]
74    pub const fn is_failed(&self) -> bool {
75        matches!(self, Self::Failed)
76    }
77}
78
79/// Individual test result
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TestResultEntry {
82    /// Test name
83    pub name: String,
84    /// Test status
85    pub status: TestStatus,
86    /// Duration of test execution
87    pub duration: Duration,
88    /// Error message if failed
89    pub error: Option<String>,
90    /// Screenshot on failure
91    #[serde(skip)]
92    pub failure_screenshot: Option<Screenshot>,
93    /// Stack trace if available
94    pub stack_trace: Option<String>,
95    /// Timestamp when test completed
96    pub timestamp: SystemTime,
97}
98
99impl TestResultEntry {
100    /// Create a passing test result
101    #[must_use]
102    pub fn passed(name: impl Into<String>, duration: Duration) -> Self {
103        Self {
104            name: name.into(),
105            status: TestStatus::Passed,
106            duration,
107            error: None,
108            failure_screenshot: None,
109            stack_trace: None,
110            timestamp: SystemTime::now(),
111        }
112    }
113
114    /// Create a failing test result
115    #[must_use]
116    pub fn failed(name: impl Into<String>, duration: Duration, error: impl Into<String>) -> Self {
117        Self {
118            name: name.into(),
119            status: TestStatus::Failed,
120            duration,
121            error: Some(error.into()),
122            failure_screenshot: None,
123            stack_trace: None,
124            timestamp: SystemTime::now(),
125        }
126    }
127
128    /// Create a skipped test result
129    #[must_use]
130    pub fn skipped(name: impl Into<String>) -> Self {
131        Self {
132            name: name.into(),
133            status: TestStatus::Skipped,
134            duration: Duration::ZERO,
135            error: None,
136            failure_screenshot: None,
137            stack_trace: None,
138            timestamp: SystemTime::now(),
139        }
140    }
141
142    /// Add a screenshot to the result
143    #[must_use]
144    pub fn with_screenshot(mut self, screenshot: Screenshot) -> Self {
145        self.failure_screenshot = Some(screenshot);
146        self
147    }
148
149    /// Add a stack trace to the result
150    #[must_use]
151    pub fn with_stack_trace(mut self, trace: impl Into<String>) -> Self {
152        self.stack_trace = Some(trace.into());
153        self
154    }
155}
156
157/// Trace data for performance analysis
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct TraceData {
160    /// Total test duration
161    pub total_duration: Duration,
162    /// Individual step timings
163    pub step_timings: Vec<(String, Duration)>,
164    /// Memory usage samples
165    pub memory_samples: Vec<(Duration, u64)>,
166    /// Frame rate samples
167    pub fps_samples: Vec<(Duration, f64)>,
168}
169
170impl TraceData {
171    /// Create new trace data
172    #[must_use]
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    /// Add a step timing
178    pub fn add_step(&mut self, name: impl Into<String>, duration: Duration) {
179        self.step_timings.push((name.into(), duration));
180    }
181
182    /// Add a memory sample
183    pub fn add_memory_sample(&mut self, elapsed: Duration, bytes: u64) {
184        self.memory_samples.push((elapsed, bytes));
185    }
186
187    /// Add a FPS sample
188    pub fn add_fps_sample(&mut self, elapsed: Duration, fps: f64) {
189        self.fps_samples.push((elapsed, fps));
190    }
191
192    /// Get average FPS
193    #[must_use]
194    pub fn average_fps(&self) -> f64 {
195        if self.fps_samples.is_empty() {
196            return 0.0;
197        }
198        let sum: f64 = self.fps_samples.iter().map(|(_, fps)| fps).sum();
199        sum / self.fps_samples.len() as f64
200    }
201
202    /// Get peak memory usage
203    #[must_use]
204    pub fn peak_memory(&self) -> u64 {
205        self.memory_samples
206            .iter()
207            .map(|(_, mem)| *mem)
208            .max()
209            .unwrap_or(0)
210    }
211}
212
213/// Andon Cord pulled error
214///
215/// This error is returned when fail-fast mode stops execution
216#[derive(Debug)]
217pub struct AndonCordPulled {
218    /// Name of the failing test
219    pub test_name: String,
220    /// Failure message
221    pub failure: String,
222    /// Screenshot at time of failure
223    pub screenshot: Option<Screenshot>,
224}
225
226/// Test reporter with Andon Cord support
227///
228/// The reporter collects test results and can generate various output formats.
229/// In AndonCord mode, it stops on the first failure.
230///
231/// # Example
232///
233/// ```ignore
234/// let mut reporter = Reporter::andon(); // Fail-fast mode
235///
236/// reporter.record(TestResultEntry::passed("test_1", Duration::from_millis(100)))?;
237/// reporter.record(TestResultEntry::failed("test_2", Duration::from_millis(50), "assertion failed"))?;
238/// // ^ This will return Err(AndonCordPulled)
239/// ```
240#[derive(Debug, Default)]
241pub struct Reporter {
242    /// Test results
243    results: Vec<TestResultEntry>,
244    /// Screenshots taken during testing
245    screenshots: Vec<(String, Screenshot)>,
246    /// Visual diffs
247    visual_diffs: Vec<(String, VisualDiff)>,
248    /// Trace data
249    traces: Vec<TraceData>,
250    /// Failure mode
251    failure_mode: FailureMode,
252    /// Suite name
253    suite_name: String,
254    /// Start time
255    start_time: Option<SystemTime>,
256}
257
258impl Reporter {
259    /// Create new reporter with default settings (CollectAll mode)
260    #[must_use]
261    pub fn new() -> Self {
262        Self {
263            suite_name: "Test Suite".to_string(),
264            ..Default::default()
265        }
266    }
267
268    /// Create reporter with Andon Cord mode (fail-fast)
269    #[must_use]
270    pub fn andon() -> Self {
271        Self {
272            failure_mode: FailureMode::AndonCord,
273            suite_name: "Test Suite".to_string(),
274            ..Default::default()
275        }
276    }
277
278    /// Create reporter with CollectAll mode
279    #[must_use]
280    pub fn collect_all() -> Self {
281        Self {
282            failure_mode: FailureMode::CollectAll,
283            suite_name: "Test Suite".to_string(),
284            ..Default::default()
285        }
286    }
287
288    /// Set suite name
289    #[must_use]
290    pub fn with_name(mut self, name: impl Into<String>) -> Self {
291        self.suite_name = name.into();
292        self
293    }
294
295    /// Start the test suite
296    pub fn start(&mut self) {
297        self.start_time = Some(SystemTime::now());
298    }
299
300    /// Record a test result
301    ///
302    /// # Errors
303    ///
304    /// In AndonCord mode, returns error if test failed
305    pub fn record(&mut self, result: TestResultEntry) -> ProbarResult<()> {
306        let failed = result.status.is_failed();
307        let failure_info = if failed {
308            Some((
309                result.name.clone(),
310                result.error.clone().unwrap_or_default(),
311            ))
312        } else {
313            None
314        };
315
316        self.results.push(result);
317
318        if self.failure_mode == FailureMode::AndonCord {
319            if let Some((test_name, failure)) = failure_info {
320                // ANDON CORD PULLED: Stop immediately
321                return Err(ProbarError::AssertionFailed {
322                    message: format!("ANDON CORD PULLED: Test '{test_name}' failed: {failure}"),
323                });
324            }
325        }
326
327        Ok(())
328    }
329
330    /// Add a screenshot
331    pub fn add_screenshot(&mut self, name: impl Into<String>, screenshot: Screenshot) {
332        self.screenshots.push((name.into(), screenshot));
333    }
334
335    /// Add a visual diff
336    pub fn add_visual_diff(&mut self, name: impl Into<String>, diff: VisualDiff) {
337        self.visual_diffs.push((name.into(), diff));
338    }
339
340    /// Add trace data
341    pub fn add_trace(&mut self, trace: TraceData) {
342        self.traces.push(trace);
343    }
344
345    /// Get number of passed tests
346    #[must_use]
347    pub fn passed_count(&self) -> usize {
348        self.results.iter().filter(|r| r.status.is_passed()).count()
349    }
350
351    /// Get number of failed tests
352    #[must_use]
353    pub fn failed_count(&self) -> usize {
354        self.results.iter().filter(|r| r.status.is_failed()).count()
355    }
356
357    /// Get total test count
358    #[must_use]
359    pub fn total_count(&self) -> usize {
360        self.results.len()
361    }
362
363    /// Get pass rate (0.0 to 1.0)
364    #[must_use]
365    pub fn pass_rate(&self) -> f64 {
366        if self.results.is_empty() {
367            return 1.0;
368        }
369        self.passed_count() as f64 / self.results.len() as f64
370    }
371
372    /// Check if all tests passed
373    #[must_use]
374    pub fn all_passed(&self) -> bool {
375        self.failed_count() == 0
376    }
377
378    /// Get total duration
379    #[must_use]
380    pub fn total_duration(&self) -> Duration {
381        self.results.iter().map(|r| r.duration).sum()
382    }
383
384    /// Get test results
385    #[must_use]
386    pub fn results(&self) -> &[TestResultEntry] {
387        &self.results
388    }
389
390    /// Get failing tests
391    #[must_use]
392    pub fn failures(&self) -> Vec<&TestResultEntry> {
393        self.results
394            .iter()
395            .filter(|r| r.status.is_failed())
396            .collect()
397    }
398
399    /// Generate summary string
400    #[must_use]
401    pub fn summary(&self) -> String {
402        format!(
403            "{}: {}/{} passed ({:.1}%)",
404            self.suite_name,
405            self.passed_count(),
406            self.total_count(),
407            self.pass_rate() * 100.0
408        )
409    }
410
411    /// Generate HTML report
412    ///
413    /// # Errors
414    ///
415    /// Returns error if file writing fails
416    pub fn generate_html(&self, output_path: &Path) -> ProbarResult<()> {
417        let html = self.render_html();
418        std::fs::write(output_path, html)?;
419        Ok(())
420    }
421
422    /// Render HTML report content
423    #[must_use]
424    pub fn render_html(&self) -> String {
425        let mut html = String::new();
426
427        // Header
428        html.push_str(r#"<!DOCTYPE html>
429<html>
430<head>
431    <meta charset="UTF-8">
432    <title>Probar Test Report</title>
433    <style>
434        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
435        .summary { background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
436        .progress-bar { background: #ddd; height: 20px; border-radius: 10px; overflow: hidden; }
437        .passed { background: #4caf50; height: 100%; }
438        .test { padding: 10px; margin: 5px 0; border-radius: 4px; }
439        .test.pass { background: #e8f5e9; border-left: 4px solid #4caf50; }
440        .test.fail { background: #ffebee; border-left: 4px solid #f44336; }
441        .test.skip { background: #fff3e0; border-left: 4px solid #ff9800; }
442        .error { color: #d32f2f; font-family: monospace; white-space: pre-wrap; }
443        .visual-diff { display: flex; gap: 10px; margin: 10px 0; }
444        .visual-diff img { max-width: 300px; border: 1px solid #ddd; }
445    </style>
446</head>
447<body>
448"#);
449
450        // Summary
451        html.push_str(&format!(
452            r#"<div class="summary">
453    <h1>{}</h1>
454    <h2>Results: {}/{} passed ({:.1}%)</h2>
455    <div class="progress-bar">
456        <div class="passed" style="width: {:.1}%"></div>
457    </div>
458    <p>Duration: {:.2}s</p>
459</div>
460"#,
461            self.suite_name,
462            self.passed_count(),
463            self.total_count(),
464            self.pass_rate() * 100.0,
465            self.pass_rate() * 100.0,
466            self.total_duration().as_secs_f64()
467        ));
468
469        // Test results
470        html.push_str("<h2>Test Results</h2>\n");
471        for result in &self.results {
472            let class = match result.status {
473                TestStatus::Passed => "pass",
474                TestStatus::Failed => "fail",
475                TestStatus::Skipped | TestStatus::Pending => "skip",
476            };
477
478            html.push_str(&format!(
479                r#"<div class="test {}">
480    <strong>{}</strong> - {:?} ({:.2}ms)
481"#,
482                class,
483                result.name,
484                result.status,
485                result.duration.as_secs_f64() * 1000.0
486            ));
487
488            if let Some(error) = &result.error {
489                html.push_str(&format!(r#"    <div class="error">{error}</div>"#));
490            }
491
492            html.push_str("</div>\n");
493        }
494
495        // Visual diffs
496        if !self.visual_diffs.is_empty() {
497            html.push_str("<h2>Visual Differences</h2>\n");
498            for (name, diff) in &self.visual_diffs {
499                html.push_str(&format!(
500                    r#"<div>
501    <h3>{}</h3>
502    <p>Similarity: {:.1}%</p>
503    <div class="visual-diff">
504        <div><strong>Expected</strong><br><img alt="Expected"></div>
505        <div><strong>Actual</strong><br><img alt="Actual"></div>
506        <div><strong>Diff</strong><br><img alt="Diff"></div>
507    </div>
508</div>
509"#,
510                    name,
511                    diff.perceptual_similarity * 100.0
512                ));
513            }
514        }
515
516        // Footer
517        html.push_str(
518            r#"
519<footer>
520    <p>Generated by Probar - WASM Game Testing Framework</p>
521</footer>
522</body>
523</html>
524"#,
525        );
526
527        html
528    }
529
530    /// Generate JUnit XML for CI integration
531    ///
532    /// # Errors
533    ///
534    /// Returns error if file writing fails
535    pub fn generate_junit(&self, output_path: &Path) -> ProbarResult<()> {
536        let xml = self.render_junit();
537        std::fs::write(output_path, xml)?;
538        Ok(())
539    }
540
541    /// Render JUnit XML content
542    #[must_use]
543    pub fn render_junit(&self) -> String {
544        let mut xml = String::new();
545
546        xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
547        xml.push('\n');
548        xml.push_str(&format!(
549            r#"<testsuite name="{}" tests="{}" failures="{}" time="{:.3}">"#,
550            self.suite_name,
551            self.total_count(),
552            self.failed_count(),
553            self.total_duration().as_secs_f64()
554        ));
555        xml.push('\n');
556
557        for result in &self.results {
558            xml.push_str(&format!(
559                r#"  <testcase name="{}" time="{:.3}">"#,
560                result.name,
561                result.duration.as_secs_f64()
562            ));
563            xml.push('\n');
564
565            if let Some(error) = &result.error {
566                xml.push_str(&format!(
567                    r#"    <failure message="{}">{}</failure>"#,
568                    escape_xml(error),
569                    escape_xml(error)
570                ));
571                xml.push('\n');
572            }
573
574            xml.push_str("  </testcase>\n");
575        }
576
577        xml.push_str("</testsuite>\n");
578        xml
579    }
580}
581
582/// Escape XML special characters
583fn escape_xml(s: &str) -> String {
584    s.replace('&', "&amp;")
585        .replace('<', "&lt;")
586        .replace('>', "&gt;")
587        .replace('"', "&quot;")
588        .replace('\'', "&apos;")
589}
590
591// ============================================================================
592// EXTREME TDD: Tests written FIRST per spec Section 6.1
593// ============================================================================
594
595#[cfg(test)]
596#[allow(clippy::unwrap_used, clippy::expect_used)]
597mod tests {
598    use super::*;
599
600    mod failure_mode_tests {
601        use super::*;
602
603        #[test]
604        fn test_default_failure_mode() {
605            let mode = FailureMode::default();
606            assert_eq!(mode, FailureMode::AndonCord);
607        }
608    }
609
610    mod test_status_tests {
611        use super::*;
612
613        #[test]
614        fn test_status_is_passed() {
615            assert!(TestStatus::Passed.is_passed());
616            assert!(!TestStatus::Failed.is_passed());
617            assert!(!TestStatus::Skipped.is_passed());
618        }
619
620        #[test]
621        fn test_status_is_failed() {
622            assert!(!TestStatus::Passed.is_failed());
623            assert!(TestStatus::Failed.is_failed());
624            assert!(!TestStatus::Skipped.is_failed());
625        }
626    }
627
628    mod test_result_entry_tests {
629        use super::*;
630
631        #[test]
632        fn test_passed_result() {
633            let result = TestResultEntry::passed("test_1", Duration::from_millis(100));
634            assert_eq!(result.name, "test_1");
635            assert_eq!(result.status, TestStatus::Passed);
636            assert!(result.error.is_none());
637        }
638
639        #[test]
640        fn test_failed_result() {
641            let result =
642                TestResultEntry::failed("test_2", Duration::from_millis(50), "assertion failed");
643            assert_eq!(result.name, "test_2");
644            assert_eq!(result.status, TestStatus::Failed);
645            assert_eq!(result.error, Some("assertion failed".to_string()));
646        }
647
648        #[test]
649        fn test_skipped_result() {
650            let result = TestResultEntry::skipped("test_3");
651            assert_eq!(result.status, TestStatus::Skipped);
652            assert_eq!(result.duration, Duration::ZERO);
653        }
654
655        #[test]
656        fn test_with_stack_trace() {
657            let result = TestResultEntry::failed("test", Duration::ZERO, "error")
658                .with_stack_trace("at line 42");
659            assert_eq!(result.stack_trace, Some("at line 42".to_string()));
660        }
661    }
662
663    mod trace_data_tests {
664        use super::*;
665
666        #[test]
667        fn test_new_trace() {
668            let trace = TraceData::new();
669            assert!(trace.step_timings.is_empty());
670            assert!(trace.memory_samples.is_empty());
671        }
672
673        #[test]
674        fn test_add_step() {
675            let mut trace = TraceData::new();
676            trace.add_step("setup", Duration::from_millis(10));
677            assert_eq!(trace.step_timings.len(), 1);
678        }
679
680        #[test]
681        fn test_average_fps() {
682            let mut trace = TraceData::new();
683            trace.add_fps_sample(Duration::ZERO, 60.0);
684            trace.add_fps_sample(Duration::from_secs(1), 50.0);
685            trace.add_fps_sample(Duration::from_secs(2), 55.0);
686            assert!((trace.average_fps() - 55.0).abs() < f64::EPSILON);
687        }
688
689        #[test]
690        fn test_average_fps_empty() {
691            let trace = TraceData::new();
692            assert!((trace.average_fps() - 0.0).abs() < f64::EPSILON);
693        }
694
695        #[test]
696        fn test_peak_memory() {
697            let mut trace = TraceData::new();
698            trace.add_memory_sample(Duration::ZERO, 1000);
699            trace.add_memory_sample(Duration::from_secs(1), 5000);
700            trace.add_memory_sample(Duration::from_secs(2), 3000);
701            assert_eq!(trace.peak_memory(), 5000);
702        }
703    }
704
705    mod reporter_tests {
706        use super::*;
707
708        #[test]
709        fn test_new_reporter() {
710            let reporter = Reporter::new();
711            assert_eq!(reporter.total_count(), 0);
712            assert!(reporter.all_passed());
713        }
714
715        #[test]
716        fn test_andon_reporter() {
717            let reporter = Reporter::andon();
718            assert_eq!(reporter.failure_mode, FailureMode::AndonCord);
719        }
720
721        #[test]
722        fn test_collect_all_reporter() {
723            let reporter = Reporter::collect_all();
724            assert_eq!(reporter.failure_mode, FailureMode::CollectAll);
725        }
726
727        #[test]
728        fn test_with_name() {
729            let reporter = Reporter::new().with_name("My Tests");
730            assert_eq!(reporter.suite_name, "My Tests");
731        }
732
733        #[test]
734        fn test_record_passing() {
735            let mut reporter = Reporter::andon();
736            let result = reporter.record(TestResultEntry::passed("test", Duration::ZERO));
737            assert!(result.is_ok());
738            assert_eq!(reporter.passed_count(), 1);
739        }
740
741        #[test]
742        fn test_andon_cord_pulled() {
743            let mut reporter = Reporter::andon();
744            let result = reporter.record(TestResultEntry::failed("test", Duration::ZERO, "error"));
745            assert!(result.is_err());
746        }
747
748        #[test]
749        fn test_collect_all_continues() {
750            let mut reporter = Reporter::collect_all();
751            let result1 =
752                reporter.record(TestResultEntry::failed("test1", Duration::ZERO, "error"));
753            let result2 = reporter.record(TestResultEntry::passed("test2", Duration::ZERO));
754            assert!(result1.is_ok()); // CollectAll doesn't stop
755            assert!(result2.is_ok());
756            assert_eq!(reporter.failed_count(), 1);
757            assert_eq!(reporter.passed_count(), 1);
758        }
759
760        #[test]
761        fn test_pass_rate() {
762            let mut reporter = Reporter::collect_all();
763            reporter
764                .record(TestResultEntry::passed("t1", Duration::ZERO))
765                .unwrap();
766            reporter
767                .record(TestResultEntry::passed("t2", Duration::ZERO))
768                .unwrap();
769            reporter
770                .record(TestResultEntry::failed("t3", Duration::ZERO, "err"))
771                .unwrap();
772            reporter
773                .record(TestResultEntry::passed("t4", Duration::ZERO))
774                .unwrap();
775
776            assert!((reporter.pass_rate() - 0.75).abs() < f64::EPSILON);
777        }
778
779        #[test]
780        fn test_pass_rate_empty() {
781            let reporter = Reporter::new();
782            assert!((reporter.pass_rate() - 1.0).abs() < f64::EPSILON);
783        }
784
785        #[test]
786        fn test_total_duration() {
787            let mut reporter = Reporter::collect_all();
788            reporter
789                .record(TestResultEntry::passed("t1", Duration::from_millis(100)))
790                .unwrap();
791            reporter
792                .record(TestResultEntry::passed("t2", Duration::from_millis(200)))
793                .unwrap();
794            assert_eq!(reporter.total_duration(), Duration::from_millis(300));
795        }
796
797        #[test]
798        fn test_failures() {
799            let mut reporter = Reporter::collect_all();
800            reporter
801                .record(TestResultEntry::passed("t1", Duration::ZERO))
802                .unwrap();
803            reporter
804                .record(TestResultEntry::failed("t2", Duration::ZERO, "err"))
805                .unwrap();
806            reporter
807                .record(TestResultEntry::passed("t3", Duration::ZERO))
808                .unwrap();
809
810            let failures = reporter.failures();
811            assert_eq!(failures.len(), 1);
812            assert_eq!(failures[0].name, "t2");
813        }
814
815        #[test]
816        fn test_summary() {
817            let mut reporter = Reporter::collect_all().with_name("Game Tests");
818            reporter
819                .record(TestResultEntry::passed("t1", Duration::ZERO))
820                .unwrap();
821            reporter
822                .record(TestResultEntry::passed("t2", Duration::ZERO))
823                .unwrap();
824
825            let summary = reporter.summary();
826            assert!(summary.contains("Game Tests"));
827            assert!(summary.contains("2/2"));
828            assert!(summary.contains("100.0%"));
829        }
830
831        #[test]
832        fn test_render_html() {
833            let mut reporter = Reporter::collect_all().with_name("HTML Test");
834            reporter
835                .record(TestResultEntry::passed("t1", Duration::from_millis(50)))
836                .unwrap();
837            reporter
838                .record(TestResultEntry::failed(
839                    "t2",
840                    Duration::from_millis(10),
841                    "assertion failed",
842                ))
843                .unwrap();
844
845            let html = reporter.render_html();
846            assert!(html.contains("HTML Test"));
847            assert!(html.contains("t1"));
848            assert!(html.contains("t2"));
849            assert!(html.contains("assertion failed"));
850        }
851
852        #[test]
853        fn test_render_junit() {
854            let mut reporter = Reporter::collect_all().with_name("JUnit Test");
855            reporter
856                .record(TestResultEntry::passed(
857                    "passing_test",
858                    Duration::from_millis(100),
859                ))
860                .unwrap();
861            reporter
862                .record(TestResultEntry::failed(
863                    "failing_test",
864                    Duration::from_millis(50),
865                    "error msg",
866                ))
867                .unwrap();
868
869            let xml = reporter.render_junit();
870            assert!(xml.contains("JUnit Test"));
871            assert!(xml.contains("passing_test"));
872            assert!(xml.contains("failing_test"));
873            assert!(xml.contains("error msg"));
874        }
875    }
876
877    mod escape_xml_tests {
878        use super::*;
879
880        #[test]
881        fn test_escape_special_chars() {
882            assert_eq!(escape_xml("a & b"), "a &amp; b");
883            assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
884            assert_eq!(escape_xml("\"quoted\""), "&quot;quoted&quot;");
885            assert_eq!(escape_xml("it's"), "it&apos;s");
886        }
887
888        #[test]
889        fn test_no_escape_needed() {
890            assert_eq!(escape_xml("plain text"), "plain text");
891        }
892    }
893
894    mod additional_coverage_tests {
895        use super::*;
896        use tempfile::tempdir;
897
898        #[test]
899        fn test_with_screenshot() {
900            let screenshot = Screenshot::new(vec![1, 2, 3], 100, 100);
901            let result = TestResultEntry::failed("test", Duration::ZERO, "error")
902                .with_screenshot(screenshot);
903            assert!(result.failure_screenshot.is_some());
904        }
905
906        #[test]
907        fn test_reporter_start() {
908            let mut reporter = Reporter::new();
909            assert!(reporter.start_time.is_none());
910            reporter.start();
911            assert!(reporter.start_time.is_some());
912        }
913
914        #[test]
915        fn test_reporter_add_screenshot() {
916            let mut reporter = Reporter::new();
917            let screenshot = Screenshot::new(vec![1, 2, 3], 100, 100);
918            reporter.add_screenshot("test_shot", screenshot);
919            assert_eq!(reporter.screenshots.len(), 1);
920        }
921
922        #[test]
923        fn test_reporter_add_visual_diff() {
924            let mut reporter = Reporter::new();
925            let diff = VisualDiff::new(0.95, vec![1, 2, 3]);
926            reporter.add_visual_diff("test_diff", diff);
927            assert_eq!(reporter.visual_diffs.len(), 1);
928        }
929
930        #[test]
931        fn test_reporter_add_trace() {
932            let mut reporter = Reporter::new();
933            let mut trace = TraceData::new();
934            trace.add_step("step1", Duration::from_millis(100));
935            reporter.add_trace(trace);
936            assert_eq!(reporter.traces.len(), 1);
937        }
938
939        #[test]
940        fn test_reporter_results_accessor() {
941            let mut reporter = Reporter::collect_all();
942            reporter
943                .record(TestResultEntry::passed("t1", Duration::ZERO))
944                .unwrap();
945            reporter
946                .record(TestResultEntry::passed("t2", Duration::ZERO))
947                .unwrap();
948
949            let results = reporter.results();
950            assert_eq!(results.len(), 2);
951            assert_eq!(results[0].name, "t1");
952            assert_eq!(results[1].name, "t2");
953        }
954
955        #[test]
956        fn test_generate_html_to_file() {
957            let mut reporter = Reporter::collect_all().with_name("File Test");
958            reporter
959                .record(TestResultEntry::passed("t1", Duration::ZERO))
960                .unwrap();
961
962            let dir = tempdir().unwrap();
963            let path = dir.path().join("report.html");
964
965            let result = reporter.generate_html(&path);
966            assert!(result.is_ok());
967            assert!(path.exists());
968
969            let content = std::fs::read_to_string(&path).unwrap();
970            assert!(content.contains("File Test"));
971        }
972
973        #[test]
974        fn test_generate_junit_to_file() {
975            let mut reporter = Reporter::collect_all().with_name("JUnit File Test");
976            reporter
977                .record(TestResultEntry::passed("t1", Duration::ZERO))
978                .unwrap();
979
980            let dir = tempdir().unwrap();
981            let path = dir.path().join("report.xml");
982
983            let result = reporter.generate_junit(&path);
984            assert!(result.is_ok());
985            assert!(path.exists());
986
987            let content = std::fs::read_to_string(&path).unwrap();
988            assert!(content.contains("JUnit File Test"));
989        }
990
991        #[test]
992        fn test_render_html_with_visual_diffs() {
993            let mut reporter = Reporter::collect_all().with_name("Visual Test");
994            reporter
995                .record(TestResultEntry::passed("t1", Duration::ZERO))
996                .unwrap();
997
998            let diff = VisualDiff::new(0.85, vec![1, 2, 3]);
999            reporter.add_visual_diff("homepage", diff);
1000
1001            let html = reporter.render_html();
1002            assert!(html.contains("Visual Differences"));
1003            assert!(html.contains("homepage"));
1004            assert!(html.contains("85.0%")); // 0.85 * 100
1005        }
1006    }
1007}