fix: Changes EventScheduler API so it is more explicit (#2164)

### Summary

Refactors ScheduledEvent and EventScheduler API to use TimeOnly so recurrence offset is explicit.

API:

```cs
public ScheduledEvent(
    DateTime startAfter,
    DateTime endOn,
    TimeOnly time,
    IRecurrencePattern recurrence,
    TimeZoneInfo timeZone = null
)
```

Example:
```cs
// Schedule a daily event at 8:00 AM UTC
EventScheduler.DailyAt(
    new DateTime(2024, 6, 1, 8, 0, 0, DateTimeKind.Utc),
    () => Console.WriteLine("Daily event triggered!")
);

// Schedule a custom recurring event at 3:30 PM UTC every Monday
var recurrence = new WeeklyRecurrencePattern(1, DaysOfWeek.Monday);
EventScheduler.Shared.ScheduleEvent(
    DateTime.UtcNow,
    new TimeOnly(15, 30),
    () => Console.WriteLine("Weekly Monday event!"),
    recurrence
);
```
This commit is contained in:
Kamron Batman 2025-04-28 16:16:33 -07:00 • committed by GitHub
parent f89b2be653
commit 21a4092dd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 495 additions and 250 deletions

View file

@ -9,16 +9,62 @@ namespace UOContent.Tests;
[Collection("Sequential Tests")]
public class EventSchedulerTests
{
// Test implementations for controlled testing
private class TestRecurrencePattern : IRecurrencePattern
{
private readonly TimeSpan _interval;
private readonly int _maxOccurrences;
private int _currentOccurrence;
public TestRecurrencePattern(TimeSpan interval, int maxOccurrences = int.MaxValue)
{
_interval = interval;
_maxOccurrences = maxOccurrences;
_currentOccurrence = 0;
}
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
{
_currentOccurrence++;
if (_currentOccurrence > _maxOccurrences)
{
return DateTime.MaxValue;
}
return afterUtc + _interval;
}
}
private class TestScheduledEvent : ScheduledEvent
{
public int CallCount { get; private set; }
public Action Callback { get; }
public TestScheduledEvent(DateTime startAfter, Action callback, IRecurrencePattern recurrence = null)
: base(startAfter, TimeOnly.FromDateTime(startAfter), recurrence, TimeZoneInfo.Utc)
{
CallCount = 0;
Callback = callback;
}
public override void OnEvent()
{
CallCount++;
Callback?.Invoke();
}
}
private static void Init()
{
Core._now = DateTime.UtcNow;
Core._now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
Timer.Init(0);
EventScheduler.Configure();
}
private static void Finish()
{
EventScheduler.Instance.Stop();
EventScheduler.Shared.Stop();
}
[Fact]
@ -27,153 +73,20 @@ public class EventSchedulerTests
Init();
try
{
}
finally
{
bool called = false;
var evt = EventScheduler.Instance.ScheduleEvent(Core._now, () => called = true);
var evt = EventScheduler.Shared.ScheduleEvent(
Core._now,
TimeOnly.FromDateTime(Core._now),
() => called = true
);
Timer.Slice(8);
Assert.True(called);
Assert.Equal(Core._now, evt.NextOccurrence);
}
Finish();
}
[Theory]
[InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap (invalid)
[InlineData(2024, 11, 3, 1, 0, "America/New_York")] // DST fall back (ambiguous)
[InlineData(2024, 6, 1, 5, 0, "America/New_York")] // Normal time
public void HourlyRecurrence(
int year, int month, int day, int hour, int minute, string tzId)
{
Init();
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
// Set Core._now to just before the target hour
var beforeLocal = local.AddHours(-1);
Core._now = beforeLocal.LocalToUtc(tz);
bool called = false;
var evt = EventScheduler.Instance.ScheduleEvent(
Core._now,
() => called = true,
EventScheduler.Hourly,
tz
);
// Advance to the target hour
Core._now = local.LocalToUtc(tz);
Assert.Equal(Core._now, evt.NextOccurrence);
Timer.Slice(8);
// Hourly recurrence should always execute, even in DST gaps/ambiguous times
Assert.True(called);
Assert.Equal(Core._now.AddHours(1), evt.NextOccurrence);
}
finally
{
Finish();
}
}
[Theory]
[InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap
[InlineData(2024, 11, 3, 1, 30, "America/New_York")] // DST fall back
[InlineData(2024, 6, 2, 5, 0, "America/New_York")] // Normal time
public void MonthlyRecurrence(
int year, int month, int day, int hour, int minute, string tzId)
{
Init();
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
Core._now = local.AddDays(-1).LocalToUtc(tz);
bool called = false;
var evt = EventScheduler.Instance.ScheduleEvent(
Core._now,
() => called = true,
EventScheduler.GetMonthlyRecurrence(day),
tz
);
Core._now = local.LocalToUtc(tz);
var invalidTime = tz.IsInvalidTime(local);
// Invalid time ranges are not executed, so the occurrence is an additional month later
Assert.Equal(invalidTime ? local.AddMonths(1).LocalToUtc(tz) : Core._now, evt.NextOccurrence);
Timer.Slice(8);
if (invalidTime)
{
Assert.False(called);
}
else
{
Assert.True(called);
}
}
finally
{
Finish();
}
}
[Theory]
[InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York")] // DST fallback
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Last, "America/New_York")] // Last Sunday
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday
[InlineData(2024, 4, 3, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday (Doesn't exist)
public void MonthlyOrdinalRecurrence(
int year, int month, int day, int hour, int minute, DayOfWeek dow, OrdinalDayOccurrence ordinal, string tzId)
{
Init();
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = new DateTime(year, month, day, hour, minute, 0).AddDays(-1);
Core._now = local.LocalToUtc(tz);
bool called = false;
var pattern = new MonthlyOrdinalRecurrencePattern(ordinal, dow);
var evt = EventScheduler.Instance.ScheduleEvent(
Core._now,
() => called = true,
pattern,
tz
);
var testTime = Core._now.AddDays(1);
if (month == 4 && ordinal == OrdinalDayOccurrence.Fifth)
{
// If there is no fifth occurrence, NextOccurrence should be in the next month
var expectedNext = pattern.GetNextOccurrence(Core._now, tz);
Assert.Equal(expectedNext, evt.NextOccurrence);
Core._now = testTime;
Timer.Slice(8);
Assert.False(called);
}
else
{
Assert.Equal(testTime, evt.NextOccurrence);
Core._now = testTime;
Timer.Slice(8);
Assert.True(called);
}
EventScheduler.Shared.StopEvent(evt);
}
finally
{
@ -182,70 +95,45 @@ public class EventSchedulerTests
}
[Fact]
public void ScheduleEvent_NullCallback_Throws()
public void Scheduler_OrdersEventsByNextOccurrence()
{
Init();
try
{
Assert.Throws<ArgumentNullException>(() =>
EventScheduler.Instance.ScheduleEvent(Core._now, null)
var executionOrder = new List<int>();
// Create events with staggered occurrences
var evt3 = new TestScheduledEvent(
Core._now.AddSeconds(30),
() => executionOrder.Add(3)
);
}
finally
{
Finish();
}
}
[Fact]
public void AdvanceEvent_PastEndDate_DoesNotReschedule()
{
Init();
var evt1 = new TestScheduledEvent(
Core._now.AddSeconds(10),
() => executionOrder.Add(1)
);
try
{
bool called = false;
var evt = new CallbackScheduledEvent(Core._now, Core._now, () => called = true);
var evt2 = new TestScheduledEvent(
Core._now.AddSeconds(20),
() => executionOrder.Add(2)
);
EventScheduler.Instance.ScheduleEvent(evt);
// Add out of order
EventScheduler.Shared.ScheduleEvent(evt3);
EventScheduler.Shared.ScheduleEvent(evt1);
EventScheduler.Shared.ScheduleEvent(evt2);
// Advance time to after all events
Core._now = Core._now.AddSeconds(40);
Timer.Slice(8);
Assert.True(called);
Assert.DoesNotContain(
evt,
typeof(EventScheduler)
.GetField(
"_schedule",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
)!
.GetValue(EventScheduler.Instance) as IEnumerable<CallbackScheduledEvent> ?? []
);
}
finally
{
Finish();
}
}
// Verify they executed in time order, not addition order
Assert.Equal([1, 2, 3], executionOrder);
[Theory]
[InlineData("UTC")] // No DST adjustments
[InlineData("Asia/Kathmandu")] // Unusual offset (UTC+5:45)
public void LocalToUtc_SpecialTimeZones_HandlesCorrectly(string tzId)
{
Init();
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Unspecified);
var expected = TimeZoneInfo.ConvertTimeToUtc(local, tz);
var actual = local.LocalToUtc(tz);
Assert.Equal(expected, actual);
Assert.Equal(DateTimeKind.Utc, actual.Kind);
EventScheduler.Shared.StopEvent(evt1);
EventScheduler.Shared.StopEvent(evt2);
EventScheduler.Shared.StopEvent(evt3);
}
finally
{
@ -254,27 +142,167 @@ public class EventSchedulerTests
}
[Fact]
public void LocalToUtc_EdgeCases_BeforeAndAfterTransition()
public void Scheduler_HandlesRecurringEvents()
{
Init();
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
// 1:59 AM (just before spring forward)
var beforeSpring = new DateTime(2024, 3, 10, 1, 59, 0);
// 3:01 AM (just after spring forward)
var afterSpring = new DateTime(2024, 3, 10, 3, 1, 0);
int callCount = 0;
var beforeUtc = beforeSpring.LocalToUtc(tz);
var afterUtc = afterSpring.LocalToUtc(tz);
// Create a recurrence pattern that fires every 10 seconds, up to 3 times
var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3);
// Should be 1 hour + 2 minutes apart in UTC (not 1 hour 2 minutes)
Assert.Equal(2, (afterUtc - beforeUtc).TotalMinutes);
var evt = new TestScheduledEvent(
Core._now,
() => callCount++,
recurrence
);
EventScheduler.Shared.ScheduleEvent(evt);
// Advance time to after all occurrences should have happened
Core._now = Core._now.AddSeconds(50);
Timer.Slice(8);
// Should have fired 4 times (3 recurrences)
Assert.Equal(3, callCount);
Assert.Equal(3, evt.CallCount);
EventScheduler.Shared.StopEvent(evt);
}
finally
{
Finish();
}
}
[Fact]
public void Scheduler_HandlesExceptionsInEvents()
{
Init();
try
{
bool failedEventCalled = false;
bool laterEventCalled = false;
// Event that throws exception
var failedEvt = EventScheduler.Shared.ScheduleEvent(
Core._now.AddSeconds(10),
() =>
{
failedEventCalled = true;
throw new Exception("Test exception");
}
);
// Later event that should still execute
var laterEvt = EventScheduler.Shared.ScheduleEvent(
Core._now.AddSeconds(20),
() => laterEventCalled = true
);
// Advance time to after both events
Core._now = Core._now.AddSeconds(30);
Timer.Slice(8);
// Both should have been called despite the exception
Assert.True(failedEventCalled);
Assert.True(laterEventCalled);
EventScheduler.Shared.StopEvent(failedEvt);
EventScheduler.Shared.StopEvent(laterEvt);
}
finally
{
Finish();
}
}
[Fact]
public void Scheduler_RemovesEventsCorrectly()
{
Init();
try
{
bool eventCalled = false;
var evt = EventScheduler.Shared.ScheduleEvent(
Core._now.AddSeconds(10),
() => eventCalled = true
);
// Remove before execution
EventScheduler.Shared.StopEvent(evt);
// Advance time
Core._now = Core._now.AddSeconds(20);
Timer.Slice(8);
// Event should not have executed
Assert.False(eventCalled);
}
finally
{
Finish();
}
}
[Fact]
public void Scheduler_HandlesEventsWithEndDates()
{
Init();
try
{
int callCount = 0;
// Create a recurrence pattern that fires every 10 seconds
var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10));
// Create a custom event with an end date
var startTime = Core._now;
var endTime = Core._now.AddSeconds(36);
var customEvent = new CustomEvent(
startTime,
endTime,
TimeOnly.FromDateTime(startTime),
recurrence,
() => callCount++
);
EventScheduler.Shared.ScheduleEvent(customEvent);
Core._now = Core._now.AddSeconds(50);
Timer.Slice(8);
// Should have fired 3 times only (initial + 2 within the timeframe)
Assert.Equal(3, callCount);
}
finally
{
Finish();
}
}
private class CustomEvent : ScheduledEvent
{
private readonly Action _callback;
public CustomEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
IRecurrencePattern recurrence,
Action callback
) : base(startAfter, endOn, time, recurrence, TimeZoneInfo.Utc) => _callback = callback;
public override void OnEvent()
{
_callback?.Invoke();
}
}
}

