From d699593458d018a77d544037926f5e58b15fa1ff Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 10:07:28 +0200 Subject: [PATCH] feat(scheduler): extension project, job model, cron and at parsing, validation --- VolcanicAgenticCore.slnx | 1 + .../JobValidation.cs | 80 +++++++++++++++++ .../Jobs.cs | 42 +++++++++ .../SchedulerOptions.cs | 34 ++++++++ .../Schedules.cs | 64 ++++++++++++++ ...ic.AgenticCore.Extensions.Scheduler.csproj | 14 +++ .../ZoneNames.cs | 22 +++++ .../Scheduler/JobValidationTests.cs | 69 +++++++++++++++ .../Scheduler/ScheduleTests.cs | 87 +++++++++++++++++++ ...lcanic.AgenticCore.Extensions.Tests.csproj | 1 + 10 files changed, 414 insertions(+) create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerOptions.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/Schedules.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/ZoneNames.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs diff --git a/VolcanicAgenticCore.slnx b/VolcanicAgenticCore.slnx index f387f8f..0ce0713 100644 --- a/VolcanicAgenticCore.slnx +++ b/VolcanicAgenticCore.slnx @@ -5,6 +5,7 @@ + diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs new file mode 100644 index 0000000..1697073 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs @@ -0,0 +1,80 @@ +using System.Globalization; + +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// Rules shared by the schedule tool (new jobs) and the store loader (stored jobs). +internal static class JobValidation +{ + public const int MaxNameLength = 80; + public const int MaxTextLength = 4000; + public const int MaxTimeoutSeconds = 3600; + public static readonly TimeSpan PastGrace = TimeSpan.FromSeconds(60); + + /// Validates a new job and returns its schedule (at resolved in ). Throws . + public static JobSchedule ValidateNew(NewJob job, DateTimeOffset now, TimeZoneInfo zone) + { + var name = job.Name?.Trim() ?? ""; + if (name.Length == 0) throw new JobValidationException("name is required (a short label such as \"Call the dentist\")."); + if (name.Length > MaxNameLength) throw new JobValidationException($"name is {Count(name.Length)} characters; the limit is {Count(MaxNameLength)}."); + var hasAt = !string.IsNullOrWhiteSpace(job.At); + var hasCron = !string.IsNullOrWhiteSpace(job.Cron); + if (hasAt == hasCron) throw new JobValidationException("Set exactly one of at (a one-off time) or cron (a recurring schedule)."); + CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds); + + if (hasCron) + { + Schedules.ParseCron(job.Cron!); + return new JobSchedule(null, job.Cron!.Trim()); + } + var at = Schedules.ParseAt(job.At!, zone); + if (at < now - PastGrace) + { + var local = TimeZoneInfo.ConvertTime(at, zone); + var localNow = TimeZoneInfo.ConvertTime(now, zone); + throw new JobValidationException(string.Create(CultureInfo.InvariantCulture, + $"at {local:yyyy-MM-dd HH:mm} ({ZoneNames.Name(zone)}) is in the past; now is {localNow:yyyy-MM-dd HH:mm}. Pick a future time.")); + } + return new JobSchedule(at, null); + } + + /// Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late). + public static string? CheckStored(ScheduledJob job) + { + try + { + if (string.IsNullOrWhiteSpace(job.Id) || string.IsNullOrWhiteSpace(job.Target)) return "missing id or target"; + if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > MaxNameLength) return "missing or too long name"; + if (job.Schedule is null || (job.Schedule.At is null) == (job.Schedule.Cron is null)) return "needs exactly one of at and cron"; + if (job.Schedule.Cron is { } cron) Schedules.ParseCron(cron); + CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds); + return null; + } + catch (JobValidationException ex) + { + return ex.Message; + } + } + + private static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds) + { + var hasText = !string.IsNullOrWhiteSpace(text); + var hasCommand = !string.IsNullOrWhiteSpace(command); + switch (kind) + { + case JobKind.Reminder when !hasText: throw new JobValidationException("A reminder needs text (the message to send)."); + case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent)."); + case JobKind.Reminder or JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers."); + case JobKind.Watcher when !hasCommand: throw new JobValidationException("A watcher needs command (the shell command to run)."); + case JobKind.Watcher when hasText: throw new JobValidationException("A watcher takes no text: its script's output becomes the prompt."); + case not (JobKind.Reminder or JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be reminder, prompt or watcher."); + } + if (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}."); + if (timeoutSeconds is { } seconds) + { + if (kind != JobKind.Watcher) throw new JobValidationException("timeoutSeconds is only for watchers."); + if (seconds is < 1 or > MaxTimeoutSeconds) throw new JobValidationException("timeoutSeconds must be between 1 and 3600."); + } + } + + private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs new file mode 100644 index 0000000..ea48f95 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs @@ -0,0 +1,42 @@ +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent. +public enum JobKind { Reminder, Prompt, Watcher } + +/// Exactly one is set. is an absolute instant; is 5-field wall-clock time in the target's zone. +public sealed record JobSchedule(DateTimeOffset? At, string? Cron); + +/// The outcome of a job's last fire. is stored redacted. +public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At); + +/// A persisted job (jobs.json). +public sealed record ScheduledJob +{ + public required string Id { get; init; } + /// Who the job belongs to: the chat id (invariant culture) on Telegram. + public required string Target { get; init; } + public required string Name { get; init; } + public required JobKind Kind { get; init; } + public required JobSchedule Schedule { get; init; } + /// Reminder and prompt only. + public string? Text { get; init; } + /// Watcher only. + public string? Command { get; init; } + /// Watcher only; default , at most 3600. + public int? TimeoutSeconds { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastRunAt { get; init; } + public JobResult? LastResult { get; init; } +} + +/// What the schedule tool asks for. is ISO 8601 text; without an offset it's read in the target's zone. +public sealed record NewJob(string Name, JobKind Kind, string? At = null, string? Cron = null, string? Text = null, string? Command = null, int? TimeoutSeconds = null); + +/// A job and its next fire time (null: never, e.g. a one-off already firing). +public sealed record JobView(ScheduledJob Job, DateTimeOffset? NextRunAt); + +/// What a zone change did to one target's jobs. +public sealed record ZoneChange(int CronJobs, int OneOffs); + +/// A job the model (or a stored file) got wrong. The message is a sentence the model can act on. +public sealed class JobValidationException(string message) : Exception(message); diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerOptions.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerOptions.cs new file mode 100644 index 0000000..eb7b71c --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerOptions.cs @@ -0,0 +1,34 @@ +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// The Scheduler configuration section. There is no zone here: zones are per target (the host's IJobDelivery.TimeZoneFor). +public sealed class SchedulerOptions +{ + public const string SectionName = "Scheduler"; + + public bool Enabled { get; set; } = true; + + /// jobs.json. Empty: the host fills under its agent directory. + public string? StorePath { get; set; } + + /// Watcher timeout when the job sets none. + public TimeSpan DefaultScriptTimeout { get; set; } = TimeSpan.FromSeconds(300); + + public int MaxJobsPerTarget { get; set; } = 50; + + /// Executable and leading arguments; the command is appended. Empty: /bin/sh -c, or cmd.exe /d /s /c on Windows (the bash tool's shell). + public List Shell { get; set; } = []; + + /// <agentDir>/scheduler/jobs.json. + public static string DefaultStorePath(string agentDir) => Path.Combine(agentDir, "scheduler", "jobs.json"); + + /// Configuration problems that must stop the host, empty when valid. + public IReadOnlyList Validate() + { + var errors = new List(); + if (DefaultScriptTimeout < TimeSpan.FromSeconds(1) || DefaultScriptTimeout > TimeSpan.FromSeconds(JobValidation.MaxTimeoutSeconds)) + errors.Add("Scheduler:DefaultScriptTimeout must be between 1 second and 1 hour."); + if (MaxJobsPerTarget < 1) errors.Add("Scheduler:MaxJobsPerTarget must be at least 1."); + if (Shell.Any(string.IsNullOrWhiteSpace)) errors.Add("Scheduler:Shell entries must not be empty."); + return errors; + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Schedules.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Schedules.cs new file mode 100644 index 0000000..f30fc0c --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Schedules.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Cronos; + +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// Cron (Cronos, 5 fields, in a zone) and one-off times. +public static partial class Schedules +{ + private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\"."; + + /// Parses a standard 5-field expression or a macro (@daily). Seconds and H are rejected. + public static CronExpression ParseCron(string expression) + { + var text = expression?.Trim() ?? ""; + if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint); + if (!text.StartsWith('@')) + { + var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal)))) + throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}"); + if (fields.Length == 6) + throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}"); + if (fields.Length != 5) + throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}"); + } + try + { + return CronExpression.Parse(text, CronFormat.Standard); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}"); + } + } + + /// The next occurrence strictly after , as wall-clock time in (Cronos DST rules). + public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) => + ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false); + + /// ISO 8601. With an offset (or Z) it's used as given; without one it's wall-clock time in . + public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone) + { + var trimmed = text?.Trim() ?? ""; + var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00."; + if (HasOffset().IsMatch(trimmed)) + { + return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute) + ? absolute + : throw new JobValidationException(hint); + } + if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint); + wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); + // A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour). + if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1); + // An ambiguous fall-back time is read as the first of the two (the larger offset). + var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall); + return new DateTimeOffset(wall, offset); + } + + /// A time of day followed by Z or an offset. A bare date such as 2026-09-27 must not match (its -27 isn't an offset). + [GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)] + private static partial Regex HasOffset(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj new file mode 100644 index 0000000..5311e90 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj @@ -0,0 +1,14 @@ + + + Scheduler extension: one-off and cron reminders, scheduled agent prompts and watcher scripts. + + + + + + + + + + + diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ZoneNames.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ZoneNames.cs new file mode 100644 index 0000000..3532278 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ZoneNames.cs @@ -0,0 +1,22 @@ +using System.Globalization; + +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// Zone ids and offsets for display and TZ (the extension can't reference the core's TimeZoneIds). +internal static class ZoneNames +{ + /// The IANA id (converting a Windows id), or null for an unnamed Local zone. + public static string? Iana(TimeZoneInfo zone) + { + if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null; + if (zone.HasIanaId) return zone.Id; + return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null; + } + + /// The IANA id when there is one, else the zone's own id. + public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id; + + /// UTC+09:00. + public static string Offset(TimeSpan offset) => + (offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", CultureInfo.InvariantCulture); +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs new file mode 100644 index 0000000..d4153ab --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/JobValidationTests.cs @@ -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(() => 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 = "" })); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs new file mode 100644 index 0000000..b537986 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleTests.cs @@ -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 Occurrences(string cron, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone) + { + var list = new List(); + 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(() => 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(() => Schedules.ParseAt("tomorrow at nine", Berlin)); + Assert.Contains("ISO 8601", ex.Message); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj index 3f20899..2890f7e 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj @@ -24,6 +24,7 @@ +