using ClawdDotNet.Core.Tasks; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// Checking schedules, next run and runs per day (#594, #595, #597) as well as the hint /// about growing context (#598). /// public sealed class TaskboardExtensionTests { private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Utc); private static TaskWhen Cron(string value, string tz = "Europe/Berlin") => new() { Kind = TaskWhenKind.Cron, Value = value, TimeZone = tz }; // ─── #597 checking schedules ─── [Fact] public void Valid_schedule_with_range_and_step_passes_the_check() => TaskSchedule.ValidateWhen(Cron("0 7-22/3 * * *")).ShouldBeNull(); [Fact] public void Broken_cron_expression_is_named() => TaskSchedule.ValidateWhen(Cron("0 7-22/x * * *")).ShouldNotBeNull().ShouldContain("'x' ist keine Zahl"); [Fact] public void Unknown_time_zone_is_named() => TaskSchedule.ValidateWhen(Cron("0 9 * * *", "Phantasie/Ort")).ShouldNotBeNull().ShouldContain("Phantasie/Ort"); [Theory] [InlineData(TaskWhenKind.Every, "30m", true)] [InlineData(TaskWhenKind.Every, "halbstündlich", false)] [InlineData(TaskWhenKind.At, "2026-09-15T08:00", true)] [InlineData(TaskWhenKind.At, "morgen früh", false)] public void Interval_and_time_are_checked(TaskWhenKind kind, string value, bool valid) => (TaskSchedule.ValidateWhen(new TaskWhen { Kind = kind, Value = value, TimeZone = "Europe/Berlin" }) is null) .ShouldBe(valid); // ─── #594 next run ─── [Fact] public void Next_run_of_a_cron_schedule_in_Berlin_time() { var task = new TaskItem { Id = "t-7e0bbe", When = Cron("0 7-22/3 * * *") }; // 12:30 Berlin (CEST) -> next occurrence 13:00 Berlin = 11:00 UTC TaskSchedule.NextOccurrenceUtc(task, Utc(2026, 9, 14, 10, 30)).ShouldBe(Utc(2026, 9, 14, 11, 0)); } [Fact] public void Next_run_of_an_interval_counts_from_the_last_run() { var task = new TaskItem { Id = "t-1", When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "2h", TimeZone = "" }, LastOccurrence = "2026-09-14T09:00:00.0000000Z" }; TaskSchedule.NextOccurrenceUtc(task, Utc(2026, 9, 14, 10, 0)).ShouldBe(Utc(2026, 9, 14, 11, 0)); } [Fact] public void One_off_task_is_pending_until_it_ran() { var now = Utc(2026, 9, 14, 10, 0); TaskSchedule.NextOccurrenceUtc(new TaskItem { Id = "t-1" }, now).ShouldBe(now); TaskSchedule.NextOccurrenceUtc(new TaskItem { Id = "t-1", LastOccurrence = "x" }, now).ShouldBeNull(); } // ─── #595 runs per day ─── [Fact] public void Runs_per_day_from_cron_and_interval() { var now = Utc(2026, 9, 14, 10, 0); TaskSchedule.RunsPerDay(new TaskItem { When = Cron("0 7-22/3 * * *") }, now).ShouldBe(6); TaskSchedule.RunsPerDay(new TaskItem { When = Cron("5 * * * *") }, now).ShouldBe(24); TaskSchedule.RunsPerDay(new TaskItem { When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m" } }, now).ShouldBe(48); TaskSchedule.RunsPerDay(new TaskItem(), now).ShouldBe(0); } // ─── #598 hint ─── [Fact] public void Recurring_in_chat_history_gets_the_hint() => TaskHints.GrowingContext(new TaskItem { Assignee = "@nachrichten", When = Cron("0 7-22/3 * * *") }) .ShouldNotBeNull().ShouldContain("@new:nachrichten"); [Theory] [InlineData("@new:nachrichten", true)] [InlineData("@nachrichten", false)] [InlineData("@human", true)] public void No_hint_for_fresh_runs_one_off_and_human_tasks(string assignee, bool recurring) => TaskHints.GrowingContext(new TaskItem { Assignee = assignee, When = recurring ? Cron("5 * * * *") : null }).ShouldBeNull(); [Fact] public void No_hint_for_tool_jobs() => TaskHints.GrowingContext(new TaskItem { Assignee = "@telegram", Type = TaskItemType.ToolJob, When = Cron("* * * * *") }).ShouldBeNull(); }