diff --git a/Src/FluentAssertions/Primitives/TimeOnlyAssertions.cs b/Src/FluentAssertions/Primitives/TimeOnlyAssertions.cs index 1a552dc901..aa34829871 100644 --- a/Src/FluentAssertions/Primitives/TimeOnlyAssertions.cs +++ b/Src/FluentAssertions/Primitives/TimeOnlyAssertions.cs @@ -127,6 +127,59 @@ public AndConstraint Be(TimeOnly? expected, string because = "", pa return new AndConstraint((TAssertions)this); } + /// + /// Asserts that the current is within the specified time + /// from the specified value. + /// + /// + /// Use this assertion when, for example the database truncates datetimes to nearest 20ms. If you want to assert to the exact datetime, + /// use . + /// + /// + /// The expected time to compare the actual value with. + /// + /// + /// The maximum amount of time which the two values may differ. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndConstraint BeCloseTo(TimeOnly nearbyTime, TimeSpan precision, string because = "", + params object[] becauseArgs) + { + if (precision < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(precision), $"The value of {nameof(precision)} must be non-negative."); + } + + long distanceToMinInTicks = (nearbyTime - TimeOnly.MinValue).Ticks; + TimeOnly minimumValue = nearbyTime.Add(-TimeSpan.FromTicks(Math.Min(precision.Ticks, distanceToMinInTicks))); + + long distanceToMaxInTicks = (TimeOnly.MaxValue - nearbyTime).Ticks; + TimeOnly maximumValue = nearbyTime.Add(TimeSpan.FromTicks(Math.Min(precision.Ticks, distanceToMaxInTicks))); + + TimeSpan? difference = (Subject >= nearbyTime + ? Subject - nearbyTime + : nearbyTime - Subject)?.Duration(); + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the time} to be within {0} from {1}{reason}", precision, nearbyTime) + .ForCondition(Subject is not null) + .FailWith(", but found .") + .Then + .ForCondition((Subject >= minimumValue) && (Subject <= maximumValue)) + .FailWith(", but {0} was off by {1}.", Subject, difference) + .Then + .ClearExpectation(); + + return new AndConstraint((TAssertions)this); + } + /// /// Asserts that the current is before the specified value. /// diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index 074b41c059..5086cb5d72 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -2308,6 +2308,7 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint Be(System.TimeOnly? expected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint BeAfter(System.TimeOnly expected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint BeBefore(System.TimeOnly expected, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint BeCloseTo(System.TimeOnly nearbyTime, System.TimeSpan precision, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint BeOnOrAfter(System.TimeOnly expected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint BeOnOrBefore(System.TimeOnly expected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint BeOneOf(params System.Nullable[] validValues) { } diff --git a/Tests/FluentAssertions.Specs/Primitives/TimeOnlyAssertionSpecs.cs b/Tests/FluentAssertions.Specs/Primitives/TimeOnlyAssertionSpecs.cs index 5004451aae..797fd4edaa 100644 --- a/Tests/FluentAssertions.Specs/Primitives/TimeOnlyAssertionSpecs.cs +++ b/Tests/FluentAssertions.Specs/Primitives/TimeOnlyAssertionSpecs.cs @@ -1,4 +1,5 @@ using System; +using FluentAssertions.Extensions; using Xunit; using Xunit.Sdk; @@ -170,6 +171,141 @@ public void Should_succeed_when_asserting_timeonly_value_is_not_equal_to_a_diffe } } + public class BeCloseTo + { + [Fact] + public void When_asserting_that_time_is_close_to_a_negative_precision_it_should_throw() + { + // Arrange + var dateTime = TimeOnly.FromDateTime(DateTime.UtcNow); + var actual = new TimeOnly(dateTime.Ticks - 1); + + // Act + Action act = () => actual.Should().BeCloseTo(dateTime, -1.Ticks()); + + // Assert + act.Should().Throw() + .WithMessage("* value of precision must be non-negative*"); + } + + [Fact] + public void When_a_time_is_close_to_a_later_time_by_one_tick_it_should_succeed() + { + // Arrange + var dateTime = TimeOnly.FromDateTime(DateTime.UtcNow); + var actual = new TimeOnly(dateTime.Ticks - 1); + + // Act + Action act = () => actual.Should().BeCloseTo(dateTime, TimeSpan.FromTicks(1)); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_a_time_is_close_to_an_earlier_time_by_one_tick_it_should_succeed() + { + // Arrange + var dateTime = TimeOnly.FromDateTime(DateTime.UtcNow); + var actual = new TimeOnly(dateTime.Ticks - 1); + + // Act + Action act = () => actual.Should().BeCloseTo(dateTime, TimeSpan.FromTicks(1)); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_asserting_subject_time_is_close_to_the_minimum_time_it_should_succeed() + { + // Arrange + TimeOnly time = TimeOnly.MinValue.Add(50.Milliseconds()); + TimeOnly nearbyTime = TimeOnly.MinValue; + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 100.Milliseconds()); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_asserting_subject_time_is_close_to_the_maximum_time_it_should_succeed() + { + // Arrange + TimeOnly time = TimeOnly.MaxValue.Add(-50.Milliseconds()); + TimeOnly nearbyTime = TimeOnly.MaxValue; + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 100.Milliseconds()); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_asserting_subject_time_is_close_to_another_value_that_is_later_by_more_than_20ms_it_should_throw() + { + // Arrange + TimeOnly time = new TimeOnly(12, 15, 30, 979); + TimeOnly nearbyTime = new TimeOnly(12, 15, 31); + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 20.Milliseconds()); + + // Assert + act.Should().Throw() + .WithMessage( + "Expected time to be within 20ms from <12:15:31.000>, but <12:15:30.979> was off by 21ms."); + } + + [Fact] + public void When_asserting_subject_time_is_close_to_another_value_that_is_earlier_by_more_than_20ms_it_should_throw() + { + // Arrange + TimeOnly time = new TimeOnly(12, 15, 31, 021); + TimeOnly nearbyTime = new TimeOnly(12, 15, 31); + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 20.Milliseconds()); + + // Assert + act.Should().Throw() + .WithMessage( + "Expected time to be within 20ms from <12:15:31.000>, but <12:15:31.021> was off by 21ms."); + } + + [Fact] + public void When_asserting_subject_time_is_close_to_an_earlier_time_by_35ms_it_should_succeed() + { + // Arrange + TimeOnly time = new TimeOnly(12, 15, 31, 035); + TimeOnly nearbyTime = new TimeOnly(12, 15, 31); + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 35.Milliseconds()); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_asserting_subject_nulltime_is_close_to_another_it_should_throw() + { + // Arrange + TimeOnly? time = null; + TimeOnly nearbyTime = new TimeOnly(12, 15, 31); + + // Act + Action act = () => time.Should().BeCloseTo(nearbyTime, 35.Milliseconds()); + + // Assert + act.Should().Throw() + .WithMessage("Expected*, but found ."); + } + } + public class BeBefore { [Fact]