Skip to main content

quick_junit/
proptest_impls.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Proptest `Arbitrary` implementations for quick-junit types.
5//!
6//! These implementations enable property-based testing of serialization and deserialization.
7
8use crate::{
9    FlakyOrRerun, NonSuccessKind, NonSuccessReruns, Property, Report, ReportUuid, TestCase,
10    TestCaseStatus, TestRerun, TestSuite, XmlString,
11};
12use chrono::{DateTime, FixedOffset};
13use proptest::{
14    arbitrary::Arbitrary,
15    collection, option,
16    prelude::*,
17    strategy::{BoxedStrategy, Map, Strategy},
18};
19use std::time::Duration;
20
21impl Arbitrary for XmlString {
22    type Parameters = <String as Arbitrary>::Parameters;
23    type Strategy = Map<<String as Arbitrary>::Strategy, fn(String) -> XmlString>;
24
25    fn arbitrary_with(args: Self::Parameters) -> Self::Strategy {
26        String::arbitrary_with(args).prop_map(|s| {
27            // Strip leading and trailing whitespace since XML isn't intended to
28            // preserve that.
29            XmlString::new(s.trim())
30        })
31    }
32}
33
34pub(crate) fn text_node_strategy() -> impl Strategy<Value = XmlString> {
35    any::<XmlString>().prop_filter("Non-empty string", |s| !s.is_empty())
36}
37
38/// Strategy for generating realistic test case names like "module::submodule::test_name"
39pub(crate) fn test_name_strategy() -> impl Strategy<Value = XmlString> {
40    // Generate alphanumeric identifier
41    let ident = "[a-z][a-z0-9_]{0,15}";
42
43    // Generate 1-4 segments joined by ::
44    collection::vec(ident, 1..=4).prop_map(|segments| XmlString::new(segments.join("::")))
45}
46
47/// Strategy for generating valid XML attribute names (alphanumeric, no special chars)
48pub(crate) fn xml_attr_name_strategy() -> impl Strategy<Value = XmlString> {
49    // XML attribute names: must start with letter or underscore, followed by letters, digits, hyphens, underscores, or periods
50    "[a-zA-Z_][a-zA-Z0-9_.-]{0,15}".prop_map(XmlString::new)
51}
52
53/// Strategy for generating arbitrary DateTime<FixedOffset>
54pub(crate) fn datetime_strategy() -> impl Strategy<Value = DateTime<FixedOffset>> {
55    // Generate timestamps within a reasonable range (2000-2100)
56    // to avoid edge cases with very old or very future dates
57    // Generate offsets in minute increments only (RFC 3339 doesn't preserve seconds in offsets)
58    (946684800i64..4102444800i64, -1440i32..1440i32).prop_map(|(secs, offset_minutes)| {
59        let offset_secs = offset_minutes * 60;
60        let offset =
61            FixedOffset::east_opt(offset_secs).unwrap_or(FixedOffset::east_opt(0).unwrap());
62        DateTime::from_timestamp(secs, 0)
63            .unwrap()
64            .with_timezone(&offset)
65    })
66}
67
68/// Strategy for generating arbitrary Duration
69pub(crate) fn duration_strategy() -> impl Strategy<Value = Duration> {
70    // Generate durations up to 1 hour, in milliseconds to avoid precision issues
71    (0u64..3_600_000u64).prop_map(Duration::from_millis)
72}
73
74/// Strategy for generating an IndexMap with XML attribute names as keys
75pub(crate) fn xml_attr_index_map_strategy(
76) -> impl Strategy<Value = indexmap::IndexMap<XmlString, XmlString>> {
77    collection::hash_map(xml_attr_name_strategy(), any::<XmlString>(), 0..3)
78        .prop_map(|hm| hm.into_iter().collect())
79}
80
81impl Arbitrary for NonSuccessReruns {
82    type Parameters = ();
83    type Strategy = BoxedStrategy<Self>;
84
85    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
86        (
87            any::<FlakyOrRerun>(),
88            collection::vec(any::<TestRerun>(), 0..5),
89        )
90            .prop_map(|(kind, runs)| {
91                // Normalize: empty runs always use Rerun, since the kind is
92                // unobservable in serialized XML when there are no elements.
93                let kind = if runs.is_empty() {
94                    FlakyOrRerun::Rerun
95                } else {
96                    kind
97                };
98                NonSuccessReruns { kind, runs }
99            })
100            .boxed()
101    }
102}
103
104fn test_suite_children_strategy() -> impl Strategy<
105    Value = (
106        Vec<TestCase>,
107        Vec<Property>,
108        Option<XmlString>,
109        Option<XmlString>,
110    ),
111> {
112    prop_oneof![
113        // Weight the fully-childless case with some probability so that
114        // roundtrip proptests reliably exercise self-closing <testsuite/>
115        // serialization and its is_empty parse path.
116        1 => Just((Vec::new(), Vec::new(), None, None)),
117        7 => (
118            collection::vec(any::<TestCase>(), 0..10),
119            collection::vec(any::<Property>(), 0..5),
120            any::<Option<XmlString>>(),
121            any::<Option<XmlString>>(),
122        ),
123    ]
124}
125
126impl Arbitrary for TestSuite {
127    type Parameters = ();
128    type Strategy = BoxedStrategy<Self>;
129
130    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
131        (
132            test_name_strategy(),
133            option::of(datetime_strategy()),
134            option::of(duration_strategy()),
135            option::of(0..=1000usize),
136            test_suite_children_strategy(),
137            collection::hash_map(xml_attr_name_strategy(), any::<XmlString>(), 0..5),
138        )
139            .prop_map(
140                |(
141                    name,
142                    timestamp,
143                    time,
144                    disabled,
145                    (test_cases, properties, system_out, system_err),
146                    extra,
147                )| {
148                    // Compute counts from test_cases
149                    let tests = test_cases.len();
150                    let mut failures = 0;
151                    let mut errors = 0;
152                    let mut skipped = 0;
153
154                    for test_case in &test_cases {
155                        match &test_case.status {
156                            TestCaseStatus::Success { .. } => {}
157                            TestCaseStatus::NonSuccess { kind, .. } => match kind {
158                                NonSuccessKind::Failure => failures += 1,
159                                NonSuccessKind::Error => errors += 1,
160                            },
161                            TestCaseStatus::Skipped { .. } => skipped += 1,
162                        }
163                    }
164
165                    TestSuite {
166                        name,
167                        tests,
168                        skipped,
169                        disabled,
170                        errors,
171                        failures,
172                        timestamp,
173                        time,
174                        test_cases,
175                        properties,
176                        system_out,
177                        system_err,
178                        extra: extra.into_iter().collect(),
179                    }
180                },
181            )
182            .boxed()
183    }
184}
185
186impl Arbitrary for Report {
187    type Parameters = ();
188    type Strategy = BoxedStrategy<Self>;
189
190    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
191        (
192            test_name_strategy(),
193            any::<Option<ReportUuid>>(),
194            option::of(datetime_strategy()),
195            option::of(duration_strategy()),
196            collection::vec(any::<TestSuite>(), 0..5),
197        )
198            .prop_map(|(name, uuid, timestamp, time, test_suites)| {
199                // Compute counts from test_suites
200                let tests = test_suites.iter().map(|ts| ts.tests).sum();
201                let failures = test_suites.iter().map(|ts| ts.failures).sum();
202                let errors = test_suites.iter().map(|ts| ts.errors).sum();
203                let skipped = test_suites.iter().map(|ts| ts.skipped).sum();
204
205                // Mirror add_test_suite disabled aggregation (None if all
206                // suites None, otherwise Some of sum of present values).
207                let disabled = if test_suites.iter().all(|ts| ts.disabled.is_none()) {
208                    None
209                } else {
210                    Some(test_suites.iter().filter_map(|ts| ts.disabled).sum())
211                };
212
213                Report {
214                    name,
215                    uuid,
216                    timestamp,
217                    time,
218                    tests,
219                    failures,
220                    errors,
221                    skipped,
222                    disabled,
223                    test_suites,
224                }
225            })
226            .boxed()
227    }
228}