Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
axunonb marked this conversation as resolved.
#
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DTSTART is not in line with the RRULE, which makes the case undefined according to the RFC (similar in other test cases). Unless this test should explicitly cover the undefined case, it would be good to have DTSTART aligned with RRULE, otherwise readers would have to think about whether the deviation is intentional.

The time part could be omitted, which would make the test somewhat more concise.

DURATION:PT1H
Comment thread
axunonb marked this conversation as resolved.
Outdated
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
Comment thread
axunonb marked this conversation as resolved.
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
Comment thread
axunonb marked this conversation as resolved.
Outdated
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 ##############################
50 changes: 10 additions & 40 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,61 +33,30 @@ 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,
CalDateTime? fromDate,
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))]
Expand Down
54 changes: 54 additions & 0 deletions Ical.Net.Tests/TestHelpers/OccurrenceTester.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
});
}
}
64 changes: 38 additions & 26 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,27 +310,29 @@ private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, Re
private struct ExpandContext
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool DatesFullyExpanded { get; set; }
/// <example>
/// 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.
/// </example>
public bool IsCandidateSetFullyExpanded { get; set; }
Comment thread
axunonb marked this conversation as resolved.
}

/// <summary>
/// Returns a list of possible dates generated from the applicable BY* rules, using the specified date as a seed.
/// </summary>
/// <param name="date">The seed date.</param>
/// <param name="seedDate">The seed date. It is always returned in the list of possible dates.</param>
/// <param name="pattern"></param>
/// <param name="expandBehaviors"></param>
/// <returns>A list of possible dates.</returns>
private IEnumerable<CalDateTime> GetCandidates(CalDateTime date, RecurrencePattern pattern, bool?[] expandBehaviors)
private IEnumerable<CalDateTime> GetCandidates(CalDateTime seedDate, RecurrencePattern pattern, bool?[] expandBehaviors)
{
var expandContext = new ExpandContext() { DatesFullyExpanded = false };
var expandContext = new ExpandContext { IsCandidateSetFullyExpanded = false };

IEnumerable<CalDateTime> dates = [date];
IEnumerable<CalDateTime> dates = [seedDate];
dates = GetMonthVariants(dates, pattern, expandBehaviors[0]);
dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1], ref expandContext);
dates = GetYearDayVariants(dates, pattern, expandBehaviors[2], ref expandContext);
Expand Down Expand Up @@ -422,7 +424,7 @@ private static IEnumerable<CalDateTime> GetWeekNoVariants(IEnumerable<CalDateTim
var weekNoDates = GetWeekNoVariantsExpanded(dates, pattern);

// subsequent parts should only limit, not expand
expandContext.DatesFullyExpanded = true;
expandContext.IsCandidateSetFullyExpanded = true;

// Apply BYMONTH limit behavior, as we might have expanded over month/year boundaries
// in this method and BYMONTH has already been applied before, so wouldn't be again.
Expand Down Expand Up @@ -491,9 +493,9 @@ private static List<int> GetByWeekNoForYearNormalized(RecurrencePattern pattern,
/// </param>
/// <param name="expandContext">
/// Context that indicates whether earlier parts have already fully expanded the candidate set.
/// If <see cref="ExpandContext.DatesFullyExpanded"/> is <c>true</c> then expansion must not be
/// If <see cref="ExpandContext.IsCandidateSetFullyExpanded"/> is <c>true</c> then expansion must not be
/// performed again and the method should behave in limit mode.
/// When this method performs an expansion it will set <see cref="ExpandContext.DatesFullyExpanded"/>
/// When this method performs an expansion it will set <see cref="ExpandContext.IsCandidateSetFullyExpanded"/>
/// to <c>true</c> to prevent later parts from expanding again.
/// </param>
/// <returns>
Expand All @@ -506,9 +508,9 @@ private static IEnumerable<CalDateTime> GetYearDayVariants(IEnumerable<CalDateTi
if (expand is null || pattern.ByYearDay.Count == 0)
return dates;

if ((expand == true) && !expandContext.DatesFullyExpanded)
if ((expand == true) && !expandContext.IsCandidateSetFullyExpanded)
{
expandContext.DatesFullyExpanded = true;
expandContext.IsCandidateSetFullyExpanded = true;
return GetYearDayVariantsExpanded(dates, pattern);
}

Expand Down Expand Up @@ -559,9 +561,9 @@ private static IEnumerable<CalDateTime> GetMonthDayVariants(IEnumerable<CalDateT
if (expand == null || pattern.ByMonthDay.Count == 0)
return dates;

if (expand.Value && !expandContext.DatesFullyExpanded)
if (expand.Value && !expandContext.IsCandidateSetFullyExpanded)
{
expandContext.DatesFullyExpanded = true;
expandContext.IsCandidateSetFullyExpanded = true;
return GetMonthDayVariantsExpanded(dates, pattern);
}

Expand Down Expand Up @@ -623,10 +625,10 @@ private static IEnumerable<CalDateTime> GetDayVariants(IEnumerable<CalDateTime>
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);
}

Expand All @@ -636,15 +638,25 @@ private static IEnumerable<CalDateTime> GetDayVariants(IEnumerable<CalDateTime>

private static IEnumerable<CalDateTime> GetDayVariantsLimited(IEnumerable<CalDateTime> 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));
Comment thread
axunonb marked this conversation as resolved.
}));

private static IEnumerable<CalDateTime> GetDayVariantsExpanded(IEnumerable<CalDateTime> dates, RecurrencePattern pattern)
{
foreach (var date in dates)
{
var weekDayDates = new SortedSet<CalDateTime>();
var weekDayDates = new SortedSet<CalDateTime>(); // SortedSet uses CalDateTime.CompareTo
foreach (var day in pattern.ByDay)
foreach (var d in GetAbsWeekDays(date, day, pattern))
weekDayDates.Add(d);
Expand Down Expand Up @@ -738,8 +750,8 @@ private static IEnumerable<CalDateTime> 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))
Expand Down
Loading
Loading