89 lines
3.6 KiB
C#
89 lines
3.6 KiB
C#
|
|
using w4c_workflows.Services.Triggers;
|
||
|
|
using Xunit;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Tests;
|
||
|
|
|
||
|
|
public class CronExpressionTests
|
||
|
|
{
|
||
|
|
private static DateTimeOffset Utc(int year, int month, int day, int hour, int minute)
|
||
|
|
=> new(year, month, day, hour, minute, 0, TimeSpan.Zero);
|
||
|
|
|
||
|
|
[Theory]
|
||
|
|
[InlineData("* * * * *", 2026, 8, 28, 14, 21, true)]
|
||
|
|
[InlineData("*/15 * * * *", 2026, 8, 28, 14, 0, true)]
|
||
|
|
[InlineData("*/15 * * * *", 2026, 8, 28, 14, 1, false)]
|
||
|
|
[InlineData("0 */6 * * *", 2026, 8, 28, 6, 0, true)]
|
||
|
|
[InlineData("0 */6 * * *", 2026, 8, 28, 12, 0, true)]
|
||
|
|
[InlineData("0 */6 * * *", 2026, 8, 28, 13, 0, false)]
|
||
|
|
[InlineData("0 9 * * 1-5", 2026, 8, 28, 9, 0, true)] // Friday
|
||
|
|
[InlineData("0 9 * * 1-5", 2026, 8, 29, 9, 0, false)] // Saturday
|
||
|
|
[InlineData("0 0 1 * *", 2026, 8, 1, 0, 0, true)]
|
||
|
|
[InlineData("0 0 1 * *", 2026, 8, 2, 0, 0, false)]
|
||
|
|
[InlineData("0 9 * * 0", 2026, 8, 30, 9, 0, true)] // Sunday via 0
|
||
|
|
[InlineData("0 9 * * 7", 2026, 8, 30, 9, 0, true)] // Sunday via alias 7
|
||
|
|
[InlineData("30 4,16 * * *", 2026, 8, 28, 16, 30, true)]
|
||
|
|
[InlineData("30 4,16 * * *", 2026, 8, 28, 12, 30, false)]
|
||
|
|
[InlineData("15-30 * * * *", 2026, 8, 28, 14, 22, true)]
|
||
|
|
[InlineData("15-30 * * * *", 2026, 8, 28, 14, 31, false)]
|
||
|
|
public void Matches_standard_5_field_expressions(
|
||
|
|
string expr, int y, int m, int d, int h, int min, bool expected)
|
||
|
|
{
|
||
|
|
Assert.True(CronExpression.TryParse(expr, out var cron, out var error), error);
|
||
|
|
Assert.Equal(expected, cron!.Matches(new DateTime(y, m, d, h, min, 0, DateTimeKind.Utc)));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void GetNextOccurrence_returns_next_matching_minute()
|
||
|
|
{
|
||
|
|
Assert.True(CronExpression.TryParse("0 9 * * *", out var cron, out _));
|
||
|
|
var next = cron!.GetNextOccurrence(Utc(2026, 8, 28, 10, 0));
|
||
|
|
Assert.Equal(Utc(2026, 8, 29, 9, 0), next);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void GetNextOccurrence_skips_to_next_day_for_weekday_schedule()
|
||
|
|
{
|
||
|
|
Assert.True(CronExpression.TryParse("0 9 * * 1-5", out var cron, out _));
|
||
|
|
// Friday 10:00 → next is Monday 09:00.
|
||
|
|
var next = cron!.GetNextOccurrence(Utc(2026, 8, 28, 10, 0));
|
||
|
|
Assert.Equal(Utc(2026, 8, 31, 9, 0), next);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void GetNextOccurrence_is_strictly_after_the_given_instant()
|
||
|
|
{
|
||
|
|
Assert.True(CronExpression.TryParse("* * * * *", out var cron, out _));
|
||
|
|
var after = Utc(2026, 8, 28, 14, 0);
|
||
|
|
var next = cron!.GetNextOccurrence(after);
|
||
|
|
Assert.Equal(Utc(2026, 8, 28, 14, 1), next);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Theory]
|
||
|
|
[InlineData("")]
|
||
|
|
[InlineData(" ")]
|
||
|
|
[InlineData("0 9 * *")] // 4 fields
|
||
|
|
[InlineData("0 9 * * * *")] // 6 fields
|
||
|
|
[InlineData("60 * * * *")] // minute out of range
|
||
|
|
[InlineData("0 24 * * *")] // hour out of range
|
||
|
|
[InlineData("0 9 32 * *")] // day-of-month out of range
|
||
|
|
[InlineData("0 9 * 13 *")] // month out of range
|
||
|
|
[InlineData("0 9 * * 8")] // day-of-week out of range
|
||
|
|
[InlineData("0 9 * * *")] // valid — ensure it is NOT rejected
|
||
|
|
public void TryParse_validates_field_count_and_ranges(string expr)
|
||
|
|
{
|
||
|
|
var result = CronExpression.TryParse(expr, out var cron, out _);
|
||
|
|
if (expr == "0 9 * * *")
|
||
|
|
Assert.True(result);
|
||
|
|
else
|
||
|
|
Assert.False(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void GetNextOccurrence_returns_null_for_unmatchable_schedule()
|
||
|
|
{
|
||
|
|
// Feb 30 never occurs — no match within the bounded horizon.
|
||
|
|
Assert.True(CronExpression.TryParse("0 0 30 2 *", out var cron, out _));
|
||
|
|
Assert.Null(cron!.GetNextOccurrence(Utc(2026, 8, 28, 0, 0)));
|
||
|
|
}
|
||
|
|
}
|