commit 7d4c9703073b3154024303e0b2f836c609c209b5 Author: upstream Date: Fri Sep 4 09:32:17 2026 +0200 Upstream HangfireIO/Cronos state of the touched files before b3970718a7 diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs new file mode 100644 index 0000000..9197940 --- /dev/null +++ b/src/Cronos/CronExpression.cs @@ -0,0 +1,1270 @@ +// The MIT License(MIT) +// +// Copyright (c) 2017 Hangfire OÜ +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Cronos +{ + /// + /// Provides a parser and scheduler for cron expressions. + /// + public sealed class CronExpression: IEquatable + { + private const long NotFound = -1; + + /// + /// Represents a cron expression that fires on Jan 1st every year at midnight. + /// Equals to "0 0 1 1 *". + /// + public static readonly CronExpression Yearly = Parse("0 0 1 1 *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time once per year. + /// Equals to "H H H H H *". + /// + public static CronExpression YearlyWithJitter(int jitterSeed) => + Parse("H H H H H *", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires every Sunday at midnight. + /// Equals to "0 0 * * 0". + /// + public static readonly CronExpression Weekly = Parse("0 0 * * 0", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time once per week. + /// Equals to "H H H * * H". + /// + public static CronExpression WeeklyWithJitter(int jitterSeed) => + Parse("H H H * * H", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires on 1st day of every month at midnight. + /// Equals to "0 0 1 * *". + /// + public static readonly CronExpression Monthly = Parse("0 0 1 * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time once per month. + /// Equals to "H H H H * *". + /// + public static CronExpression MonthlyWithJitter(int jitterSeed) => + Parse("H H H H * *", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires every day at midnight. + /// Equals to "0 0 * * *". + /// + public static readonly CronExpression Daily = Parse("0 0 * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time every day. + /// Equals to "H H H * * *". + /// + public static CronExpression DailyWithJitter(int jitterSeed) => + Parse("H H H * * *", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires every hour at the beginning of the hour. + /// Equals to "0 * * * *". + /// + public static readonly CronExpression Hourly = Parse("0 * * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time every hour. + /// Equals to "H H * * * *". + /// + public static CronExpression HourlyWithJitter(int jitterSeed) => + Parse("H H * * * *", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires every minute. + /// Equals to "* * * * *". + /// + public static readonly CronExpression EveryMinute = Parse("* * * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified second every minute. + /// Equals to "H * * * * *". + /// + public static CronExpression EveryMinuteWithJitter(int jitterSeed) => + Parse("H * * * * *", CronFormat.IncludeSeconds, jitterSeed); + + /// + /// Represents a cron expression that fires every second. + /// Equals to "* * * * * *". + /// + public static readonly CronExpression EverySecond = Parse("* * * * * *", CronFormat.IncludeSeconds); + + private static readonly TimeZoneInfo UtcTimeZone = TimeZoneInfo.Utc; + + private static readonly int[] DeBruijnPositions = + { + 0, 1, 2, 53, 3, 7, 54, 27, + 4, 38, 41, 8, 34, 55, 48, 28, + 62, 5, 39, 46, 44, 42, 22, 9, + 24, 35, 59, 56, 49, 18, 29, 11, + 63, 52, 6, 26, 37, 40, 33, 47, + 61, 45, 43, 21, 23, 58, 17, 10, + 51, 25, 36, 32, 60, 20, 57, 16, + 50, 31, 19, 15, 30, 14, 13, 12 + }; + + private readonly ulong _second; // 60 bits -> from 0 bit to 59 bit + private readonly ulong _minute; // 60 bits -> from 0 bit to 59 bit + private readonly uint _hour; // 24 bits -> from 0 bit to 23 bit + private readonly uint _dayOfMonth; // 31 bits -> from 1 bit to 31 bit + private readonly ushort _month; // 12 bits -> from 1 bit to 12 bit + private readonly byte _dayOfWeek; // 8 bits -> from 0 bit to 7 bit + + private readonly byte _nthDayOfWeek; + private readonly byte _lastMonthOffset; + + private readonly CronExpressionFlag _flags; + + internal CronExpression( + ulong second, + ulong minute, + uint hour, + uint dayOfMonth, + ushort month, + byte dayOfWeek, + byte nthDayOfWeek, + byte lastMonthOffset, + CronExpressionFlag flags) + { + _second = second; + _minute = minute; + _hour = hour; + _dayOfMonth = dayOfMonth; + _month = month; + _dayOfWeek = dayOfWeek; + _nthDayOfWeek = nthDayOfWeek; + _lastMonthOffset = lastMonthOffset; + _flags = flags; + } + + /// + /// Constructs a new based on the specified + /// cron expression. Its supported expressions consist of 5 fields: + /// minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression) + { + return Parse(expression, CronFormat.Standard); + } + + /// + /// Constructs a new based on the specified + /// cron expression and jitter seed. Its supported expressions consist of 5 fields: + /// minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression, int jitterSeed) + { + return Parse(expression, CronFormat.Standard, jitterSeed); + } + + /// + /// Constructs a new based on the specified + /// cron expression. Its supported expressions consist of 5 or 6 fields: + /// second (optional), minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression, CronFormat format) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + return CronParser.Parse(expression, format); + } + + /// + /// Constructs a new based on the specified + /// cron expression and jitter seed. Its supported expressions consist of 5 or 6 fields: + /// second (optional), minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression, CronFormat format, int jitterSeed) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + return CronParser.Parse(expression, format, jitterSeed); + } + + /// + /// Constructs a new based on the specified cron expression with the + /// format. + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { + return TryParse(expression, CronFormat.Standard, out cronExpression); + } + + /// + /// Constructs a new based on the specified cron expression and jitter seed with + /// the format. + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { + return TryParse(expression, CronFormat.Standard, jitterSeed, out cronExpression); + } + + /// + /// Constructs a new based on the specified cron expression with the specified + /// . + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, CronFormat format, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + try + { + cronExpression = Parse(expression, format); + return true; + } + catch (CronFormatException) + { + cronExpression = null; + return false; + } + catch (MissingSeedException) + { + cronExpression = null; + return false; + } + } + + /// + /// Constructs a new based on the specified cron expression and jitter seed with + /// the specified . + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, CronFormat format, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + try + { + cronExpression = Parse(expression, format, jitterSeed); + return true; + } + catch (CronFormatException) + { + cronExpression = null; + return false; + } + catch (MissingSeedException) + { + cronExpression = null; + return false; + } + } + + /// + /// Calculates next occurrence starting with (optionally ) in UTC time zone. + /// + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + + var found = FindOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + + /// + /// Calculates previous occurrence starting with (optionally ) in UTC time zone. + /// + /// + public DateTime? GetPreviousOccurrence(DateTime fromUtc, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + + var found = FindPreviousOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + + /// + /// Calculates next occurrence starting with (optionally ) in given + /// + /// + public DateTime? GetNextOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone)); + + if (ReferenceEquals(zone, UtcTimeZone)) + { + var found = FindOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + + var fromOffset = new DateTimeOffset(fromUtc); + +#pragma warning disable CA1062 + var occurrence = GetOccurrenceConsideringTimeZone(fromOffset, zone, inclusive); +#pragma warning restore CA1062 + + return occurrence?.UtcDateTime; + } + + /// + /// Calculates previous occurrence starting with (optionally ) in given + /// + /// + public DateTime? GetPreviousOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false) + { + if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc)); + if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone)); + + if (ReferenceEquals(zone, UtcTimeZone)) + { + var found = FindPreviousOccurrence(fromUtc.Ticks, inclusive); + if (found == NotFound) return null; + + return new DateTime(found, DateTimeKind.Utc); + } + + var fromOffset = new DateTimeOffset(fromUtc); + +#pragma warning disable CA1062 + var occurrence = GetPreviousOccurrenceConsideringTimeZone(fromOffset, zone, inclusive); +#pragma warning restore CA1062 + + return occurrence?.UtcDateTime; + } + + /// + /// Calculates next occurrence starting with (optionally ) in given + /// + /// + public DateTimeOffset? GetNextOccurrence(DateTimeOffset from, TimeZoneInfo zone, bool inclusive = false) + { + if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone)); + + if (ReferenceEquals(zone, UtcTimeZone)) + { + var found = FindOccurrence(from.UtcTicks, inclusive); + if (found == NotFound) return null; + + return new DateTimeOffset(found, TimeSpan.Zero); + } + +#pragma warning disable CA1062 + return GetOccurrenceConsideringTimeZone(from, zone, inclusive); +#pragma warning restore CA1062 + } + + /// + /// Calculates previous occurrence starting with (optionally ) in given + /// + /// + public DateTimeOffset? GetPreviousOccurrence(DateTimeOffset from, TimeZoneInfo zone, bool inclusive = false) + { + if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone)); + + if (ReferenceEquals(zone, UtcTimeZone)) + { + var found = FindPreviousOccurrence(from.UtcTicks, inclusive); + if (found == NotFound) return null; + + return new DateTimeOffset(found, TimeSpan.Zero); + } + +#pragma warning disable CA1062 + return GetPreviousOccurrenceConsideringTimeZone(from, zone, inclusive); +#pragma warning restore CA1062 + } + + /// + /// Returns the list of next occurrences within the given date/time range, + /// including and excluding + /// by default, and UTC time zone. When none of the occurrences found, an + /// empty list is returned. + /// + /// + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetNextOccurrence(fromUtc, fromInclusive); + occurrence < toUtc || occurrence == toUtc && toInclusive; + // ReSharper disable once RedundantArgumentDefaultValue + // ReSharper disable once ArgumentsStyleLiteral + occurrence = GetNextOccurrence(occurrence.Value, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of next occurrences within the given date/time range, including + /// and excluding by default, and + /// specified time zone. When none of the occurrences found, an empty list is returned. + /// + /// + public IEnumerable GetOccurrences( + DateTime fromUtc, + DateTime toUtc, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetNextOccurrence(fromUtc, zone, fromInclusive); + occurrence < toUtc || occurrence == toUtc && toInclusive; + // ReSharper disable once RedundantArgumentDefaultValue + // ReSharper disable once ArgumentsStyleLiteral + occurrence = GetNextOccurrence(occurrence.Value, zone, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of occurrences within the given date/time offset range, + /// including and excluding by + /// default. When none of the occurrences found, an empty list is returned. + /// + /// + public IEnumerable GetOccurrences( + DateTimeOffset from, + DateTimeOffset to, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + if (from > to) ThrowFromShouldBeLessThanToException(nameof(from), nameof(to)); + + for (var occurrence = GetNextOccurrence(from, zone, fromInclusive); + occurrence < to || occurrence == to && toInclusive; + // ReSharper disable once RedundantArgumentDefaultValue + // ReSharper disable once ArgumentsStyleLiteral + occurrence = GetNextOccurrence(occurrence.Value, zone, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of previous occurrences within the given date/time range, + /// including and excluding + /// by default, and UTC time zone. When none of the occurrences found, an + /// empty list is returned. + /// + /// + public IEnumerable GetOccurrencesDescending( + DateTime fromUtc, + DateTime toUtc, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc < toUtc) ThrowFromShouldBeGreaterThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetPreviousOccurrence(fromUtc, fromInclusive); + occurrence > toUtc || occurrence == toUtc && toInclusive; + occurrence = GetPreviousOccurrence(occurrence.Value, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of previous occurrences within the given date/time range, including + /// and excluding by default, and + /// specified time zone. When none of the occurrences found, an empty list is returned. + /// + /// + public IEnumerable GetOccurrencesDescending( + DateTime fromUtc, + DateTime toUtc, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + if (fromUtc < toUtc) ThrowFromShouldBeGreaterThanToException(nameof(fromUtc), nameof(toUtc)); + + for (var occurrence = GetPreviousOccurrence(fromUtc, zone, fromInclusive); + occurrence > toUtc || occurrence == toUtc && toInclusive; + occurrence = GetPreviousOccurrence(occurrence.Value, zone, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + /// Returns the list of previous occurrences within the given date/time offset range, + /// including and excluding by default. + /// When none of the occurrences found, an empty list is returned. + /// + /// + public IEnumerable GetOccurrencesDescending( + DateTimeOffset from, + DateTimeOffset to, + TimeZoneInfo zone, + bool fromInclusive = true, + bool toInclusive = false) + { + if (from < to) ThrowFromShouldBeGreaterThanToException(nameof(from), nameof(to)); + + for (var occurrence = GetPreviousOccurrence(from, zone, fromInclusive); + occurrence > to || occurrence == to && toInclusive; + occurrence = GetPreviousOccurrence(occurrence.Value, zone, inclusive: false)) + { + yield return occurrence.Value; + } + } + + /// + public override string ToString() + { + var expressionBuilder = new StringBuilder(); + + if (_second != 1UL) + { + AppendFieldValue(expressionBuilder, CronField.Seconds, _second).Append(' '); + } + + AppendFieldValue(expressionBuilder, CronField.Minutes, _minute).Append(' '); + AppendFieldValue(expressionBuilder, CronField.Hours, _hour).Append(' '); + AppendDayOfMonth(expressionBuilder, _dayOfMonth).Append(' '); + AppendFieldValue(expressionBuilder, CronField.Months, _month).Append(' '); + AppendDayOfWeek(expressionBuilder, _dayOfWeek); + + return expressionBuilder.ToString(); + } + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// + /// true if the specified is equal to the current ; otherwise, false. + /// + public bool Equals(CronExpression? other) + { + if (ReferenceEquals(other, null)) return false; + + return _second == other._second && + _minute == other._minute && + _hour == other._hour && + _dayOfMonth == other._dayOfMonth && + _month == other._month && + _dayOfWeek == other._dayOfWeek && + _nthDayOfWeek == other._nthDayOfWeek && + _lastMonthOffset == other._lastMonthOffset && + _flags == other._flags; + } + + /// + /// Determines whether the specified is equal to this instance. + /// + /// The to compare with this instance. + /// + /// true if the specified is equal to this instance; + /// otherwise, false. + /// + public override bool Equals(object? obj) => Equals(obj as CronExpression); + + /// + /// Returns a hash code for this instance. + /// + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data + /// structures like a hash table. + /// + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + { + unchecked + { + var hashCode = _second.GetHashCode(); + hashCode = (hashCode * 397) ^ _minute.GetHashCode(); + hashCode = (hashCode * 397) ^ _hour.GetHashCode(); + hashCode = (hashCode * 397) ^ _dayOfMonth.GetHashCode(); + hashCode = (hashCode * 397) ^ _month.GetHashCode(); + hashCode = (hashCode * 397) ^ _dayOfWeek.GetHashCode(); + hashCode = (hashCode * 397) ^ _nthDayOfWeek.GetHashCode(); + hashCode = (hashCode * 397) ^ _lastMonthOffset.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)_flags; + + return hashCode; + } + } + + /// + /// Implements the operator ==. + /// + public static bool operator ==(CronExpression? left, CronExpression? right) => Equals(left, right); + + /// + /// Implements the operator !=. + /// + public static bool operator !=(CronExpression? left, CronExpression? right) => !Equals(left, right); + + 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)) + { + var currentOffset = from.Offset; + var standardOffset = zone.GetUtcOffset(fromLocal); + + if (standardOffset != currentOffset) + { + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, fromLocal); + var daylightTimeLocalEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, fromLocal, daylightOffset).DateTime; + + // Early period, try to find anything here. + var foundInDaylightOffset = FindOccurrence(fromLocal.Ticks, daylightTimeLocalEnd.Ticks, inclusive); + if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset); + + fromLocal = TimeZoneHelper.GetStandardTimeStart(zone, fromLocal, daylightOffset).DateTime; + inclusive = true; + } + + // Skip late ambiguous interval. + var ambiguousIntervalLocalEnd = TimeZoneHelper.GetAmbiguousIntervalEnd(zone, fromLocal).DateTime; + + if (HasFlag(CronExpressionFlag.Interval)) + { + var foundInStandardOffset = FindOccurrence(fromLocal.Ticks, ambiguousIntervalLocalEnd.Ticks - 1, inclusive); + if (foundInStandardOffset != NotFound) return new DateTimeOffset(foundInStandardOffset, standardOffset); + } + + fromLocal = ambiguousIntervalLocalEnd; + inclusive = true; + } + + var occurrenceTicks = FindOccurrence(fromLocal.Ticks, inclusive); + if (occurrenceTicks == NotFound) return null; + + var occurrence = new DateTime(occurrenceTicks, DateTimeKind.Unspecified); + + if (zone.IsInvalidTime(occurrence)) + { + var nextValidTime = TimeZoneHelper.GetDaylightTimeStart(zone, occurrence); + return nextValidTime; + } + + if (TimeZoneHelper.IsAmbiguousTime(zone, occurrence)) + { + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, occurrence); + return new DateTimeOffset(occurrence, daylightOffset); + } + + return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence)); + } + + private DateTimeOffset? GetPreviousOccurrenceConsideringTimeZone(DateTimeOffset fromUtc, TimeZoneInfo zone, bool inclusive) + { + if (!DateTimeHelper.IsRound(fromUtc)) + { + fromUtc = DateTimeHelper.FloorToSeconds(fromUtc); + } + + var from = TimeZoneInfo.ConvertTime(fromUtc, zone); + var fromLocal = from.DateTime; + + if (TimeZoneHelper.IsAmbiguousTime(zone, fromLocal)) + { + var currentOffset = from.Offset; + var standardOffset = zone.GetUtcOffset(fromLocal); + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, fromLocal); + var ambiguousIntervalStart = TimeZoneHelper.GetStandardTimeStart(zone, fromLocal, daylightOffset).DateTime; + + if (standardOffset == currentOffset) + { + if (HasFlag(CronExpressionFlag.Interval)) + { + var foundInStandardOffset = FindPreviousOccurrence(fromLocal.Ticks, ambiguousIntervalStart.Ticks, inclusive); + if (foundInStandardOffset != NotFound) return new DateTimeOffset(foundInStandardOffset, standardOffset); + } + + var daylightTimeLocalEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, fromLocal, daylightOffset).DateTime; + var foundInDaylightOffset = FindPreviousOccurrence(daylightTimeLocalEnd.Ticks, ambiguousIntervalStart.Ticks, true); + if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset); + + fromLocal = ambiguousIntervalStart.AddTicks(-1); + inclusive = true; + } + else + { + var foundInDaylightOffset = FindPreviousOccurrence(fromLocal.Ticks, ambiguousIntervalStart.Ticks, inclusive); + if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset); + + fromLocal = ambiguousIntervalStart.AddTicks(-1); + inclusive = true; + } + } + + var occurrenceTicks = FindPreviousOccurrence(fromLocal.Ticks, inclusive); + if (occurrenceTicks == NotFound) return null; + + var occurrence = new DateTime(occurrenceTicks, DateTimeKind.Unspecified); + + if (zone.IsInvalidTime(occurrence)) + { + var previousValidTime = TimeZoneHelper.GetStandardTimeEnd(zone, occurrence); + if (previousValidTime.Date < occurrence.Date) + { + var daylightTimeStart = TimeZoneHelper.GetDaylightTimeStart(zone, occurrence); + return new DateTimeOffset(occurrence, daylightTimeStart.Offset); + } + + return previousValidTime; + } + + if (TimeZoneHelper.IsAmbiguousTime(zone, occurrence)) + { + var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, occurrence); + if (HasFlag(CronExpressionFlag.Interval)) + { + return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence)); + } + + return new DateTimeOffset(occurrence, daylightOffset); + } + + return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence)); + } + + private long FindOccurrence(long startTimeTicks, long endTimeTicks, bool startInclusive) + { + var found = FindOccurrence(startTimeTicks, startInclusive); + + if (found == NotFound || found > endTimeTicks) return NotFound; + return found; + } + + private long FindPreviousOccurrence(long startTimeTicks, long endTimeTicks, bool startInclusive) + { + var found = FindPreviousOccurrence(startTimeTicks, startInclusive); + + if (found == NotFound || found < endTimeTicks) return NotFound; + return found; + } + + private long FindOccurrence(long ticks, bool startInclusive) + { + if (!startInclusive) ticks++; + + CalendarHelper.FillDateTimeParts( + ticks, + out int startSecond, + out int startMinute, + out int startHour, + out int startDay, + out int startMonth, + out int startYear); + + var minMatchedDay = GetFirstSet(_dayOfMonth); + + var second = startSecond; + var minute = startMinute; + var hour = startHour; + var day = startDay; + var month = startMonth; + var year = startYear; + + if (!GetBit(_second, second) && !Move(_second, ref second)) minute++; + if (!GetBit(_minute, minute) && !Move(_minute, ref minute)) hour++; + if (!GetBit(_hour, hour) && !Move(_hour, ref hour)) day++; + + // If NearestWeekday flag is set it's possible forward shift. + if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CronField.DaysOfMonth.First; + + if (!GetBit(_dayOfMonth, day) && !Move(_dayOfMonth, ref day)) goto RetryMonth; + if (!GetBit(_month, month)) goto RetryMonth; + + Retry: + + if (day > GetLastDayOfMonth(year, month)) goto RetryMonth; + + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) day = GetLastDayOfMonth(year, month); + + var lastCheckedDay = day; + + if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CalendarHelper.MoveToNearestWeekDay(year, month, day); + + if (IsDayOfWeekMatch(year, month, day)) + { + if (CalendarHelper.IsGreaterThan(year, month, day, startYear, startMonth, startDay)) goto RolloverDay; + if (hour > startHour) goto RolloverHour; + if (minute > startMinute) goto RolloverMinute; + goto ReturnResult; + + RolloverDay: hour = GetFirstSet(_hour); + RolloverHour: minute = GetFirstSet(_minute); + RolloverMinute: second = GetFirstSet(_second); + + ReturnResult: + + var found = CalendarHelper.DateTimeToTicks(year, month, day, hour, minute, second); + if (found >= ticks) return found; + } + + day = lastCheckedDay; + if (Move(_dayOfMonth, ref day)) goto Retry; + + RetryMonth: + + if (!Move(_month, ref month)) + { + year++; + if (year > DateTime.MaxValue.Year) + { + return NotFound; + } + } + + day = minMatchedDay; + + goto Retry; + } + + private long FindPreviousOccurrence(long ticks, bool startInclusive) + { + if (!startInclusive) + { + if (ticks == DateTime.MinValue.Ticks) return NotFound; + ticks--; + } + + CalendarHelper.FillDateTimeParts( + ticks, + out int startSecond, + out int startMinute, + out int startHour, + out int startDay, + out int startMonth, + out int startYear); + + if (ticks % TimeSpan.TicksPerSecond != 0) startSecond--; + + var maxMatchedDay = HasFlag(CronExpressionFlag.DayOfMonthLast) || HasFlag(CronExpressionFlag.NearestWeekday) + ? CronField.DaysOfMonth.Last + : GetLastSet(_dayOfMonth); + + var second = startSecond; + var minute = startMinute; + var hour = startHour; + var day = startDay; + var month = startMonth; + var year = startYear; + + if (!GetBit(_second, second) && !MoveBack(_second, ref second)) minute--; + if (minute < CronField.Minutes.First || !GetBit(_minute, minute) && !MoveBack(_minute, ref minute)) hour--; + if (hour < CronField.Hours.First || !GetBit(_hour, hour) && !MoveBack(_hour, ref hour)) day--; + + if (!GetBit(_month, month)) goto RetryMonth; + + Retry: + + if (!TryGetPreviousDay(year, month, day, out var lastCheckedDay, out var actualDay)) goto RetryMonth; + + if (CalendarHelper.IsGreaterThan(startYear, startMonth, startDay, year, month, actualDay)) goto RolloverDay; + if (hour < startHour) goto RolloverHour; + if (minute < startMinute) goto RolloverMinute; + goto ReturnResult; + + RolloverDay: hour = GetLastSet(_hour); + RolloverHour: minute = GetLastSet(_minute); + RolloverMinute: second = GetLastSet(_second); + + ReturnResult: + + var found = CalendarHelper.DateTimeToTicks(year, month, actualDay, hour, minute, second); + if (found <= ticks) return found; + + day = lastCheckedDay; + if (MoveBackDay(ref day)) goto Retry; + + RetryMonth: + + if (!MoveBack(_month, ref month)) + { + year--; + if (year < DateTime.MinValue.Year) + { + return NotFound; + } + } + + day = maxMatchedDay; + + goto Retry; + } + + private static bool Move(ulong fieldBits, ref int fieldValue) + { + if (fieldBits >> ++fieldValue == 0) + { + fieldValue = GetFirstSet(fieldBits); + return false; + } + + fieldValue += GetFirstSet(fieldBits >> fieldValue); + return true; + } + + private static bool MoveBack(ulong fieldBits, ref int fieldValue) + { + var eligibleBits = GetBitsAtOrBelow(fieldBits, fieldValue - 1); + if (eligibleBits == 0) + { + fieldValue = GetLastSet(fieldBits); + return false; + } + + fieldValue = GetLastSet(eligibleBits); + return true; + } + + private bool TryGetPreviousDay(int year, int month, int dayLimit, out int day, out int actualDay) + { + var lastDayOfMonth = CalendarHelper.GetDaysInMonth(year, month); + var maxDay = dayLimit > lastDayOfMonth ? lastDayOfMonth : dayLimit; + + if (HasFlag(CronExpressionFlag.NearestWeekday)) + { + day = HasFlag(CronExpressionFlag.DayOfMonthLast) + ? GetLastDayOfMonth(year, month) + : GetFirstSet(_dayOfMonth); + + actualDay = CalendarHelper.MoveToNearestWeekDay(year, month, day); + return actualDay <= maxDay && IsDayOfWeekMatch(year, month, actualDay); + } + + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + day = GetLastDayOfMonth(year, month); + actualDay = day; + return actualDay <= maxDay && IsDayOfWeekMatch(year, month, actualDay); + } + + if (_dayOfMonth == CronField.DaysOfMonth.AllBits && + !HasFlag(CronExpressionFlag.DayOfWeekLast) && + !HasFlag(CronExpressionFlag.NthDayOfWeek)) + { + day = maxDay; + actualDay = day; + + if (_dayOfWeek == CronField.DaysOfWeek.AllBits) + { + return true; + } + + var dayOfWeek = (int)CalendarHelper.GetDayOfWeek(year, month, day); + + for (var delta = 0; delta < 7 && day - delta >= CronField.DaysOfMonth.First; delta++) + { + var candidateDayOfWeek = dayOfWeek - delta; + if (candidateDayOfWeek < 0) candidateDayOfWeek += 7; + + if (((_dayOfWeek >> candidateDayOfWeek) & 1) == 0) continue; + + day -= delta; + actualDay = day; + return true; + } + + actualDay = default; + return false; + } + + day = maxDay; + + if (!GetBit(_dayOfMonth, day) && !MoveBackDay(ref day)) + { + actualDay = default; + return false; + } + + Retry: + + if (day > lastDayOfMonth) + { + if (MoveBackDay(ref day)) goto Retry; + + actualDay = default; + return false; + } + + actualDay = day; + if (IsDayOfWeekMatch(year, month, actualDay)) + { + return true; + } + + if (MoveBackDay(ref day)) + { + goto Retry; + } + + actualDay = default; + return false; + } + + private bool MoveBackDay(ref int day) + { + if (HasFlag(CronExpressionFlag.NearestWeekday) || HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + return false; + } + + if (_dayOfMonth == CronField.DaysOfMonth.AllBits) + { + if (day <= CronField.DaysOfMonth.First) + { + day = CronField.DaysOfMonth.Last; + return false; + } + + day--; + return true; + } + + return MoveBack(_dayOfMonth, ref day); + } + + private bool IsDayMatch(int year, int month, int day) + { + bool dayOfMonthMatch; + + if (HasFlag(CronExpressionFlag.NearestWeekday)) + { + dayOfMonthMatch = false; + var lastDay = CalendarHelper.GetDaysInMonth(year, month); + + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + var targetDay = GetLastDayOfMonth(year, month); + dayOfMonthMatch = CalendarHelper.MoveToNearestWeekDay(year, month, targetDay) == day; + } + else + { + for (var targetDay = CronField.DaysOfMonth.First; targetDay <= lastDay; targetDay++) + { + if (!GetBit(_dayOfMonth, targetDay)) continue; + if (CalendarHelper.MoveToNearestWeekDay(year, month, targetDay) != day) continue; + + dayOfMonthMatch = true; + break; + } + } + } + else if (HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + dayOfMonthMatch = GetLastDayOfMonth(year, month) == day; + } + else + { + dayOfMonthMatch = GetBit(_dayOfMonth, day); + } + + return dayOfMonthMatch && IsDayOfWeekMatch(year, month, day); + } + + private int GetLastDayOfMonth(int year, int month) + { + return CalendarHelper.GetDaysInMonth(year, month) - _lastMonthOffset; + } + + private bool IsDayOfWeekMatch(int year, int month, int day) + { + if (HasFlag(CronExpressionFlag.DayOfWeekLast) && !CalendarHelper.IsLastDayOfWeek(year, month, day) || + HasFlag(CronExpressionFlag.NthDayOfWeek) && !CalendarHelper.IsNthDayOfWeek(day, _nthDayOfWeek)) + { + return false; + } + + if (_dayOfWeek == CronField.DaysOfWeek.AllBits) return true; + + var dayOfWeek = CalendarHelper.GetDayOfWeek(year, month, day); + + return ((_dayOfWeek >> (int)dayOfWeek) & 1) != 0; + } + + private static int GetFirstSet(ulong value) + { + // TODO: Add description and source + ulong res = unchecked((ulong)((long)value & -(long)value) * 0x022fdd63cc95386d) >> 58; + return DeBruijnPositions[res]; + } + + private static int GetLastSet(ulong value) + { + var index = 0; + + while ((value >>= 1) != 0) + { + index++; + } + + return index; + } + + private static int GetLastSetWithin(ulong fieldBits, int maxValue) + { + return GetLastSet(GetBitsAtOrBelow(fieldBits, maxValue)); + } + + private static ulong GetBitsAtOrBelow(ulong fieldBits, int maxValue) + { + if (maxValue < 0) return 0; + if (maxValue >= 63) return fieldBits; + + return fieldBits & ((1UL << (maxValue + 1)) - 1); + } + + private bool HasFlag(CronExpressionFlag value) + { + return (_flags & value) != 0; + } + + private static StringBuilder AppendFieldValue(StringBuilder expressionBuilder, CronField field, ulong fieldValue) + { + if (field.AllBits == fieldValue) return expressionBuilder.Append('*'); + + // Unset 7 bit for Day of week field because both 0 and 7 stand for Sunday. + if (field == CronField.DaysOfWeek) fieldValue &= ~(1U << field.Last); + + for (var i = GetFirstSet(fieldValue);; i = GetFirstSet(fieldValue >> i << i)) + { + expressionBuilder.Append(i); + if (fieldValue >> ++i == 0) break; + expressionBuilder.Append(','); + } + + return expressionBuilder; + } + + private StringBuilder AppendDayOfMonth(StringBuilder expressionBuilder, uint domValue) + { + if (HasFlag(CronExpressionFlag.DayOfMonthLast)) + { + expressionBuilder.Append('L'); + if (_lastMonthOffset != 0) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "-{0}", _lastMonthOffset)); + } + else + { + AppendFieldValue(expressionBuilder, CronField.DaysOfMonth, (uint)domValue); + } + + if (HasFlag(CronExpressionFlag.NearestWeekday)) expressionBuilder.Append('W'); + + return expressionBuilder; + } + + private void AppendDayOfWeek(StringBuilder expressionBuilder, uint dowValue) + { + AppendFieldValue(expressionBuilder, CronField.DaysOfWeek, dowValue); + + if (HasFlag(CronExpressionFlag.DayOfWeekLast)) expressionBuilder.Append('L'); + else if (HasFlag(CronExpressionFlag.NthDayOfWeek)) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "#{0}", _nthDayOfWeek)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowFromShouldBeLessThanToException(string fromName, string toName) + { + throw new ArgumentException($"The value of the {fromName} argument should be less than the value of the {toName} argument.", fromName); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowFromShouldBeGreaterThanToException(string fromName, string toName) + { + throw new ArgumentException($"The value of the {fromName} argument should be greater than or equal to the value of the {toName} argument.", fromName); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowWrongDateTimeKindException(string paramName) + { + throw new ArgumentException("The supplied DateTime must have the Kind property set to Utc", paramName); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + private static void ThrowArgumentNullException(string paramName) + { + throw new ArgumentNullException(paramName); + } + + private static bool GetBit(ulong value, int index) + { + return (value & (1UL << index)) != 0; + } + } +} diff --git a/tests/Cronos.Tests/CronExpressionReverseFacts.cs b/tests/Cronos.Tests/CronExpressionReverseFacts.cs new file mode 100644 index 0000000..437e9dd --- /dev/null +++ b/tests/Cronos.Tests/CronExpressionReverseFacts.cs @@ -0,0 +1,469 @@ +using System; +using System.Globalization; +using System.Linq; +using Xunit; + +namespace Cronos.Tests +{ + public class CronExpressionReverseFacts + { + private static readonly bool IsUnix = +#if NETCOREAPP1_1 + !System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); +#else + Environment.OSVersion.Platform == PlatformID.MacOSX || Environment.OSVersion.Platform == PlatformID.Unix; +#endif + + 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); + + [Theory] + [InlineData(DateTimeKind.Unspecified, false)] + [InlineData(DateTimeKind.Unspecified, true)] + [InlineData(DateTimeKind.Local, false)] + [InlineData(DateTimeKind.Local, true)] + public void GetPreviousOccurrence_ThrowsAnException_WhenFromDoesNotHaveUtcKind(DateTimeKind kind, bool inclusive) + { + var from = new DateTime(2017, 03, 15, 0, 0, 0, kind); + + var exception = Assert.Throws(() => CronExpression.EveryMinute.GetPreviousOccurrence(from, inclusive)); + + Assert.Equal("fromUtc", exception.ParamName); + } + + [Theory] + [InlineData(DateTimeKind.Unspecified, false)] + [InlineData(DateTimeKind.Unspecified, true)] + [InlineData(DateTimeKind.Local, false)] + [InlineData(DateTimeKind.Local, true)] + public void GetPreviousOccurrence_DateTimeTimeZone_ThrowsAnException_WhenFromHasAWrongKind(DateTimeKind kind, bool inclusive) + { + var from = new DateTime(2017, 03, 22, 0, 0, 0, kind); + + var exception = Assert.Throws(() => CronExpression.EveryMinute.GetPreviousOccurrence(from, TimeZoneInfo.Local, inclusive)); + + Assert.Equal("fromUtc", exception.ParamName); + } + + [Fact] + public void GetPreviousOccurrence_DateTimeTimeZone_ThrowsAnException_WhenZoneIsNull() + { + var exception = Assert.Throws( + () => CronExpression.EveryMinute.GetPreviousOccurrence(DateTime.UtcNow, null!)); + + Assert.Equal("zone", exception.ParamName); + } + + [Fact] + public void GetPreviousOccurrence_DateTimeOffsetTimeZone_ThrowsAnException_WhenZoneIsNull() + { + var exception = Assert.Throws( + () => CronExpression.EveryMinute.GetPreviousOccurrence(DateTimeOffset.UtcNow, null!)); + + Assert.Equal("zone", exception.ParamName); + } + + [Theory] + [InlineData(false, "2017-03-22 09:31:00")] + [InlineData(true, "2017-03-22 09:32:00")] + public void GetPreviousOccurrence_ReturnsCorrectUtcDate(bool inclusive, string expectedString) + { + var from = new DateTime(2017, 03, 22, 9, 32, 0, DateTimeKind.Utc); + var expected = GetUtcDateTime(expectedString); + + var occurrence = CronExpression.EveryMinute.GetPreviousOccurrence(from, inclusive); + + Assert.Equal(expected, occurrence); + Assert.Equal(DateTimeKind.Utc, occurrence?.Kind); + } + + [Fact] + public void GetPreviousOccurrence_ReturnsCorrectDate_WhenSecondsAreIncluded() + { + var expression = CronExpression.Parse("20 * * * * *", CronFormat.IncludeSeconds); + var from = GetUtcDateTime("2017-03-22 17:35:40"); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Equal(GetUtcDateTime("2017-03-22 17:35:20"), previous); + } + + [Fact] + public void GetPreviousOccurrence_ReturnsCorrectDate_ForComplexUtcExpression() + { + var expression = CronExpression.Parse("*/10 12-20 * DEC 3"); + var from = GetUtcDateTime("2017-12-06 12:00:00"); + + var previous = expression.GetPreviousOccurrence(from, inclusive: true); + + Assert.Equal(GetUtcDateTime("2017-12-06 12:00:00"), previous); + } + + [Theory] + [InlineData("10-30 * * * * *", "2017-03-22 17:35:31", "2017-03-22 17:35:30", CronFormat.IncludeSeconds)] + [InlineData("0 0 20-5/5 * *", "2017-06-05 00:00:00", "2017-06-04 00:00:00", CronFormat.Standard)] + [InlineData("0 0 * 12-2 *", "2017-03-01 00:00:00", "2017-02-28 00:00:00", CronFormat.Standard)] + [InlineData("0 0 * * thu-sat", "2016-12-12 00:00:00", "2016-12-10 00:00:00", CronFormat.Standard)] + [InlineData("0 5 18 13 * 5", "2017-10-13 18:05:00", "2017-01-13 18:05:00", CronFormat.IncludeSeconds)] + [InlineData("0 0 L-1 * *", "2016-12-30 00:00:00", "2016-11-29 00:00:00", CronFormat.Standard)] + [InlineData("0 0 L-2W * *", "2017-03-01 00:00:00", "2017-02-27 00:00:00", CronFormat.Standard)] + [InlineData("0 0 * * SUN#1", "2017-02-05 00:00:00", "2017-01-01 00:00:00", CronFormat.Standard)] + public void GetPreviousOccurrence_ReturnsCorrectDate_ForBroaderUtcMatrix(string cronExpression, string fromString, string expectedString, CronFormat format) + { + var expression = CronExpression.Parse(cronExpression, format); + var from = GetUtcDateTime(fromString); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Equal(GetUtcDateTime(expectedString), previous); + } + + [Fact] + public void GetPreviousOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasJitterSeed() + { + var expression = CronExpression.Parse("@daily", 12345); + var from = GetLocalInstant("2017-03-22 12:00:00", EasternTimeZone); + var startOfDay = GetLocalInstant("2017-03-22 00:00:00", EasternTimeZone); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + var next = expression.GetNextOccurrence(previous!.Value, EasternTimeZone); + var nextFromStartOfDay = expression.GetNextOccurrence(startOfDay, EasternTimeZone, inclusive: true); + + Assert.True(previous < from); + Assert.Equal(nextFromStartOfDay, next); + Assert.True(next > from); + } + + [Fact] + public void GetPreviousOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash() + { + var expression = CronExpression.Parse("H * * * *", 3); + var from = GetLocalInstant("2017-03-23 17:18:00", EasternTimeZone); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + var next = expression.GetNextOccurrence(previous!.Value, EasternTimeZone); + + Assert.Equal(GetLocalInstant("2017-03-23 17:17:00", EasternTimeZone), previous); + Assert.Equal(GetLocalInstant("2017-03-23 18:17:00", EasternTimeZone), next); + Assert.True(previous < from); + Assert.True(next > from); + } + + [Fact] + public void GetPreviousOccurrence_ReturnsNull_WhenCronExpressionIsUnreachable() + { + var expression = CronExpression.Parse("* * 31 2 *"); + var from = GetUtcDateTime("2017-03-22 00:00:00"); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Null(previous); + } + + [Fact] + public void GetPreviousOccurrence_RollsBackAcrossMonthBoundary() + { + var expression = CronExpression.Parse("0 0 1 * *"); + var from = GetUtcDateTime("2017-03-01 00:00:00"); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Equal(GetUtcDateTime("2017-02-01 00:00:00"), previous); + } + + [Theory] + [InlineData("0 0 L * *", "2017-03-31 00:00:00", "2017-02-28 00:00:00")] + [InlineData("0 0 1W * *", "2017-03-03 00:00:00", "2017-03-01 00:00:00")] + [InlineData("0 0 LW * *", "2017-04-01 00:00:00", "2017-03-31 00:00:00")] + [InlineData("0 0 * * 6#3", "2017-03-19 00:00:00", "2017-03-18 00:00:00")] + public void GetPreviousOccurrence_ReturnsCorrectDate_ForSpecialDayModifiers(string cronExpression, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression); + var from = GetUtcDateTime(fromString); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Equal(GetUtcDateTime(expectedString), previous); + } + + [Theory] + [InlineData("0 0 L-1W * *", "2017-03-01 00:00:00", "2017-02-27 00:00:00")] + [InlineData("0 0 ? * sat-tue", "2016-12-14 00:00:00", "2016-12-13 00:00:00")] + [InlineData("0 0 13 * 5", "2017-10-13 00:00:00", "2017-01-13 00:00:00")] + public void GetPreviousOccurrence_ReturnsCorrectDate_ForAdditionalReverseCases(string cronExpression, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression); + var from = GetUtcDateTime(fromString); + + var previous = expression.GetPreviousOccurrence(from); + + Assert.Equal(GetUtcDateTime(expectedString), previous); + } + + [Fact] + public void GetOccurrencesDescending_ReturnsExpectedCollection() + { + var expression = CronExpression.Parse("* * * * *"); + var from = GetUtcDateTime("2017-03-22 00:02:00"); + var to = GetUtcDateTime("2017-03-22 00:00:00"); + + var occurrences = expression + .GetOccurrencesDescending(from, to, fromInclusive: true, toInclusive: true) + .ToArray(); + + Assert.Equal( + new[] + { + GetUtcDateTime("2017-03-22 00:02:00"), + GetUtcDateTime("2017-03-22 00:01:00"), + GetUtcDateTime("2017-03-22 00:00:00") + }, + occurrences); + } + + [Fact] + public void GetOccurrencesDescending_ReturnsReverseOfForwardCollection() + { + var expression = CronExpression.Parse("*/15 * * * *"); + var from = GetUtcDateTime("2017-03-22 01:00:00"); + var to = GetUtcDateTime("2017-03-22 00:00:00"); + + var descending = expression.GetOccurrencesDescending(from, to, fromInclusive: true, toInclusive: true).ToArray(); + var ascending = expression.GetOccurrences(to, from, fromInclusive: true, toInclusive: true).Reverse().ToArray(); + + Assert.Equal(ascending, descending); + } + + [Fact] + public void GetOccurrencesDescending_ReturnsReverseOfForwardCollection_WhenZoneIsSpecified() + { + var expression = CronExpression.Parse("0 */30 * * * *", CronFormat.IncludeSeconds); + var from = GetUtcDateTime("2016-11-06 07:00:00"); + var to = GetUtcDateTime("2016-11-06 04:00:00"); + + var descending = expression.GetOccurrencesDescending(from, to, EasternTimeZone, fromInclusive: true, toInclusive: true).ToArray(); + var ascending = expression.GetOccurrences(to, from, EasternTimeZone, fromInclusive: true, toInclusive: true).Reverse().ToArray(); + + Assert.Equal(ascending, descending); + } + + [Fact] + public void GetPreviousOccurrence_CanStepAcrossJordanDstAdjustedMonthlySequence() + { + var expression = CronExpression.Parse("30 0 L * *"); + var from = GetInstant("2017-04-30 00:30:00 +03:00"); + + var previous = expression.GetPreviousOccurrence(from, JordanTimeZone, inclusive: false); + var beforePrevious = expression.GetPreviousOccurrence(previous!.Value, JordanTimeZone, inclusive: false); + + Assert.Equal(GetInstant("2017-03-31 00:30:00 +03:00"), previous); + Assert.Equal(GetInstant("2017-02-28 00:30:00 +02:00"), beforePrevious); + } + + [Fact] + public void GetPreviousOccurrence_CanStepAcrossLordHoweRepeatedHourIntervalSequence() + { + var expression = CronExpression.Parse("0 */30 1 * * *", CronFormat.IncludeSeconds); + var from = GetInstant("2017-04-02 01:30:00 +10:30"); + + var previous = expression.GetPreviousOccurrence(from, LordHoweTimeZone, inclusive: false); + var beforePrevious = expression.GetPreviousOccurrence(previous!.Value, LordHoweTimeZone, inclusive: false); + + Assert.Equal(GetInstant("2017-04-02 01:30:00 +11:00"), previous); + Assert.Equal(GetInstant("2017-04-02 01:00:00 +11:00"), beforePrevious); + } + + [Fact] + public void GetOccurrencesDescending_DateTime_ThrowsAnException_WhenFromLessThanTo() + { + var expression = CronExpression.Parse("* * * * *"); + + var exception = Assert.Throws( + () => expression.GetOccurrencesDescending(DateTime.UtcNow, DateTime.UtcNow.AddMinutes(1)).ToArray()); + + Assert.Equal("fromUtc", exception.ParamName); + } + + [Fact] + public void GetPreviousOccurrence_UsesSingleOccurrenceForAmbiguousNonIntervalTime() + { + var expression = CronExpression.Parse("30 1 * * *"); + var from = GetInstant("2017-11-05 03:00:00 -05:00"); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + + Assert.Equal(GetInstant("2017-11-05 01:30:00 -04:00"), previous); + } + + [Fact] + public void GetPreviousOccurrence_UsesSingleOccurrenceForAmbiguousNonIntervalHashTime() + { + var expression = CronExpression.Parse("0 H 1 * * *", CronFormat.IncludeSeconds, 3); + var from = GetInstant("2017-11-05 03:00:00 -05:00"); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + + Assert.Equal(GetInstant("2017-11-05 01:17:00 -04:00"), previous); + } + + [Fact] + public void GetPreviousOccurrence_PreservesAmbiguousIntervalOccurrencesInReverseOrder() + { + var expression = CronExpression.Parse("*/30 1 5 11 *"); + var from = GetInstant("2017-11-05 02:00:00 -05:00"); + + var first = expression.GetPreviousOccurrence(from, EasternTimeZone); + var second = expression.GetPreviousOccurrence(first!.Value, EasternTimeZone); + var third = expression.GetPreviousOccurrence(second!.Value, EasternTimeZone); + + Assert.Equal(GetInstant("2017-11-05 01:30:00 -05:00"), first); + Assert.Equal(GetInstant("2017-11-05 01:00:00 -05:00"), second); + Assert.Equal(GetInstant("2017-11-05 01:30:00 -04:00"), third); + } + + [Fact] + public void GetPreviousOccurrence_PreservesAmbiguousHashIntervalOccurrencesInReverseOrder() + { + var expression = CronExpression.Parse("0 H/30 1 * * *", CronFormat.IncludeSeconds, 3); + var from = GetInstant("2017-11-05 02:00:00 -05:00"); + + var first = expression.GetPreviousOccurrence(from, EasternTimeZone); + var second = expression.GetPreviousOccurrence(first!.Value, EasternTimeZone); + var third = expression.GetPreviousOccurrence(second!.Value, EasternTimeZone); + + Assert.Equal(GetInstant("2017-11-05 01:38:00 -05:00"), first); + Assert.Equal(GetInstant("2017-11-05 01:08:00 -05:00"), second); + Assert.Equal(GetInstant("2017-11-05 01:38:00 -04:00"), third); + } + + [Fact] + public void GetPreviousOccurrence_AdjustsInvalidTimeBackwardAcrossSpringForward() + { + var expression = CronExpression.Parse("30 2 * * *"); + var from = GetInstant("2017-03-12 04:00:00 -04:00"); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + + Assert.Equal(GetInstant("2017-03-12 01:59:59 -05:00"), previous); + } + + [Fact] + public void GetPreviousOccurrence_AdjustsInvalidHashTimeBackwardAcrossSpringForward() + { + var expression = CronExpression.Parse("0 H 2 * * *", CronFormat.IncludeSeconds, 3); + var from = GetInstant("2017-03-12 04:00:00 -04:00"); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone); + + Assert.Equal(GetInstant("2017-03-12 01:59:59 -05:00"), previous); + } + + [Theory] + [InlineData("30 0 L * *", "2017-04-30 00:30 +03:00", "2017-03-31 00:30 +03:00")] + [InlineData("30 0 LW * *", "2018-04-30 00:30 +03:00", "2018-03-30 00:30 +03:00")] + public void GetPreviousOccurrence_HandleJordanForwardShiftCases(string cronExpression, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression); + var from = GetInstant(fromString); + + var previous = expression.GetPreviousOccurrence(from, JordanTimeZone); + + Assert.Equal(GetInstant(expectedString), previous); + } + + [Fact] + public void GetPreviousOccurrence_HandleLordHoweBackwardShift_ForNonIntervalExpression() + { + var expression = CronExpression.Parse("0 30 1 * * *", CronFormat.IncludeSeconds); + var from = GetInstant("2017-04-03 01:30:00 +10:30"); + + var previous = expression.GetPreviousOccurrence(from, LordHoweTimeZone); + + Assert.Equal(GetInstant("2017-04-02 01:30:00 +11:00"), previous); + } + + [Fact] + public void GetPreviousOccurrence_HandlePacificBackwardShift_AroundRepeatedHour() + { + var expression = CronExpression.Parse("30 23 * * *"); + var from = GetInstant("2017-05-14 23:30:00 -04:00"); + + var previous = expression.GetPreviousOccurrence(from, PacificTimeZone); + + Assert.Equal(GetInstant("2017-05-13 23:30:00 -03:00"), previous); + } + + [Fact] + public void GetPreviousOccurrence_DoesNotReturnValueLaterThanNonRoundInput() + { + var expression = CronExpression.Parse("* * * * *"); + var from = GetInstant("2017-03-12 03:00:00.5000000 -04:00"); + + var previous = expression.GetPreviousOccurrence(from, EasternTimeZone, inclusive: true); + + Assert.True(previous <= from); + } + + [Fact] + public void GetPreviousOccurrence_FromDateTimeMinValueInclusive_SuccessfullyReturned() + { + var from = new DateTime(0, DateTimeKind.Utc); + + var previous = CronExpression.Parse("* * * * *") + .GetPreviousOccurrence(from, inclusive: true); + + Assert.Equal(from, previous); + } + + private static DateTime GetUtcDateTime(string dateTimeString) + { + var dateTime = DateTime.ParseExact( + dateTimeString, + new[] + { + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm" + }, + CultureInfo.InvariantCulture, + DateTimeStyles.None); + + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + + private static DateTimeOffset GetLocalInstant(string localDateTimeString, TimeZoneInfo zone) + { + var dateTime = DateTime.ParseExact( + localDateTimeString, + new[] + { + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm" + }, + CultureInfo.InvariantCulture, + DateTimeStyles.None); + + return new DateTimeOffset(dateTime, zone.GetUtcOffset(dateTime)); + } + + private static DateTimeOffset GetInstant(string dateTimeOffsetString) + { + return DateTimeOffset.ParseExact( + dateTimeOffsetString, + new[] + { + "yyyy-MM-dd HH:mm zzz", + "yyyy-MM-dd HH:mm:ss zzz", + "yyyy-MM-dd HH:mm:ss.fffffff zzz" + }, + CultureInfo.InvariantCulture, + DateTimeStyles.None); + } + } +}