Skip to main content

sofie_values/
fixture.rs

1//! The same seed gives the same values, so a failing test fails again.
2
3macro_rules! samples {
4    ($value:ty, accepted: [$($accepted:literal),*], refused: [$($refused:literal),*]) => {
5        impl Samples for $value {
6            const ACCEPTED: &'static [&'static str] = &[$($accepted),*];
7            const REFUSED: &'static [&'static str] = &[$($refused),*];
8        }
9    };
10}
11
12mod calls;
13#[cfg(any(
14    feature = "geo",
15    feature = "measure",
16    feature = "percentage",
17    feature = "person",
18    feature = "phone",
19    feature = "time",
20    feature = "web"
21))]
22mod groups;
23
24use serde::Serialize;
25use serde_json::Value as Json;
26use sofie_core::{Constraint, Instant, Uuid};
27
28use crate::{Currency, Email, Money, Secret};
29
30pub use calls::Calls;
31
32/// `SplitMix64`: good enough for picking names, not for anything secret.
33#[derive(Clone, Debug, Default)]
34pub struct Seed {
35    state: u64,
36}
37
38impl Seed {
39    #[must_use]
40    pub fn new(seed: u64) -> Self {
41        Self { state: seed }
42    }
43
44    pub fn draw(&mut self) -> u64 {
45        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
46        let mut mixed = self.state;
47        mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
48        mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
49        mixed ^ (mixed >> 31)
50    }
51
52    pub fn between(&mut self, low: i64, high: i64) -> i64 {
53        let span = high.abs_diff(low).saturating_add(1);
54        low.saturating_add_unsigned(self.draw() % span)
55    }
56
57    pub fn pick<'a, T>(&mut self, choices: &'a [T]) -> &'a T {
58        let index = self.draw() % choices.len() as u64;
59        &choices[usize::try_from(index).expect("an index of a slice fits usize")]
60    }
61}
62
63pub trait Fixture: Sized {
64    fn fixture(seed: &mut Seed) -> Self;
65}
66
67pub trait Samples {
68    const ACCEPTED: &'static [&'static str];
69    const REFUSED: &'static [&'static str];
70}
71
72pub trait InputFixture {
73    fn fixture_json(seed: &mut Seed) -> Json;
74}
75
76pub(crate) const FIRST_NAMES: [&str; 12] = [
77    "Ada", "Grace", "Alan", "Edsger", "Barbara", "Donald", "Frances", "Ken", "Margaret", "Niklaus",
78    "Radia", "Tim",
79];
80pub(crate) const LAST_NAMES: [&str; 12] = [
81    "Lovelace",
82    "Hopper",
83    "Turing",
84    "Dijkstra",
85    "Liskov",
86    "Knuth",
87    "Allen",
88    "Thompson",
89    "Hamilton",
90    "Wirth",
91    "Perlman",
92    "Berners-Lee",
93];
94const WORDS: [&str; 16] = [
95    "amber", "birch", "cobalt", "delta", "ember", "fjord", "granite", "harbor", "indigo",
96    "juniper", "kestrel", "linen", "meadow", "nectar", "orchard", "pepper",
97];
98const CURRENCIES: [&str; 4] = ["EUR", "USD", "GBP", "CHF"];
99/// 2026-01-01T00:00:00Z.
100const FIRST_SECOND: i64 = 1_767_225_600;
101const SECONDS_PER_YEAR: i64 = 365 * 86_400;
102
103/// `id` stands in for an `Id<…>` field, which names a record by its uuid.
104#[must_use]
105pub fn field_json<T: Fixture + Serialize>(seed: &mut Seed, constraints: &[Constraint]) -> Json {
106    let json = serde_json::to_value(T::fixture(seed)).expect("a fixture serializes to JSON");
107    fitted(json, constraints)
108}
109
110pub fn variant<E: sofie_core::Variants>(seed: &mut Seed) -> E {
111    if E::TRANSITIONS.is_empty() {
112        return seed.pick(E::VARIANTS).1;
113    }
114    E::VARIANTS[0].1
115}
116
117fn fitted(json: Json, constraints: &[Constraint]) -> Json {
118    constraints
119        .iter()
120        .fold(json, |json, constraint| match (json, *constraint) {
121            (Json::String(text), Constraint::MaxLength(max)) => {
122                Json::String(text.chars().take(count(max)).collect())
123            }
124            (Json::String(mut text), Constraint::MinLength(min)) => {
125                while text.chars().count() < count(min) {
126                    text.push('x');
127                }
128                Json::String(text)
129            }
130            (Json::Number(number), Constraint::Max(max)) => {
131                Json::from(number.as_i64().map_or(max, |number| number.min(max)))
132            }
133            (Json::Number(number), Constraint::Min(min)) => {
134                Json::from(number.as_i64().map_or(min, |number| number.max(min)))
135            }
136            (json, _) => json,
137        })
138}
139
140fn count(bound: i64) -> usize {
141    usize::try_from(bound.max(0)).unwrap_or(usize::MAX)
142}
143
144fn words(seed: &mut Seed) -> String {
145    format!("{} {}", seed.pick(&WORDS), seed.pick(&WORDS))
146}
147
148macro_rules! numbers {
149    ($($number:ty: $low:literal..=$high:literal),*) => {$(
150        impl Fixture for $number {
151            fn fixture(seed: &mut Seed) -> Self {
152                <$number>::try_from(seed.between($low, $high)).expect("the range fits the type")
153            }
154        }
155    )*};
156}
157
158numbers!(i16: 1..=999, i32: 1..=9_999, i64: 1..=99_999, u16: 1..=999, u32: 1..=9_999);
159
160impl Fixture for bool {
161    fn fixture(seed: &mut Seed) -> Self {
162        seed.draw().is_multiple_of(2)
163    }
164}
165
166impl Fixture for String {
167    fn fixture(seed: &mut Seed) -> Self {
168        words(seed)
169    }
170}
171
172impl<T: Fixture> Fixture for Option<T> {
173    fn fixture(seed: &mut Seed) -> Self {
174        Some(T::fixture(seed))
175    }
176}
177
178impl<T: Fixture> Fixture for Vec<T> {
179    fn fixture(seed: &mut Seed) -> Self {
180        (0..seed.between(1, 3)).map(|_| T::fixture(seed)).collect()
181    }
182}
183
184impl<T: Fixture> Fixture for Secret<T> {
185    fn fixture(seed: &mut Seed) -> Self {
186        Self::new(T::fixture(seed))
187    }
188}
189
190impl Fixture for Uuid {
191    fn fixture(seed: &mut Seed) -> Self {
192        let bits = u128::from(seed.draw()) << 64 | u128::from(seed.draw());
193        let version = bits & !(0xF << 76) | (0x4 << 76);
194        Self::from_u128(version & !(0x3 << 62) | (0x2 << 62))
195    }
196}
197
198/// A whole second, as a database stores it exactly.
199impl Fixture for Instant {
200    fn fixture(seed: &mut Seed) -> Self {
201        let second = FIRST_SECOND + seed.between(0, SECONDS_PER_YEAR - 1);
202        sofie_core::Timestamp::from_second(second)
203            .expect("a second of 2026 is a timestamp")
204            .into()
205    }
206}
207
208samples!(Instant, accepted: ["\"2026-10-11T09:30:00Z\"", "\"2026-10-11T12:30:00+03:00\""], refused: ["\"yesterday\"", "\"2026-13-01T00:00:00Z\"", "42"]);
209
210impl Fixture for Currency {
211    fn fixture(seed: &mut Seed) -> Self {
212        Currency::parse(seed.pick(&CURRENCIES)).expect("a listed currency parses")
213    }
214}
215
216samples!(Currency, accepted: ["\"EUR\"", "\"JPY\""], refused: ["\"eur\"", "\"EURO\"", "\"\""]);
217
218impl Fixture for Money {
219    fn fixture(seed: &mut Seed) -> Self {
220        let currency = Currency::fixture(seed);
221        Money::new(seed.between(100, 99_999), currency)
222    }
223}
224
225samples!(
226    Money,
227    accepted: [r#"{"minor":14990,"currency":"EUR"}"#, r#"{"amount":"12.50","currency":"USD"}"#],
228    refused: [r#"{"minor":100,"currency":"euro"}"#, r#"{"amount":"1.234","currency":"EUR"}"#, r#""12.50 EUR""#]
229);
230
231/// `example.com` never reaches anyone.
232impl Fixture for Email {
233    fn fixture(seed: &mut Seed) -> Self {
234        let first = seed.pick(&FIRST_NAMES).to_lowercase();
235        let last = seed.pick(&LAST_NAMES).to_lowercase();
236        let number = seed.between(1, 999);
237        Email::parse(&format!("{first}.{last}{number}@example.com"))
238            .expect("a fixture address parses")
239    }
240}
241
242samples!(Email, accepted: ["\"ada@example.com\"", "\"Grace.Hopper@Example.COM\""], refused: ["\"ada\"", "\"ada@\"", "\"@example.com\"", "\"\""]);
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn json<T: Fixture + Serialize>(seed: u64) -> Json {
249        serde_json::to_value(T::fixture(&mut Seed::new(seed))).unwrap()
250    }
251
252    #[test]
253    fn the_same_seed_makes_the_same_fixtures_and_another_seed_others() {
254        assert_eq!(json::<Email>(7), json::<Email>(7));
255        assert_ne!(json::<Email>(7), json::<Email>(8));
256        assert_eq!(json::<Money>(7), json::<Money>(7));
257    }
258
259    #[test]
260    fn fixtures_look_like_what_people_enter() {
261        let email = json::<Email>(1);
262        let money = Money::fixture(&mut Seed::new(1));
263        let instant = Instant::fixture(&mut Seed::new(1));
264
265        assert!(email.as_str().unwrap().ends_with("@example.com"), "{email}");
266        assert!((100..=99_999).contains(&money.minor()), "{money:?}");
267        assert!(
268            instant.timestamp().as_second() >= FIRST_SECOND,
269            "{instant:?}"
270        );
271        assert_eq!(instant.timestamp().subsec_nanosecond(), 0);
272    }
273
274    #[test]
275    fn a_uuid_fixture_is_a_version_4_uuid() {
276        let uuid = Uuid::fixture(&mut Seed::new(3)).to_string();
277
278        assert_eq!(uuid.as_bytes()[14], b'4', "{uuid}");
279        assert!(
280            matches!(uuid.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
281            "{uuid}"
282        );
283    }
284
285    #[test]
286    fn a_fitted_fixture_keeps_its_field_s_constraints() {
287        let long = Json::from("cobalt harbor");
288
289        assert_eq!(fitted(long.clone(), &[Constraint::MaxLength(6)]), "cobalt");
290        assert_eq!(
291            fitted(Json::from("ab"), &[Constraint::MinLength(4)]),
292            "abxx"
293        );
294        assert_eq!(fitted(Json::from(500), &[Constraint::Max(99)]), 99);
295        assert_eq!(fitted(Json::from(5), &[Constraint::Min(10)]), 10);
296        assert_eq!(fitted(long.clone(), &[Constraint::Required]), long);
297    }
298
299    fn check<T: Fixture + Samples + Serialize + serde::de::DeserializeOwned>() {
300        let fixture = serde_json::to_value(T::fixture(&mut Seed::new(5))).unwrap();
301        assert!(
302            serde_json::from_value::<T>(fixture.clone()).is_ok(),
303            "{fixture}"
304        );
305        for accepted in T::ACCEPTED {
306            assert!(serde_json::from_str::<T>(accepted).is_ok(), "{accepted}");
307        }
308        for refused in T::REFUSED {
309            assert!(serde_json::from_str::<T>(refused).is_err(), "{refused}");
310        }
311    }
312
313    #[test]
314    fn each_value_s_fixture_and_samples_parse_as_they_say() {
315        check::<Instant>();
316        check::<Currency>();
317        check::<Money>();
318        check::<Email>();
319        #[cfg(feature = "percentage")]
320        check::<crate::Percentage>();
321        #[cfg(feature = "person")]
322        check::<crate::PersonName>();
323        #[cfg(feature = "phone")]
324        check::<crate::PhoneNumber>();
325        #[cfg(feature = "geo")]
326        check::<crate::Coordinate>();
327        #[cfg(feature = "measure")]
328        {
329            check::<crate::Weight>();
330            check::<crate::Length>();
331            check::<crate::Energy>();
332        }
333        #[cfg(feature = "time")]
334        {
335            check::<crate::Date>();
336            check::<crate::TimeZone>();
337            check::<crate::LocalDateTime>();
338            check::<crate::TimeOfDay>();
339            check::<crate::Weekday>();
340        }
341        #[cfg(feature = "web")]
342        {
343            check::<crate::Locale>();
344            check::<crate::Slug>();
345            check::<crate::Url>();
346        }
347    }
348}