hindsight_tests/
result.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! Test result types
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Represents a test execution result
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct TestResult {
12    /// Test name (full path including module)
13    pub name: String,
14    /// Test outcome
15    pub outcome: TestOutcome,
16    /// Duration in milliseconds
17    pub duration_ms: u64,
18    /// Timestamp when the test was run
19    pub timestamp: DateTime<Utc>,
20    /// Test output (stdout/stderr)
21    pub output: Option<String>,
22}
23
24impl TestResult {
25    /// Check if the test passed
26    #[must_use]
27    pub fn passed(&self) -> bool {
28        self.outcome == TestOutcome::Passed
29    }
30
31    /// Check if the test failed
32    #[must_use]
33    pub fn failed(&self) -> bool {
34        self.outcome == TestOutcome::Failed
35    }
36
37    /// Get the duration as a human-readable string
38    #[must_use]
39    pub fn duration_display(&self) -> String {
40        if self.duration_ms < 1000 {
41            format!("{}ms", self.duration_ms)
42        } else {
43            format!("{:.2}s", self.duration_ms as f64 / 1000.0)
44        }
45    }
46
47    /// Extract the test module path (everything before the last ::)
48    #[must_use]
49    pub fn module_path(&self) -> Option<&str> {
50        self.name.rsplit_once("::").map(|(module, _)| module)
51    }
52
53    /// Extract the test function name (everything after the last ::)
54    #[must_use]
55    pub fn test_fn_name(&self) -> &str {
56        self.name
57            .rsplit_once("::")
58            .map(|(_, name)| name)
59            .unwrap_or(&self.name)
60    }
61}
62
63/// Possible test outcomes
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum TestOutcome {
67    /// Test passed
68    Passed,
69    /// Test failed
70    Failed,
71    /// Test was ignored/skipped
72    Ignored,
73    /// Test timed out
74    TimedOut,
75}
76
77impl TestOutcome {
78    /// Returns true if this outcome represents a successful test
79    #[must_use]
80    pub fn is_success(&self) -> bool {
81        matches!(self, Self::Passed | Self::Ignored)
82    }
83
84    /// Returns a human-readable status symbol
85    #[must_use]
86    pub fn symbol(&self) -> &'static str {
87        match self {
88            Self::Passed => "✅",
89            Self::Failed => "❌",
90            Self::Ignored => "⏭️",
91            Self::TimedOut => "⏰",
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use chrono::TimeZone;
100    use similar_asserts::assert_eq;
101
102    fn sample_result() -> TestResult {
103        TestResult {
104            name: "hindsight_git::commit::tests::test_is_valid_sha".to_string(),
105            outcome: TestOutcome::Passed,
106            duration_ms: 42,
107            timestamp: Utc.with_ymd_and_hms(2026, 1, 17, 2, 33, 6).unwrap(),
108            output: None,
109        }
110    }
111
112    #[test]
113    fn test_result_serialization_roundtrip() {
114        let result = sample_result();
115        let json = serde_json::to_string(&result).expect("serialize");
116        let deserialized: TestResult = serde_json::from_str(&json).expect("deserialize");
117        assert_eq!(result, deserialized);
118    }
119
120    #[test]
121    fn test_result_json_format() {
122        let result = sample_result();
123        let json = serde_json::to_string_pretty(&result).expect("serialize");
124        assert!(json.contains("\"outcome\": \"passed\""));
125        assert!(json.contains("\"duration_ms\": 42"));
126    }
127
128    #[test]
129    fn test_passed_returns_true_for_passed() {
130        let result = sample_result();
131        assert!(result.passed());
132        assert!(!result.failed());
133    }
134
135    #[test]
136    fn test_failed_returns_true_for_failed() {
137        let mut result = sample_result();
138        result.outcome = TestOutcome::Failed;
139        assert!(result.failed());
140        assert!(!result.passed());
141    }
142
143    #[test]
144    fn test_duration_display_milliseconds() {
145        let result = sample_result();
146        assert_eq!(result.duration_display(), "42ms");
147    }
148
149    #[test]
150    fn test_duration_display_seconds() {
151        let mut result = sample_result();
152        result.duration_ms = 1500;
153        assert_eq!(result.duration_display(), "1.50s");
154    }
155
156    #[test]
157    fn test_module_path() {
158        let result = sample_result();
159        assert_eq!(result.module_path(), Some("hindsight_git::commit::tests"));
160    }
161
162    #[test]
163    fn test_test_fn_name() {
164        let result = sample_result();
165        assert_eq!(result.test_fn_name(), "test_is_valid_sha");
166    }
167
168    #[test]
169    fn test_test_fn_name_no_module() {
170        let mut result = sample_result();
171        result.name = "simple_test".to_string();
172        assert_eq!(result.test_fn_name(), "simple_test");
173    }
174
175    #[test]
176    fn test_outcome_is_success() {
177        assert!(TestOutcome::Passed.is_success());
178        assert!(TestOutcome::Ignored.is_success());
179        assert!(!TestOutcome::Failed.is_success());
180        assert!(!TestOutcome::TimedOut.is_success());
181    }
182
183    #[test]
184    fn test_outcome_symbol() {
185        assert_eq!(TestOutcome::Passed.symbol(), "✅");
186        assert_eq!(TestOutcome::Failed.symbol(), "❌");
187        assert_eq!(TestOutcome::Ignored.symbol(), "⏭️");
188        assert_eq!(TestOutcome::TimedOut.symbol(), "⏰");
189    }
190
191    #[test]
192    fn test_outcome_serialization() {
193        let outcomes = vec![
194            (TestOutcome::Passed, "\"passed\""),
195            (TestOutcome::Failed, "\"failed\""),
196            (TestOutcome::Ignored, "\"ignored\""),
197            (TestOutcome::TimedOut, "\"timedout\""),
198        ];
199
200        for (outcome, expected) in outcomes {
201            let json = serde_json::to_string(&outcome).expect("serialize");
202            assert_eq!(json, expected);
203        }
204    }
205
206    #[test]
207    fn test_result_with_output() {
208        let mut result = sample_result();
209        result.output = Some("assertion failed: expected 5, got 3".to_string());
210
211        let json = serde_json::to_string(&result).expect("serialize");
212        let deserialized: TestResult = serde_json::from_str(&json).expect("deserialize");
213
214        assert_eq!(
215            deserialized.output,
216            Some("assertion failed: expected 5, got 3".to_string())
217        );
218    }
219}
220
221#[cfg(test)]
222mod property_tests {
223    use super::*;
224    use proptest::prelude::*;
225
226    /// Strategy to generate valid TestOutcome values
227    fn outcome_strategy() -> impl Strategy<Value = TestOutcome> {
228        prop_oneof![
229            Just(TestOutcome::Passed),
230            Just(TestOutcome::Failed),
231            Just(TestOutcome::Ignored),
232            Just(TestOutcome::TimedOut),
233        ]
234    }
235
236    /// Strategy to generate test names in the format "crate::module::test_fn"
237    fn test_name_strategy() -> impl Strategy<Value = String> {
238        (
239            "[a-z_]{1,20}", // crate name
240            "[a-z_]{1,20}", // module name
241            "[a-z_]{1,30}", // test function name
242        )
243            .prop_map(|(crate_name, module, test_fn)| {
244                format!("{}::{}::tests::{}", crate_name, module, test_fn)
245            })
246    }
247
248    /// Strategy to generate arbitrary TestResult values
249    fn test_result_strategy() -> impl Strategy<Value = TestResult> {
250        (
251            test_name_strategy(),
252            outcome_strategy(),
253            0u64..1_000_000u64,         // duration_ms
254            0i64..2_000_000_000i64,     // timestamp as unix seconds
255            proptest::option::of(".*"), // output
256        )
257            .prop_map(|(name, outcome, duration_ms, ts, output)| {
258                let timestamp = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now);
259                TestResult {
260                    name,
261                    outcome,
262                    duration_ms,
263                    timestamp,
264                    output,
265                }
266            })
267    }
268
269    proptest! {
270        /// Property: Round-trip JSON serialization preserves all fields
271        #[test]
272        fn prop_test_result_roundtrip_serialization(result in test_result_strategy()) {
273            let json = serde_json::to_string(&result).expect("serialize");
274            let deserialized: TestResult = serde_json::from_str(&json).expect("deserialize");
275            prop_assert_eq!(result, deserialized);
276        }
277
278        /// Property: passed() and failed() are mutually exclusive when Passed or Failed
279        #[test]
280        fn prop_passed_failed_exclusive(result in test_result_strategy()) {
281            // Can't be both passed and failed
282            prop_assert!(!(result.passed() && result.failed()));
283
284            // If passed, outcome must be Passed
285            if result.passed() {
286                prop_assert_eq!(result.outcome, TestOutcome::Passed);
287            }
288
289            // If failed, outcome must be Failed
290            if result.failed() {
291                prop_assert_eq!(result.outcome, TestOutcome::Failed);
292            }
293        }
294
295        /// Property: duration_display format is consistent
296        #[test]
297        fn prop_duration_display_format(result in test_result_strategy()) {
298            let display = result.duration_display();
299            // Should end with ms or s
300            prop_assert!(
301                display.ends_with("ms") || display.ends_with('s'),
302                "Display '{}' should end with 'ms' or 's'",
303                display
304            );
305
306            // If < 1000ms, should show ms
307            if result.duration_ms < 1000 {
308                prop_assert!(display.ends_with("ms"));
309            } else {
310                prop_assert!(display.ends_with('s') && !display.ends_with("ms"));
311            }
312        }
313
314        /// Property: test_fn_name is always a suffix of name
315        #[test]
316        fn prop_test_fn_name_is_suffix(result in test_result_strategy()) {
317            let fn_name = result.test_fn_name();
318            prop_assert!(
319                result.name.ends_with(fn_name),
320                "Function name '{}' should be suffix of '{}'",
321                fn_name,
322                result.name
323            );
324        }
325
326        /// Property: module_path + "::" + test_fn_name == name (when module_path exists)
327        #[test]
328        fn prop_module_path_plus_fn_equals_name(result in test_result_strategy()) {
329            if let Some(module) = result.module_path() {
330                let reconstructed = format!("{}::{}", module, result.test_fn_name());
331                prop_assert_eq!(result.name, reconstructed);
332            }
333        }
334
335        /// Property: TestOutcome::is_success is true only for Passed and Ignored
336        #[test]
337        fn prop_outcome_is_success_consistency(outcome in outcome_strategy()) {
338            let expected = matches!(outcome, TestOutcome::Passed | TestOutcome::Ignored);
339            prop_assert_eq!(outcome.is_success(), expected);
340        }
341
342        /// Property: All outcomes have non-empty symbols
343        #[test]
344        fn prop_outcome_has_symbol(outcome in outcome_strategy()) {
345            prop_assert!(!outcome.symbol().is_empty());
346        }
347
348        /// Property: Outcome serialization is lowercase
349        #[test]
350        fn prop_outcome_serialization_lowercase(outcome in outcome_strategy()) {
351            let json = serde_json::to_string(&outcome).expect("serialize");
352            // Remove quotes and check lowercase
353            let value = json.trim_matches('"');
354            prop_assert_eq!(value, value.to_lowercase());
355        }
356    }
357}