Skip to content
26 changes: 19 additions & 7 deletions src/Cronos/CronExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ public IEnumerable<DateTime> GetOccurrences(
return new DateTime(found, DateTimeKind.Utc);
}

var zonedStart = TimeZoneInfo.ConvertTime(fromUtc, zone);
var zonedStartOffset = new DateTimeOffset(zonedStart, zonedStart - fromUtc);
var occurrence = GetOccurrenceByZonedTimes(zonedStartOffset, zone, inclusive);
var fromOffset = new DateTimeOffset(fromUtc);

var occurrence = GetOccurrenceConsideringTimeZone(fromOffset, zone, inclusive);

return occurrence?.UtcDateTime;
}

Expand Down Expand Up @@ -254,8 +255,7 @@ public IEnumerable<DateTime> GetOccurrences(
return new DateTimeOffset(found, TimeSpan.Zero);
}

var zonedStart = TimeZoneInfo.ConvertTime(from, zone);
return GetOccurrenceByZonedTimes(zonedStart, zone, inclusive);
return GetOccurrenceConsideringTimeZone(from, zone, inclusive);
}

/// <summary>
Expand Down Expand Up @@ -365,9 +365,21 @@ public override int GetHashCode()
/// </summary>
public static bool operator !=(CronExpression left, CronExpression right) => !Equals(left, right);


