1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct TestResult {
12 pub name: String,
14 pub outcome: TestOutcome,
16 pub duration_ms: u64,
18 pub timestamp: DateTime<Utc>,
20 pub output: Option<String>,
22}
23
24impl TestResult {
25 #[must_use]
27 pub fn passed(&self) -> bool {
28 self.outcome == TestOutcome::Passed
29 }
30
31 #[must_use]
33 pub fn failed(&self) -> bool {
34 self.outcome == TestOutcome::Failed
35 }
36
37 #[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 #[must_use]
49 pub fn module_path(&self) -> Option<&str> {
50 self.name.rsplit_once("::").map(|(module, _)| module)
51 }
52
53 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum TestOutcome {
67 Passed,
69 Failed,
71 Ignored,
73 TimedOut,
75}
76
77impl TestOutcome {
78 #[must_use]
80 pub fn is_success(&self) -> bool {
81 matches!(self, Self::Passed | Self::Ignored)
82 }
83
84 #[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 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 fn test_name_strategy() -> impl Strategy<Value = String> {
238 (
239 "[a-z_]{1,20}", "[a-z_]{1,20}", "[a-z_]{1,30}", )
243 .prop_map(|(crate_name, module, test_fn)| {
244 format!("{}::{}::tests::{}", crate_name, module, test_fn)
245 })
246 }
247
248 fn test_result_strategy() -> impl Strategy<Value = TestResult> {
250 (
251 test_name_strategy(),
252 outcome_strategy(),
253 0u64..1_000_000u64, 0i64..2_000_000_000i64, proptest::option::of(".*"), )
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 #[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 #[test]
280 fn prop_passed_failed_exclusive(result in test_result_strategy()) {
281 prop_assert!(!(result.passed() && result.failed()));
283
284 if result.passed() {
286 prop_assert_eq!(result.outcome, TestOutcome::Passed);
287 }
288
289 if result.failed() {
291 prop_assert_eq!(result.outcome, TestOutcome::Failed);
292 }
293 }
294
295 #[test]
297 fn prop_duration_display_format(result in test_result_strategy()) {
298 let display = result.duration_display();
299 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 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 #[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 #[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 #[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 #[test]
344 fn prop_outcome_has_symbol(outcome in outcome_strategy()) {
345 prop_assert!(!outcome.symbol().is_empty());
346 }
347
348 #[test]
350 fn prop_outcome_serialization_lowercase(outcome in outcome_strategy()) {
351 let json = serde_json::to_string(&outcome).expect("serialize");
352 let value = json.trim_matches('"');
354 prop_assert_eq!(value, value.to_lowercase());
355 }
356 }
357}