forked from admin/VolcanicAgenticCore
feat(scheduler): extension project, job model, cron and at parsing, validation
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class JobValidationTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin
|
||||
private static readonly TimeZoneInfo Berlin = ScheduleTests.Berlin;
|
||||
|
||||
private static string Error(NewJob job) =>
|
||||
Assert.Throws<JobValidationException>(() => JobValidation.ValidateNew(job, Now, Berlin)).Message;
|
||||
|
||||
[Fact]
|
||||
public void Past_at_is_rejected_beyond_the_grace()
|
||||
{
|
||||
Assert.Equal("at 2026-09-26 13:00 (Europe/Berlin) is in the past; now is 2026-09-26 14:03. Pick a future time.",
|
||||
Error(new NewJob("x", JobKind.Reminder, At: "2026-09-26T13:00", Text: "hi")));
|
||||
var schedule = JobValidation.ValidateNew(new NewJob("x", JobKind.Reminder, At: "2026-09-26T12:02:30Z", Text: "hi"), Now, Berlin);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 2, 30, TimeSpan.Zero), schedule.At); // within 60 s of grace
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("2026-09-27T09:00", "0 9 * * *")]
|
||||
public void Exactly_one_of_at_and_cron(string? at, string? cron) =>
|
||||
Assert.Equal("Set exactly one of at (a one-off time) or cron (a recurring schedule).",
|
||||
Error(new NewJob("x", JobKind.Reminder, at, cron, Text: "hi")));
|
||||
|
||||
[Fact]
|
||||
public void Kind_specific_fields()
|
||||
{
|
||||
const string cron = "0 9 * * *";
|
||||
Assert.Equal("A reminder needs text (the message to send).", Error(new NewJob("x", JobKind.Reminder, Cron: cron)));
|
||||
Assert.Equal("A prompt job needs text (what to ask the agent).", Error(new NewJob("x", JobKind.Prompt, Cron: cron)));
|
||||
Assert.Equal("command is only for watchers.", Error(new NewJob("x", JobKind.Reminder, Cron: cron, Text: "t", Command: "ls")));
|
||||
Assert.Equal("A watcher needs command (the shell command to run).", Error(new NewJob("x", JobKind.Watcher, Cron: cron)));
|
||||
Assert.Equal("A watcher takes no text: its script's output becomes the prompt.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Text: "t", Command: "ls")));
|
||||
Assert.Equal("timeoutSeconds is only for watchers.", Error(new NewJob("x", JobKind.Prompt, Cron: cron, Text: "t", TimeoutSeconds: 10)));
|
||||
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 0)));
|
||||
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3601)));
|
||||
Assert.Equal(new JobSchedule(null, cron), JobValidation.ValidateNew(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3600), Now, Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_is_required_and_capped()
|
||||
{
|
||||
Assert.Equal("name is required (a short label such as \"Call the dentist\").", Error(new NewJob(" ", JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||
Assert.Equal("name is 81 characters; the limit is 80.", Error(new NewJob(new string('n', 81), JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_is_capped_at_4000() =>
|
||||
Assert.Equal("text is 4001 characters; the limit is 4000.", Error(new NewJob("x", JobKind.Prompt, Cron: "@daily", Text: new string('t', 4001))));
|
||||
|
||||
[Fact]
|
||||
public void Stored_jobs_are_checked_without_the_future_rule()
|
||||
{
|
||||
var job = new ScheduledJob
|
||||
{
|
||||
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Reminder, Text = "hi",
|
||||
Schedule = new JobSchedule(Now.AddDays(-3), null), CreatedAt = Now.AddDays(-4),
|
||||
};
|
||||
Assert.Null(JobValidation.CheckStored(job)); // an overdue one-off is fine on load: it fires late
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(Now, "0 9 * * *") }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(null, "0 0 9 * * *") }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Kind = JobKind.Watcher }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Id = "" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user