Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Implement review comments
Restore former behavior in `Evaluator.IncrementDate`:
`case FrequencyType.Yearly: dt = old.AddDays(-old.DayOfYear + 1).AddYears(interval);`

`RecurrencePatternEvaluator.GetIntervalLowerLimit`
For yearly FREQUENCY but no BYMONTH or BYWEEKNO use the original DTSTART's month for interval boundary, preventing occurrences earlier than the intended range.

Move test cases of this PR to `RecurrenceTestCases.txt`
Added tests mentioned as failing in the review
  • Loading branch information
axunonb committed Dec 12, 2025
commit e07ffca7140eb794453a8422b94260345996dd9d
25 changes: 25 additions & 0 deletions Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ RRULE:FREQ=HOURLY;BYHOUR=0;BYYEARDAY=360,-2;UNTIL=20260101T000000
DTSTART:20251220T000000
INSTANCES:20251226T000000,20251230T000000

# BYYEARDAY with 1st day of the year and UNTIL
RRULE:FREQ=YEARLY;BYYEARDAY=1,10;UNTIL=20260105
DTSTART:20250110
INSTANCES:20250110,20260101

# BYHOUR, BYMINUTE, BYSECOND limit behaviour
# note that the max number of increments is 1000, so we can only observer a limited time span
RRULE:FREQ=SECONDLY;BYHOUR=1,2;BYMINUTE=3,4,59;BYSECOND=5,6;UNTIL=20250216T020500
Expand Down Expand Up @@ -156,3 +161,23 @@ RRULE:FREQ=YEARLY;BYWEEKNO=-1;BYDAY=SU;COUNT=3
DTSTART:99971228
EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException
EXCEPTION-STEP:Enumeration

# Yearly recurrence starting on Feb 28 of a leap year, 5 occurrences
DTSTART:20240228
RRULE:FREQ=YEARLY;COUNT=5
INSTANCES:20240228,20250228,20260228,20270228,20280228

# Yearly recurrence starting on Feb 29 of a leap year, 4 occurrences
DTSTART:20240229
RRULE:FREQ=YEARLY;COUNT=4
INSTANCES:20240229,20280229,20320229,20360229

# First and last day of the year, 3 occurrences with first/last day of year expansion, issue #889
DTSTART:20250101
RRULE:FREQ=YEARLY;BYYEARDAY=-1,1;BYMONTHDAY=-1,1;COUNT=3
INSTANCES:20250101,20251231,20260101

# Second and second last day of June, all occurrences in June 2025, issue #889
DTSTART:20250601
RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=2,-2;UNTIL=20250630
INSTANCES:20250602,20250629
46 changes: 0 additions & 46 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4807,50 +4807,4 @@ public void GetOccurrences_WithMultipleOverridesForSameRecurrenceId_ShouldUseLat

Assert.That(occurrences.Select(o => o.Period.StartTime).ToArray(), Is.EqualTo(expected));
}

[Test, Category("Recurrence")]
public void FreqYearly_With_ByYearDay_ByMonthDay()
{
const string ics = """
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
RRULE:FREQ=YEARLY;BYYEARDAY=-1,1;BYMONTHDAY=-1,1;COUNT=3
DTSTART:20250101
END:VEVENT
END:VCALENDAR
""";

var cal = Calendar.Load(ics)!;
Period[] expected =
[
new (new CalDateTime(2025, 1, 1), Duration.FromDays(1)),
new (new CalDateTime(2025, 12, 31), Duration.FromDays(1)),
new (new CalDateTime(2026, 1, 1), Duration.FromDays(1))
];

EventOccurrenceTest(cal, new CalDateTime(2025, 1, 1), null, expected, null);
}

