diff --git a/Source/Bogus.Tests/DataSetTests/DateTest.cs b/Source/Bogus.Tests/DataSetTests/DateTest.cs index 076c69b7..7acf26ef 100644 --- a/Source/Bogus.Tests/DataSetTests/DateTest.cs +++ b/Source/Bogus.Tests/DataSetTests/DateTest.cs @@ -1,16 +1,23 @@ using System; using System.Globalization; +using System.Linq; using Bogus.DataSets; using FluentAssertions; +using FluentAssertions.Execution; using Xunit; +using Xunit.Abstractions; namespace Bogus.Tests.DataSetTests { public partial class DateTest : SeededTest { - public DateTest() + private readonly ITestOutputHelper _testOutput; + + public DateTest(ITestOutputHelper testOutput) { date = new Date(); + + _testOutput = testOutput; } private readonly Date date; @@ -352,5 +359,285 @@ public void can_get_timezone_string() { date.TimeZoneString().Should().Be("Asia/Yerevan"); } + + public class FactWhenDaylightSavingsSupported : FactAttribute + { + public FactWhenDaylightSavingsSupported() + { + if (!TimeZoneInfo.Local.SupportsDaylightSavingTime) + { + Skip = "Test is only meaningful when Daylight Savings is supported by the local timezone."; + } + } + } + + [FactWhenDaylightSavingsSupported] + public void will_not_generate_values_that_do_not_exist_due_to_daylight_savings() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + + // When converting back, .NET picks the end of the transition window instead of the start. + var transitionEndTime = transitionStartTime.ToUniversalTime().ToLocalTime(); + + // Act + var value = faker.Date.Between(transitionStartTime.AddHours(-1), transitionEndTime.AddHours(+2)); + + // Assert + using (new AssertionScope()) + { + transitionEndTime.Should().NotBe(transitionStartTime); + + if ((value >= transitionStartTime) && (value < transitionStartTime.AddHours(1))) + value.Should().NotBeBefore(transitionEndTime); + } + } + + [FactWhenDaylightSavingsSupported] + public void will_adjust_start_time_to_avoid_dst_transition() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + var windowStart = transitionStartTime + TimeSpan.FromTicks((transitionEndTime - transitionStartTime).Ticks / 2); + var windowEnd = transitionEndTime.AddMinutes(30); + + // Act & Assert + using (new AssertionScope()) + { + bool haveSampleThatIsNotWindowEnd = false; + + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(windowStart, windowEnd); + + sample.Should().BeOnOrAfter(transitionEndTime); + sample.Should().BeOnOrBefore(windowEnd); + + haveSampleThatIsNotWindowEnd = (sample < windowEnd); + } + + haveSampleThatIsNotWindowEnd.Should().BeTrue(because: $"the effective range should include values other than {nameof(windowEnd)}"); + } + } + + [FactWhenDaylightSavingsSupported] + public void will_adjust_end_time_to_avoid_dst_transition() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + var windowStart = transitionStartTime.AddMinutes(-30); + var windowEnd = transitionStartTime + TimeSpan.FromTicks((transitionEndTime - transitionStartTime).Ticks / 2); + + // Act & Assert + using (new AssertionScope()) + { + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(windowStart, windowEnd); + + sample.Should().BeOnOrAfter(windowStart); + sample.Should().BeOnOrBefore(transitionStartTime); + } + } + } + + [FactWhenDaylightSavingsSupported] + public void works_when_range_is_exactly_daylight_savings_transition_window() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + // Act & Assert + using (new AssertionScope()) + { + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(transitionStartTime, transitionEndTime); + + sample.Should().BeOneOf(transitionStartTime, transitionEndTime); + } + } + } + + [FactWhenDaylightSavingsSupported] + public void works_when_range_start_is_exactly_daylight_savings_transition_window_start() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + var windowStart = transitionStartTime; + var windowEnd = transitionEndTime.AddMinutes(-5); + + // Act & Assert + using (new AssertionScope()) + { + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(windowStart, windowEnd); + + sample.Should().Be(windowStart); + } + } + } + + [FactWhenDaylightSavingsSupported] + public void works_when_range_end_is_exactly_daylight_savings_transition_window_end() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + var windowStart = transitionStartTime.AddMinutes(5); + var windowEnd = transitionEndTime; + + // Act & Assert + using (new AssertionScope()) + { + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(windowStart, windowEnd); + + sample.Should().Be(windowEnd); + } + } + } + + [FactWhenDaylightSavingsSupported] + public void works_when_start_time_is_invalid_due_to_DST_change_window() + { + // Arrange + var faker = new Faker(); + + faker.Random = new Randomizer(localSeed: 5); + + var dstRules = TimeZoneInfo.Local.GetAdjustmentRules(); + + var now = DateTime.Now; + + var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now)); + + var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart); + var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta; + + var date1 = new DateTimeOffset(transitionEndTime.AddMinutes(-1), TimeZoneInfo.Local.BaseUtcOffset); + var date2 = new DateTimeOffset(transitionEndTime, TimeZoneInfo.Local.BaseUtcOffset + effectiveRule.DaylightDelta); + + // Act + var sample = faker.Date.BetweenOffset(date1, date2); + + // Assert + _testOutput.WriteLine("BetweenOffset result: {0}", sample); + + sample.Should().Be(transitionEndTime); + } + + private DateTime CalculateTransitionDateTime(DateTime now, TimeZoneInfo.TransitionTime transition) + { + // Based on code found at: https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.isfixeddaterule + + if (transition.IsFixedDateRule) + { + return new DateTime( + now.Year, + transition.Month, + transition.Day, + transition.TimeOfDay.Hour, + transition.TimeOfDay.Minute, + transition.TimeOfDay.Second, + transition.TimeOfDay.Millisecond, + DateTimeKind.Local); + } + + var calendar = CultureInfo.CurrentCulture.Calendar; + + var startOfWeek = transition.Week * 7 - 6; + + var firstDayOfWeek = (int)calendar.GetDayOfWeek(new DateTime(now.Year, transition.Month, 1)); + var changeDayOfWeek = (int)transition.DayOfWeek; + + int transitionDay = + firstDayOfWeek <= changeDayOfWeek + ? startOfWeek + changeDayOfWeek - firstDayOfWeek + : startOfWeek + changeDayOfWeek - firstDayOfWeek + 7; + + if (transitionDay > calendar.GetDaysInMonth(now.Year, transition.Month)) + transitionDay -= 7; + + return new DateTime( + now.Year, + transition.Month, + transitionDay, + transition.TimeOfDay.Hour, + transition.TimeOfDay.Minute, + transition.TimeOfDay.Second, + transition.TimeOfDay.Millisecond, + DateTimeKind.Local); + } } } \ No newline at end of file diff --git a/Source/Bogus/DataSets/Date.cs b/Source/Bogus/DataSets/Date.cs index 4bb1bb25..eec46f62 100644 --- a/Source/Bogus/DataSets/Date.cs +++ b/Source/Bogus/DataSets/Date.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace Bogus.DataSets { @@ -44,11 +45,7 @@ public DateTime Past(int yearsToGoBack = 1, DateTime? refDate = null) var minDate = maxDate.AddYears(-yearsToGoBack); - var totalTimeSpanTicks = (maxDate - minDate).Ticks; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - - return maxDate - partTimeSpan; + return Between(minDate, maxDate); } /// @@ -62,11 +59,7 @@ public DateTimeOffset PastOffset(int yearsToGoBack = 1, DateTimeOffset? refDate var minDate = maxDate.AddYears(-yearsToGoBack); - var totalTimeSpanTicks = (maxDate - minDate).Ticks; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - - return maxDate - partTimeSpan; + return BetweenOffset(minDate, maxDate); } /// @@ -112,11 +105,7 @@ public DateTime Future(int yearsToGoForward = 1, DateTime? refDate = null) var maxDate = minDate.AddYears(yearsToGoForward); - var totalTimeSpanTicks = (maxDate - minDate).Ticks; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - - return minDate + partTimeSpan; + return Between(minDate, maxDate); } /// @@ -130,11 +119,7 @@ public DateTimeOffset FutureOffset(int yearsToGoForward = 1, DateTimeOffset? ref var maxDate = minDate.AddYears(yearsToGoForward); - var totalTimeSpanTicks = (maxDate - minDate).Ticks; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - - return minDate + partTimeSpan; + return BetweenOffset(minDate, maxDate); } /// @@ -144,16 +129,196 @@ public DateTimeOffset FutureOffset(int yearsToGoForward = 1, DateTimeOffset? ref /// End time public DateTime Between(DateTime start, DateTime end) { - var minTicks = Math.Min(start.Ticks, end.Ticks); - var maxTicks = Math.Max(start.Ticks, end.Ticks); + ComputeRealRange(ref start, ref end, start.Kind, out var preferRangeBoundary); + + var startTicks = start.ToUniversalTime().Ticks; + var endTicks = end.ToUniversalTime().Ticks; + + var minTicks = Math.Min(startTicks, endTicks); + var maxTicks = Math.Max(startTicks, endTicks); var totalTimeSpanTicks = maxTicks - minTicks; + // Right around daylight savings time transition, there can be two different local DateTime values + // that are actually exactly the same DateTime. The ToLocalTime conversion might pick the wrong + // one in edge cases; it will pick the later one, and if the caller's window includes the earlier + // one, we should return that instead to follow the principle of least surprise. + if (totalTimeSpanTicks == 0) + return preferRangeBoundary; + var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - return new DateTime(minTicks, start.Kind) + partTimeSpan; + var value = new DateTime(minTicks, DateTimeKind.Utc) + partTimeSpan; + + if (start.Kind != DateTimeKind.Utc) + { + value = value.ToLocalTime(); + + if (value > end) + value = end; + } + + return value; + } + + /// + /// Takes a date/time range, as indicated by and , + /// and ensures that the range indicators are in the correct order and both reference actual + /// values. This takes into account the fact that when Daylight Savings Time + /// comes into effect, there is a 1-hour interval in the local calendar which does not exist, and + /// values in this change are not meaningful. + /// + /// This function only worries about the start and end times. Impossible + /// values within the range are excluded automatically by means of the + /// function. + /// + /// The version of this function built targeting .NET Standard 1.3 does not check Daylight Savings Time + /// transitions, as this API does not expose Daylight Savings Time information. + /// + /// A ref to be adjusted forward out of an impossible date/time range if necessary. + /// A ref to be adjusted backward out of an impossible date/time range if necessary. + /// A indicating how the supplied values should be interpreted with respect to DST change windows. + /// An out that indicates which value should be used if the supplied range is empty due to being entirely contained within a DST transition range. + private void ComputeRealRange(ref DateTime start, ref DateTime end, DateTimeKind kind, out DateTime preferRangeBoundary) + { + preferRangeBoundary = end; + + if (start > end) + { + var tmp = start; + + start = end; + end = tmp; + } + +#if !NETSTANDARD1_3 + if (kind == DateTimeKind.Local) + { + var window = GetForwardDSTTransitionWindow(start); + + var startLocal = start.ToLocalTime(); + var endLocal = end.ToLocalTime(); + + if ((startLocal >= window.Start) && (startLocal <= window.End)) + { + start = new DateTime(window.Start.Ticks, start.Kind); + + if (start == end) + return; + } + + window = GetForwardDSTTransitionWindow(end); + + if ((end >= window.Start) && (end < window.End)) + { + end = new DateTime(window.End.Ticks, end.Kind); + + // We had to bump the end, meaning that the end was not already a valid + // DateTime value (within the DST transition range), so prefer the start + // instead. + preferRangeBoundary = start; + } + + if (start > end) + throw new Exception("DateTime range does not contain any real DateTime values due to daylight savings transitions"); + } +#endif + } + +#if !NETSTANDARD1_3 + struct DateTimeRange + { + public DateTime Start; + public DateTime End; } + /// + /// Finds the window of time that doesn't exist in the local timezone due to Daylight Savings Time coming into + /// effect. In timezones that do not have Daylight Savings Time transitions, this function returns . + /// + /// + /// A reference value for determining the DST transition window accurately. Daylight Savings Time + /// rules can change over time, and the API exposes information about which Daylight Savings + /// Time rules are in effect for which date ranges. + /// + /// + /// A that indicates the start & end of the interval of date/time values that do not + /// exist in the local calendar in the interval indicated by the supplied , or + /// if no such range exists. + /// + private DateTimeRange GetForwardDSTTransitionWindow(DateTime dateTime) + { + // Based on code found at: https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.isfixeddaterule + var rule = FindEffectiveTimeZoneAdjustmentRule(dateTime); + + if (rule == null) + return default(DateTimeRange); + + var transition = rule.DaylightTransitionStart; + + DateTime startTime; + + if (transition.IsFixedDateRule) + { + startTime = new DateTime( + dateTime.Year, + transition.Month, + transition.Day, + transition.TimeOfDay.Hour, + transition.TimeOfDay.Minute, + transition.TimeOfDay.Second, + transition.TimeOfDay.Millisecond); + } + else + { + var calendar = CultureInfo.CurrentCulture.Calendar; + + var startOfWeek = transition.Week * 7 - 6; + + var firstDayOfWeek = (int)calendar.GetDayOfWeek(new DateTime(dateTime.Year, transition.Month, 1)); + var changeDayOfWeek = (int)transition.DayOfWeek; + + int transitionDay = + firstDayOfWeek <= changeDayOfWeek + ? startOfWeek + changeDayOfWeek - firstDayOfWeek + : startOfWeek + changeDayOfWeek - firstDayOfWeek + 7; + + if (transitionDay > calendar.GetDaysInMonth(dateTime.Year, transition.Month)) + transitionDay -= 7; + + startTime = new DateTime( + dateTime.Year, + transition.Month, + transitionDay, + transition.TimeOfDay.Hour, + transition.TimeOfDay.Minute, + transition.TimeOfDay.Second, + transition.TimeOfDay.Millisecond); + } + + return + new DateTimeRange() + { + Start = startTime, + End = startTime + rule.DaylightDelta, + }; + } + + /// + /// Identifies the timezone adjustment rule in effect in the local timezone at the specified + /// . If no adjustment rule is in effect, returns . + /// + /// The value for which to find an adjustment rule. + private TimeZoneInfo.AdjustmentRule FindEffectiveTimeZoneAdjustmentRule(DateTime dateTime) + { + foreach (var rule in TimeZoneInfo.Local.GetAdjustmentRules()) + if ((dateTime >= rule.DateStart) && (dateTime <= rule.DateEnd)) + return rule; + + return default; + } +#endif + /// /// Get a random between and . /// @@ -161,14 +326,24 @@ public DateTime Between(DateTime start, DateTime end) /// End time public DateTimeOffset BetweenOffset(DateTimeOffset start, DateTimeOffset end) { - var minTicks = Math.Min(start.Ticks, end.Ticks); - var maxTicks = Math.Max(start.Ticks, end.Ticks); + var startTime = start.ToUniversalTime().DateTime; + var endTime = end.ToUniversalTime().DateTime; - var totalTimeSpanTicks = maxTicks - minTicks; + if (startTime > endTime) + return end; + else + { + ComputeRealRange(ref startTime, ref endTime, start.DateTime.Kind, out var preferRangeBoundary); - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); + var sample = Between(startTime, endTime) + start.Offset; + + // In practice, we will only ever get samples that are exactly equal to the end time + // if the range is empty due to being contained within a DST transition window. + if (sample == end) + sample = preferRangeBoundary; - return new DateTimeOffset(minTicks, start.Offset) + partTimeSpan; + return new DateTimeOffset(new DateTime(sample.Ticks, DateTimeKind.Unspecified), start.Offset); + } } /// @@ -178,15 +353,13 @@ public DateTimeOffset BetweenOffset(DateTimeOffset start, DateTimeOffset end) /// The date to start calculations. Default is . public DateTime Recent(int days = 1, DateTime? refDate = null) { - var maxDate = refDate ?? SystemClock(); + var systemClock = SystemClock(); - var minDate = days == 0 ? SystemClock().Date : maxDate.AddDays(-days); + var maxDate = refDate ?? systemClock; - var totalTimeSpanTicks = (maxDate - minDate).Ticks; + var minDate = days == 0 ? systemClock.Date : maxDate.AddDays(-days); - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); - - return maxDate - partTimeSpan; + return Between(minDate, maxDate); } /// @@ -196,15 +369,13 @@ public DateTime Recent(int days = 1, DateTime? refDate = null) /// The date to start calculations. Default is . public DateTimeOffset RecentOffset(int days = 1, DateTimeOffset? refDate = null) { - var maxDate = refDate ?? SystemClock(); + var systemClock = SystemClock(); - var minDate = days == 0 ? SystemClock().Date : maxDate.AddDays(-days); + var maxDate = refDate ?? systemClock; - var totalTimeSpanTicks = (maxDate - minDate).Ticks; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); + var minDate = days == 0 ? systemClock.Date : maxDate.AddDays(-days); - return maxDate - partTimeSpan; + return BetweenOffset(minDate, maxDate); } ///