diff --git a/Source/Bogus.Tests/DataSetTests/DateTest.cs b/Source/Bogus.Tests/DataSetTests/DateTest.cs index d2ecd90b..0bffc883 100644 --- a/Source/Bogus.Tests/DataSetTests/DateTest.cs +++ b/Source/Bogus.Tests/DataSetTests/DateTest.cs @@ -394,6 +394,72 @@ public void will_not_generate_values_that_do_not_exist_due_to_daylight_savings() 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 + 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 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 + for (int i = 0; i < 10000; i++) + { + var sample = faker.Date.Between(windowStart, windowEnd); + + sample.Should().BeOnOrAfter(windowStart); + sample.Should().BeOnOrBefore(transitionStartTime); + } + } + 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 diff --git a/Source/Bogus/DataSets/Date.cs b/Source/Bogus/DataSets/Date.cs index 4862eda5..d5997b0b 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 { @@ -128,6 +129,8 @@ public DateTimeOffset FutureOffset(int yearsToGoForward = 1, DateTimeOffset? ref /// End time public DateTime Between(DateTime start, DateTime end) { + ComputeRealRange(ref start, ref end); + var startTicks = start.ToUniversalTime().Ticks; var endTicks = end.ToUniversalTime().Ticks; @@ -146,6 +149,142 @@ public DateTime Between(DateTime start, DateTime 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. + /// + /// This function does not check Daylight Savings Time transitions when running under .NET Standard 1.3, + /// 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. + private void ComputeRealRange(ref DateTime start, ref DateTime end) + { + if (start > end) + { + var tmp = start; + + start = end; + end = tmp; + } + +#if !NETSTANDARD1_3 + var window = GetForwardDSTTransitionWindow(start); + + if ((start > window.Start) && (start <= window.End)) + start = new DateTime(window.End.Ticks, start.Kind); + + window = GetForwardDSTTransitionWindow(end); + + if ((end >= window.Start) && (end < window.End)) + end = new DateTime(window.Start.Ticks, end.Kind); + + 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 . /// @@ -153,19 +292,12 @@ public DateTime Between(DateTime start, DateTime end) /// End time public DateTimeOffset BetweenOffset(DateTimeOffset start, DateTimeOffset end) { - 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; - - var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks); + var startTime = new DateTime(start.DateTime.Ticks, DateTimeKind.Utc); + var endTime = new DateTime(end.DateTime.Ticks, DateTimeKind.Utc); - var dateTime = new DateTime(minTicks, DateTimeKind.Unspecified) + partTimeSpan; + var sample = Between(startTime, endTime); - return new DateTimeOffset(dateTime + start.Offset, start.Offset); + return new DateTimeOffset(new DateTime(sample.Ticks, DateTimeKind.Unspecified), start.Offset); } ///