From 9a265df65d34129c2cb5eb7d13befab7c81c3bba Mon Sep 17 00:00:00 2001 From: axunonb Date: Mon, 1 Dec 2025 18:25:09 +0100 Subject: [PATCH 1/5] Implement `BYDAY` with offset and limiting behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The following `RRULE` cases were not implemented (see the diff for newly added unit tests for details): 1. YEARLY + BYMONTH + numeric BYDAY offsets - Pattern: `FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO` - Semantics: treat numeric BYDAY as “nth weekday inside each BYMONTH” (produce the 2nd Monday of each specified month). 2. YEARLY + numeric BYDAY without BYMONTH - Pattern: `FREQ=YEARLY;BYDAY=20MO` - Semantics: interpret numeric BYDAY as “nth weekday of the YEAR” (the 20th Monday of the year). 3. YEARLY + BYMONTH + negative numeric BYDAY - Pattern: `FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU` - Semantics: negative offsets select from the end of the month (e.g. `-1SU` → last Sunday in each BYMONTH). 4. Implemented the per-month offset expansion and preserved BYWEEKNO / BYMONTH compatibility and negative-offset These behaviors are closely related and required special handling in `RecurrencePatternEvaluator.GetAbsWeekDays` and `GetAbsWeekDaysYearlyPerMonthOffsets`. Fixes #782 --- Ical.Net.Tests/RecurrenceTests.cs | 129 ++++++++++++++- .../RecurrenceYearlyByMonthOffsetsTests.cs | 147 ++++++++++++++++++ .../Evaluation/RecurrencePatternEvaluator.cs | 63 +++++--- Ical.Net/Evaluation/RecurrenceUtil.cs | 18 ++- 4 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 437abec49..4c4ab8930 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -35,7 +35,6 @@ int eventIndex ) { var evt = cal.Events.Skip(eventIndex).First(); - var rule = evt.RecurrenceRules.FirstOrDefault(); var occurrences = toDate == null ? evt.GetOccurrences(fromDate).ToList() @@ -4807,4 +4806,132 @@ public void GetOccurrences_WithMultipleOverridesForSameRecurrenceId_ShouldUseLat Assert.That(occurrences.Select(o => o.Period.StartTime).ToArray(), Is.EqualTo(expected)); } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_SimpleCase() + { + const string tzId = "Europe/Berlin"; + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART;TZID=Europe/Berlin:20250913T090000 + DURATION:PT1H + RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var from = new CalDateTime(2025, 1, 1); + var to = new CalDateTime(2028, 1, 1); + + // Expected occurrences: only months, where the 13th is a Monday + var expected = new[] + { + new Period(new CalDateTime(2025, 10, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2026, 4, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2026, 7, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2027, 9, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2027, 12, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + }; + + EventOccurrenceTest(cal, from, to, expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByMonthDay_ExpandMatrix_Note2() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var expected = new[] + { + new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new Period(new CalDateTime(2027, 6, 8), Duration.FromDays(1)), + new Period(new CalDateTime(2032, 6, 8), Duration.FromDays(1)), + }; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2035, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_MonthlyByMonthDay_WithOffsets_LimitingBehavior() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var expected = new[] + { + new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), + }; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByMonthAndMonthDay_WithOffsets() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var expected = new[] + { + new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), + }; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByYearDay_WithOffsets_LimitingBehavior() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYYEARDAY=152,173,251,272,321,342;BYDAY=22MO,26MO,36TU,37TU,49TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var expected = new[] + { + new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), + }; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2030, 1, 1), expected, null); + } } diff --git a/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs b/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs new file mode 100644 index 000000000..501a3cbfd --- /dev/null +++ b/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs @@ -0,0 +1,147 @@ +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; + +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +public class RecurrenceYearlyByMonthOffsetsTests +{ + /// + /// Helper: nth weekday in a given month (n > 0 => nth, n < 0 => -nth from end) + /// + /// + private static CalDateTime GetNthWeekdayOfMonth(int year, int month, DayOfWeek dow, int n) + { + var firstOfMonth = new CalDateTime(year, month, 1, 9, 0, 0); + var d = firstOfMonth; + while (d.DayOfWeek != dow && d.Month == month) + { + d = d.AddDays(1); + } + + var list = new List(); + while (d.Month == month) + { + list.Add(d); + d = d.AddDays(7); + } + + if (n > 0) + { + return (n <= list.Count) ? list[n - 1] : throw new InvalidOperationException("Offset out of range"); + } + + var idx = list.Count + n; // n negative + return (idx >= 0 && idx < list.Count) ? list[idx] : throw new InvalidOperationException("Offset out of range"); + } + + /// + /// Helper: nth weekday of the year (n > 0) + /// + /// + private static CalDateTime GetNthWeekdayOfYear(int year, DayOfWeek dow, int n) + { + var d = new CalDateTime(year, 1, 1, 9, 0, 0); + while (d.DayOfWeek != dow) + { + d = d.AddDays(1); + } + + var result = d.AddDays((n - 1) * 7); + if (result.Year != year) + throw new InvalidOperationException("Offset out of range for year"); + return result; + } + + [Test, Category("Recurrence")] + public void Yearly_ByMonth_With_NumericByDay_Offset_Produces_NthWeekdayPerMonth() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 6, 1, 9, 0, 0), + Duration = Duration.FromHours(1) + }; + + // 2nd Monday of each BYMONTH + evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4")); + + var cal = new Calendar(); + cal.Events.Add(evt); + + var occ = cal.GetOccurrences(new CalDateTime(2026, 1, 1)).Take(4).Select(o => o.Period.StartTime).ToList(); + + var expected = + new[] + { + GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Monday, 2), + GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Monday, 2), + GetNthWeekdayOfMonth(2027, 6, DayOfWeek.Monday, 2), + GetNthWeekdayOfMonth(2027, 9, DayOfWeek.Monday, 2), + }; + + Assert.That(occ, Is.EqualTo(expected)); + } + + [Test, Category("Recurrence")] + public void Yearly_Without_ByMonth_NumericByDay_Interpreted_As_NthWeekdayOfYear() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 1, 1, 9, 0, 0), + Duration = Duration.FromHours(1) + }; + + // 20th Monday of the YEAR + evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYDAY=20MO;COUNT=3")); + + var cal = new Calendar(); + cal.Events.Add(evt); + + var occ = cal.GetOccurrences(new CalDateTime(1997, 1, 1)).Take(3).Select(o => o.Period.StartTime).ToList(); + + var expected = + new[] + { + GetNthWeekdayOfYear(2026, DayOfWeek.Monday, 20), + GetNthWeekdayOfYear(2027, DayOfWeek.Monday, 20), + GetNthWeekdayOfYear(2028, DayOfWeek.Monday, 20), + }; + + Assert.That(occ, Is.EqualTo(expected)); + } + + [Test, Category("Recurrence")] + public void Yearly_ByMonth_With_NegativeNumericByDay_Returns_LastWeekdayOfEachMonth() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 6, 1, 9, 0, 0), + Duration = Duration.FromHours(1) + }; + + // last Sunday of each BYMONTH + evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2")); + + var cal = new Calendar(); + cal.Events.Add(evt); + + var occ = cal.GetOccurrences(new CalDateTime(2026, 1, 1)).Take(2).Select(o => o.Period.StartTime).ToList(); + + var expected = + new[] + { + GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Sunday, -1), + GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Sunday, -1), + }; + + Assert.That(occ, Is.EqualTo(expected)); + } +} diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index c5e5b4eec..382bc5430 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -310,27 +310,30 @@ 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. /// /// + /// The anchor month for BYMONTHDAY expansion when BYMONTH is not specified. /// A list of possible dates. private IEnumerable GetCandidates(CalDateTime date, 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 +425,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 +509,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 +639,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); @@ -738,8 +751,8 @@ private static IEnumerable GetAbsWeekDaysWeekly(CalDateTime date, R var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)); - //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. + // 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. while (currentWeekNo == weekNo || (nextWeekNo < weekNo && currentWeekNo == nextWeekNo && pattern.Frequency == FrequencyType.Weekly)) { if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo)) diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs index e7e9150bc..1e1068f97 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; From 8aceaa440d2da9477c285368f3440d3ab8d559f4 Mon Sep 17 00:00:00 2001 From: axunonb Date: Wed, 3 Dec 2025 01:47:10 +0100 Subject: [PATCH 2/5] Unit tests: Introduce OccurrenceTester as a helper usable for all test classes --- Ical.Net.Tests/RecurrenceTests.cs | 177 +------- .../RecurrenceYearlyByMonthOffsetsTests.cs | 147 ------- .../RecurrenceYearlyWithOffsetsTests.cs | 379 ++++++++++++++++++ Ical.Net.Tests/TestHelpers/CalCalc.cs | 55 +++ .../TestHelpers/OccurrenceTester.cs | 54 +++ 5 files changed, 498 insertions(+), 314 deletions(-) delete mode 100644 Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs create mode 100644 Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs create mode 100644 Ical.Net.Tests/TestHelpers/CalCalc.cs create mode 100644 Ical.Net.Tests/TestHelpers/OccurrenceTester.cs diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 4c4ab8930..154eecd9d 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,37 +33,7 @@ private void EventOccurrenceTest( 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"); - } - }); - } + ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, eventIndex); private void EventOccurrenceTest( Calendar cal, @@ -70,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))] @@ -4806,132 +4777,4 @@ public void GetOccurrences_WithMultipleOverridesForSameRecurrenceId_ShouldUseLat Assert.That(occurrences.Select(o => o.Period.StartTime).ToArray(), Is.EqualTo(expected)); } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_SimpleCase() - { - const string tzId = "Europe/Berlin"; - var ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART;TZID=Europe/Berlin:20250913T090000 - DURATION:PT1H - RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var from = new CalDateTime(2025, 1, 1); - var to = new CalDateTime(2028, 1, 1); - - // Expected occurrences: only months, where the 13th is a Monday - var expected = new[] - { - new Period(new CalDateTime(2025, 10, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2026, 4, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2026, 7, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2027, 9, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2027, 12, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - }; - - EventOccurrenceTest(cal, from, to, expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByMonthDay_ExpandMatrix_Note2() - { - var ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var expected = new[] - { - new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new Period(new CalDateTime(2027, 6, 8), Duration.FromDays(1)), - new Period(new CalDateTime(2032, 6, 8), Duration.FromDays(1)), - }; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2035, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_MonthlyByMonthDay_WithOffsets_LimitingBehavior() - { - var ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var expected = new[] - { - new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), - }; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByMonthAndMonthDay_WithOffsets() - { - var ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var expected = new[] - { - new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), - }; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByYearDay_WithOffsets_LimitingBehavior() - { - var ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYYEARDAY=152,173,251,272,321,342;BYDAY=22MO,26MO,36TU,37TU,49TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var expected = new[] - { - new Period(new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new Period(new CalDateTime(2026, 12, 8), Duration.FromDays(1)), - }; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2030, 1, 1), expected, null); - } } diff --git a/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs b/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs deleted file mode 100644 index 501a3cbfd..000000000 --- a/Ical.Net.Tests/RecurrenceYearlyByMonthOffsetsTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright ical.net project maintainers and contributors. -// Licensed under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; - -using Ical.Net.CalendarComponents; -using Ical.Net.DataTypes; -using NUnit.Framework; - -namespace Ical.Net.Tests; - -[TestFixture] -public class RecurrenceYearlyByMonthOffsetsTests -{ - /// - /// Helper: nth weekday in a given month (n > 0 => nth, n < 0 => -nth from end) - /// - /// - private static CalDateTime GetNthWeekdayOfMonth(int year, int month, DayOfWeek dow, int n) - { - var firstOfMonth = new CalDateTime(year, month, 1, 9, 0, 0); - var d = firstOfMonth; - while (d.DayOfWeek != dow && d.Month == month) - { - d = d.AddDays(1); - } - - var list = new List(); - while (d.Month == month) - { - list.Add(d); - d = d.AddDays(7); - } - - if (n > 0) - { - return (n <= list.Count) ? list[n - 1] : throw new InvalidOperationException("Offset out of range"); - } - - var idx = list.Count + n; // n negative - return (idx >= 0 && idx < list.Count) ? list[idx] : throw new InvalidOperationException("Offset out of range"); - } - - /// - /// Helper: nth weekday of the year (n > 0) - /// - /// - private static CalDateTime GetNthWeekdayOfYear(int year, DayOfWeek dow, int n) - { - var d = new CalDateTime(year, 1, 1, 9, 0, 0); - while (d.DayOfWeek != dow) - { - d = d.AddDays(1); - } - - var result = d.AddDays((n - 1) * 7); - if (result.Year != year) - throw new InvalidOperationException("Offset out of range for year"); - return result; - } - - [Test, Category("Recurrence")] - public void Yearly_ByMonth_With_NumericByDay_Offset_Produces_NthWeekdayPerMonth() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 6, 1, 9, 0, 0), - Duration = Duration.FromHours(1) - }; - - // 2nd Monday of each BYMONTH - evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4")); - - var cal = new Calendar(); - cal.Events.Add(evt); - - var occ = cal.GetOccurrences(new CalDateTime(2026, 1, 1)).Take(4).Select(o => o.Period.StartTime).ToList(); - - var expected = - new[] - { - GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Monday, 2), - GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Monday, 2), - GetNthWeekdayOfMonth(2027, 6, DayOfWeek.Monday, 2), - GetNthWeekdayOfMonth(2027, 9, DayOfWeek.Monday, 2), - }; - - Assert.That(occ, Is.EqualTo(expected)); - } - - [Test, Category("Recurrence")] - public void Yearly_Without_ByMonth_NumericByDay_Interpreted_As_NthWeekdayOfYear() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 1, 1, 9, 0, 0), - Duration = Duration.FromHours(1) - }; - - // 20th Monday of the YEAR - evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYDAY=20MO;COUNT=3")); - - var cal = new Calendar(); - cal.Events.Add(evt); - - var occ = cal.GetOccurrences(new CalDateTime(1997, 1, 1)).Take(3).Select(o => o.Period.StartTime).ToList(); - - var expected = - new[] - { - GetNthWeekdayOfYear(2026, DayOfWeek.Monday, 20), - GetNthWeekdayOfYear(2027, DayOfWeek.Monday, 20), - GetNthWeekdayOfYear(2028, DayOfWeek.Monday, 20), - }; - - Assert.That(occ, Is.EqualTo(expected)); - } - - [Test, Category("Recurrence")] - public void Yearly_ByMonth_With_NegativeNumericByDay_Returns_LastWeekdayOfEachMonth() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 6, 1, 9, 0, 0), - Duration = Duration.FromHours(1) - }; - - // last Sunday of each BYMONTH - evt.RecurrenceRules.Add(new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2")); - - var cal = new Calendar(); - cal.Events.Add(evt); - - var occ = cal.GetOccurrences(new CalDateTime(2026, 1, 1)).Take(2).Select(o => o.Period.StartTime).ToList(); - - var expected = - new[] - { - GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Sunday, -1), - GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Sunday, -1), - }; - - Assert.That(occ, Is.EqualTo(expected)); - } -} diff --git a/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs b/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs new file mode 100644 index 000000000..850bd8048 --- /dev/null +++ b/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs @@ -0,0 +1,379 @@ +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +#nullable enable +using System; +using System.Linq; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Evaluation; +using Ical.Net.Tests.TestHelpers; +using NUnit.Framework; + +namespace Ical.Net.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 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. +/// +/// +/// Disclaimer: Other iCalendar libraries may interpret this differently. +/// +[TestFixture] +public class RecurrenceYearlyWithOffsetsTests +{ + private void EventOccurrenceTest( + Calendar cal, + CalDateTime? fromDate, + CalDateTime? toDate, + Period[] expectedPeriods, + string[]? timeZones, + int eventIndex + ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, eventIndex); + + private void EventOccurrenceTest( + Calendar cal, + CalDateTime? fromDate, + CalDateTime? toDate, + Period[] expectedPeriods, + string[]? timeZones + ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, 0); + + [Test, Category("Recurrence")] + public void Yearly_ByMonth_With_NumericByDay_Offset_Produces_NthWeekdayPerMonth() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 6, 1, 9, 0, 0), + Duration = Duration.FromHours(1), + // 2nd Monday of each BYMONTH + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4")] + }; + + var cal = new Calendar(); + cal.Events.Add(evt); + + CalDateTime[] expected = + [ + CalCalc.GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Monday, 2), + CalCalc.GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Monday, 2), + CalCalc.GetNthWeekdayOfMonth(2027, 6, DayOfWeek.Monday, 2), + CalCalc.GetNthWeekdayOfMonth(2027, 9, DayOfWeek.Monday, 2) + ]; + + var expectedPeriods = expected + .Select(dt => new Period(dt, Duration.FromHours(1))) + .ToArray(); + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), null, expectedPeriods, null); + } + + [Test, Category("Recurrence")] + public void Yearly_Without_ByMonth_NumericByDay_Interpreted_As_NthWeekdayOfYear() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 1, 1, 9, 0, 0), + Duration = Duration.FromHours(1), + // 20th Monday of the YEAR + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYDAY=20MO;COUNT=3")] + }; + + var cal = new Calendar(); + cal.Events.Add(evt); + + CalDateTime[] expected = + [ + CalCalc.GetNthWeekdayOfYear(2026, DayOfWeek.Monday, 20), + CalCalc.GetNthWeekdayOfYear(2027, DayOfWeek.Monday, 20), + CalCalc.GetNthWeekdayOfYear(2028, DayOfWeek.Monday, 20) + ]; + + var expectedPeriods = expected + .Select(dt => new Period(dt, Duration.FromHours(1))) + .ToArray(); + + EventOccurrenceTest(cal, new CalDateTime(1997, 1, 1), null, expectedPeriods, null); + } + + [Test, Category("Recurrence")] + public void Yearly_ByMonth_With_NegativeNumericByDay_Returns_LastWeekdayOfEachMonth() + { + var evt = new CalendarEvent + { + Start = new CalDateTime(2026, 6, 1, 9, 0, 0), + Duration = Duration.FromHours(1), + // last Sunday of each BYMONTH + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2")] + }; + + var cal = new Calendar(); + cal.Events.Add(evt); + + CalDateTime[] expected = + [ + CalCalc.GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Sunday, -1), + CalCalc.GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Sunday, -1) + ]; + + var expectedPeriods = expected + .Select(dt => new Period(dt, Duration.FromHours(1))) + .ToArray(); + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), null, expectedPeriods, null); + } + + [Test, Category("Recurrence")] + [TestCase("BYDAY=1MO", false)] + [TestCase("BYDAY=6MO", true)] // 6th Monday in March doesn't exist, should throw + public void Yearly_WithByMonth_ByDayOffsetIsWithinMonth(string byDay, bool shouldThrow) + { + // Rule: Yearly on the nth Monday (1MO or 6MO) of March. + // The numeric offset (1) applies WITHIN the specified month (March). + var start = new CalDateTime(2024, 1, 1, 9, 0, 0); + + var calendarEvent = new CalendarEvent + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=3;" + byDay)] + }; + + var cal = new Calendar(); + cal.Events.Add(calendarEvent); + + var from = new CalDateTime(2024, 1, 1); + var to = new CalDateTime(2027, 1, 1); + + if (shouldThrow) + { + Assert.That(() => cal.GetOccurrences(from).TakeWhileBefore(to).ToList(), + Throws.TypeOf(), + "Invalid BYDAY offset within month."); + return; + } + + CalDateTime[] expected = + [ + new (2024, 3, 4, 9, 0, 0), // 1st Monday of March 2024 + new (2025, 3, 3, 9, 0, 0), // 1st Monday of March 2025 + new (2026, 3, 2, 9, 0, 0) // 1st Monday of March 2026 + ]; + + var expectedPeriods = expected + .Select(dt => new Period(dt, Duration.FromHours(1))) + .ToArray(); + + EventOccurrenceTest(cal, from, to, expectedPeriods, null); + } + + [Test, Category("Recurrence")] + [TestCase("BYDAY=1MO", false)] + [TestCase("BYDAY=2MO", true)] // 2nd Monday in a week doesn't exist, should throw + public void Yearly_WithByWeekNo_WithoutByMonth_ByDayOffsetIsWithinWeek(string byDay, bool shouldThrow) + { + // Rule: Yearly on the nth Monday (1MO or 2MO) of ISO week 10. + // BYMONTH is NOT present. The numeric offset (2) applies WITHIN the specified week. + var start = new CalDateTime(2024, 1, 1, 9, 0, 0); + + var calendarEvent = new CalendarEvent + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYWEEKNO=10;" + byDay)] + }; + + var cal = new Calendar(); + cal.Events.Add(calendarEvent); + + var from = new CalDateTime(2024, 1, 1); + var to = new CalDateTime(2027, 1, 1); + + if (shouldThrow) + { + Assert.That(() => cal.GetOccurrences(from).TakeWhileBefore(to).ToList(), + Throws.TypeOf(), + "Invalid BYDAY offset within week."); + return; + } + + var occurrences = cal.GetOccurrences(from).TakeWhileBefore(to).ToList(); + + CalDateTime[] expected = + [ + new (2024, 3, 4, 9, 0, 0), // Monday of week 10, 2024 + new (2025, 3, 3, 9, 0, 0), // Monday of week 10, 2025 + new (2026, 3, 2, 9, 0, 0) // Monday of week 10, 2026 + ]; + + Assert.That(occurrences.Select(o => o.Period.StartTime), Is.EquivalentTo(expected)); + } + + [Test, Category("Recurrence")] + public void Yearly_WithoutByMonthOrByWeekNo_ByDayOffsetIsWithinYear() + { + // Rule: Yearly on the 15th Monday of the year. + // Neither BYMONTH nor BYWEEKNO is present. + // The numeric offset (15) applies WITHIN the entire year. + var start = new CalDateTime(2024, 1, 1, 9, 0, 0); + + var calendarEvent = new CalendarEvent + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYDAY=15MO")] + }; + + var cal = new Calendar(); + cal.Events.Add(calendarEvent); + + var from = new CalDateTime(2024, 1, 1); + var to = new CalDateTime(2027, 1, 1); + var occurrences = cal.GetOccurrences(from).TakeWhileBefore(to).ToList(); + + CalDateTime[] expected = + [ + new(2024, 4, 8, 9, 0, 0), // 15th Monday of 2024 + new(2025, 4, 14, 9, 0, 0), // 15th Monday of 2025 + new (2026, 4, 13, 9, 0, 0) // 15th Monday of 2026 + ]; + + Assert.That(occurrences.Select(o => o.Period.StartTime), Is.EquivalentTo(expected)); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_SimpleCase() + { + const string tzId = "Europe/Berlin"; + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART;TZID=Europe/Berlin:20250913T090000 + DURATION:PT1H + RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + var from = new CalDateTime(2025, 1, 1); + var to = new CalDateTime(2028, 1, 1); + + // Expected occurrences: only months, where the 13th is a Monday + var expected = new[] + { + new Period(new CalDateTime(2025, 10, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2026, 4, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2026, 7, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2027, 9, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + new Period(new CalDateTime(2027, 12, 13, 9, 0, 0, tzId), Duration.FromHours(1)), + }; + + EventOccurrenceTest(cal, from, to, expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByMonthDay_ExpandMatrix_Note2() + { + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;COUNT=4 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + Period[] expected = + [ + new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new (new CalDateTime(2027, 6, 8), Duration.FromDays(1)), + new (new CalDateTime(2032, 6, 8), Duration.FromDays(1)) + ]; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2035, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_MonthlyByMonthDay_WithOffsets_LimitingBehavior() + { + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + Period[] expected = + [ + new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) + ]; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByMonthAndMonthDay_WithOffsets() + { + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + Period[] expected = + [ + new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) + ]; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); + } + + [Test, Category("Recurrence")] + public void ByMonthDay_With_ByDay_YearlyByYearDay_WithOffsets_LimitingBehavior() + { + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20260601 + RRULE:FREQ=YEARLY;BYYEARDAY=152,173,251,272,321,342;BYDAY=22MO,26MO,36TU,37TU,49TU;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics)!; + Period[] expected = + [ + new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), + new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), + new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) + ]; + + EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2030, 1, 1), expected, null); + } +} diff --git a/Ical.Net.Tests/TestHelpers/CalCalc.cs b/Ical.Net.Tests/TestHelpers/CalCalc.cs new file mode 100644 index 000000000..6a5218f4b --- /dev/null +++ b/Ical.Net.Tests/TestHelpers/CalCalc.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Ical.Net.DataTypes; + +namespace Ical.Net.Tests.TestHelpers; + +public static class CalCalc +{ + /// + /// Helper: nth weekday in a given month (n > 0 => nth, n < 0 => -nth from end) + /// + /// + public static CalDateTime GetNthWeekdayOfMonth(int year, int month, DayOfWeek dow, int n) + { + var firstOfMonth = new CalDateTime(year, month, 1, 9, 0, 0); + var d = firstOfMonth; + while (d.DayOfWeek != dow && d.Month == month) + { + d = d.AddDays(1); + } + + var list = new List(); + while (d.Month == month) + { + list.Add(d); + d = d.AddDays(7); + } + + if (n > 0) + { + return (n <= list.Count) ? list[n - 1] : throw new InvalidOperationException("Offset out of range"); + } + + var idx = list.Count + n; // n negative + return (idx >= 0 && idx < list.Count) ? list[idx] : throw new InvalidOperationException("Offset out of range"); + } + + /// + /// Helper: nth weekday of the year (n > 0) + /// + /// + public static CalDateTime GetNthWeekdayOfYear(int year, DayOfWeek dow, int n) + { + var d = new CalDateTime(year, 1, 1, 9, 0, 0); + while (d.DayOfWeek != dow) + { + d = d.AddDays(1); + } + + var result = d.AddDays((n - 1) * 7); + if (result.Year != year) + throw new InvalidOperationException("Offset out of range for year"); + return result; + } +} \ No newline at end of file diff --git a/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs new file mode 100644 index 000000000..94316e965 --- /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"); + } + } + }); + } +} From beea7041d98c8646f4b043fd6172ecd3807badfa Mon Sep 17 00:00:00 2001 From: axunonb Date: Wed, 10 Dec 2025 08:55:34 +0100 Subject: [PATCH 3/5] Replace CalCalc helpers with hardcoded dates in tests Updated recurrence yearly offset tests to use hardcoded CalDateTime values instead of CalCalc helper methods. Removed CalCalc.cs as its methods are no longer referenced. --- .../RecurrenceYearlyWithOffsetsTests.cs | 18 +++--- Ical.Net.Tests/TestHelpers/CalCalc.cs | 55 ------------------- .../Evaluation/RecurrencePatternEvaluator.cs | 3 +- 3 files changed, 10 insertions(+), 66 deletions(-) delete mode 100644 Ical.Net.Tests/TestHelpers/CalCalc.cs diff --git a/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs b/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs index 850bd8048..013234be0 100644 --- a/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs +++ b/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs @@ -63,10 +63,10 @@ public void Yearly_ByMonth_With_NumericByDay_Offset_Produces_NthWeekdayPerMonth( CalDateTime[] expected = [ - CalCalc.GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Monday, 2), - CalCalc.GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Monday, 2), - CalCalc.GetNthWeekdayOfMonth(2027, 6, DayOfWeek.Monday, 2), - CalCalc.GetNthWeekdayOfMonth(2027, 9, DayOfWeek.Monday, 2) + new(2026, 6, 8, 9, 0, 0), + new(2026, 9, 14, 9, 0, 0), + new(2027, 6, 14, 9, 0, 0), + new(2027, 9, 13, 9, 0, 0) ]; var expectedPeriods = expected @@ -92,9 +92,9 @@ public void Yearly_Without_ByMonth_NumericByDay_Interpreted_As_NthWeekdayOfYear( CalDateTime[] expected = [ - CalCalc.GetNthWeekdayOfYear(2026, DayOfWeek.Monday, 20), - CalCalc.GetNthWeekdayOfYear(2027, DayOfWeek.Monday, 20), - CalCalc.GetNthWeekdayOfYear(2028, DayOfWeek.Monday, 20) + new(2026, 5, 18, 9, 0, 0), + new(2027, 5, 17, 9, 0, 0), + new(2028, 5, 15, 9, 0, 0) ]; var expectedPeriods = expected @@ -120,8 +120,8 @@ public void Yearly_ByMonth_With_NegativeNumericByDay_Returns_LastWeekdayOfEachMo CalDateTime[] expected = [ - CalCalc.GetNthWeekdayOfMonth(2026, 6, DayOfWeek.Sunday, -1), - CalCalc.GetNthWeekdayOfMonth(2026, 9, DayOfWeek.Sunday, -1) + new(2026, 6, 28, 9, 0, 0), + new(2026, 9, 27, 9, 0, 0) ]; var expectedPeriods = expected diff --git a/Ical.Net.Tests/TestHelpers/CalCalc.cs b/Ical.Net.Tests/TestHelpers/CalCalc.cs deleted file mode 100644 index 6a5218f4b..000000000 --- a/Ical.Net.Tests/TestHelpers/CalCalc.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using Ical.Net.DataTypes; - -namespace Ical.Net.Tests.TestHelpers; - -public static class CalCalc -{ - /// - /// Helper: nth weekday in a given month (n > 0 => nth, n < 0 => -nth from end) - /// - /// - public static CalDateTime GetNthWeekdayOfMonth(int year, int month, DayOfWeek dow, int n) - { - var firstOfMonth = new CalDateTime(year, month, 1, 9, 0, 0); - var d = firstOfMonth; - while (d.DayOfWeek != dow && d.Month == month) - { - d = d.AddDays(1); - } - - var list = new List(); - while (d.Month == month) - { - list.Add(d); - d = d.AddDays(7); - } - - if (n > 0) - { - return (n <= list.Count) ? list[n - 1] : throw new InvalidOperationException("Offset out of range"); - } - - var idx = list.Count + n; // n negative - return (idx >= 0 && idx < list.Count) ? list[idx] : throw new InvalidOperationException("Offset out of range"); - } - - /// - /// Helper: nth weekday of the year (n > 0) - /// - /// - public static CalDateTime GetNthWeekdayOfYear(int year, DayOfWeek dow, int n) - { - var d = new CalDateTime(year, 1, 1, 9, 0, 0); - while (d.DayOfWeek != dow) - { - d = d.AddDays(1); - } - - var result = d.AddDays((n - 1) * 7); - if (result.Year != year) - throw new InvalidOperationException("Offset out of range for year"); - return result; - } -} \ No newline at end of file diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index 382bc5430..ff294650c 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -327,9 +327,8 @@ private struct ExpandContext /// The seed date. It is always returned in the list of possible dates. /// /// - /// The anchor month for BYMONTHDAY expansion when BYMONTH is not specified. /// 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 { IsCandidateSetFullyExpanded = false }; From 7d556e80cdb10c16698dbdad251b050fec239a8f Mon Sep 17 00:00:00 2001 From: axunonb Date: Sat, 13 Dec 2025 16:31:57 +0100 Subject: [PATCH 4/5] Move `RecurrenceYearlyWithOffsetsTests` to `RecurrenceTestCases.txt` Move tests are for RFC errata 1913 (https://www.rfc-editor.org/errata_search.php?rfc=1913&eid=1913) and contain the disclaimer "Other iCalendar libraries may interpret this differently" --- .../Recurrence/RecurrenceTestCases.txt | 91 +++++ .../RecurrenceYearlyWithOffsetsTests.cs | 379 ------------------ 2 files changed, 91 insertions(+), 379 deletions(-) delete mode 100644 Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs diff --git a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt index 0030fa1ad..5370dc4c4 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt +++ b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt @@ -181,3 +181,94 @@ 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. +# +# 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:20260601T090000 +DURATION:PT1H +INSTANCES:20260608T090000,20260914T090000,20270614T090000,20270913T090000 + +# Yearly without BYMONTH numeric BYDAY (20MO) - 20th Monday of the YEAR +RRULE:FREQ=YEARLY;BYDAY=20MO;COUNT=3 +DTSTART:20260101T090000 +DURATION:PT1H +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:20260601T090000 +DURATION:PT1H +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:20240101T090000 +DURATION:PT1H +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 +DURATION:PT1H +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:20240101T090000 +DURATION:PT1H +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 +DURATION:PT1H +EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException +EXCEPTION-STEP:Enumeration + +# Yearly BYDAY=15MO - 15th Monday of the year +RRULE:FREQ=YEARLY;BYDAY=15MO;COUNT=3 +DTSTART:20240101T090000 +DURATION:PT1H +INSTANCES:20240408T090000,20250414T090000,20260413T090000 + +# Monthly BYMONTHDAY=13 with BYDAY=MO +RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO;UNTIL=20271231T235959Z +DTSTART:20250913T090000 +DURATION:PT1H +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 +DURATION:P1D +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 +DURATION:P1D +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 +DURATION:P1D +INSTANCES:20260601,20260908,20261208 + +############################## END ERRATA 1913 TESTS ############################## diff --git a/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs b/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs deleted file mode 100644 index 013234be0..000000000 --- a/Ical.Net.Tests/RecurrenceYearlyWithOffsetsTests.cs +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright ical.net project maintainers and contributors. -// Licensed under the MIT license. -#nullable enable -using System; -using System.Linq; -using Ical.Net.CalendarComponents; -using Ical.Net.DataTypes; -using Ical.Net.Evaluation; -using Ical.Net.Tests.TestHelpers; -using NUnit.Framework; - -namespace Ical.Net.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 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. -/// -/// -/// Disclaimer: Other iCalendar libraries may interpret this differently. -/// -[TestFixture] -public class RecurrenceYearlyWithOffsetsTests -{ - private void EventOccurrenceTest( - Calendar cal, - CalDateTime? fromDate, - CalDateTime? toDate, - Period[] expectedPeriods, - string[]? timeZones, - int eventIndex - ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, eventIndex); - - private void EventOccurrenceTest( - Calendar cal, - CalDateTime? fromDate, - CalDateTime? toDate, - Period[] expectedPeriods, - string[]? timeZones - ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, 0); - - [Test, Category("Recurrence")] - public void Yearly_ByMonth_With_NumericByDay_Offset_Produces_NthWeekdayPerMonth() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 6, 1, 9, 0, 0), - Duration = Duration.FromHours(1), - // 2nd Monday of each BYMONTH - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4")] - }; - - var cal = new Calendar(); - cal.Events.Add(evt); - - CalDateTime[] expected = - [ - new(2026, 6, 8, 9, 0, 0), - new(2026, 9, 14, 9, 0, 0), - new(2027, 6, 14, 9, 0, 0), - new(2027, 9, 13, 9, 0, 0) - ]; - - var expectedPeriods = expected - .Select(dt => new Period(dt, Duration.FromHours(1))) - .ToArray(); - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), null, expectedPeriods, null); - } - - [Test, Category("Recurrence")] - public void Yearly_Without_ByMonth_NumericByDay_Interpreted_As_NthWeekdayOfYear() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 1, 1, 9, 0, 0), - Duration = Duration.FromHours(1), - // 20th Monday of the YEAR - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYDAY=20MO;COUNT=3")] - }; - - var cal = new Calendar(); - cal.Events.Add(evt); - - CalDateTime[] expected = - [ - new(2026, 5, 18, 9, 0, 0), - new(2027, 5, 17, 9, 0, 0), - new(2028, 5, 15, 9, 0, 0) - ]; - - var expectedPeriods = expected - .Select(dt => new Period(dt, Duration.FromHours(1))) - .ToArray(); - - EventOccurrenceTest(cal, new CalDateTime(1997, 1, 1), null, expectedPeriods, null); - } - - [Test, Category("Recurrence")] - public void Yearly_ByMonth_With_NegativeNumericByDay_Returns_LastWeekdayOfEachMonth() - { - var evt = new CalendarEvent - { - Start = new CalDateTime(2026, 6, 1, 9, 0, 0), - Duration = Duration.FromHours(1), - // last Sunday of each BYMONTH - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2")] - }; - - var cal = new Calendar(); - cal.Events.Add(evt); - - CalDateTime[] expected = - [ - new(2026, 6, 28, 9, 0, 0), - new(2026, 9, 27, 9, 0, 0) - ]; - - var expectedPeriods = expected - .Select(dt => new Period(dt, Duration.FromHours(1))) - .ToArray(); - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), null, expectedPeriods, null); - } - - [Test, Category("Recurrence")] - [TestCase("BYDAY=1MO", false)] - [TestCase("BYDAY=6MO", true)] // 6th Monday in March doesn't exist, should throw - public void Yearly_WithByMonth_ByDayOffsetIsWithinMonth(string byDay, bool shouldThrow) - { - // Rule: Yearly on the nth Monday (1MO or 6MO) of March. - // The numeric offset (1) applies WITHIN the specified month (March). - var start = new CalDateTime(2024, 1, 1, 9, 0, 0); - - var calendarEvent = new CalendarEvent - { - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYMONTH=3;" + byDay)] - }; - - var cal = new Calendar(); - cal.Events.Add(calendarEvent); - - var from = new CalDateTime(2024, 1, 1); - var to = new CalDateTime(2027, 1, 1); - - if (shouldThrow) - { - Assert.That(() => cal.GetOccurrences(from).TakeWhileBefore(to).ToList(), - Throws.TypeOf(), - "Invalid BYDAY offset within month."); - return; - } - - CalDateTime[] expected = - [ - new (2024, 3, 4, 9, 0, 0), // 1st Monday of March 2024 - new (2025, 3, 3, 9, 0, 0), // 1st Monday of March 2025 - new (2026, 3, 2, 9, 0, 0) // 1st Monday of March 2026 - ]; - - var expectedPeriods = expected - .Select(dt => new Period(dt, Duration.FromHours(1))) - .ToArray(); - - EventOccurrenceTest(cal, from, to, expectedPeriods, null); - } - - [Test, Category("Recurrence")] - [TestCase("BYDAY=1MO", false)] - [TestCase("BYDAY=2MO", true)] // 2nd Monday in a week doesn't exist, should throw - public void Yearly_WithByWeekNo_WithoutByMonth_ByDayOffsetIsWithinWeek(string byDay, bool shouldThrow) - { - // Rule: Yearly on the nth Monday (1MO or 2MO) of ISO week 10. - // BYMONTH is NOT present. The numeric offset (2) applies WITHIN the specified week. - var start = new CalDateTime(2024, 1, 1, 9, 0, 0); - - var calendarEvent = new CalendarEvent - { - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYWEEKNO=10;" + byDay)] - }; - - var cal = new Calendar(); - cal.Events.Add(calendarEvent); - - var from = new CalDateTime(2024, 1, 1); - var to = new CalDateTime(2027, 1, 1); - - if (shouldThrow) - { - Assert.That(() => cal.GetOccurrences(from).TakeWhileBefore(to).ToList(), - Throws.TypeOf(), - "Invalid BYDAY offset within week."); - return; - } - - var occurrences = cal.GetOccurrences(from).TakeWhileBefore(to).ToList(); - - CalDateTime[] expected = - [ - new (2024, 3, 4, 9, 0, 0), // Monday of week 10, 2024 - new (2025, 3, 3, 9, 0, 0), // Monday of week 10, 2025 - new (2026, 3, 2, 9, 0, 0) // Monday of week 10, 2026 - ]; - - Assert.That(occurrences.Select(o => o.Period.StartTime), Is.EquivalentTo(expected)); - } - - [Test, Category("Recurrence")] - public void Yearly_WithoutByMonthOrByWeekNo_ByDayOffsetIsWithinYear() - { - // Rule: Yearly on the 15th Monday of the year. - // Neither BYMONTH nor BYWEEKNO is present. - // The numeric offset (15) applies WITHIN the entire year. - var start = new CalDateTime(2024, 1, 1, 9, 0, 0); - - var calendarEvent = new CalendarEvent - { - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [new RecurrencePattern("FREQ=YEARLY;BYDAY=15MO")] - }; - - var cal = new Calendar(); - cal.Events.Add(calendarEvent); - - var from = new CalDateTime(2024, 1, 1); - var to = new CalDateTime(2027, 1, 1); - var occurrences = cal.GetOccurrences(from).TakeWhileBefore(to).ToList(); - - CalDateTime[] expected = - [ - new(2024, 4, 8, 9, 0, 0), // 15th Monday of 2024 - new(2025, 4, 14, 9, 0, 0), // 15th Monday of 2025 - new (2026, 4, 13, 9, 0, 0) // 15th Monday of 2026 - ]; - - Assert.That(occurrences.Select(o => o.Period.StartTime), Is.EquivalentTo(expected)); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_SimpleCase() - { - const string tzId = "Europe/Berlin"; - const string ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART;TZID=Europe/Berlin:20250913T090000 - DURATION:PT1H - RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - var from = new CalDateTime(2025, 1, 1); - var to = new CalDateTime(2028, 1, 1); - - // Expected occurrences: only months, where the 13th is a Monday - var expected = new[] - { - new Period(new CalDateTime(2025, 10, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2026, 4, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2026, 7, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2027, 9, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - new Period(new CalDateTime(2027, 12, 13, 9, 0, 0, tzId), Duration.FromHours(1)), - }; - - EventOccurrenceTest(cal, from, to, expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByMonthDay_ExpandMatrix_Note2() - { - const string ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;COUNT=4 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - Period[] expected = - [ - new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new (new CalDateTime(2027, 6, 8), Duration.FromDays(1)), - new (new CalDateTime(2032, 6, 8), Duration.FromDays(1)) - ]; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2035, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_MonthlyByMonthDay_WithOffsets_LimitingBehavior() - { - const string ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - Period[] expected = - [ - new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) - ]; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByMonthAndMonthDay_WithOffsets() - { - const string ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - Period[] expected = - [ - new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) - ]; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2027, 1, 1), expected, null); - } - - [Test, Category("Recurrence")] - public void ByMonthDay_With_ByDay_YearlyByYearDay_WithOffsets_LimitingBehavior() - { - const string ics = """ - BEGIN:VCALENDAR - VERSION:2.0 - BEGIN:VEVENT - DTSTART:20260601 - RRULE:FREQ=YEARLY;BYYEARDAY=152,173,251,272,321,342;BYDAY=22MO,26MO,36TU,37TU,49TU;COUNT=3 - END:VEVENT - END:VCALENDAR - """; - - var cal = Calendar.Load(ics)!; - Period[] expected = - [ - new (new CalDateTime(2026, 6, 1), Duration.FromDays(1)), - new (new CalDateTime(2026, 9, 8), Duration.FromDays(1)), - new (new CalDateTime(2026, 12, 8), Duration.FromDays(1)) - ]; - - EventOccurrenceTest(cal, new CalDateTime(2026, 1, 1), new CalDateTime(2030, 1, 1), expected, null); - } -} From ab0dff514eaca30a113d99fc9fb0deb08e076efe Mon Sep 17 00:00:00 2001 From: axunonb Date: Tue, 23 Dec 2025 22:52:23 +0100 Subject: [PATCH 5/5] Implement recommendations from review --- .../Recurrence/RecurrenceTestCases.txt | 28 ++--- .../Evaluation/RecurrencePatternEvaluator.cs | 102 ++++++++++-------- 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt index 5370dc4c4..f9cedd4f1 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt +++ b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt @@ -191,84 +191,68 @@ INSTANCES:20250602,20250629 # 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:20260601T090000 -DURATION:PT1H +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:20260101T090000 -DURATION:PT1H +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:20260601T090000 -DURATION:PT1H +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:20240101T090000 -DURATION:PT1H +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 -DURATION:PT1H 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:20240101T090000 -DURATION:PT1H +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 -DURATION:PT1H EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException EXCEPTION-STEP:Enumeration -# Yearly BYDAY=15MO - 15th Monday of the year -RRULE:FREQ=YEARLY;BYDAY=15MO;COUNT=3 -DTSTART:20240101T090000 -DURATION:PT1H -INSTANCES:20240408T090000,20250414T090000,20260413T090000 - # Monthly BYMONTHDAY=13 with BYDAY=MO RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO;UNTIL=20271231T235959Z DTSTART:20250913T090000 -DURATION:PT1H 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 -DURATION:P1D 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 -DURATION:P1D 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 -DURATION:P1D INSTANCES:20260601,20260908,20261208 ############################## END ERRATA 1913 TESTS ############################## diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index ff294650c..5552449d9 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -693,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); @@ -711,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); } } @@ -738,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; + + 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 53th week of the year, but all another are 1st week number. + // 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); @@ -780,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); } ///