private DateTimeOffset? GetOccurrenceByZonedTimes(DateTimeOffset from, TimeZoneInfo zone, bool inclusive)
private DateTimeOffset? GetOccurrenceConsideringTimeZone(DateTimeOffset fromUtc, TimeZoneInfo zone, bool inclusive)
{
if (!DateTimeHelper.IsRound(fromUtc))
{
// Rarely, if fromUtc is very close to DST transition, `TimeZoneInfo.ConvertTime` may not convert it correctly on Windows.
// E.g., In Jordan Time DST started 2017-03-31 00:00 local time. Clocks jump forward from `2017-03-31 00:00 +02:00` to `2017-03-31 01:00 +3:00`.
// But `2017-03-30 23:59:59.9999000 +02:00` will be converted to `2017-03-31 00:59:59.9999000 +03:00` instead of `2017-03-30 23:59:59.9999000 +02:00` on Windows.
// It can lead to skipped occurrences. To avoid such errors we floor fromUtc to seconds:
// `2017-03-30 23:59:59.9999000 +02:00` will be floored to `2017-03-30 23:59:59.0000000 +02:00` and will be converted to `2017-03-30 23:59:59.0000000 +02:00`.
fromUtc = DateTimeHelper.FloorToSeconds(fromUtc);
inclusive = false;
}

var from = TimeZoneInfo.ConvertTime(fromUtc, zone);

var fromLocal = from.DateTime;

if (TimeZoneHelper.IsAmbiguousTime(zone, fromLocal))
Expand Down
15 changes: 15 additions & 0 deletions src/Cronos/DateTimeHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace Cronos
{
internal static class DateTimeHelper
{
private static readonly TimeSpan OneSecond = TimeSpan.FromSeconds(1);

public static DateTimeOffset FloorToSeconds(DateTimeOffset dateTimeOffset) => dateTimeOffset.AddTicks(-GetExtraTicks(dateTimeOffset.Ticks));

public static bool IsRound(DateTimeOffset dateTimeOffset) => GetExtraTicks(dateTimeOffset.Ticks) == 0;

private static long GetExtraTicks(long ticks) => ticks % OneSecond.Ticks;
}
}
118 changes: 118 additions & 0 deletions tests/Cronos.Tests/CronExpressionFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ public class CronExpressionFacts
private static readonly string EasternTimeZoneId = IsUnix ? "America/New_York" : "Eastern Standard Time";
private static readonly string JordanTimeZoneId = IsUnix ? "Asia/Amman" : "Jordan Standard Time";
private static readonly string LordHoweTimeZoneId = IsUnix ? "Australia/Lord_Howe" : "Lord Howe Standard Time";
private static readonly string PacificTimeZoneId = IsUnix ? "America/Santiago" : "Pacific SA Standard Time";

private static readonly TimeZoneInfo EasternTimeZone = TimeZoneInfo.FindSystemTimeZoneById(EasternTimeZoneId);
private static readonly TimeZoneInfo JordanTimeZone = TimeZoneInfo.FindSystemTimeZoneById(JordanTimeZoneId);
private static readonly TimeZoneInfo LordHoweTimeZone = TimeZoneInfo.FindSystemTimeZoneById(LordHoweTimeZoneId);
private static readonly TimeZoneInfo PacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById(PacificTimeZoneId);

private static readonly DateTime Today = new DateTime(2016, 12, 09);

Expand Down Expand Up @@ -1134,6 +1136,81 @@ public void GetNextOccurrence_HandleDST_WhenTheClockJumpsForward_And_TimeZoneIsE

[Theory]

// 2017-03-31 00:00 is time in Jordan Time Zone when the clock jumps forward
// from 2017-03-30 23:59:59.9999999 +02:00 standard time (ST) to 01:00:00.0000000 am +03:00 DST.
// ________23:59:59.9999999 ST///invalid///01:00:00.0000000 DST________

// Run missed.

[InlineData("30 0 L * *", "2017-03-30 23:59:59.9999999 +02:00", "2017-03-31 01:00:00 +03:00", false)]
[InlineData("30 0 L * *", "2017-03-30 23:59:59.9999000 +02:00", "2017-03-31 01:00:00 +03:00", false)]
[InlineData("30 0 L * *", "2017-03-30 23:59:59.9990000 +02:00", "2017-03-31 01:00:00 +03:00", false)]
[InlineData("30 0 L * *", "2017-03-30 23:59:59.9900000 +02:00", "2017-03-31 01:00:00 +03:00", false)]
[InlineData("30 0 L * *", "2017-03-30 23:59:59.9000000 +02:00", "2017-03-31 01:00:00 +03:00", false)]
[InlineData("30 0 L * *", "2017-03-30 23:59:59.0000000 +02:00", "2017-03-31 01:00:00 +03:00", false)]

[InlineData("30 0 L * *", "2017-03-31 01:00:00.0000001 +02:00", "2017-04-30 00:30:00 +03:00", true)]
public void GetNextOccurrence_HandleDST_WhenTheClockJumpsForwardAndFromIsAroundDST(string cronExpression, string fromString, string expectedString, bool inclusive)
{
var expression = CronExpression.Parse(cronExpression);

var fromInstant = GetInstant(fromString);
var expectedInstant = GetInstant(expectedString);

var executed = expression.GetNextOccurrence(fromInstant, JordanTimeZone, inclusive);

Assert.Equal(expectedInstant, executed);
Assert.Equal(expectedInstant.Offset, executed?.Offset);
}

[Theory]

// 2017-05-14 00:00 is time in Chile Time Zone when the clock jumps backward
// from 2017-05-13 23:59:59.9999999 -03:00 standard time (ST) to 2017-05-13 23:00:00.0000000 am -04:00 DST .
// ________23:59:59.9999999 -03:00 ST -> 23:00:00.0000000 -04:00 DST

[InlineData("30 23 * * *", "2017-05-13 23:59:59.9999999 -03:00", "2017-05-14 23:30:00 -04:00", false)]
[InlineData("30 23 * * *", "2017-05-13 23:59:59.9999000 -03:00", "2017-05-14 23:30:00 -04:00", false)]
[InlineData("30 23 * * *", "2017-05-13 23:59:59.9990000 -03:00", "2017-05-14 23:30:00 -04:00", false)]
[InlineData("30 23 * * *", "2017-05-13 23:59:59.9900000 -03:00", "2017-05-14 23:30:00 -04:00", false)]
[InlineData("30 23 * * *", "2017-05-13 23:59:59.9000000 -03:00", "2017-05-14 23:30:00 -04:00", false)]
[InlineData("30 23 * * *", "2017-05-13 23:59:59.0000000 -03:00", "2017-05-14 23:30:00 -04:00", false)]

[InlineData("30 23 * * *", "2017-05-14 00:00:00.0000001 -04:00", "2017-05-14 23:30:00 -04:00", true)]
public void GetNextOccurrence_HandleDST_WhenTheClockJumpsBackwardAndFromIsAroundDST(string cronExpression, string fromString, string expectedString, bool inclusive)
{
var expression = CronExpression.Parse(cronExpression);

var fromInstant = GetInstant(fromString);
var expectedInstant = GetInstant(expectedString);

var executed = expression.GetNextOccurrence(fromInstant, PacificTimeZone, inclusive);

Assert.Equal(expectedInstant, executed);
Assert.Equal(expectedInstant.Offset, executed?.Offset);
}

[Theory]
[InlineData("0 7 * * *", "2020-05-20 07:00:00.0000001 -04:00", "2020-05-21 07:00:00 -04:00", true)]
[InlineData("0 7 * * *", "2020-05-20 07:00:00.0000001 -04:00", "2020-05-21 07:00:00 -04:00", false)]

[InlineData("0 7 * * *", "2023-08-12 07:00:00.9999999 -04:00", "2023-08-13 07:00:00 -04:00", true)]
[InlineData("0 7 * * *", "2023-08-12 07:00:00.9999999 -04:00", "2023-08-13 07:00:00 -04:00", false)]
public void GetNextOccurrence_ReturnsCorrectDate_WhenFromIsNotRoundAndZoneIsSpecified(string cronExpression, string fromString, string expectedString, bool inclusive)
{
var expression = CronExpression.Parse(cronExpression);

var fromInstant = GetInstant(fromString);
var expectedInstant = GetInstant(expectedString);

var executed = expression.GetNextOccurrence(fromInstant, EasternTimeZone, inclusive);

Assert.Equal(expectedInstant, executed);
Assert.Equal(expectedInstant.Offset, executed?.Offset);
}

[Theory]

// 2017-10-01 is date when the clock jumps forward from 1:59 am +10:30 standard time (ST) to 2:30 am +11:00 DST on Lord Howe.
// ________1:59 ST///invalid///2:30 DST________

Expand Down Expand Up @@ -2206,6 +2283,46 @@ public void GetNextOccurrence_ReturnsCorrectDate_WhenFromIsDateTimeAndZoneIsSpec
Assert.Equal(expectedInstant.UtcDateTime, nextOccurrence);
}

