From 9b0d0cedbce5743d347c5ffa47714004faae7812 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 27 Sep 2026 10:03:01 +0200 Subject: [PATCH 1/4] feat(chrono-assertions): implement `is_close_to_with_margin` for chrono's `NaiveDate`, `NaiveTime` and `NaiveDateTime` --- Cargo.toml | 2 + examples/fixture/mod.rs | 2 + src/chrono/mod.rs | 188 +++++++++++++++++ src/chrono/tests.rs | 377 +++++++++++++++++++++++++++++++++++ src/lib.rs | 5 + src/prelude.rs | 4 + src/temporal_margin/mod.rs | 194 ++++++++++++++++++ src/temporal_margin/tests.rs | 180 +++++++++++++++++ tests/version_numbers.rs | 34 +--- 9 files changed, 953 insertions(+), 33 deletions(-) create mode 100644 src/chrono/mod.rs create mode 100644 src/chrono/tests.rs create mode 100644 src/temporal_margin/mod.rs create mode 100644 src/temporal_margin/tests.rs diff --git a/Cargo.toml b/Cargo.toml index f685cfc..99f44f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["std", "colored", "float-cmp", "panic", "recursive", "regex"] bigdecimal = ["dep:bigdecimal", "dep:once_cell"] +chrono = ["dep:chrono"] colored = ["dep:sdiff"] float-cmp = ["dep:float-cmp"] num-bigint = ["dep:num-bigint", "dep:once_cell"] @@ -45,6 +46,7 @@ hashbrown = "0.17" # optional bigdecimal = { version = "0.4", optional = true, default-features = false } +chrono = { version = "0.4", optional = true, default-features = false, features = ["alloc"] } float-cmp = { version = "0.10", optional = true } indexmap = { version = "2", optional = true, default-features = false } num-bigint = { version = "0.5", optional = true, default-features = false } diff --git a/examples/fixture/mod.rs b/examples/fixture/mod.rs index 713f282..fc17d73 100644 --- a/examples/fixture/mod.rs +++ b/examples/fixture/mod.rs @@ -4,6 +4,8 @@ mod dummy_extern_uses { use anyhow as _; #[cfg(feature = "bigdecimal")] use bigdecimal as _; + #[cfg(feature = "chrono")] + use chrono as _; use fakeenv as _; #[cfg(feature = "float-cmp")] use float_cmp as _; diff --git a/src/chrono/mod.rs b/src/chrono/mod.rs new file mode 100644 index 0000000..79f3d60 --- /dev/null +++ b/src/chrono/mod.rs @@ -0,0 +1,188 @@ +use crate::assertions::AssertIsCloseToWithinMargin; +use crate::colored::mark_diff; +use crate::expectations::{IsCloseTo, is_close_to, not}; +use crate::spec::{ + DiffFormat, Expectation, Expecting, Expression, FailingStrategy, Invertible, Represent, + Represented, Spec, +}; +use crate::temporal_margin::TemporalMargin; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeDelta}; + +impl TemporalMargin { + /// Converts this temporal margin to a [`TimeDelta`] value. + pub fn to_time_delta(self) -> TimeDelta { + match self { + Self::NanoSeconds(nanos) => TimeDelta::nanoseconds(i64::from(nanos)), + Self::MicroSeconds(micros) => TimeDelta::microseconds(i64::from(micros)), + Self::MilliSeconds(millis) => TimeDelta::milliseconds(i64::from(millis)), + Self::Seconds(seconds) => TimeDelta::seconds(i64::from(seconds)), + Self::Minutes(minutes) => TimeDelta::minutes(i64::from(minutes)), + Self::Hours(hours) => TimeDelta::hours(i64::from(hours)), + Self::Days(days) => TimeDelta::days(i64::from(days)), + Self::Weeks(weeks) => TimeDelta::weeks(i64::from(weeks)), + } + } +} + +impl AssertIsCloseToWithinMargin for Spec<'_, NaiveTime, D, R> +where + D: Represent + Represent, + R: FailingStrategy, +{ + fn is_close_to_with_margin( + self, + expected: NaiveTime, + margin: impl Into, + ) -> Self { + self.expecting(is_close_to(expected).within_margin(margin)) + } + + fn is_not_close_to_with_margin( + self, + expected: NaiveTime, + margin: impl Into, + ) -> Self { + self.expecting(not(is_close_to(expected).within_margin(margin))) + } +} + +impl Expectation for IsCloseTo +where + D: Represent + Represent, +{ + fn test(&mut self, subject: &NaiveTime) -> bool { + subject.signed_duration_since(self.expected).abs() <= self.margin.to_time_delta() + } + + fn message( + &self, + expression: &Expression<'_>, + actual: &NaiveTime, + inverted: bool, + representation: &D, + format: &DiffFormat, + ) -> String { + let not = if inverted { "not " } else { "" }; + let (marked_actual, marked_expected) = + mark_diff(actual, &self.expected, representation, format); + let represented_expected = Represented::from((&self.expected, representation)); + let represented_margin = Represented::from((&self.margin, representation)); + format!( + r"expected {expression} to be {not}close to {represented_expected:?} within {represented_margin} + but was: {marked_actual} + expected: {marked_expected}" + ) + } +} + +impl Invertible for IsCloseTo {} + +impl AssertIsCloseToWithinMargin for Spec<'_, NaiveDate, D, R> +where + D: Represent + Represent, + R: FailingStrategy, +{ + fn is_close_to_with_margin( + self, + expected: NaiveDate, + margin: impl Into, + ) -> Self { + self.expecting(is_close_to(expected).within_margin(margin)) + } + + fn is_not_close_to_with_margin( + self, + expected: NaiveDate, + margin: impl Into, + ) -> Self { + self.expecting(not(is_close_to(expected).within_margin(margin))) + } +} + +impl Expectation for IsCloseTo +where + D: Represent + Represent, +{ + fn test(&mut self, subject: &NaiveDate) -> bool { + subject.signed_duration_since(self.expected).abs() <= self.margin.to_time_delta() + } + + fn message( + &self, + expression: &Expression<'_>, + actual: &NaiveDate, + inverted: bool, + representation: &D, + format: &DiffFormat, + ) -> String { + let not = if inverted { "not " } else { "" }; + let (marked_actual, marked_expected) = + mark_diff(actual, &self.expected, representation, format); + let represented_expected = Represented::from((&self.expected, representation)); + let represented_margin = Represented::from((&self.margin, representation)); + format!( + r"expected {expression} to be {not}close to {represented_expected:?} within {represented_margin} + but was: {marked_actual} + expected: {marked_expected}" + ) + } +} + +impl Invertible for IsCloseTo {} + +impl AssertIsCloseToWithinMargin + for Spec<'_, NaiveDateTime, D, R> +where + D: Represent + Represent, + R: FailingStrategy, +{ + fn is_close_to_with_margin( + self, + expected: NaiveDateTime, + margin: impl Into, + ) -> Self { + self.expecting(is_close_to(expected).within_margin(margin)) + } + + fn is_not_close_to_with_margin( + self, + expected: NaiveDateTime, + margin: impl Into, + ) -> Self { + self.expecting(not(is_close_to(expected).within_margin(margin))) + } +} + +impl Expectation for IsCloseTo +where + D: Represent + Represent, +{ + fn test(&mut self, subject: &NaiveDateTime) -> bool { + subject.signed_duration_since(self.expected).abs() <= self.margin.to_time_delta() + } + + fn message( + &self, + expression: &Expression<'_>, + actual: &NaiveDateTime, + inverted: bool, + representation: &D, + format: &DiffFormat, + ) -> String { + let not = if inverted { "not " } else { "" }; + let (marked_actual, marked_expected) = + mark_diff(actual, &self.expected, representation, format); + let represented_expected = Represented::from((&self.expected, representation)); + let represented_margin = Represented::from((&self.margin, representation)); + format!( + r"expected {expression} to be {not}close to {represented_expected:?} within {represented_margin} + but was: {marked_actual} + expected: {marked_expected}" + ) + } +} + +impl Invertible for IsCloseTo {} + +#[cfg(test)] +mod tests; diff --git a/src/chrono/tests.rs b/src/chrono/tests.rs new file mode 100644 index 0000000..cced496 --- /dev/null +++ b/src/chrono/tests.rs @@ -0,0 +1,377 @@ +#![allow(clippy::expect_used)] + +use crate::prelude::*; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + +#[test] +fn naive_time_is_equal_to() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_equal_to( + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); +} + +#[test] +fn naive_time_is_before() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_before( + NaiveTime::from_hms_milli_opt(12, 34, 56, 790).expect("invalid naive time value"), + ); +} + +#[test] +fn naive_time_is_after() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_after( + NaiveTime::from_hms_milli_opt(12, 34, 56, 788).expect("invalid naive time value"), + ); +} + +#[test] +fn naive_time_is_between() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_between( + NaiveTime::from_hms_milli_opt(12, 34, 56, 788).expect("invalid naive time value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 790).expect("invalid naive time value"), + ); +} + +#[test] +fn naive_time_is_close_to_within_milliseconds_exact_equal() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_close_to_with_margin( + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + 10.milliseconds(), + ); +} + +#[test] +fn naive_time_is_close_to_within_milliseconds_plus_margin() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_close_to_with_margin( + NaiveTime::from_hms_milli_opt(12, 34, 56, 799).expect("invalid naive time value"), + 10.milliseconds(), + ); +} + +#[test] +fn naive_time_is_close_to_within_milliseconds_minus_margin() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + assert_that(subject).is_close_to_with_margin( + NaiveTime::from_hms_milli_opt(12, 34, 56, 779).expect("invalid naive time value"), + 10.milliseconds(), + ); +} + +#[test] +fn verify_naive_time_is_close_to_within_milliseconds_minus_margin_fails() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveTime::from_hms_milli_opt(12, 34, 56, 778).expect("invalid naive time value"), + 10.milliseconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [r"expected subject to be close to 12:34:56.778 within 10ms + but was: 12:34:56.789 + expected: 12:34:56.778 +"] + ); +} + +#[test] +fn verify_naive_time_is_close_to_within_milliseconds_plus_margin_fails() { + let subject = NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveTime::from_hms_milli_opt(12, 34, 56, 800).expect("invalid naive time value"), + 10.milliseconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [r"expected subject to be close to 12:34:56.800 within 10ms + but was: 12:34:56.789 + expected: 12:34:56.800 +"] + ); +} + +#[test] +fn naive_date_is_equal_to() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"); + + assert_that(subject) + .is_equal_to(NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value")); +} + +#[test] +fn naive_date_is_before() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 27).expect("invalid naive date value"); + + assert_that(subject) + .is_before(NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value")); +} + +#[test] +fn naive_date_is_after() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"); + + assert_that(subject) + .is_after(NaiveDate::from_ymd_opt(2023, 2, 27).expect("invalid naive date value")); +} + +#[test] +fn naive_date_is_between() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 27).expect("invalid naive date value"); + + assert_that(subject).is_between( + NaiveDate::from_ymd_opt(2023, 2, 26).expect("invalid naive date value"), + NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"), + ); +} + +#[test] +fn naive_date_is_close_to_within_days_exact_equal() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"); + + assert_that(subject).is_close_to_with_margin( + NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"), + 1.days(), + ); +} + +#[test] +fn naive_date_is_close_to_within_days_plus_margin() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 27).expect("invalid naive date value"); + + assert_that(subject).is_close_to_with_margin( + NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"), + 1.days(), + ); +} + +#[test] +fn naive_date_is_close_to_within_days_minus_margin() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"); + + assert_that(subject).is_close_to_with_margin( + NaiveDate::from_ymd_opt(2023, 2, 27).expect("invalid naive date value"), + 1.days(), + ); +} + +#[test] +fn verify_naive_date_is_close_to_within_days_minus_margin_fails() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveDate::from_ymd_opt(2023, 2, 26).expect("invalid naive date value"), + 1.days(), + ) + .display_failures(); + + assert_eq!( + failures, + [r"expected subject to be close to 2023-02-26 within 1d + but was: 2023-02-28 + expected: 2023-02-26 +"] + ); +} + +#[test] +fn verify_naive_date_is_close_to_within_days_plus_margin_fails() { + let subject = NaiveDate::from_ymd_opt(2023, 2, 26).expect("invalid naive date value"); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveDate::from_ymd_opt(2023, 2, 28).expect("invalid naive date value"), + 1.days(), + ) + .display_failures(); + + assert_eq!( + failures, + [r"expected subject to be close to 2023-02-28 within 1d + but was: 2023-02-26 + expected: 2023-02-28 +"] + ); +} + +#[test] +fn naive_datetime_is_equal_to() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_equal_to(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + )); +} + +#[test] +fn naive_datetime_is_before() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_before(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 790).expect("invalid naive time value"), + )); +} + +#[test] +fn naive_datetime_is_after() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_after(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 788).expect("invalid naive time value"), + )); +} + +#[test] +fn naive_datetime_is_between() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_between( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 788).expect("invalid naive time value"), + ), + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 790).expect("invalid naive time value"), + ), + ); +} + +#[test] +fn naive_datetime_is_close_to_within_milliseconds_exact_equal() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_close_to_with_margin( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ), + 10.milliseconds(), + ); +} + +#[test] +fn naive_datetime_is_close_to_within_milliseconds_plus_margin() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_close_to_with_margin( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 799).expect("invalid naive time value"), + ), + 10.milliseconds(), + ); +} + +#[test] +fn naive_datetime_is_close_to_within_milliseconds_minus_margin() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + assert_that(subject).is_close_to_with_margin( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 779).expect("invalid naive time value"), + ), + 10.milliseconds(), + ); +} + +#[test] +fn verify_naive_datetime_is_close_to_within_milliseconds_minus_margin_fails() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 778).expect("invalid naive time value"), + ), + 10.milliseconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [ + r"expected subject to be close to 2022-01-01T12:34:56.778 within 10ms + but was: 2022-01-01T12:34:56.789 + expected: 2022-01-01T12:34:56.778 +" + ] + ); +} + +#[test] +fn verify_naive_datetime_is_close_to_within_milliseconds_plus_margin_fails() { + let subject = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 789).expect("invalid naive time value"), + ); + + let failures = verify_that(subject) + .is_close_to_with_margin( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2022, 1, 1).expect("invalid naive date value"), + NaiveTime::from_hms_milli_opt(12, 34, 56, 800).expect("invalid naive time value"), + ), + 10.milliseconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [ + r"expected subject to be close to 2022-01-01T12:34:56.800 within 10ms + but was: 2022-01-01T12:34:56.789 + expected: 2022-01-01T12:34:56.800 +" + ] + ); +} diff --git a/src/lib.rs b/src/lib.rs index 816b6d7..248e162 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1120,6 +1120,9 @@ pub mod properties; #[cfg_attr(docsrs, doc(cfg(feature = "recursive")))] pub mod recursive_comparison; pub mod spec; +#[cfg(feature = "chrono")] +#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))] +pub mod temporal_margin; #[cfg(feature = "bigdecimal")] mod bigdecimal; @@ -1127,6 +1130,8 @@ mod boolean; mod c_string; mod char; mod char_count; +#[cfg(feature = "chrono")] +mod chrono; mod collection; #[cfg(all(feature = "std", feature = "colored"))] mod env; diff --git a/src/prelude.rs b/src/prelude.rs index c9590b1..cb3878c 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -43,3 +43,7 @@ pub use super::{ #[cfg(feature = "recursive")] #[cfg_attr(docsrs, doc(cfg(feature = "recursive")))] pub use super::value; + +#[cfg(feature = "chrono")] +#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))] +pub use super::temporal_margin::IntoTemporalMargin; diff --git a/src/temporal_margin/mod.rs b/src/temporal_margin/mod.rs new file mode 100644 index 0000000..ba63210 --- /dev/null +++ b/src/temporal_margin/mod.rs @@ -0,0 +1,194 @@ +//! Defines common data types used for asserting temporal types provided by the +//! supported crates. + +use crate::std::{ + fmt, + fmt::{Debug, Display}, +}; +use std::cmp::Ordering; + +/// Represents a temporal margin with various time units. +#[derive(Clone, Copy)] +pub enum TemporalMargin { + /// A temporal margin of nanoseconds. + NanoSeconds(i32), + /// A temporal margin of microseconds. + MicroSeconds(i32), + /// A temporal margin of milliseconds. + MilliSeconds(i32), + /// A temporal margin of seconds. + Seconds(i32), + /// A temporal margin of minutes. + Minutes(i32), + /// A temporal margin of hours. + Hours(i32), + /// A temporal margin of days. + Days(i32), + /// A temporal margin of weeks. + Weeks(i32), +} + +impl Default for TemporalMargin { + fn default() -> Self { + Self::MilliSeconds(0) + } +} + +impl Debug for TemporalMargin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} + +impl Display for TemporalMargin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NanoSeconds(nanoseconds) => write!(f, "{nanoseconds}ns"), + Self::MicroSeconds(microseconds) => write!(f, "{microseconds}µs"), + Self::MilliSeconds(milliseconds) => write!(f, "{milliseconds}ms"), + Self::Seconds(seconds) => write!(f, "{seconds}s"), + Self::Minutes(minutes) => write!(f, "{minutes}m"), + Self::Hours(hours) => write!(f, "{hours}h"), + Self::Days(days) => write!(f, "{days}d"), + Self::Weeks(weeks) => write!(f, "{weeks}w"), + } + } +} + +impl PartialEq for TemporalMargin { + fn eq(&self, other: &Self) -> bool { + self.to_nanoseconds() == other.to_nanoseconds() + } +} + +impl Eq for TemporalMargin {} + +impl PartialOrd for TemporalMargin { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TemporalMargin { + fn cmp(&self, other: &Self) -> Ordering { + self.to_nanoseconds().cmp(&other.to_nanoseconds()) + } +} + +impl TemporalMargin { + fn to_nanoseconds(self) -> i64 { + match self { + Self::NanoSeconds(nanos) => i64::from(nanos), + Self::MicroSeconds(micros) => i64::from(micros) * 1000, + Self::MilliSeconds(millis) => i64::from(millis) * 1000 * 1000, + Self::Seconds(secs) => i64::from(secs) * 1000 * 1000 * 1000, + Self::Minutes(mins) => i64::from(mins) * 60 * 1000 * 1000 * 1000, + Self::Hours(hours) => i64::from(hours) * 60 * 60 * 1000 * 1000 * 1000, + Self::Days(days) => i64::from(days) * 24 * 60 * 60 * 1000 * 1000 * 1000, + Self::Weeks(weeks) => i64::from(weeks) * 7 * 24 * 60 * 60 * 1000 * 1000 * 1000, + } + } +} + +/// An extension trait for constructing [`TemporalMargin`]s from numeric values +/// using a postfix notation. +/// +/// # Examples +/// +/// ``` +/// use asserting::prelude::*; +/// use asserting::temporal_margin::TemporalMargin; +/// +/// assert_eq!(10.milliseconds(), TemporalMargin::MilliSeconds(10)); +/// assert_eq!(22.seconds(), TemporalMargin::Seconds(22)); +/// assert_eq!(60.minutes(), TemporalMargin::Minutes(60)); +/// assert_eq!(5.days(), TemporalMargin::Days(5)); +/// ``` +pub trait IntoTemporalMargin { + /// Constructs a [`TemporalMargin`] of nanoseconds. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn nanoseconds(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of microseconds. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn microseconds(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of milliseconds. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn milliseconds(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of seconds. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn seconds(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of minutes. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn minutes(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of hours. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn hours(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of days. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn days(self) -> TemporalMargin; + /// Constructs a [`TemporalMargin`] of weeks. + /// + /// # Panics + /// + /// An implementation may panic if the numeric value cannot be converted + /// to an `i64`. + fn weeks(self) -> TemporalMargin; +} + +impl IntoTemporalMargin for i32 { + fn nanoseconds(self) -> TemporalMargin { + TemporalMargin::NanoSeconds(self) + } + fn microseconds(self) -> TemporalMargin { + TemporalMargin::MicroSeconds(self) + } + fn milliseconds(self) -> TemporalMargin { + TemporalMargin::MilliSeconds(self) + } + fn seconds(self) -> TemporalMargin { + TemporalMargin::Seconds(self) + } + fn minutes(self) -> TemporalMargin { + TemporalMargin::Minutes(self) + } + fn hours(self) -> TemporalMargin { + TemporalMargin::Hours(self) + } + fn days(self) -> TemporalMargin { + TemporalMargin::Days(self) + } + fn weeks(self) -> TemporalMargin { + TemporalMargin::Weeks(self) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/temporal_margin/tests.rs b/src/temporal_margin/tests.rs new file mode 100644 index 0000000..7ae2944 --- /dev/null +++ b/src/temporal_margin/tests.rs @@ -0,0 +1,180 @@ +use super::*; + +#[test] +fn the_default_temporal_margin_is_zero_seconds() { + assert_eq!(TemporalMargin::default(), TemporalMargin::Seconds(0)); +} + +#[test] +fn debug_string_of_999_nanoseconds() { + assert_eq!(format!("{:?}", TemporalMargin::NanoSeconds(999)), "999ns"); +} + +#[test] +fn debug_string_of_999_microseconds() { + assert_eq!(format!("{:?}", TemporalMargin::MicroSeconds(999)), "999µs"); +} + +#[test] +fn debug_string_of_999_milliseconds() { + assert_eq!(format!("{:?}", TemporalMargin::MilliSeconds(999)), "999ms"); +} + +#[test] +fn debug_string_of_59_seconds() { + assert_eq!(format!("{:?}", TemporalMargin::Seconds(59)), "59s"); +} + +#[test] +fn debug_string_of_59_minutes() { + assert_eq!(format!("{:?}", TemporalMargin::Minutes(59)), "59m"); +} + +#[test] +fn debug_string_of_23_hours() { + assert_eq!(format!("{:?}", TemporalMargin::Hours(23)), "23h"); +} + +#[test] +fn debug_string_of_365_days() { + assert_eq!(format!("{:?}", TemporalMargin::Days(365)), "365d"); +} + +#[test] +fn debug_string_of_52_weeks() { + assert_eq!(format!("{:?}", TemporalMargin::Weeks(52)), "52w"); +} + +#[test] +fn display_string_of_999_nanoseconds() { + assert_eq!(TemporalMargin::NanoSeconds(999).to_string(), "999ns"); +} + +#[test] +fn display_string_of_999_microseconds() { + assert_eq!(TemporalMargin::MicroSeconds(999).to_string(), "999µs"); +} + +#[test] +fn display_string_of_999_milliseconds() { + assert_eq!(TemporalMargin::MilliSeconds(999).to_string(), "999ms"); +} + +#[test] +fn display_string_of_59_seconds() { + assert_eq!(TemporalMargin::Seconds(59).to_string(), "59s"); +} + +#[test] +fn display_string_of_59_minutes() { + assert_eq!(TemporalMargin::Minutes(59).to_string(), "59m"); +} + +#[test] +fn display_string_of_23_hours() { + assert_eq!(TemporalMargin::Hours(23).to_string(), "23h"); +} + +#[test] +fn display_string_of_365_days() { + assert_eq!(TemporalMargin::Days(365).to_string(), "365d"); +} + +#[test] +fn display_string_of_52_weeks() { + assert_eq!(TemporalMargin::Weeks(52).to_string(), "52w"); +} + +#[test] +fn temporal_margin_of_999ns_to_nanoseconds() { + let nanoseconds = TemporalMargin::NanoSeconds(999); + assert_eq!(nanoseconds.to_nanoseconds(), 999); +} + +#[test] +fn temporal_margin_of_999us_to_nanoseconds() { + let nanoseconds = TemporalMargin::MicroSeconds(999); + assert_eq!(nanoseconds.to_nanoseconds(), 999_000); +} + +#[test] +fn temporal_margin_of_999ms_to_nanoseconds() { + let nanoseconds = TemporalMargin::MilliSeconds(999); + assert_eq!(nanoseconds.to_nanoseconds(), 999_000_000); +} + +#[test] +fn temporal_margin_of_59s_to_nanoseconds() { + let nanoseconds = TemporalMargin::Seconds(59); + assert_eq!(nanoseconds.to_nanoseconds(), 59 * 1_000_000_000); +} + +#[test] +fn temporal_margin_of_59m_to_nanoseconds() { + let nanoseconds = TemporalMargin::Minutes(59); + assert_eq!(nanoseconds.to_nanoseconds(), 59 * 60 * 1_000_000_000); +} + +#[test] +fn temporal_margin_of_24h_to_nanoseconds() { + let nanoseconds = TemporalMargin::Hours(24); + assert_eq!(nanoseconds.to_nanoseconds(), 24 * 60 * 60 * 1_000_000_000); +} + +#[test] +fn temporal_margin_of_366d_to_nanoseconds() { + let nanoseconds = TemporalMargin::Days(366); + assert_eq!( + nanoseconds.to_nanoseconds(), + 366 * 24 * 60 * 60 * 1_000_000_000 + ); +} + +#[test] +fn temporal_margin_of_52w_nanoseconds() { + let nanoseconds = TemporalMargin::Weeks(52); + assert_eq!( + nanoseconds.to_nanoseconds(), + 52 * 7 * 24 * 60 * 60 * 1_000_000_000 + ); +} + +#[test] +fn construct_temporal_margin_of_nanoseconds_from_i32() { + assert_eq!(999_i32.nanoseconds(), TemporalMargin::NanoSeconds(999)); +} + +#[test] +fn construct_temporal_margin_of_microseconds_from_i32() { + assert_eq!(999_i32.microseconds(), TemporalMargin::MicroSeconds(999)); +} + +#[test] +fn construct_temporal_margin_of_milliseconds_from_i32() { + assert_eq!(999_i32.milliseconds(), TemporalMargin::MilliSeconds(999)); +} + +#[test] +fn construct_temporal_margin_of_seconds_from_i32() { + assert_eq!(60_i32.seconds(), TemporalMargin::Seconds(60)); +} + +#[test] +fn construct_temporal_margin_of_minutes_from_i32() { + assert_eq!(60_i32.minutes(), TemporalMargin::Minutes(60)); +} + +#[test] +fn construct_temporal_margin_of_hours_from_i32() { + assert_eq!(24_i32.hours(), TemporalMargin::Hours(24)); +} + +#[test] +fn construct_temporal_margin_of_days_from_i32() { + assert_eq!(365_i32.days(), TemporalMargin::Days(365)); +} + +#[test] +fn construct_temporal_margin_of_weeks_from_i32() { + assert_eq!(52_i32.weeks(), TemporalMargin::Weeks(52)); +} diff --git a/tests/version_numbers.rs b/tests/version_numbers.rs index 247942e..36f5a67 100644 --- a/tests/version_numbers.rs +++ b/tests/version_numbers.rs @@ -1,38 +1,6 @@ //! Check the version number of this crate specified in the crate root and the //! README. - -// workaround for false positive 'unused extern crate' warnings until -// Rust issue [#95513](https://github.com/rust-lang/rust/issues/95513) is fixed -mod dummy_extern_uses { - use anyhow as _; - use asserting as _; - #[cfg(feature = "bigdecimal")] - use bigdecimal as _; - use fakeenv as _; - #[cfg(feature = "float-cmp")] - use float_cmp as _; - use hashbrown as _; - #[cfg(feature = "recursive")] - use indexmap as _; - #[cfg(feature = "num-bigint")] - use num_bigint as _; - #[cfg(any(feature = "bigdecimal", feature = "num-bigint"))] - use once_cell as _; - use proptest as _; - #[cfg(feature = "recursive")] - use rapidhash as _; - #[cfg(feature = "regex")] - use regex as _; - #[cfg(feature = "rust-decimal")] - use rust_decimal as _; - #[cfg(feature = "colored")] - use sdiff as _; - use serde as _; - use serde_bytes as _; - #[cfg(feature = "recursive")] - use serde_core as _; - use time as _; -} +#![allow(unused_crate_dependencies)] #[test] fn test_readme_deps() { From 7f0e114398307d8bc67acf3ffe190ce73dc0f0ca Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 27 Sep 2026 11:10:51 +0200 Subject: [PATCH 2/4] feat(chrono-assertions): implement `is_close_to_with_margin` for chrono's `NaiveDate`, `NaiveTime` and `NaiveDateTime` --- src/temporal_margin/tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/temporal_margin/tests.rs b/src/temporal_margin/tests.rs index 7ae2944..a325f87 100644 --- a/src/temporal_margin/tests.rs +++ b/src/temporal_margin/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::prelude::*; #[test] fn the_default_temporal_margin_is_zero_seconds() { @@ -178,3 +179,23 @@ fn construct_temporal_margin_of_days_from_i32() { fn construct_temporal_margin_of_weeks_from_i32() { assert_eq!(52_i32.weeks(), TemporalMargin::Weeks(52)); } + +#[test] +fn compare_temporal_margin_23h_to_1d() { + assert_that(23.hours()).is_less_than(1.days()); +} + +#[test] +fn compare_temporal_margin_2000us_to_1ms() { + assert_that(2000.microseconds()).is_greater_than(1.milliseconds()); +} + +#[test] +fn compare_temporal_margin_14d_to_2w() { + assert_that(14.days()).is_equal_to(2.weeks()); +} + +#[test] +fn compare_temporal_margin_2h_to_121m() { + assert_that(2.hours()).is_less_than(121.minutes()); +} From 7c29e099160c803090e42f8655ba835f67425e08 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 27 Sep 2026 12:34:03 +0200 Subject: [PATCH 3/4] feat(chrono-assertions): implement `is_close_to_with_margin` for chrono's `DateTime` --- src/chrono/mod.rs | 64 ++++++++++++++++++- src/chrono/tests.rs | 145 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/src/chrono/mod.rs b/src/chrono/mod.rs index 79f3d60..cd8ab55 100644 --- a/src/chrono/mod.rs +++ b/src/chrono/mod.rs @@ -6,7 +6,7 @@ use crate::spec::{ Represented, Spec, }; use crate::temporal_margin::TemporalMargin; -use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeDelta}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, TimeZone}; impl TemporalMargin { /// Converts this temporal margin to a [`TimeDelta`] value. @@ -184,5 +184,67 @@ where impl Invertible for IsCloseTo {} +impl AssertIsCloseToWithinMargin, TemporalMargin> + for Spec<'_, DateTime, D, R> +where + TZ1: TimeZone, + TZ2: TimeZone, + D: Represent> + Represent> + Represent, + R: FailingStrategy, +{ + fn is_close_to_with_margin( + self, + expected: DateTime, + margin: impl Into, + ) -> Self { + self.expecting(is_close_to(expected).within_margin(margin)) + } + + fn is_not_close_to_with_margin( + self, + expected: DateTime, + margin: impl Into, + ) -> Self { + self.expecting(not(is_close_to(expected).within_margin(margin))) + } +} + +impl Expectation, D> for IsCloseTo, TemporalMargin> +where + TZ1: TimeZone, + TZ2: TimeZone, + D: Represent> + Represent> + Represent, +{ + fn test(&mut self, subject: &DateTime) -> bool { + subject + .naive_utc() + .signed_duration_since(self.expected.naive_utc()) + .abs() + <= self.margin.to_time_delta() + } + + fn message( + &self, + expression: &Expression<'_>, + actual: &DateTime, + inverted: bool, + representation: &D, + format: &DiffFormat, + ) -> String { + let not = if inverted { "not " } else { "" }; + let (marked_actual, marked_expected) = + mark_diff(actual, &self.expected, representation, format); + let represented_expected = Represented::from((&self.expected, representation)); + let represented_margin = Represented::from((&self.margin, representation)); + format!( + r"expected {expression} to be {not}close to {represented_expected:?} within {represented_margin} + but was: {marked_actual} + expected: {marked_expected}" + ) + } +} + +impl Invertible for IsCloseTo, TemporalMargin> where TZ: TimeZone {} + #[cfg(test)] mod tests; diff --git a/src/chrono/tests.rs b/src/chrono/tests.rs index cced496..d50ff0e 100644 --- a/src/chrono/tests.rs +++ b/src/chrono/tests.rs @@ -1,7 +1,7 @@ #![allow(clippy::expect_used)] use crate::prelude::*; -use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use chrono::{FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; #[test] fn naive_time_is_equal_to() { @@ -375,3 +375,146 @@ fn verify_naive_datetime_is_close_to_within_milliseconds_plus_margin_fails() { ] ); } + +#[test] +fn datetime_utc_is_equal_to() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_equal_to(Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap()); +} + +#[test] +fn datetime_utc_is_before() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_before(Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 57).unwrap()); +} + +#[test] +fn datetime_utc_is_after() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_after(Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 55).unwrap()); +} + +#[test] +fn datetime_utc_is_between() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_between( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + ); +} + +#[test] +fn datetime_utc_is_close_to_within_milliseconds_exact_equal() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + 3.seconds(), + ); +} + +#[test] +fn datetime_utc_is_close_to_within_milliseconds_plus_margin() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + 3.seconds(), + ); +} + +#[test] +fn datetime_utc_is_close_to_within_milliseconds_minus_margin() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + assert_that(subject).is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + 3.seconds(), + ); +} + +#[test] +fn verify_datetime_utc_is_close_to_within_seconds_minus_margin_fails() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + let failures = verify_that(subject) + .is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 52).unwrap(), + 3.seconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [ + r"expected subject to be close to 2022-01-01T12:34:52Z within 3s + but was: 2022-01-01T12:34:56Z + expected: 2022-01-01T12:34:52Z +" + ] + ); +} + +#[test] +fn verify_datetime_utc_is_close_to_within_seconds_plus_margin_fails() { + let subject = Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(); + + let failures = verify_that(subject) + .is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 35, 00).unwrap(), + 3.seconds(), + ) + .display_failures(); + + assert_eq!( + failures, + [ + r"expected subject to be close to 2022-01-01T12:35:00Z within 3s + but was: 2022-01-01T12:34:56Z + expected: 2022-01-01T12:35:00Z +" + ] + ); +} + +#[test] +fn datetime_fixedoffset_is_close_to_within_minutes_a_datetime_utc() { + let subject = FixedOffset::east_opt(3600) + .expect("invalid fixed offset value") + .with_ymd_and_hms(2022, 1, 1, 12, 34, 56) + .unwrap(); + + assert_that(subject).is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + 60.minutes(), + ); +} + +#[test] +fn verify_datetime_fixedoffset_is_close_to_within_minutes_a_datetime_utc_fails() { + let subject = FixedOffset::east_opt(3600) + .expect("invalid fixed offset value") + .with_ymd_and_hms(2022, 1, 1, 12, 34, 56) + .unwrap(); + + let failures = verify_that(subject) + .is_close_to_with_margin( + Utc.with_ymd_and_hms(2022, 1, 1, 12, 34, 56).unwrap(), + 59.minutes(), + ) + .display_failures(); + + assert_eq!( + failures, + [ + r"expected subject to be close to 2022-01-01T12:34:56Z within 59m + but was: 2022-01-01T12:34:56+01:00 + expected: 2022-01-01T12:34:56Z +" + ] + ); +} From 2e13d553ceded28457c5946a5f133b5a2cdc1df7 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 27 Sep 2026 13:25:52 +0200 Subject: [PATCH 4/4] feat(chrono-assertions): activate chrono/std feature when the feature "std" is activated for the asserting crate --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index f4ebc7b..44b72c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ panic = ["std"] regex = ["dep:regex"] std = [ "bigdecimal?/std", + "chrono?/std", "float-cmp?/std", "indexmap?/std", "num-bigint?/std",