forked from admin/VolcanicAgenticCore
88 lines
3.7 KiB
C#
88 lines
3.7 KiB
C#
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||
|
||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||
|
||
public sealed class ScheduleTests
|
||
{
|
||
internal static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||
internal static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
||
|
||
private static DateTimeOffset Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, TimeSpan.Zero);
|
||
|
||
private static List<DateTimeOffset> Occurrences(string cron, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone)
|
||
{
|
||
var list = new List<DateTimeOffset>();
|
||
for (var next = Schedules.NextCron(cron, from, zone); next is { } n && n < to; next = Schedules.NextCron(cron, n, zone)) list.Add(n);
|
||
return list;
|
||
}
|
||
|
||
[Fact]
|
||
public void Next_cron_occurrence_is_strictly_after_from_in_the_zone()
|
||
{
|
||
var nineBerlin = Utc(2026, 9, 26, 7, 0); // 09:00 CEST
|
||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Berlin));
|
||
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Tokyo)); // 09:00 JST on the 27th
|
||
}
|
||
|
||
[Fact]
|
||
public void Spring_forward_day_fires_once_at_the_transition()
|
||
{
|
||
// 2026-03-29: Berlin skips 02:00–03:00. 02:30 doesn't exist, so the job fires at the transition (01:00Z).
|
||
var fires = Occurrences("30 2 * * *", Utc(2026, 3, 28, 12, 0), Utc(2026, 3, 30, 12, 0), Berlin);
|
||
Assert.Equal([Utc(2026, 3, 29, 1, 0), Utc(2026, 3, 30, 0, 30)], fires);
|
||
}
|
||
|
||
[Fact]
|
||
public void Fall_back_day_fires_once()
|
||
{
|
||
// 2026-10-25: Berlin repeats 02:00–03:00; 02:30 happens twice but the job fires once that day.
|
||
var fires = Occurrences("30 2 * * *", Utc(2026, 10, 24, 12, 0), Utc(2026, 10, 26, 12, 0), Berlin);
|
||
Assert.Single(fires, f => TimeZoneInfo.ConvertTime(f, Berlin).Day == 25);
|
||
}
|
||
|
||
[Fact]
|
||
public void At_without_an_offset_is_read_in_the_zone()
|
||
{
|
||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00", Berlin));
|
||
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.ParseAt("2026-09-27T09:00", Tokyo));
|
||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27 09:00", Berlin));
|
||
Assert.Equal(Utc(2026, 9, 26, 22, 0), Schedules.ParseAt("2026-09-27", Berlin)); // a bare date is local midnight, not an offset
|
||
}
|
||
|
||
[Fact]
|
||
public void At_with_an_offset_is_used_as_given()
|
||
{
|
||
Assert.Equal(Utc(2026, 9, 27, 4, 0), Schedules.ParseAt("2026-09-27T09:00+05:00", Berlin));
|
||
Assert.Equal(Utc(2026, 9, 27, 9, 0), Schedules.ParseAt("2026-09-27T09:00Z", Tokyo));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("not a cron", "has 3 fields")]
|
||
[InlineData("61 * * * *", "is invalid")]
|
||
[InlineData("0 0 8 * * *", "seconds aren't supported")]
|
||
[InlineData("H * * * *", "H (hash)")]
|
||
[InlineData("0 H/2 * * *", "H (hash)")]
|
||
[InlineData("", "cron is empty")]
|
||
public void Invalid_cron_is_rejected_with_a_readable_message(string cron, string fragment)
|
||
{
|
||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseCron(cron));
|
||
Assert.Contains(fragment, ex.Message);
|
||
if (cron.Length > 0) Assert.Contains("minute hour day-of-month month day-of-week", ex.Message);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("@daily")]
|
||
[InlineData("@hourly")]
|
||
[InlineData("0 8 * * THU")]
|
||
[InlineData("*/10 * * * *")]
|
||
[InlineData("0 8 * * 1-5")]
|
||
public void Macros_and_day_names_parse(string cron) => Schedules.ParseCron(cron);
|
||
|
||
[Fact]
|
||
public void Unparseable_at_is_rejected()
|
||
{
|
||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt("tomorrow at nine", Berlin));
|
||
Assert.Contains("ISO 8601", ex.Message);
|
||
}
|
||
}
|