Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
151 changes: 151 additions & 0 deletions Ical.Net.Tests/RecurrenceIdentifierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//
// Copyright ical.net project maintainers and contributors.
// Licensed under the MIT license.
//

using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Ical.Net.Serialization.DataTypes;
using Ical.Net.Utility;
using NUnit.Framework;

namespace Ical.Net.Tests;

[TestFixture]
internal class RecurrenceIdentifierTests
{
#pragma warning disable CS0618 // Type or member is obsolete

[TestCase(RecurrenceRange.ThisAndFuture, ";RANGE=THISANDFUTURE")]
[TestCase(RecurrenceRange.ThisInstance, "")] // This means no RANGE parameter
[TestCase(9999, "")] // Invalid values should be treated as ThisInstance
public void RecurrenceIdentifierWithTzId_ShouldSerializeCorrectly(RecurrenceRange range, string rangeString)
{
var evt = new CalendarEvent
{
RecurrenceIdentifier = new RecurrenceIdentifier(new CalDateTime(2025, 7, 1, 10, 0, 0, "America/New_York"), range)
};

var serializer = new EventSerializer();
var serialized = serializer.SerializeToString(evt)!;
var expected = $"RECURRENCE-ID;TZID=America/New_York{rangeString}:20250701T100000";

Assert.That(serialized, Does.Contain(expected));
}

[TestCase(RecurrenceRange.ThisAndFuture, ";RANGE=THISANDFUTURE", "20250701T100000")]
[TestCase(RecurrenceRange.ThisAndFuture, ";VALUE=DATE;RANGE=THISANDFUTURE", "20250701")]
[TestCase(RecurrenceRange.ThisInstance, "", "20250701T100000")]
[TestCase(RecurrenceRange.ThisInstance, ";VALUE=DATE", "20250701")]
public void RecurrenceIdentifierWithoutTzId_ShouldSerializeCorrectly(RecurrenceRange range, string dtRangeString, string dateTime)
{
var evt = new CalendarEvent
{
RecurrenceIdentifier = new RecurrenceIdentifier(new CalDateTime(dateTime), range)
};

var serializer = new EventSerializer();
var serialized = serializer.SerializeToString(evt)!;
var expected = $"RECURRENCE-ID{dtRangeString}:{dateTime}";

Assert.That(serialized, Does.Contain(expected));
}

[TestCase("20250701T100000", ";RANGE=THISANDFUTURE", RecurrenceRange.ThisAndFuture)]
[TestCase("20250701", ";VALUE=DATE;RANGE=THISANDFUTURE", RecurrenceRange.ThisAndFuture)]
[TestCase("20250701T100000", "", RecurrenceRange.ThisInstance)]
[TestCase("20250701", ";VALUE=DATE", RecurrenceRange.ThisInstance)]
[TestCase("20250701T100000", ";RANGE=invalid", RecurrenceRange.ThisInstance)]
public void RecurrenceIdentifierWithoutTzId_ShouldDeserializeCorrectly(string dt, string recId, RecurrenceRange expected)
{
var cal = $"""
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTAMP:20250928T221419Z
RECURRENCE-ID{recId}:{dt}
SEQUENCE:1
UID:c03cbcb3-6b37-49d6-9e05-a06a34a3ee57
END:VEVENT
END:VCALENDAR
""";

var recurrenceId = Calendar.Load(cal)!.Events[0]!.RecurrenceIdentifier;
using (Assert.EnterMultipleScope())
{
Assert.That(recurrenceId!.StartTime, Is.EqualTo(new CalDateTime(dt)));
Assert.That(recurrenceId.Range, Is.EqualTo(expected));
}
}

[Test]
public void RecurrenceId_IsCompatibleWith_RecurrenceIdentifier()
{
var dt = new CalDateTime("20250930");
var evt1 = new CalendarEvent
{
RecurrenceId = dt
};

var evt2 = new CalendarEvent
{
RecurrenceIdentifier = new RecurrenceIdentifier(dt)
};

var evtFuture = new CalendarEvent
{
RecurrenceIdentifier = new RecurrenceIdentifier(dt, RecurrenceRange.ThisAndFuture)
};

using (Assert.EnterMultipleScope())
{
Assert.That(evt1.RecurrenceId, Is.EqualTo(evt1.RecurrenceIdentifier?.StartTime));
Assert.That(evt2.RecurrenceId, Is.EqualTo(evt2.RecurrenceIdentifier.StartTime));
Assert.That(evt1.RecurrenceIdentifier?.Range, Is.EqualTo(RecurrenceRange.ThisInstance));
// RecurrenceId only supports ThisInstance implicitly,
// so RecurrenceInstance with ThisAndFuture returns null
Assert.That(evtFuture.RecurrenceId, Is.Null);
}
}

[Test]
public void RecurrenceIdentifierSerializer_LowLevel()
{
var recurrenceId = new RecurrenceIdentifier(new CalDateTime("20250930T140000", "Europe/Paris"), RecurrenceRange.ThisAndFuture);
var serializer = new RecurrenceIdentifierSerializer();

var serialized = serializer.SerializeToString(recurrenceId);
// Invalid parameter type should not throw, but return null
var serializedAsNull = serializer.SerializeToString(string.Empty);

var param = ParameterProviderHelper.GetRecurrenceIdentifierParameters(recurrenceId);

using (Assert.EnterMultipleScope())
{
Assert.That(serializer.TargetType == recurrenceId.GetType());
Assert.That(serialized, Is.EqualTo("20250930T140000"));
Assert.That(serializedAsNull, Is.Null);
Assert.That(param, Has.Exactly(2).Items);
Assert.That(param[0].Name, Is.EqualTo("TZID"));
Assert.That(param[0].Value, Is.EqualTo("Europe/Paris"));
Assert.That(param[1].Name, Is.EqualTo("RANGE"));
Assert.That(param[1].Value, Is.EqualTo("THISANDFUTURE"));
}
}

[TestCase("20250831", RecurrenceRange.ThisInstance, 1)] // other earlier
[TestCase("20250901", RecurrenceRange.ThisInstance, 0)] // same date, same range
[TestCase("20250901", RecurrenceRange.ThisAndFuture, -1)] // same date, higher range
[TestCase("20250902", RecurrenceRange.ThisInstance, -1)] // other later
[TestCase("20250902", RecurrenceRange.ThisAndFuture, -1)] // other later, higher range
public void CompareToTests(string dt, RecurrenceRange range, int expected)
{
var self = new RecurrenceIdentifier(new CalDateTime("20250901"), RecurrenceRange.ThisInstance);
var other = new RecurrenceIdentifier(new CalDateTime(dt), range);

Assert.That(self.CompareTo(other), Is.EqualTo(expected));
Assert.That(self.CompareTo(null), Is.EqualTo(1));
}

#pragma warning restore CS0618 // Type or member is obsolete
}
78 changes: 55 additions & 23 deletions Ical.Net.Tests/VTimeZoneTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
Expand Down Expand Up @@ -108,14 +109,11 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly()
// Unwrap the lines to make it easier to search for specific values
var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal));

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:Europe/Moscow"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Assert.That(serialized, Does.Contain("BEGIN:DAYLIGHT"), "The daylight timezone info was not serialized");
});
Assert.Multiple(() =>
{
Assert.That(serialized, Does.Contain("TZNAME:MSD"), "MSD was not serialized");
Assert.That(serialized, Does.Contain("TZNAME:MSK"), "MSK info was not serialized");
Assert.That(serialized, Does.Contain("TZNAME:MSD"), "MSD was not serialized");
Expand All @@ -127,7 +125,7 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly()
Assert.That(serialized, Does.Contain("DTSTART:19171228T000000"), "DTSTART:19171228T000000 was not serialized");
// RDATE may contain multiple dates, separated by a comma
Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19991031T030000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized");
});
}
}

