Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RELEASE_TYPE: patch

Add generators for Duration and Instant
14 changes: 11 additions & 3 deletions src/generators/default.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use super::{
BoolGenerator, BoxedGenerator, FloatGenerator, Generator, HashMapGenerator, IntegerGenerator,
OptionalGenerator, TextGenerator, VecGenerator, booleans, collections::ArrayGenerator, floats,
hashmaps, integers, optional, text, vecs,
BoolGenerator, BoxedGenerator, DurationGenerator, FloatGenerator, Generator, HashMapGenerator,
IntegerGenerator, OptionalGenerator, TextGenerator, VecGenerator, booleans,
collections::ArrayGenerator, durations, floats, hashmaps, integers, optional, text, vecs,
};
use std::collections::HashMap;
use std::hash::Hash;
use std::time::Duration;

/// Trait for types that have a default generator.
///
Expand Down Expand Up @@ -187,6 +188,13 @@ where
}
}

impl DefaultGenerator for Duration {
type Generator = DurationGenerator;
fn default_generator() -> Self::Generator {
durations()
}
}

impl<K: DefaultGenerator + 'static, V: DefaultGenerator + 'static> DefaultGenerator
for HashMap<K, V>
where
Expand Down
2 changes: 2 additions & 0 deletions src/generators/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod generators;
mod misc;
mod numeric;
mod strings;
mod time;
mod tuples;

#[cfg(feature = "rand")]
Expand Down Expand Up @@ -52,6 +53,7 @@ pub use strings::{
IpAddressGenerator, RegexGenerator, TextGenerator, TimeGenerator, UrlGenerator, binary, dates,
datetimes, domains, emails, from_regex, ip_addresses, text, times, urls,
};
pub use time::{DurationGenerator, InstantGenerator, durations, instants};
#[doc(hidden)]
pub use tuples::{
tuples0, tuples1, tuples2, tuples3, tuples4, tuples5, tuples6, tuples7, tuples8, tuples9,
Expand Down
140 changes: 140 additions & 0 deletions src/generators/time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use super::{BasicGenerator, Generator, TestCase};
use crate::cbor_utils::cbor_map;
use std::time::{Duration, Instant};

/// Generator for [`Duration`] values. Created by [`durations()`].
///
/// Internally generates nanoseconds as a `u64`, so the maximum representable
/// duration is approximately 584 years (`u64::MAX` nanoseconds).
/// Use `min_value` and `max_value` to constrain the range.
pub struct DurationGenerator {
min_nanos: u64,
max_nanos: u64,
}

impl DurationGenerator {
/// Set the minimum duration (inclusive).
pub fn min_value(mut self, min: Duration) -> Self {
self.min_nanos = duration_to_nanos(min);
self
}

/// Set the maximum duration (inclusive).
pub fn max_value(mut self, max: Duration) -> Self {
self.max_nanos = duration_to_nanos(max);
self
}

fn build_schema(&self) -> ciborium::Value {
assert!(
self.min_nanos <= self.max_nanos,
"Cannot have max_value < min_value"
);
cbor_map! {
"type" => "integer",
"min_value" => self.min_nanos,
"max_value" => self.max_nanos
}
}
}

impl Generator<Duration> for DurationGenerator {
fn do_draw(&self, tc: &TestCase) -> Duration {
let nanos: u64 = super::generate_from_schema(tc, &self.build_schema());
Duration::from_nanos(nanos)
}

fn as_basic(&self) -> Option<BasicGenerator<'_, Duration>> {
Some(BasicGenerator::new(self.build_schema(), |raw| {
let nanos: u64 = super::deserialize_value(raw);
Duration::from_nanos(nanos)
}))
}
}

/// Generate [`Duration`] values.
///
/// By default, generates durations from zero up to `u64::MAX` nanoseconds
/// (approximately 584 years). Use `min_value` and `max_value` to constrain
/// the range.
///
/// # Example
///
/// ```no_run
/// use std::time::Duration;
///
/// #[hegel::test]
/// fn my_test(tc: hegel::TestCase) {
/// let d = tc.draw(hegel::generators::durations()
/// .max_value(Duration::from_secs(60)));
/// assert!(d <= Duration::from_secs(60));
/// }
/// ```
pub fn durations() -> DurationGenerator {
DurationGenerator {
min_nanos: 0,
max_nanos: u64::MAX,
}
}