View file

@ -0,0 +1,168 @@
using System;
using Server;
using Server.Engines.Events;
using Xunit;
namespace UOContent.Tests;
public class RecurrencePatternTests
{
[Theory]
[InlineData(2024, 5, 15, 12, 30, 1, 2024, 5, 15, 13, 30)] // Normal case
[InlineData(2024, 5, 15, 23, 30, 2, 2024, 5, 16, 1, 30)] // Cross day boundary
public void HourlyRecurrencePattern_GetNextOccurrence_ReturnsCorrectTime(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalHours,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new HourlyRecurrencePattern(intervalHours);
var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc);
var time = new TimeOnly(0, expectedMinute);
// Act
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
// Assert
var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(2024, 5, 15, 12, 30, 1, 2024, 5, 16, 12, 30)] // Next day
[InlineData(2024, 5, 31, 12, 30, 2, 2024, 6, 2, 12, 30)] // Across month boundary
public void DailyRecurrencePattern_GetNextOccurrence_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new DailyRecurrencePattern(intervalDays);
var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc);
var time = new TimeOnly(expectedHour, expectedMinute);
// Act
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
// Assert
var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York", 2024, 11, 3)]
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Last, "America/New_York", 2024, 3, 31)]
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York", 2024, 3, 31)]
[InlineData(2024, 4, 3, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York", 2024, 6, 30)] // June has 5 Sundays (2, 9, 16, 23, 30)
public void MonthlyOrdinalRecurrencePattern_GetNextOccurrence_ReturnsCorrectDate(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
DayOfWeek dow, OrdinalDayOccurrence ordinal, string tzId,
int expectedYear, int expectedMonth, int expectedDay)
{
// Arrange
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var pattern = new MonthlyOrdinalRecurrencePattern(ordinal, dow);
// Use a day BEFORE the expected date
var startLocal = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0);
var beforeUtc = startLocal.AddDays(-1).LocalToUtc(tz);
var timeOnly = new TimeOnly(startHour, startMinute);
// Act
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
// Assert - convert back to local for comparison
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, startHour, startMinute, 0), resultLocal);
}
[Theory]
[InlineData("America/New_York", 2024, 3, 10, 2, 30)] // Spring forward - 2:30 AM doesn't exist
[InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - 1:30 AM happens twice
public void MonthlyRecurrencePattern_DSTTransitions_HandlesCorrectly(
string tzId, int year, int month, int day, int hour, int minute)
{
// Arrange
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var pattern = new MonthlyRecurrencePattern(day);
var localDateBefore = new DateTime(year, month, day, 0, 0, 0);
var beforeUtc = localDateBefore.LocalToUtc(tz);
var timeOnly = new TimeOnly(hour, minute);
// Act - shouldn't throw exceptions
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
// Assert - just make sure we got a valid result
Assert.NotEqual(DateTime.MaxValue, result);
// For invalid times (spring forward), should skip to next valid time
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
if (month == 3) // Spring forward
{
// Should skip the invalid 2:30 AM time
Assert.True(resultLocal.Hour >= 3 || resultLocal > new DateTime(year, month, day));
}
}
[Fact]
public void MonthlyOrdinalRecurrencePattern_DST_FallBack_HandlesAmbiguousTimeCorrectly()
{
// This tests specifically the DST fall back case that was failing
var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var pattern = new MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence.First, DayOfWeek.Sunday);
// November 3, 2024 at 1:30 AM - First Sunday, falls on DST transition
var beforeDate = new DateTime(2024, 11, 2, 12, 0, 0);
var beforeUtc = beforeDate.LocalToUtc(tz);
var timeOnly = new TimeOnly(1, 30);
// Act
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
// Assert - should be November 3, 2024 at 1:30 AM
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
Assert.Equal(new DateTime(2024, 11, 3, 1, 30, 0), resultLocal);
}
[Fact]
public void MonthlyRecurrence_MonthWithoutDay_SkipsToNextValidMonth()
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new MonthlyRecurrencePattern(31);
// February doesn't have 31 days
var februaryDate = new DateTime(2024, 2, 1, 12, 0, 0, DateTimeKind.Utc);
var timeOnly = new TimeOnly(12, 0);
// Act
var result = pattern.GetNextOccurrence(februaryDate, timeOnly, tz);
// Assert - should be March 31
Assert.Equal(new DateTime(2024, 3, 31, 12, 0, 0), result);
}
// Additional utility method tests
[Theory]
[InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - ambiguous time
public void LocalToUtc_AmbiguousTime_HandlesConsistently(
string tzId, int year, int month, int day, int hour, int minute)
{
// Arrange
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var localTime = new DateTime(year, month, day, hour, minute, 0);
// Act
var result = localTime.LocalToUtc(tz);
// Assert - should be consistent, not testing exact value
Assert.Equal(DateTimeKind.Utc, result.Kind);
// Convert back should give either standard or DST time
var backToLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
Assert.Equal(new DateTime(year, month, day, hour, minute, 0), backToLocal);
}
}