using ClaudeDo.Data.Models; using ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Tests.Prime; public class NextDueCalculatorTests { private static PrimeScheduleDto Schedule( PrimeDays days, TimeSpan time, bool enabled = true, DateTimeOffset? lastRun = null) => new(Guid.NewGuid(), (int)days, time, 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(PrimeDays.All, new(7, 0, 0), enabled: false); Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30))); } [Fact] public void No_Days_Selected_Returns_Null() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var s = Schedule(PrimeDays.None, new(7, 0, 0)); 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)); // Tue var s = Schedule(PrimeDays.All, 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(PrimeDays.All, 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)); // Tue var s = Schedule(PrimeDays.All, 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 Weekdays_Only_Skips_Weekend() { var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0)); 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 Single_Day_Schedule_Targets_That_Weekday() { var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up var s = Schedule(PrimeDays.Friday, new(7, 0, 0)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)); Assert.NotNull(r); Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek); Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime)); } [Fact] public void Already_Fired_Today_Skips_To_Next_Eligible_Day() { 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(PrimeDays.All, 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 Multiple_Schedules_Returns_Earliest() { var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var early = Schedule(PrimeDays.All, new(7, 0, 0)); var late = Schedule(PrimeDays.All, 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); } }