diff --git a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt index 0030fa1a..f9cedd4f 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt +++ b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt @@ -181,3 +181,78 @@ INSTANCES:20250101,20251231,20260101 DTSTART:20250601 RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=2,-2;UNTIL=20250630 INSTANCES:20250602,20250629 + +############################## START ERRATA 1913 TESTS ############################## +# RFC errata 1913 (https://www.rfc-editor.org/errata_search.php?rfc=1913&eid=1913): +# 'The numeric value in a BYDAY rule part with the FREQ rule part set to YEARLY corresponds +# to an offset within the month when the BYMONTH rule part is present, and corresponds to an +# offset within the year when the BYWEEKNO or BYMONTH rule parts are NOT present.' +# +# These 12 tests verify the behavior with the interpretation that +# * when only BYMONTH is present, the numeric BYDAY offset applies within the month, +# * when BYWEEKNO is present, the numeric BYDAY offset applies within the week. +# Note: RFC says that BYWEEKNO + numeric BYDAY is invalid. We're taking the lenient approach here. +# +# Disclaimer: Other iCalendar libraries may interpret this differently. + +# Yearly BYMONTH numeric BYDAY (2MO) - 2nd Monday in each BYMONTH +RRULE:FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4 +DTSTART:20260608T090000 +INSTANCES:20260608T090000,20260914T090000,20270614T090000,20270913T090000 + +# Yearly without BYMONTH numeric BYDAY (20MO) - 20th Monday of the YEAR +RRULE:FREQ=YEARLY;BYDAY=20MO;COUNT=3 +DTSTART:20260518T090000 +INSTANCES:20260518T090000,20270517T090000,20280515T090000 + +# Yearly BYMONTH negative BYDAY (-1SU) - last Sunday of each BYMONTH +RRULE:FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2 +DTSTART:20260628T090000 +INSTANCES:20260628T090000,20260927T090000 + +# Yearly BYMONTH=3 BYDAY=1MO - valid: 1st Monday of March each year +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=1MO;UNTIL=20270101T000000Z +DTSTART:20240304T090000 +START-AT:20240101 +INSTANCES:20240304T090000,20250303T090000,20260302T090000 + +# Yearly BYMONTH=3 BYDAY=6MO - invalid: 6th Monday in March doesn't exist +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=6MO +DTSTART:20240101T090000 +START-AT:20240101 +EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException +EXCEPTION-STEP:Enumeration + +# Yearly BYWEEKNO=10 BYDAY=1MO - valid: Monday of ISO week 10 each year +RRULE:FREQ=YEARLY;BYWEEKNO=10;BYDAY=1MO;COUNT=3 +DTSTART:20240304T090000 +INSTANCES:20240304T090000,20250303T090000,20260302T090000 + +# Yearly BYWEEKNO=10 BYDAY=2MO - invalid: 2nd Monday in a week doesn't exist -> out of range +RRULE:FREQ=YEARLY;BYWEEKNO=10;BYDAY=2MO +DTSTART:20240101T090000 +EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException +EXCEPTION-STEP:Enumeration + +# Monthly BYMONTHDAY=13 with BYDAY=MO +RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO;UNTIL=20271231T235959Z +DTSTART:20250913T090000 +START-AT:20251013 +INSTANCES:20251013T090000,20260413T090000,20260713T090000,20270913T090000,20271213T090000 + +# Yearly BYMONTHDAY=1,8 with BYDAY offsets (expand matrix - note2) +RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;UNTIL=20350101T000000 +DTSTART:20260601 +INSTANCES:20260601,20270608,20320608 + +# Monthly BYMONTHDAY=1,8; BYDAY=1MO,2TU - limiting behavior: Monthly by month day with day offsets +RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;UNTIL=20270101 +DTSTART:20260601 +INSTANCES:20260601,20260908,20261208 + +# Yearly BYMONTH=6-12; BYMONTHDAY=1,8; BYDAY=1MO,2TU - Yearly by month and by month day, with day offsets +RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;UNTIL=20270101 +DTSTART:20260601 +INSTANCES:20260601,20260908,20261208 + +############################## END ERRATA 1913 TESTS ############################## diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 437abec4..154eecd9 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -15,6 +15,7 @@ using Ical.Net.Evaluation; using Ical.Net.Serialization; using Ical.Net.Serialization.DataTypes; +using Ical.Net.Tests.TestHelpers; using NUnit.Framework; using NUnit.Framework.Constraints; @@ -32,38 +33,7 @@ private void EventOccurrenceTest( Period[] expectedPeriods, string[]? timeZones, int eventIndex - ) - { - var evt = cal.Events.Skip(eventIndex).First(); - var rule = evt.RecurrenceRules.FirstOrDefault(); - - var occurrences = toDate == null - ? evt.GetOccurrences(fromDate).ToList() - : evt.GetOccurrences(fromDate).TakeWhileBefore(toDate).ToList(); - - Assert.Multiple(() => - { - Assert.That( - occurrences, - Has.Count.EqualTo(expectedPeriods.Length), - "There should have been " + expectedPeriods.Length + " occurrences; there were " + occurrences.Count); - - if (evt.RecurrenceRules.Count > 0) - { - Assert.That(evt.RecurrenceRules, Has.Count.EqualTo(1)); - } - - for (var i = 0; i < expectedPeriods.Length; i++) - { - var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveDuration!.Value); - - Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period); - if (timeZones != null) - Assert.That(period.StartTime.TimeZoneName, Is.EqualTo(timeZones[i]), - "Event " + period + " should occur in the " + timeZones[i] + " timezone"); - } - }); - } + ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, eventIndex); private void EventOccurrenceTest( Calendar cal, @@ -71,22 +41,22 @@ private void EventOccurrenceTest( CalDateTime? toDate, Period[] expectedPeriods, string[]? timeZones - ) => EventOccurrenceTest(cal, fromDate, toDate, expectedPeriods, timeZones, 0); + ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, 0); - private static readonly TestCaseData[] EventOccurrenceTestCases = new TestCaseData[] - { + private static readonly TestCaseData[] EventOccurrenceTestCases = + [ new(""" DTSTART;TZID=Europe/Amsterdam:20201024T023000 DURATION:PT5M RRULE:FREQ=DAILY;UNTIL=20201025T010000Z """, - new[] - { + (string[]) + [ "20201024T023000/PT5M", "20201025T023000/PT5M" - } - ), - }; + ] + ) + ]; [Test, Category("Recurrence")] [TestCaseSource(nameof(EventOccurrenceTestCases))] diff --git a/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs new file mode 100644 index 00000000..94316e96 --- /dev/null +++ b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs @@ -0,0 +1,54 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// +#nullable enable +using System.Linq; +using Ical.Net.DataTypes; +using NUnit.Framework; + +namespace Ical.Net.Tests.TestHelpers; + +internal static class OccurrenceTester +{ + public static void AssertOccurrences( + Calendar cal, + CalDateTime? fromDate, + CalDateTime? toDate, + Period[] expectedPeriods, + string[]? timeZones, + int eventIndex + ) + { + var evt = cal.Events.Skip(eventIndex).First(); + + var occurrences = toDate == null + ? evt.GetOccurrences(fromDate).ToList() + : evt.GetOccurrences(fromDate).TakeWhileBefore(toDate).ToList(); + + Assert.Multiple(() => + { + Assert.That( + occurrences, + Has.Count.EqualTo(expectedPeriods.Length), + $"There should have been {expectedPeriods.Length} occurrences; there were {occurrences.Count}"); + + if (evt.RecurrenceRules.Count > 0) + { + Assert.That(evt.RecurrenceRules, Has.Count.EqualTo(1)); + } + + for (var i = 0; i < expectedPeriods.Length; i++) + { + var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveDuration!.Value); + + Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period); + if (timeZones != null) + { + Assert.That(period.StartTime.TimeZoneName, Is.EqualTo(timeZones[i]), + $"Event {period} should occur in the {timeZones[i]} timezone"); + } + } + }); + } +} diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index c5e5b4ee..5552449d 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -310,27 +310,29 @@ private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, Re private struct ExpandContext { /// - /// Indicates whether the dates have been fully expanded. If true, subsequent parts should only limit, not expand. + /// True when the candidate set has already been expanded by an earlier BY-* part. + /// When true, subsequent BY-* parts must only limit/filter, not expand. /// - /// - /// This makes a difference in case of BYWEEKNO, which might span months and years. After it was applied (BYWEEKNO would - /// always expand), the subsequent parts mustn't expand. - /// - public bool DatesFullyExpanded { get; set; } + /// + /// BYWEEKNO can expand the candidate set across month and year boundaries. When BYWEEKNO + /// performs expansion (i.e. the expand behavior is enabled for BYWEEKNO), the evaluator + /// marks the candidate set as expanded and subsequent BY‑parts must not expand again. + /// + public bool IsCandidateSetFullyExpanded { get; set; } } /// /// Returns a list of possible dates generated from the applicable BY* rules, using the specified date as a seed. /// - /// The seed date. + /// The seed date. It is always returned in the list of possible dates. /// /// /// A list of possible dates. - private IEnumerable GetCandidates(CalDateTime date, RecurrencePattern pattern, bool?[] expandBehaviors) + private IEnumerable GetCandidates(CalDateTime seedDate, RecurrencePattern pattern, bool?[] expandBehaviors) { - var expandContext = new ExpandContext() { DatesFullyExpanded = false }; + var expandContext = new ExpandContext { IsCandidateSetFullyExpanded = false }; - IEnumerable dates = [date]; + IEnumerable dates = [seedDate]; dates = GetMonthVariants(dates, pattern, expandBehaviors[0]); dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1], ref expandContext); dates = GetYearDayVariants(dates, pattern, expandBehaviors[2], ref expandContext); @@ -422,7 +424,7 @@ private static IEnumerable GetWeekNoVariants(IEnumerable GetByWeekNoForYearNormalized(RecurrencePattern pattern, /// /// /// Context that indicates whether earlier parts have already fully expanded the candidate set. - /// If is true then expansion must not be + /// If is true then expansion must not be /// performed again and the method should behave in limit mode. - /// When this method performs an expansion it will set + /// When this method performs an expansion it will set /// to true to prevent later parts from expanding again. /// /// @@ -506,9 +508,9 @@ private static IEnumerable GetYearDayVariants(IEnumerable GetMonthDayVariants(IEnumerable GetDayVariants(IEnumerable if (expand == null || pattern.ByDay.Count == 0) return dates; - if (expand.Value && !expandContext.DatesFullyExpanded) + if (expand.Value && !expandContext.IsCandidateSetFullyExpanded) { // Expand behavior - expandContext.DatesFullyExpanded = true; + expandContext.IsCandidateSetFullyExpanded = true; return GetDayVariantsExpanded(dates, pattern); } @@ -636,15 +638,25 @@ private static IEnumerable GetDayVariants(IEnumerable private static IEnumerable GetDayVariantsLimited(IEnumerable dates, RecurrencePattern pattern) => - // If no offset is specified, simply test the day of week! - // FIXME: test with offset... - dates.Where(date => pattern.ByDay.Any(weekDay => weekDay.DayOfWeek.Equals(date.DayOfWeek))); - + // If no offset is specified, simply test the day of week. + // When an offset is present, use GetAbsWeekDays to compute the concrete + // weekday dates according to the frequency and check containment. + dates.Where(date => pattern.ByDay.Any(weekDay => + { + if (weekDay.Offset is null) + return weekDay.DayOfWeek.Equals(date.DayOfWeek); + + // When limiting with an offset (e.g. "22MO" or "1MO"), compute the + // absolute dates for that WeekDay in the appropriate scope and + // check if the candidate matches one of them. + return GetAbsWeekDays(date, weekDay, pattern).Any(d => d.Equals(date)); + })); + private static IEnumerable GetDayVariantsExpanded(IEnumerable dates, RecurrencePattern pattern) { foreach (var date in dates) { - var weekDayDates = new SortedSet(); + var weekDayDates = new SortedSet(); // SortedSet uses CalDateTime.CompareTo foreach (var day in pattern.ByDay) foreach (var d in GetAbsWeekDays(date, day, pattern)) weekDayDates.Add(d); @@ -681,15 +693,16 @@ private static IEnumerable GetAbsWeekDaysDaily(CalDateTime date, We private static IEnumerable GetAbsWeekDaysYearly(CalDateTime date, WeekDay weekDay) { var year = date.Year; + var daysInYear = DateTime.IsLeapYear(year) ? 366 : 365; - // construct a list of possible year days.. + // Go to Jan 1 and find first occurrence of target weekday date = date.AddDays(-date.DayOfYear + 1); - while (date.DayOfWeek != weekDay.DayOfWeek) - { - date = date.AddDays(1); - } + var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7; + date = date.AddDays(offset); - while (date.Year == year) + // Yield all occurrences (52 or 53 per year) + var occurrenceCount = (daysInYear - offset + 6) / 7; + for (var i = 0; i < occurrenceCount; i++) { yield return date; date = date.AddDays(7); @@ -699,24 +712,31 @@ private static IEnumerable GetAbsWeekDaysYearly(CalDateTime date, W private static IEnumerable GetAbsWeekDaysMonthly(CalDateTime date, RecurrencePattern pattern, WeekDay weekDay) { var month = date.Month; + var year = date.Year; + var daysInMonth = Calendar.GetDaysInMonth(year, month); - // construct a list of possible month days.. + // Go to first day of month and find first occurrence of target weekday date = date.AddDays(-date.Day + 1); - while (date.DayOfWeek != weekDay.DayOfWeek) - { - date = date.AddDays(1); - } + var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7; + date = date.AddDays(offset); - var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)); - while (date.Month == month) - { - var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); + // Pre-calculate occurrence count (4 or 5 occurrences per month) + var occurrenceCount = (daysInMonth - offset + 6) / 7; + + var byWeekNoNormalized = pattern.ByWeekNo.Count > 0 + ? GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)) + : null; - if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo)) - && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))) + for (var i = 0; i < occurrenceCount; i++) + { + if (byWeekNoNormalized == null || byWeekNoNormalized.Contains(Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek))) { - yield return date; + if (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month)) + { + yield return date; + } } + date = date.AddDays(7); } } @@ -726,26 +746,31 @@ private static IEnumerable GetAbsWeekDaysWeekly(CalDateTime date, R var weekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); // Go to the first day of the week - date = date.AddDays(-GetWeekDayOffset(date, pattern.FirstDayOfWeek)); + var weekDayOffset = GetWeekDayOffset(date, pattern.FirstDayOfWeek); + date = date.AddDays(-weekDayOffset); - // construct a list of possible week days.. - while (date.DayOfWeek != weekDay.DayOfWeek) - { - date = date.AddDays(1); - } + // Find first occurrence of target weekday + var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7; + date = date.AddDays(offset); - var nextWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); - var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)); + var nextWeekNo = currentWeekNo; - //When we manage weekly recurring pattern and we have boundary case: - //Weekdays: Dec 31, Jan 1, Feb 1, Mar 1, Apr 1, May 1, June 1, Dec 31 - It's the 53th week of the year, but all another are 1st week number. + var byWeekNoNormalized = pattern.ByWeekNo.Count > 0 + ? GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)) + : null; + + // When we manage weekly recurring pattern, and we have boundary case: + // Weekdays: Dec 31, Jan 1, Feb 1, Mar 1, Apr 1, May 1, June 1, Dec 31 - + // It's the 53rd week of the year, but all others are 1st week number. while (currentWeekNo == weekNo || (nextWeekNo < weekNo && currentWeekNo == nextWeekNo && pattern.Frequency == FrequencyType.Weekly)) { - if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo)) - && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))) + if (byWeekNoNormalized == null || byWeekNoNormalized.Contains(currentWeekNo)) { - yield return date; + if (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month)) + { + yield return date; + } } date = date.AddDays(7); @@ -768,18 +793,23 @@ private static int GetWeekDayOffset(CalDateTime date, DayOfWeek startOfWeek) /// The position of the element to extract. private static IEnumerable GetOffsetDates(IEnumerable dates, int? offset) { - if (offset is null) - return dates; - - if (offset == 0) - throw new EvaluationException("Encountered a day offset of 0 which is not allowed."); - - if (offset < 0) { - offset = -offset; - dates = dates.Reverse(); + switch (offset) + { + case null: + return dates; + case 0: + throw new EvaluationException("Encountered a day offset of 0 which is not allowed."); + case < 0: + { + var list = dates as IList ?? dates.ToList(); + var index = list.Count + offset.Value; + return index >= 0 && index < list.Count + ? [list[index]] + : []; + } + default: + return dates.Skip(offset.Value - 1).Take(1); } - - return dates.Skip(offset.Value - 1).Take(1); } /// diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs index e7e9150b..1e1068f9 100644 --- a/Ical.Net/Evaluation/RecurrenceUtil.cs +++ b/Ical.Net/Evaluation/RecurrenceUtil.cs @@ -42,6 +42,9 @@ from p in periods public static bool?[] GetExpandBehaviorList(RecurrencePattern p) { // See the table in RFC 5545 Section 3.3.10 (Page 43). + // Index mapping (must match RecurrencePatternEvaluator.GetCandidates order!): + // 0 = BYMONTH, 1 = BYWEEKNO, 2 = BYYEARDAY, 3 = BYMONTHDAY, 4 = BYDAY, + // 5 = BYHOUR, 6 = BYMINUTE, 7 = BYSECOND, 8 = BYSETPOS (sentinel) switch (p.Frequency) { case FrequencyType.Minutely: @@ -53,11 +56,12 @@ from p in periods case FrequencyType.Weekly: return [false, null, null, null, true, true, true, true, false]; case FrequencyType.Monthly: - { - bool?[] row = [false, null, null, true, true, true, true, true, false]; + { + bool?[] row = [false, null, null, true, true, true, true, true, false]; - // Limit if BYMONTHDAY is present; otherwise, special expand for MONTHLY. - if (p.ByMonthDay.Count > 0) + // RFC 5545 Notes 1 & 2: + // BYDAY should act as a limiter when BYMONTHDAY or BYYEARDAY are present. + if (p.ByMonthDay.Count > 0 || p.ByYearDay.Count > 0) { row[4] = false; } @@ -68,10 +72,8 @@ from p in periods { bool?[] row = [true, true, true, true, true, true, true, true, false]; - // Limit if BYYEARDAY or BYMONTHDAY is present; otherwise, - // special expand for WEEKLY if BYWEEKNO present; otherwise, - // special expand for MONTHLY if BYMONTH present; otherwise, - // special expand for YEARLY. + // RFC 5545 Notes 1 & 2: + // BYDAY should act as a limiter when BYMONTHDAY or BYYEARDAY are present. if (p.ByYearDay.Count > 0 || p.ByMonthDay.Count > 0) { row[4] = false;