/// Generator for [`Instant`] values. Created by [`instants()`].
///
/// Generates instants by adding a random [`Duration`] offset to a fixed base
/// instant captured when the generator is created. The offsets are deterministic
/// (controlled by the test engine), while the base varies between runs.
pub struct InstantGenerator {
base: Instant,
max_offset_nanos: u64,
}

impl InstantGenerator {
/// Set the maximum offset from the base instant (inclusive).
pub fn max_offset(mut self, max: Duration) -> Self {
self.max_offset_nanos = duration_to_nanos(max);
self
}
}

impl Generator<Instant> for InstantGenerator {
fn do_draw(&self, tc: &TestCase) -> Instant {
let schema = cbor_map! {
"type" => "integer",
"min_value" => 0u64,
"max_value" => self.max_offset_nanos
};
let nanos: u64 = super::generate_from_schema(tc, &schema);
self.base + Duration::from_nanos(nanos)
}
}

/// Generate [`Instant`] values.
///
/// Produces instants offset from a fixed base (`Instant::now()` at call time)
/// by a random duration. The default maximum offset is one hour. Use
/// `max_offset` to change it.
///
/// The base is captured once when `instants()` is called, so all generated
/// values within a test share the same reference point. The offsets are
/// deterministic and shrinkable.
///
/// # Example
///
/// ```no_run
/// use std::time::Duration;
///
/// #[hegel::test]
/// fn my_test(tc: hegel::TestCase) {
/// let i = tc.draw(hegel::generators::instants()
/// .max_offset(Duration::from_secs(3600)));
/// }
/// ```
pub fn instants() -> InstantGenerator {
InstantGenerator {
base: Instant::now(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the base being now is a bad idea, because it means you get a different instant each time the test replays, so now your tests are possibly flaky. I'd rather have a fixed base (e.g. time zero, which is presumably the unix epoch?) and let the user set it with a base(Instant) method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instant is intentionally opaque and Instant::now() is the only way to construct an Instant. If the goal is to generate arbitrary points in time then I think SystemTime is what we need.

e.g.
SystemTime::UNIX_EPOCH + std::time::Duration::from_nanoseconds(828273)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Record of conversation we had on zulip: It does indeed seem to be impossible to get a fixed Instant in rust (which is for the record insane). I suggested that we can at least fix the instant once at test start time by having a global (mutex locked) base set to Instant::now. It gets us reproducibility within a single process, and usually gets us reproducibility across processes unless something bad is happening.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does in fact seem insane and I would like to think hard about the right thing to do here. @echoumcp1 I'd be happy to get Duration in in a separate PR while we decide on Instant

max_offset_nanos: 3_600_000_000_000,
}
}

fn duration_to_nanos(d: Duration) -> u64 {
d.as_nanos().try_into().unwrap_or(u64::MAX)
}
38 changes: 38 additions & 0 deletions tests/test_time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
mod common;

use common::utils::assert_all_examples;
use hegel::generators;
use std::time::{Duration, Instant};

#[test]
fn test_durations_default() {
assert_all_examples(generators::durations(), |d| *d >= Duration::ZERO);
}

#[test]
fn test_durations_bounded() {
let min = Duration::from_secs(5);
let max = Duration::from_secs(60);
assert_all_examples(
generators::durations().min_value(min).max_value(max),
move |d| *d >= min && *d <= max,
);
}

#[test]
fn test_instants_default() {
let before = Instant::now();
let max_offset = Duration::from_secs(3600);
assert_all_examples(generators::instants(), move |i| {
*i >= before && *i <= Instant::now() + max_offset
});
}

#[test]
fn test_instants_bounded() {
let max_offset = Duration::from_secs(10);
let before = Instant::now();
assert_all_examples(generators::instants().max_offset(max_offset), move |i| {
*i >= before && *i <= Instant::now() + max_offset
});
}