using ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Tests.Prime; public class NextDueCalculatorTests { private static PrimeScheduleDto Schedule( DateOnly start, DateOnly end, TimeSpan time, bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); [Fact] public void Disabled_Schedule_Returns_Null() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false); Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); } [Fact] public void Future_Same_Day_Returns_Today_At_Target() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateTimeOffset(2026,5,5,7,0,0, now.Offset), r!.At); Assert.False(r.FireImmediately); } [Fact] public void Within_CatchUp_Window_Fires_Immediately() { var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.True(r!.FireImmediately); } [Fact] public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() { var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); } [Fact] public void WorkdaysOnly_Skips_Weekend() { var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek); Assert.Equal(new DateOnly(2026,5,11), DateOnly.FromDateTime(r.At.LocalDateTime)); } [Fact] public void Already_Fired_Today_Skips_To_Tomorrow() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun); var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); } [Fact] public void Past_EndDate_Returns_Null() { var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); } [Fact] public void Multiple_Schedules_Returns_Earliest() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(early.Id, r!.Schedule.Id); } }