Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs
EugeneTes fd6b9027b6 fix(scheduler): address review findings
- 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
2026-09-27 10:56:45 +02:00

102 lines
4.5 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));
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);
}
}