Skip to content

Commit

Permalink
Added tests will_adjust_start_time_to_avoid_dst_transition and will_a…
Browse files Browse the repository at this point in the history
…djust_end_time_to_avoid_dst_transition to DateTest.cs.

Added method ComputeRealRange to DataSets/Date.cs, along with helper methods GetForwardDSTTransitionWindow and FindEffectiveTimeZoneAdjustmentRule, to find the windows of time values that don't correspond to real times due to DST transition and adjust the start & end times of the requested time interval to avoid them.
Rewrote BetweenOffset in DataSets/Date.cs to leverage Between and tack the offset on afterward, so that the avoidance logic need only be in one place.
  • Loading branch information
logiclrd committed Apr 1, 2021
1 parent b3f99bf commit 4660f9c
Show file tree
Hide file tree
Showing 2 changed files with 209 additions and 11 deletions.
66 changes: 66 additions & 0 deletions Source/Bogus.Tests/DataSetTests/DateTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 143 additions & 11 deletions Source/Bogus/DataSets/Date.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;

namespace Bogus.DataSets
{
Expand Down Expand Up @@ -128,6 +129,8 @@ public DateTimeOffset FutureOffset(int yearsToGoForward = 1, DateTimeOffset? ref
/// <param name="end">End time</param>
public DateTime Between(DateTime start, DateTime end)
{
ComputeRealRange(ref start, ref end);

var startTicks = start.ToUniversalTime().Ticks;
var endTicks = end.ToUniversalTime().Ticks;

Expand All @@ -146,26 +149,155 @@ public DateTime Between(DateTime start, DateTime end)
return value;
}

/// <summary>
/// Takes a date/time range, as indicated by <paramref name="start"/> and <paramref name="end"/>,
/// and ensures that the range indicators are in the correct order and both reference actual
/// <see cref="DateTime"/> 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
/// <see cref="DateTime"/> values in this change are not meaningful.
///
/// This function only worries about the start and end times. Impossible <see cref="DateTime"/>
/// values within the range are excluded automatically by means of the <see cref="DateTime.ToLocalTime"/>
/// 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.
/// </summary>
/// <param name="start">A ref <see cref="DateTime"/> to be adjusted forward out of an impossible date/time range if necessary.</param>
/// <param name="end">A ref <see cref="DateTime"/> to be adjusted backward out of an impossible date/time range if necessary.</param>
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;
}

/// <summary>
/// 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 <see cref="null"/>.
/// </summary>
/// <param name="dateTime">
/// A reference <see cref="DateTime"/> value for determining the DST transition window accurately. Daylight Savings Time
/// rules can change over time, and the <see cref="TimeZoneInfo"/> API exposes information about which Daylight Savings
/// Time rules are in effect for which date ranges.
/// </param>
/// <returns>
/// A <see cref="DateTimeRange"/> that indicates the start &amp; end of the interval of date/time values that do not
/// exist in the local calendar in the interval indicated by the supplied <paramref name="dateTime"/>, or <see cref="null"/>
/// if no such range exists.
/// </returns>
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,
};
}

/// <summary>
/// Identifies the timezone adjustment rule in effect in the local timezone at the specified
/// <paramref name="dateTime"/>. If no adjustment rule is in effect, returns <see cref="null"/>.
/// </summary>
/// <param name="dateTime">The <see cref="DateTime"/> value for which to find an adjustment rule.</param>
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

/// <summary>
/// Get a random <see cref="DateTimeOffset"/> between <paramref name="start"/> and <paramref name="end"/>.
/// </summary>
/// <param name="start">Start time - The returned <seealso cref="DateTimeOffset"/> offset value is used from this parameter</param>
/// <param name="end">End time</param>
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);
}

/// <summary>
Expand Down

0 comments on commit 4660f9c

Please sign in to comment.