[Test, Category("VTimeZone")]
Expand All @@ -137,7 +135,7 @@ public void VTimeZoneAmericaChicagoShouldSerializeProperly()
var serializer = new CalendarSerializer();
var serialized = serializer.SerializeToString(iCal);

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:America/Chicago"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Expand All @@ -153,7 +151,7 @@ public void VTimeZoneAmericaChicagoShouldSerializeProperly()
Assert.That(serialized, Does.Contain("DTSTART:19360301T020000"), "DTSTART:19360301T020000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized");
});
}
}

[Test, Category("VTimeZone")]
Expand All @@ -163,7 +161,7 @@ public void VTimeZoneAmericaLosAngelesShouldSerializeProperly()
var serializer = new CalendarSerializer();
var serialized = serializer.SerializeToString(iCal);

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:America/Los_Angeles"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Expand All @@ -176,10 +174,7 @@ public void VTimeZoneAmericaLosAngelesShouldSerializeProperly()
Assert.That(serialized, Does.Contain("DTSTART:19180331T020000"), "DTSTART:19180331T020000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized");
});

//Assert.IsTrue(serialized.Contains("TZURL:http://tzurl.org/zoneinfo/America/Los_Angeles"), "TZURL:http://tzurl.org/zoneinfo/America/Los_Angeles was not serialized");
//Assert.IsTrue(serialized.Contains("RDATE:19600424T010000"), "RDATE:19600424T010000 was not serialized"); // NodaTime doesn't match with what tzurl has
}
}

