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, 5, 15, 12, 30, 1, AllowedDays.All, 2024, 5, 16, 12, 30)] // Next day (Thursday) [InlineData(2024, 5, 15, 12, 30, 1, AllowedDays.Monday, 2024, 5, 20, 12, 30)] // Next Monday [InlineData(2024, 5, 15, 12, 30, 2, AllowedDays.Wednesday, 2024, 5, 29, 12, 30)] // Biweekly Wednesday public void WeeklyRecurrencePattern_BasicPatterns_ReturnsCorrectDay( int startYear, int startMonth, int startDay, int startHour, int startMinute, int intervalWeeks, AllowedDays allowedDays, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.Utc; var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays); 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, 12, 30, 12, 30, 1, AllowedDays.Monday, 2025, 1, 6, 12, 30)] // Across year boundary [InlineData(2024, 5, 31, 12, 30, 1, AllowedDays.Saturday, 2024, 6, 1, 12, 30)] // Across month boundary public void WeeklyRecurrencePattern_CrossBoundaries_ReturnsCorrectDay( int startYear, int startMonth, int startDay, int startHour, int startMinute, int intervalWeeks, AllowedDays allowedDays, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.Utc; var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays); 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, 5, 15, 12, 30, AllowedMonths.January | AllowedMonths.February, AllowedDays.Wednesday, 2025, 1, 1, 12, 30)] // Skip to January [InlineData(2024, 12, 15, 12, 30, AllowedMonths.January | AllowedMonths.December, AllowedDays.Wednesday, 2024, 12, 18, 12, 30)] // Same month [InlineData(2024, 1, 31, 12, 30, AllowedMonths.February | AllowedMonths.April, AllowedDays.Saturday, 2024, 2, 3, 12, 30)] // Next month allowed public void WeeklyRecurrencePattern_MonthFiltering_ReturnsCorrectDay( int startYear, int startMonth, int startDay, int startHour, int startMinute, AllowedMonths allowedMonths, AllowedDays allowedDays, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.Utc; var pattern = new WeeklyRecurrencePattern(1, allowedMonths, allowedDays); 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("America/New_York", 2024, 3, 9, 12, 0, 1, AllowedDays.Sunday, 2024, 3, 10, 12, 0)] // Before spring forward [InlineData("America/New_York", 2024, 3, 10, 2, 30, 1, AllowedDays.Sunday, 2024, 3, 17, 2, 30)] // During spring forward (invalid time) [InlineData("America/New_York", 2024, 11, 2, 12, 0, 1, AllowedDays.Sunday, 2024, 11, 3, 12, 0)] // Before fall back [InlineData("America/New_York", 2024, 11, 3, 1, 30, 1, AllowedDays.Sunday, 2024, 11, 10, 1, 30)] // During fall back (ambiguous time) public void WeeklyRecurrencePattern_DSTTransitions_HandlesCorrectly( string tzId, int startYear, int startMonth, int startDay, int startHour, int startMinute, int intervalWeeks, AllowedDays allowedDays, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays); var startLocal = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0); var afterUtc = startLocal.LocalToUtc(tz); var time = new TimeOnly(expectedHour, expectedMinute); // Act var result = pattern.GetNextOccurrence(afterUtc, time, tz); // Assert var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0), resultLocal); } [Theory] [InlineData(2024, 5, 29, 12, 0, AllowedDays.None, 2024, 5, 29, 12, 30)] // Default to current day [InlineData(2024, 5, 29, 12, 0, AllowedDays.Wednesday | AllowedDays.Friday, 2024, 5, 29, 12, 30)] // Current day is Wednesday [InlineData(2024, 5, 29, 12, 0, AllowedDays.Monday | AllowedDays.Friday, 2024, 5, 31, 12, 30)] // Next allowed day (Friday) public void WeeklyRecurrencePattern_DaysOfWeekHandling_ReturnsCorrectDay( int startYear, int startMonth, int startDay, int startHour, int startMinute, AllowedDays allowedDays, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.Utc; var pattern = new WeeklyRecurrencePattern(1, AllowedMonths.All, allowedDays); 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, 1, 29, 12, 0, 1, 2024, 1, 29, 12, 30, AllowedMonths.January | AllowedMonths.March, 2024, 1, 30, 12, 30)] // Current month allowed [InlineData(2024, 1, 31, 12, 0, 1, 2024, 1, 31, 12, 30, AllowedMonths.January | AllowedMonths.March, 2024, 3, 1, 12, 30)] // Skip February public void WeeklyRecurrencePattern_WeekSpanningMonths_ReturnsCorrectDay( int startYear, int startMonth, int startDay, int startHour, int startMinute, int intervalWeeks, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, AllowedMonths allowedMonths, int nextExpectedYear, int nextExpectedMonth, int nextExpectedDay, int nextExpectedHour, int nextExpectedMinute) { // Arrange var tz = TimeZoneInfo.Utc; var pattern = new WeeklyRecurrencePattern(intervalWeeks, allowedMonths); var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc); var time = new TimeOnly(expectedHour, expectedMinute); // Act - first call should hit expectedDate var result = pattern.GetNextOccurrence(afterUtc, time, tz); // Assert var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc); Assert.Equal(expected, result); // Call again - should move to next available day in allowed months var nextResult = pattern.GetNextOccurrence(result, time, tz); var nextExpected = new DateTime(nextExpectedYear, nextExpectedMonth, nextExpectedDay, nextExpectedHour, nextExpectedMinute, 0, DateTimeKind.Utc); Assert.Equal(nextExpected, nextResult); } [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); } [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); } }