forked from admin/VolcanicAgenticCore
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
102 lines
4.5 KiB
C#
102 lines
4.5 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));
|
||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00:00.000+0200", Tokyo));
|
||
Assert.Equal(Utc(2026, 9, 27, 11, 0), Schedules.ParseAt("2026-09-27 09:00-02", Tokyo));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("09:00")] // no date: would take the machine's date, not the chat's
|
||
[InlineData("09:00+02:00")]
|
||
[InlineData("09/10/2026")] // culture formats aren't ISO 8601
|
||
[InlineData("27.09.2026 09:00")]
|
||
[InlineData("2026-09-27T09:00+15:00")] // beyond ±14:00
|
||
public void At_without_a_date_or_not_in_ISO_8601_is_rejected(string at)
|
||
{
|
||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt(at, Berlin));
|
||
Assert.Contains("ISO 8601 with the date", ex.Message);
|
||
}
|
||
|
||
[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);
|
||
}
|
||
}
|