[Test, Category("VTimeZone")]
Expand All @@ -189,14 +184,14 @@ public void VTimeZoneEuropeOsloShouldSerializeProperly()
var serializer = new CalendarSerializer();
var serialized = serializer.SerializeToString(iCal);

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:Europe/Oslo"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Assert.That(serialized, Does.Contain("BEGIN:DAYLIGHT"), "The daylight timezone info was not serialized");
Assert.That(serialized, Does.Contain("BYDAY=-1SU;BYMONTH=3"), "BYDAY=-1SU;BYMONTH=3 was not serialized");
Assert.That(serialized, Does.Contain("BYDAY=-1SU;BYMONTH=10"), "BYDAY=-1SU;BYMONTH=10 was not serialized");
});
}

}

Expand All @@ -208,7 +203,7 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly()
// Unwrap the lines to make it easier to search for specific values
var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal));

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:America/Anchorage"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Expand All @@ -224,7 +219,7 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly()
Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19801026T020000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized");
Assert.That(serialized, Does.Not.Contain("RDATE:19670401/P1D"), "RDate was not properly serialized for vtimezone, should be RDATE:19670401T000000");
Assert.That(serialized, Does.Contain("DTSTART:19420209T020000"), "DTSTART:19420209T020000 was not serialized");
});
}
}

[Test, Category("VTimeZone")]
Expand All @@ -234,7 +229,7 @@ public void VTimeZoneAmericaEirunepeShouldSerializeProperly()
var serializer = new CalendarSerializer();
var serialized = serializer.SerializeToString(iCal);

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:America/Eirunepe"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Expand All @@ -245,10 +240,9 @@ public void VTimeZoneAmericaEirunepeShouldSerializeProperly()
Assert.That(serialized, Does.Contain("DTSTART:19320401T000000"), "DTSTART:19320401T000000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20080624T000000"), "DTSTART:20080624T000000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:19501201T000000"), "DTSTART:19501201T000000 was not serialized");
});

// Should not contain the following
Assert.That(serialized, Does.Not.Contain("RDATE:19501201T000000/P1D"), "The RDATE was not serialized correctly, should be RDATE:19501201T000000");
// Should not contain the following
Assert.That(serialized, Does.Not.Contain("RDATE:19501201T000000/P1D"), "The RDATE was not serialized correctly, should be RDATE:19501201T000000");
}
}

[Test, Category("VTimeZone")]
Expand All @@ -258,7 +252,7 @@ public void VTimeZoneAmericaDetroitShouldSerializeProperly()
var serializer = new CalendarSerializer();
var serialized = serializer.SerializeToString(iCal);

Assert.Multiple(() =>
using (Assert.EnterMultipleScope())
{
Assert.That(serialized, Does.Contain("TZID:America/Detroit"), "Time zone not found in serialization");
Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized");
Expand All @@ -268,7 +262,45 @@ public void VTimeZoneAmericaDetroitShouldSerializeProperly()
Assert.That(serialized, Does.Contain("TZNAME:EST"), "EST was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized");
Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized");
});
}
}

