Skip to content

Commit 4660f9c

Browse files
committed
Added tests will_adjust_start_time_to_avoid_dst_transition and will_adjust_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.
1 parent b3f99bf commit 4660f9c

File tree

2 files changed

+209
-11
lines changed

2 files changed

+209
-11
lines changed

Source/Bogus.Tests/DataSetTests/DateTest.cs

+66
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,72 @@ public void will_not_generate_values_that_do_not_exist_due_to_daylight_savings()
394394
value.Should().NotBeBefore(transitionEndTime);
395395
}
396396

397+
[FactWhenDaylightSavingsSupported]
398+
public void will_adjust_start_time_to_avoid_dst_transition()
399+
{
400+
// Arrange
401+
var faker = new Faker();
402+
403+
faker.Random = new Randomizer(localSeed: 5);
404+
405+
var dstRules = TimeZoneInfo.Local.GetAdjustmentRules();
406+
407+
var now = DateTime.Now;
408+
409+
var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now));
410+
411+
var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart);
412+
var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta;
413+
414+
var windowStart = transitionStartTime + TimeSpan.FromTicks((transitionEndTime - transitionStartTime).Ticks / 2);
415+
var windowEnd = transitionEndTime.AddMinutes(30);
416+
417+
// Act & Assert
418+
bool haveSampleThatIsNotWindowEnd = false;
419+
420+
for (int i = 0; i < 10000; i++)
421+
{
422+
var sample = faker.Date.Between(windowStart, windowEnd);
423+
424+
sample.Should().BeOnOrAfter(transitionEndTime);
425+
sample.Should().BeOnOrBefore(windowEnd);
426+
427+
haveSampleThatIsNotWindowEnd = (sample < windowEnd);
428+
}
429+
430+
haveSampleThatIsNotWindowEnd.Should().BeTrue(because: "the effective range should include values other than windowEnd");
431+
}
432+
433+
[FactWhenDaylightSavingsSupported]
434+
public void will_adjust_end_time_to_avoid_dst_transition()
435+
{
436+
// Arrange
437+
var faker = new Faker();
438+
439+
faker.Random = new Randomizer(localSeed: 5);
440+
441+
var dstRules = TimeZoneInfo.Local.GetAdjustmentRules();
442+
443+
var now = DateTime.Now;
444+
445+
var effectiveRule = dstRules.Single(rule => (rule.DateStart <= now) && (rule.DateEnd >= now));
446+
447+
var transitionStartTime = CalculateTransitionDateTime(now, effectiveRule.DaylightTransitionStart);
448+
var transitionEndTime = transitionStartTime + effectiveRule.DaylightDelta;
449+
450+
var windowStart = transitionStartTime.AddMinutes(-30);
451+
var windowEnd = transitionStartTime + TimeSpan.FromTicks((transitionEndTime - transitionStartTime).Ticks / 2);
452+
453+
// Act & Assert
454+
for (int i = 0; i < 10000; i++)
455+
{
456+
var sample = faker.Date.Between(windowStart, windowEnd);
457+
458+
sample.Should().BeOnOrAfter(windowStart);
459+
sample.Should().BeOnOrBefore(transitionStartTime);
460+
}
461+
}
462+
397463
private DateTime CalculateTransitionDateTime(DateTime now, TimeZoneInfo.TransitionTime transition)
398464
{
399465
// Based on code found at: https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.isfixeddaterule

Source/Bogus/DataSets/Date.cs

+143-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Globalization;
23

34
namespace Bogus.DataSets
45
{
@@ -128,6 +129,8 @@ public DateTimeOffset FutureOffset(int yearsToGoForward = 1, DateTimeOffset? ref
128129
/// <param name="end">End time</param>
129130
public DateTime Between(DateTime start, DateTime end)
130131
{
132+
ComputeRealRange(ref start, ref end);
133+
131134
var startTicks = start.ToUniversalTime().Ticks;
132135
var endTicks = end.ToUniversalTime().Ticks;
133136

@@ -146,26 +149,155 @@ public DateTime Between(DateTime start, DateTime end)
146149
return value;
147150
}
148151

152+
/// <summary>
153+
/// Takes a date/time range, as indicated by <paramref name="start"/> and <paramref name="end"/>,
154+
/// and ensures that the range indicators are in the correct order and both reference actual
155+
/// <see cref="DateTime"/> values. This takes into account the fact that when Daylight Savings Time
156+
/// comes into effect, there is a 1-hour interval in the local calendar which does not exist, and
157+
/// <see cref="DateTime"/> values in this change are not meaningful.
158+
///
159+
/// This function only worries about the start and end times. Impossible <see cref="DateTime"/>
160+
/// values within the range are excluded automatically by means of the <see cref="DateTime.ToLocalTime"/>
161+
/// function.
162+
///
163+
/// This function does not check Daylight Savings Time transitions when running under .NET Standard 1.3,
164+
/// as this API does not expose Daylight Savings Time information.
165+
/// </summary>
166+
/// <param name="start">A ref <see cref="DateTime"/> to be adjusted forward out of an impossible date/time range if necessary.</param>
167+
/// <param name="end">A ref <see cref="DateTime"/> to be adjusted backward out of an impossible date/time range if necessary.</param>
168+
private void ComputeRealRange(ref DateTime start, ref DateTime end)
169+
{
170+
if (start > end)
171+
{
172+
var tmp = start;
173+
174+
start = end;
175+
end = tmp;
176+
}
177+
178+
#if !NETSTANDARD1_3
179+
var window = GetForwardDSTTransitionWindow(start);
180+
181+
if ((start > window.Start) && (start <= window.End))
182+
start = new DateTime(window.End.Ticks, start.Kind);
183+
184+
window = GetForwardDSTTransitionWindow(end);
185+
186+
if ((end >= window.Start) && (end < window.End))
187+
end = new DateTime(window.Start.Ticks, end.Kind);
188+
189+
if (start > end)
190+
throw new Exception("DateTime range does not contain any real DateTime values due to daylight savings transitions");
191+
#endif
192+
}
193+
194+
#if !NETSTANDARD1_3
195+
struct DateTimeRange
196+
{
197+
public DateTime Start;
198+
public DateTime End;
199+
}
200+
201+
/// <summary>
202+
/// Finds the window of time that doesn't exist in the local timezone due to Daylight Savings Time coming into
203+
/// effect. In timezones that do not have Daylight Savings Time transitions, this function returns <see cref="null"/>.
204+
/// </summary>
205+
/// <param name="dateTime">
206+
/// A reference <see cref="DateTime"/> value for determining the DST transition window accurately. Daylight Savings Time
207+
/// rules can change over time, and the <see cref="TimeZoneInfo"/> API exposes information about which Daylight Savings
208+
/// Time rules are in effect for which date ranges.
209+
/// </param>
210+
/// <returns>
211+
/// A <see cref="DateTimeRange"/> that indicates the start &amp; end of the interval of date/time values that do not
212+
/// exist in the local calendar in the interval indicated by the supplied <paramref name="dateTime"/>, or <see cref="null"/>
213+
/// if no such range exists.
214+
/// </returns>
215+
private DateTimeRange GetForwardDSTTransitionWindow(DateTime dateTime)
216+
{
217+
// Based on code found at: https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.isfixeddaterule
218+
var rule = FindEffectiveTimeZoneAdjustmentRule(dateTime);
219+
220+
if (rule == null)
221+
return default(DateTimeRange);
222+
223+
var transition = rule.DaylightTransitionStart;
224+
225+
DateTime startTime;
226+
227+
if (transition.IsFixedDateRule)
228+
{
229+
startTime = new DateTime(
230+
dateTime.Year,
231+
transition.Month,
232+
transition.Day,
233+
transition.TimeOfDay.Hour,
234+
transition.TimeOfDay.Minute,
235+
transition.TimeOfDay.Second,
236+
transition.TimeOfDay.Millisecond);
237+
}
238+
else
239+
{
240+
var calendar = CultureInfo.CurrentCulture.Calendar;
241+
242+
var startOfWeek = transition.Week * 7 - 6;
243+
244+
var firstDayOfWeek = (int)calendar.GetDayOfWeek(new DateTime(dateTime.Year, transition.Month, 1));
245+
var changeDayOfWeek = (int)transition.DayOfWeek;
246+
247+
int transitionDay =
248+
firstDayOfWeek <= changeDayOfWeek
249+
? startOfWeek + changeDayOfWeek - firstDayOfWeek
250+
: startOfWeek + changeDayOfWeek - firstDayOfWeek + 7;
251+
252+
if (transitionDay > calendar.GetDaysInMonth(dateTime.Year, transition.Month))
253+
transitionDay -= 7;
254+
255+
startTime = new DateTime(
256+
dateTime.Year,
257+
transition.Month,
258+
transitionDay,
259+
transition.TimeOfDay.Hour,
260+
transition.TimeOfDay.Minute,
261+
transition.TimeOfDay.Second,
262+
transition.TimeOfDay.Millisecond);
263+
}
264+
265+
return
266+
new DateTimeRange()
267+
{
268+
Start = startTime,
269+
End = startTime + rule.DaylightDelta,
270+
};
271+
}
272+
273+
/// <summary>
274+
/// Identifies the timezone adjustment rule in effect in the local timezone at the specified
275+
/// <paramref name="dateTime"/>. If no adjustment rule is in effect, returns <see cref="null"/>.
276+
/// </summary>
277+
/// <param name="dateTime">The <see cref="DateTime"/> value for which to find an adjustment rule.</param>
278+
private TimeZoneInfo.AdjustmentRule FindEffectiveTimeZoneAdjustmentRule(DateTime dateTime)
279+
{
280+
foreach (var rule in TimeZoneInfo.Local.GetAdjustmentRules())
281+
if ((dateTime >= rule.DateStart) && (dateTime <= rule.DateEnd))
282+
return rule;
283+
284+
return default;
285+
}
286+
#endif
287+
149288
/// <summary>
150289
/// Get a random <see cref="DateTimeOffset"/> between <paramref name="start"/> and <paramref name="end"/>.
151290
/// </summary>
152291
/// <param name="start">Start time - The returned <seealso cref="DateTimeOffset"/> offset value is used from this parameter</param>
153292
/// <param name="end">End time</param>
154293
public DateTimeOffset BetweenOffset(DateTimeOffset start, DateTimeOffset end)
155294
{
156-
var startTicks = start.ToUniversalTime().Ticks;
157-
var endTicks = end.ToUniversalTime().Ticks;
158-
159-
var minTicks = Math.Min(startTicks, endTicks);
160-
var maxTicks = Math.Max(startTicks, endTicks);
161-
162-
var totalTimeSpanTicks = maxTicks - minTicks;
163-
164-
var partTimeSpan = RandomTimeSpanFromTicks(totalTimeSpanTicks);
295+
var startTime = new DateTime(start.DateTime.Ticks, DateTimeKind.Utc);
296+
var endTime = new DateTime(end.DateTime.Ticks, DateTimeKind.Utc);
165297

166-
var dateTime = new DateTime(minTicks, DateTimeKind.Unspecified) + partTimeSpan;
298+
var sample = Between(startTime, endTime);
167299

168-
return new DateTimeOffset(dateTime + start.Offset, start.Offset);
300+
return new DateTimeOffset(new DateTime(sample.Ticks, DateTimeKind.Unspecified), start.Offset);
169301
}
170302

171303
/// <summary>

0 commit comments

Comments
 (0)