Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs

88 lines
3.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}