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))); } }