[Test, Category("Recurrence")]
public void Yearly_ByMonth_MixedPositiveAndNegative_ByMonthDay_LowerLimitHeuristic()
{
const string ics = """
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTART:20250601
RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=2,-2;UNTIL=20250627
END:VEVENT
END:VCALENDAR
""";

var cal = Calendar.Load(ics)!;
Period[] expected =
[
new (new CalDateTime(2025, 6, 2), Duration.FromDays(1))
];

EventOccurrenceTest(cal, new CalDateTime(2025, 1, 1), null, expected, null);
}
}
9 changes: 3 additions & 6 deletions Ical.Net/Evaluation/Evaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,9 @@ protected void IncrementDate(ref CalDateTime dt, RecurrencePattern pattern, int
dt = old.AddDays(-old.Day + 1).AddMonths(interval);
break;
case FrequencyType.Yearly:
// When a rule uses BYWEEKNO, recurrence enumeration
// is based on week numbers relative to the year.
// So we preserve the weekday when using BYWEEKNO and preserve month/day otherwise.
dt = (pattern.ByWeekNo.Count != 0)
? old.AddDays(-old.DayOfYear + 1).AddYears(interval)
: old.AddYears(interval);
// RecurrencePatternEvaluator relies on the assumption that after incrementing, the new refDate
// is usually at the first day of an interval.
dt = old.AddDays(-old.DayOfYear + 1).AddYears(interval);
break;
default:
// Frequency should always be valid at this stage.
Expand Down
41 changes: 25 additions & 16 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDat
if (searchEndDate < lowerLimit)
break;

var candidates = GetCandidates(intervalRefTime, pattern, expandBehavior);
var candidates =
GetCandidates((lowerLimit > intervalRefTime) ? lowerLimit : intervalRefTime, pattern, expandBehavior);

foreach (var t in candidates.Where(t => t >= originalDate))
{
Expand Down Expand Up @@ -218,7 +219,8 @@ private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDat
/// might fall earlier in the year than the intervalRefTime's month/day. In
/// that case we compute the earliest possible date/time that could be
/// generated for the interval (earliest month/day/hour/minute/second).
/// - If BYWEEKNO is present, the interval may contain days from the previous
/// - If neither BYMONTH nor BYWEEKNO is present, we use the original date's month
/// - If only BYWEEKNO is present, the interval may contain days from the previous
/// or next year (ISO week boundaries). In that case we adjust the interval
/// start to the first day of the configured week so we don't miss candidates
/// that belong to the week containing Jan 1st.
Expand All @@ -227,6 +229,20 @@ private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, Re
{
switch (pattern)
{
case { Frequency: FrequencyType.Yearly, ByMonth.Count: 0, ByWeekNo.Count: 0 }:
{
// Return intervalRefTime but use the month from the original DTSTART.
// Else, the earliest candidate for the interval might be too early
// Do this by shifting the intervalRefTime by the difference in months.
// This preserves the day/time from intervalRefTime and relies on AddMonths
// to perform month-end semantics (e.g. Jan 31 -> Feb 28/29) instead of
// manually clamping the day.
var monthDelta = originalDate.Month - intervalRefTime.Month;
var adjusted = intervalRefTime.AddMonths(monthDelta);

return new CalDateTime(adjusted.Year, adjusted.Month, adjusted.Day, adjusted.Hour, adjusted.Minute, adjusted.Second, intervalRefTime.TzId);
}

case { Frequency: FrequencyType.Yearly, ByMonth.Count: > 0, ByWeekNo.Count: 0 }:
{
// When evaluating a YEARLY rule that restricts months (BYMONTH) but not
Expand Down Expand Up @@ -275,13 +291,15 @@ private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, Re

return new CalDateTime(year, month, day, hour, minute, second, intervalRefTime.TzId);
}

case { Frequency: FrequencyType.Yearly, ByWeekNo.Count: not 0 }:
{
// YEARLY with BYWEEKNO: weeks may span year boundaries. Move the
// interval lower limit to the first day of the week so expansion over
// the week (including days before Jan 1st) is handled correctly.
return GetFirstDayOfWeekDate(intervalRefTime, pattern.FirstDayOfWeek);
}

default:
{
return intervalRefTime;
Expand Down Expand Up @@ -569,21 +587,12 @@ static bool MatchesAnyMonthDay(CalDateTime candidate, IEnumerable<int> monthDays

foreach (var date in dates)
{
if (pattern.ByMonth.Count > 0)
{
// If BYMONTH is specified, the date must be in one of those months
// and match a BYMONTHDAY value.
if (!pattern.ByMonth.Contains(date.Month))
continue;
// If BYMONTH is specified and this date's month is not included, skip it.
if (pattern.ByMonth.Count > 0 && !pattern.ByMonth.Contains(date.Month))
continue;

if (MatchesAnyMonthDay(date, pattern.ByMonthDay))
yield return date;
}
else
{
if (MatchesAnyMonthDay(date, pattern.ByMonthDay))
yield return date;
}
if (MatchesAnyMonthDay(date, pattern.ByMonthDay))
yield return date;
}
}

Expand Down
Loading