[Theory(Timeout = 1000)]

[InlineData("* * * * * *", "1991-01-01 00:00")]
[InlineData("0 * * * * *", "1991-03-02 00:00")]
[InlineData("* 0 * * * *", "1991-03-15 00:00")]
[InlineData("* * 0 * * *", "1991-03-31 00:00")]
[InlineData("* * * 1 * *", "1991-04-15 00:00")]
[InlineData("* * * * 1 *", "1991-05-25 00:00")]
[InlineData("* * * * * 0", "1991-06-27 00:00")]
[InlineData("0 0 0 * * *", "1991-07-16 00:00")]
[InlineData("0 0 0 1 * *", "1991-10-30 00:00")]
[InlineData("0 0 0 1 1 *", "1991-12-31 00:00")]
[InlineData("0 0 0 1 * 1", "1991-12-31 00:00")]
public void GetNextOccurrence_MakesProgressInsideLoop(string expression, string fromString)
{
var cronExpression = CronExpression.Parse(expression, CronFormat.IncludeSeconds);

var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone);

for (var i = 0; i < 100; i++)
{
var nextOccurrence = cronExpression.GetNextOccurrence(fromInstant.AddTicks(1), EasternTimeZone, inclusive: true);

Assert.True(nextOccurrence > fromInstant);

fromInstant = nextOccurrence.Value;
}
}

[Fact]
public void GetNextOccurrence_ReturnsAGreaterValue_EvenWhenMillisecondTruncationRuleIsAppliedDueToDST()
{
var expression = CronExpression.Parse("* * * * * *", CronFormat.IncludeSeconds);
var fromInstant = DateTimeOffset.Parse("2021-03-25 23:59:59.9999999 +02:00");

var nextInstant = expression.GetNextOccurrence(fromInstant, JordanTimeZone, inclusive: true);

Assert.True(nextInstant > fromInstant);
}

[Theory]
[InlineData("* * * * *", "2017-03-16 16:00", "2017-03-16 16:01")]
[InlineData("5 * * * *", "2017-03-16 16:05", "2017-03-16 17:05")]
Expand Down Expand Up @@ -2788,6 +2905,7 @@ private static DateTimeOffset GetInstant(string dateTimeOffsetString)
{
"yyyy-MM-dd HH:mm:ss zzz",
"yyyy-MM-dd HH:mm zzz",
"yyyy-MM-dd HH:mm:ss.fffffff zzz"
},
CultureInfo.InvariantCulture,
DateTimeStyles.None);
Expand Down
73 changes: 73 additions & 0 deletions tests/Cronos.Tests/DateTimeHelperFacts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Globalization;
using Xunit;

namespace Cronos.Tests
{
public class DateTimeHelperFacts
{
[Theory]

[InlineData("2017-03-30 23:59:59.0000000 +02:00", "2017-03-30 23:59:59.0000000 +02:00")]

[InlineData("2017-03-30 23:59:59.9000000 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9900000 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9990000 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9999000 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9999900 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9999990 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]
[InlineData("2017-03-30 23:59:59.9999999 +03:00", "2017-03-30 23:59:59.0000000 +03:00")]

[InlineData("2017-03-30 23:59:59.0000001 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.0000010 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.0000100 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.0001000 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.0010000 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.0100000 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
[InlineData("2017-03-30 23:59:59.1000000 +01:00", "2017-03-30 23:59:59.0000000 +01:00")]
public void FloorToSeconds_WorksCorrectlyWithDateTimeOffset(string dateTime, string expected)
{
var dateTimeOffset = GetDateTimeOffsetInstant(dateTime);
var expectedDateTimeOffset = GetDateTimeOffsetInstant(expected);

var flooredDateTimeOffset = DateTimeHelper.FloorToSeconds(dateTimeOffset);

Assert.Equal(expectedDateTimeOffset, flooredDateTimeOffset);
Assert.Equal(expectedDateTimeOffset.Offset, flooredDateTimeOffset.Offset);
}

private static DateTimeOffset GetDateTimeOffsetInstant(string dateTimeOffsetString)
{
dateTimeOffsetString = dateTimeOffsetString.Trim();

var dateTime = DateTimeOffset.ParseExact(
dateTimeOffsetString,
new[]
{
"yyyy-MM-dd HH:mm:ss.fffffff zzz",
},
CultureInfo.InvariantCulture,
DateTimeStyles.None);

return dateTime;
}

private static DateTime GetDateTimeInstant(string dateTimeString, DateTimeKind kind)
{
dateTimeString = dateTimeString.Trim();

var dateTime = DateTime.ParseExact(
dateTimeString,
new[]
{
"yyyy-MM-dd HH:mm:ss.fffffff",
},
CultureInfo.InvariantCulture,
DateTimeStyles.None);

dateTime = DateTime.SpecifyKind(dateTime, kind);

return dateTime;
}
}
}