ggen_e2e/
result.rs

1//! Test result and status tracking
2//!
3//! Defines test outcomes, status tracking, and result reporting.
4
5use crate::golden::GoldenMismatch;
6use crate::platform::Platform;
7use chrono::{DateTime, Utc};
8use std::path::PathBuf;
9use std::time::Duration;
10use uuid::Uuid;
11
12/// Test execution status
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "UPPERCASE")]
15pub enum TestStatus {
16    /// Test passed
17    Passed,
18    /// Test failed
19    Failed,
20    /// Test was skipped
21    Skipped,
22    /// Test execution timed out
23    TimedOut,
24}
25
26impl std::fmt::Display for TestStatus {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            TestStatus::Passed => write!(f, "PASSED"),
30            TestStatus::Failed => write!(f, "FAILED"),
31            TestStatus::Skipped => write!(f, "SKIPPED"),
32            TestStatus::TimedOut => write!(f, "TIMED_OUT"),
33        }
34    }
35}
36
37/// A single test execution instance
38#[derive(Debug, Clone)]
39pub struct TestExecution {
40    /// Unique execution ID
41    pub id: Uuid,
42    /// Fixture name being tested
43    pub fixture: String,
44    /// Target platform
45    pub platform: Platform,
46    /// Execution start time
47    pub started_at: DateTime<Utc>,
48    /// Execution end time
49    pub ended_at: Option<DateTime<Utc>>,
50    /// Docker container ID (if using container)
51    pub container_id: Option<String>,
52}
53
54impl TestExecution {
55    /// Create a new test execution
56    pub fn new(fixture: &str, platform: Platform) -> Self {
57        TestExecution {
58            id: Uuid::new_v4(),
59            fixture: fixture.to_string(),
60            platform,
61            started_at: Utc::now(),
62            ended_at: None,
63            container_id: None,
64        }
65    }
66
67    /// Set the end time
68    pub fn finish(&mut self) {
69        self.ended_at = Some(Utc::now());
70    }
71
72    /// Get the execution duration
73    pub fn duration(&self) -> Option<Duration> {
74        self.ended_at.map(|end| {
75            let duration = end.signed_duration_since(self.started_at);
76            Duration::from_secs(duration.num_seconds() as u64)
77        })
78    }
79
80    /// Set container ID
81    pub fn with_container_id(mut self, id: String) -> Self {
82        self.container_id = Some(id);
83        self
84    }
85}
86
87/// Complete test result with outcome
88#[derive(Debug)]
89pub struct TestResult {
90    /// Execution metadata
91    pub execution: TestExecution,
92    /// Test status/outcome
93    pub status: TestStatus,
94    /// Files generated by ggen sync
95    pub generated_files: Vec<PathBuf>,
96    /// Golden file mismatches (if any)
97    pub mismatches: Vec<GoldenMismatch>,
98    /// Captured stdout/stderr
99    pub logs: String,
100    /// Error message (if failed)
101    pub error: Option<String>,
102}
103
104impl TestResult {
105    /// Create a passing test result
106    pub fn passed(execution: TestExecution, files: Vec<PathBuf>) -> Self {
107        TestResult {
108            execution,
109            status: TestStatus::Passed,
110            generated_files: files,
111            mismatches: Vec::new(),
112            logs: String::new(),
113            error: None,
114        }
115    }
116
117    /// Create a failing test result
118    pub fn failed(execution: TestExecution, error: String, logs: String) -> Self {
119        TestResult {
120            execution,
121            status: TestStatus::Failed,
122            generated_files: Vec::new(),
123            mismatches: Vec::new(),
124            logs,
125            error: Some(error),
126        }
127    }
128
129    /// Create a timed out test result
130    pub fn timed_out(execution: TestExecution, timeout: Duration, logs: String) -> Self {
131        TestResult {
132            execution,
133            status: TestStatus::TimedOut,
134            generated_files: Vec::new(),
135            mismatches: Vec::new(),
136            logs,
137            error: Some(format!("Test timed out after {:?}", timeout)),
138        }
139    }
140
141    /// Create a skipped test result
142    pub fn skipped(execution: TestExecution, reason: String) -> Self {
143        TestResult {
144            execution,
145            status: TestStatus::Skipped,
146            generated_files: Vec::new(),
147            mismatches: Vec::new(),
148            logs: reason.clone(),
149            error: Some(reason),
150        }
151    }
152
153    /// Add a golden file mismatch
154    pub fn add_mismatch(&mut self, mismatch: GoldenMismatch) {
155        self.mismatches.push(mismatch);
156        self.status = TestStatus::Failed;
157    }
158
159    /// Check if test passed
160    pub fn is_success(&self) -> bool {
161        self.status == TestStatus::Passed
162    }
163
164    /// Get summary string
165    pub fn summary(&self) -> String {
166        format!(
167            "[{}] {} on {} ({})",
168            self.status,
169            self.execution.fixture,
170            self.execution.platform,
171            self.execution
172                .duration()
173                .map(|d| format!("{:?}", d))
174                .unwrap_or_else(|| "in progress".to_string())
175        )
176    }
177
178    /// Generate JUnit XML representation
179    pub fn to_junit_xml(&self) -> String {
180        let duration_secs = self
181            .execution
182            .duration()
183            .map(|d| d.as_secs_f64())
184            .unwrap_or(0.0);
185
186        let status_str = match self.status {
187            TestStatus::Passed => "passed",
188            TestStatus::Failed => "failed",
189            TestStatus::Skipped => "skipped",
190            TestStatus::TimedOut => "error",
191        };
192
193        let mut xml = format!(
194            r#"<testcase name="{}" classname="ggen_e2e.{}" time="{}" status="{}">"#,
195            self.execution.fixture, self.execution.platform, duration_secs, status_str
196        );
197
198        if let Some(error_msg) = &self.error {
199            xml.push_str(&format!(
200                r#"<failure message="{}">{}</failure>"#,
201                error_msg, error_msg
202            ));
203        }
204
205        if !self.logs.is_empty() {
206            xml.push_str(&format!(
207                r#"<system-out>{}</system-out>"#,
208                escape_xml(&self.logs)
209            ));
210        }
211
212        for mismatch in &self.mismatches {
213            xml.push_str(&format!(
214                r#"<failure type="golden_mismatch" message="{}{}">
215{}</failure>"#,
216                mismatch.file.display(),
217                "",
218                escape_xml(&mismatch.diff)
219            ));
220        }
221
222        xml.push_str("</testcase>");
223        xml
224    }
225}
226
227impl std::fmt::Display for TestResult {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        write!(f, "{}", self.summary())
230    }
231}
232
233/// Escape XML special characters
234fn escape_xml(s: &str) -> String {
235    s.replace('&', "&amp;")
236        .replace('<', "&lt;")
237        .replace('>', "&gt;")
238        .replace('"', "&quot;")
239        .replace('\'', "&apos;")
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_test_status_display() {
248        assert_eq!(TestStatus::Passed.to_string(), "PASSED");
249        assert_eq!(TestStatus::Failed.to_string(), "FAILED");
250        assert_eq!(TestStatus::Skipped.to_string(), "SKIPPED");
251        assert_eq!(TestStatus::TimedOut.to_string(), "TIMED_OUT");
252    }
253
254    #[test]
255    fn test_execution_creation() {
256        let platform = Platform {
257            name: "test".to_string(),
258            os: crate::platform::Os::Linux,
259            arch: crate::platform::Arch::X86_64,
260            docker_available: true,
261        };
262        let exec = TestExecution::new("test_fixture", platform);
263
264        assert_eq!(exec.fixture, "test_fixture");
265        assert!(exec.ended_at.is_none());
266        assert!(exec.container_id.is_none());
267    }
268
269    #[test]
270    fn test_execution_duration() {
271        let platform = Platform {
272            name: "test".to_string(),
273            os: crate::platform::Os::Linux,
274            arch: crate::platform::Arch::X86_64,
275            docker_available: true,
276        };
277        let mut exec = TestExecution::new("test", platform);
278        assert!(exec.duration().is_none());
279
280        exec.finish();
281        assert!(exec.duration().is_some());
282    }
283
284    #[test]
285    fn test_result_passed() {
286        let platform = Platform {
287            name: "test".to_string(),
288            os: crate::platform::Os::Linux,
289            arch: crate::platform::Arch::X86_64,
290            docker_available: true,
291        };
292        let exec = TestExecution::new("test", platform);
293        let files = vec![PathBuf::from("output.txt")];
294        let result = TestResult::passed(exec, files);
295
296        assert_eq!(result.status, TestStatus::Passed);
297        assert!(result.is_success());
298        assert_eq!(result.generated_files.len(), 1);
299    }
300
301    #[test]
302    fn test_result_failed() {
303        let platform = Platform {
304            name: "test".to_string(),
305            os: crate::platform::Os::Linux,
306            arch: crate::platform::Arch::X86_64,
307            docker_available: true,
308        };
309        let exec = TestExecution::new("test", platform);
310        let result = TestResult::failed(exec, "error".to_string(), "logs".to_string());
311
312        assert_eq!(result.status, TestStatus::Failed);
313        assert!(!result.is_success());
314    }
315
316    #[test]
317    fn test_escape_xml() {
318        assert_eq!(escape_xml("a&b"), "a&amp;b");
319        assert_eq!(escape_xml("a<b>c"), "a&lt;b&gt;c");
320        assert_eq!(escape_xml(r#"a"b'c"#), "a&quot;b&apos;c");
321    }
322
323    #[test]
324    fn test_to_junit_xml() {
325        let platform = Platform {
326            name: "test".to_string(),
327            os: crate::platform::Os::Linux,
328            arch: crate::platform::Arch::X86_64,
329            docker_available: true,
330        };
331        let exec = TestExecution::new("test_case", platform);
332        let result = TestResult::passed(exec, vec![]);
333
334        let xml = result.to_junit_xml();
335        assert!(xml.contains("test_case"));
336        assert!(xml.contains("testcase"));
337    }
338}