[Test, Category("VTimeZone")]
public void RecurrenceId_IsCompatibleWith_RecurrenceInstance()
{
#pragma warning disable CS0618 // Type or member is obsolete
var dt = new CalDateTime("20250930");

var iCal = CreateTestCalendar("America/Detroit");

var tzInfo1 = iCal.TimeZones.First().TimeZoneInfos.First();
tzInfo1.RecurrenceIdentifier = new RecurrenceIdentifier(dt);

iCal = CreateTestCalendar("America/Detroit");
var tzInfo2 = iCal.TimeZones.First().TimeZoneInfos.First();
tzInfo2.RecurrenceId = dt.AddDays(1);

iCal = CreateTestCalendar("America/Detroit");
var tzInfo3 = iCal.TimeZones.First().TimeZoneInfos.First();
tzInfo3.RecurrenceIdentifier = new RecurrenceIdentifier(dt, RecurrenceRange.ThisAndFuture);

using (Assert.EnterMultipleScope())
{
Assert.That(tzInfo1.RecurrenceId, Is.EqualTo(tzInfo1.RecurrenceIdentifier.StartTime));
Assert.That(tzInfo1.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisInstance));

Assert.That(tzInfo1.TzId, Is.EqualTo("America/Detroit"));

Assert.That(tzInfo2.RecurrenceIdentifier!.StartTime, Is.EqualTo(dt.AddDays(1)));
Assert.That(tzInfo2.RecurrenceId, Is.EqualTo(dt.AddDays(1)));
Assert.That(tzInfo2.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisInstance));

// RecurrenceId only supports ThisInstance implicitly,
// so RecurrenceInstance with ThisAndFuture returns null
Assert.That(tzInfo3.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisAndFuture));
Assert.That(tzInfo3.RecurrenceId, Is.Null);
}
#pragma warning restore CS0618 // Type or member is obsolete
}

private static Calendar CreateTestCalendar(string tzId, DateTime? earliestTime = null, bool includeHistoricalData = true)
Expand Down
24 changes: 22 additions & 2 deletions Ical.Net/CalendarComponents/RecurringComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Ical.Net.DataTypes;
using Ical.Net.Evaluation;
using Ical.Net.Proxies;
using Ical.Net.Utility;

namespace Ical.Net.CalendarComponents;

Expand Down Expand Up @@ -113,9 +112,30 @@ public virtual IList<RecurrencePattern> RecurrenceRules
set => Properties.Set("RRULE", value);
}

/// <summary>
/// Gets or sets the recurrence identifier for a specific instance of a recurring event.
/// </summary>
/// <remarks>Use <see cref="RecurrenceIdentifier"/> instead, which
/// supports the RANGE parameter for recurring events.</remarks>
[Obsolete("Use RecurrenceIdentifier instead, which supports the RANGE parameter.")]
public virtual CalDateTime? RecurrenceId
{
get => Properties.Get<CalDateTime>("RECURRENCE-ID");
get => RecurrenceIdentifier?.Range == RecurrenceRange.ThisInstance ? RecurrenceIdentifier.StartTime : null;
set => RecurrenceIdentifier = value is null ? null : new RecurrenceIdentifier(value, RecurrenceRange.ThisInstance);
}

/// <summary>
/// Gets or sets the recurrence identifier for a specific instance of a recurring event.
/// <para/>
/// The <see cref="RecurrenceIdentifier.Range"/> sets the scope of the recurrence instance:
/// With <see cref="RecurrenceRange.ThisInstance"/>, the instance is limited to the specific
/// occurrence identified by the <see cref="RecurrenceIdentifier.StartTime"/>.<br/>
/// With <see cref="RecurrenceRange.ThisAndFuture"/>, the instance applies to the specified
/// <see cref="RecurrenceIdentifier.StartTime"/> and all future occurrences.
/// </summary>
public virtual RecurrenceIdentifier? RecurrenceIdentifier
{
get => Properties.Get<RecurrenceIdentifier>("RECURRENCE-ID");
set => Properties.Set("RECURRENCE-ID", value);
}

Expand Down
Loading
Loading