Skip to content

Commit

Permalink
Add wrapper classes for ExceptionDates and RecurrenceDates
Browse files Browse the repository at this point in the history
- Add internal properties `PeriodKind` and `TzId` to `PeriodList`.
- Modify `Period` indexer and `Insert` to ensure consistency for timezone and `PeriodKind`
- Prepare for `PeriodList` to become `internal`
- Update `PeriodListSerializer` and `PeriodSerializer` classes for `Period` serialization
- Introduce `ExceptionDates`, `RecurrenceDates`, and `PeriodListWrapperBase` to become wrappers around these properties of `RecurringComponent`
- Add unit tests in `PeriodListWrapperTests` for `ExceptionDates` and `RecurrenceDates`.

Note: The wrappers are not yet implemented for `IRecurringComponent`s.
  • Loading branch information
axunonb committed Jan 9, 2025
1 parent 2d85dab commit fca144a
Show file tree
Hide file tree
Showing 8 changed files with 623 additions and 8 deletions.
2 changes: 1 addition & 1 deletion Ical.Net.Tests/CalendarPropertiesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ public void PropertySetValueMustAllowNull()
var property = new CalendarProperty();
Assert.DoesNotThrow(() => property.SetValue(null));
}
}
}
337 changes: 337 additions & 0 deletions Ical.Net.Tests/PeriodListWrapperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
//
// Copyright ical.net project maintainers and contributors.
// Licensed under the MIT license.
//

#nullable enable
using System.Linq;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using NUnit.Framework;

namespace Ical.Net.Tests;

[TestFixture]
public class PeriodListWrapperTests
{
#region ** ExceptionDates **

[Test]
public void AddExDateTime_ShouldCreate_DedicatePeriodList()
{
var cal = new Calendar();
var evt = new CalendarEvent();
cal.Events.Add(evt);
var exDates = new ExceptionDates(evt.ExceptionDates);

exDates // Add date-only
.Add(new CalDateTime(2025, 1, 1))
.Add(new CalDateTime(2025, 1, 2))
// duplicate
.Add(new CalDateTime(2025, 1, 2))
// Should go to a new PeriodList
.Add(new CalDateTime(2025, 1, 2, 14, 0, 0, CalDateTime.UtcTzId));

exDates.AddRange([ // Add date-time
new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"),
new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin"),
// duplicate
new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin")
]);

var serialized = new CalendarSerializer(cal).SerializeToString();

Assert.Multiple(() =>
{
// 2 dedicate PeriodList objects
Assert.That(evt.ExceptionDates, Has.Count.EqualTo(3));

// First PeriodList is date-only
Assert.That(evt.ExceptionDates[0], Has.Count.EqualTo(2));
Assert.That(evt.ExceptionDates[0].TzId, Is.Null);
Assert.That(evt.ExceptionDates[0].PeriodKind, Is.EqualTo(PeriodKind.DateOnly));

// Second PeriodList is date-time UTC
Assert.That(evt.ExceptionDates[1], Has.Count.EqualTo(1));
Assert.That(evt.ExceptionDates[1].TzId, Is.EqualTo(CalDateTime.UtcTzId));
Assert.That(evt.ExceptionDates[1].PeriodKind, Is.EqualTo(PeriodKind.DateTime));

// Second PeriodList is date-time
Assert.That(evt.ExceptionDates[2], Has.Count.EqualTo(2));
Assert.That(evt.ExceptionDates[2].TzId, Is.EqualTo("Europe/Berlin"));
Assert.That(evt.ExceptionDates[2].PeriodKind, Is.EqualTo(PeriodKind.DateTime));

Assert.That(serialized,
Does.Contain("EXDATE;VALUE=DATE:20250101,20250102\r\nEXDATE:20250102T140000Z\r\nEXDATE;TZID=Europe/Berlin:20250101T101112,20250101T101113"));

// A flattened list of all dates
Assert.That(exDates.GetAllDates().Count(), Is.EqualTo(5));
});
}

[Test]
public void RemoveExDateTime_ShouldRemove_FromPeriodList()
{
var evt = new CalendarEvent();
var exDates = new ExceptionDates(evt.ExceptionDates);

var dateOnly = new CalDateTime(2025, 1, 1);
var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin");

exDates.Add(dateOnly);
exDates.Add(dateOnly.AddDays(1));
exDates.Add(dateTime);

var dateTimeSuccess = exDates.Remove(dateTime);
var dateOnlySuccess = exDates.Remove(dateOnly);
var dateOnlyFail = !exDates.Remove(dateOnly); // already removed

Assert.Multiple(() =>
{
Assert.That(dateOnlySuccess, Is.True);
Assert.That(dateTimeSuccess, Is.True);
Assert.That(dateOnlyFail, Is.True);
Assert.That(evt.ExceptionDates[0], Has.Count.EqualTo(1));
Assert.That(evt.ExceptionDates[1], Is.Empty);
// Empty lists should work as well
evt.ExceptionDates.Clear();
Assert.That(() => exDates.Remove(dateTime), Is.False);
});
}

#endregion

#region ** RecurrenceDates **

[Test]
public void AddRDateTime_ShouldCreate_DedicatePeriodList()
{
var cal = new Calendar();
var evt = new CalendarEvent();
cal.Events.Add(evt);
var recDates = new RecurrenceDates(evt.RecurrenceDates);

recDates // Add date-only
.Add(new CalDateTime(2025, 1, 1))
.Add(new CalDateTime(2025, 1, 2))
.Add(new CalDateTime(2025, 1, 2)); // duplicate

recDates.AddRange([ // Add date-time
new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"),
new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin"),
new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin") // duplicate
]);

var serialized = new CalendarSerializer(cal).SerializeToString();

Assert.Multiple(() =>
{
// 2 dedicate PeriodList objects
Assert.That(evt.RecurrenceDates, Has.Count.EqualTo(2));

// First PeriodList is date-only
Assert.That(evt.RecurrenceDates[0], Has.Count.EqualTo(2));
Assert.That(evt.RecurrenceDates[0].TzId, Is.Null);
Assert.That(evt.RecurrenceDates[0].PeriodKind, Is.EqualTo(PeriodKind.DateOnly));

// Third PeriodList is date-time
Assert.That(evt.RecurrenceDates[1], Has.Count.EqualTo(2));
Assert.That(evt.RecurrenceDates[1].TzId, Is.EqualTo("Europe/Berlin"));
Assert.That(evt.RecurrenceDates[1].PeriodKind, Is.EqualTo(PeriodKind.DateTime));

Assert.That(serialized,
Does.Contain("RDATE;VALUE=DATE:20250101,20250102\r\nRDATE;TZID=Europe/Berlin:20250101T101112,20250101T101113"));

// A flattened list of all dates
Assert.That(recDates.GetAllDates().Count(), Is.EqualTo(4));
});
}

[Test]
public void AddRPeriod_ShouldCreate_DedicatePeriodList()
{
var cal = new Calendar();
var evt = new CalendarEvent();
cal.Events.Add(evt);
var recPeriod = new RecurrenceDates(evt.RecurrenceDates);

recPeriod
// Add date-only period
.Add(Period.Create(new CalDateTime(2025, 1, 2), new CalDateTime(2025, 1, 5)))
// Add zoned date-time period
.Add(Period.Create(new CalDateTime(2025, 2, 2, 0, 0, 0, CalDateTime.UtcTzId),
new CalDateTime(2025, 2, 2, 6, 0, 0, CalDateTime.UtcTzId)))
// duplicate
.Add(Period.Create(new CalDateTime(2025, 2, 2, 0, 0, 0, CalDateTime.UtcTzId),
new CalDateTime(2025, 2, 2, 6, 0, 0, CalDateTime.UtcTzId)));

recPeriod.AddRange([
// Add date-only period with end time
Period.Create(new CalDateTime(2025, 5, 1), new CalDateTime(2025, 5, 10)),
// Add zoned date-time period with end time
Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, CalDateTime.UtcTzId), new CalDateTime(2025, 6, 1, 14, 0, 0, CalDateTime.UtcTzId)),
// duplicate
Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, CalDateTime.UtcTzId), new CalDateTime(2025, 6, 1, 14, 0, 0, CalDateTime.UtcTzId)),
// Add date-only with duration
Period.Create(new CalDateTime(2025, 5, 1), duration: Duration.FromDays(9)),
// Add zoned date-time period with duration
Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, "Europe/Vienna"), duration: Duration.FromHours(8))
]);

var serializer = new CalendarSerializer(cal);
var serialized = serializer.SerializeToString();
// Assign the deserialized event
cal = Calendar.Load(serialized);
evt = cal.Events[0];

// Assert the serialized string and the deserialized event
Assert.Multiple(() =>
{
// 2 dedicate PeriodList objects
Assert.That(evt.RecurrenceDates, Has.Count.EqualTo(3));

// First PeriodList has date-only periods
Assert.That(evt.RecurrenceDates[0], Has.Count.EqualTo(3));
Assert.That(evt.RecurrenceDates[0].TzId, Is.Null);
Assert.That(evt.RecurrenceDates[0].PeriodKind, Is.EqualTo(PeriodKind.Period));

// Second PeriodList has UTC date-time periods
Assert.That(evt.RecurrenceDates[1], Has.Count.EqualTo(2));
Assert.That(evt.RecurrenceDates[1].TzId, Is.EqualTo("UTC"));
Assert.That(evt.RecurrenceDates[1].PeriodKind, Is.EqualTo(PeriodKind.Period));

// Third PeriodList has zoned date-time with duration
Assert.That(evt.RecurrenceDates[2], Has.Count.EqualTo(1));
Assert.That(evt.RecurrenceDates[2].TzId, Is.EqualTo("Europe/Vienna"));
Assert.That(evt.RecurrenceDates[2].PeriodKind, Is.EqualTo(PeriodKind.Period));

Assert.That(serialized,
Does.Contain("RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D\r\nRDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/2025\r\n 0601T140000Z\r\nRDATE;TZID=Europe/Vienna;VALUE=PERIOD:20250601T120000/PT8H"));

// A flattened list of all dates
Assert.That(recPeriod.GetAllDates().Count(), Is.EqualTo(0));
// A flattened list of all periods
Assert.That(recPeriod.GetAllPeriods().Count(), Is.EqualTo(6));
});
}

[Test]
public void RemoveRDateTime_ShouldRemove_FromPeriodList()
{
var evt = new CalendarEvent();
var recDates = new RecurrenceDates(evt.RecurrenceDates);

var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5));
var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6));

recDates.Add(period1).Add(period2);
recDates.Add(new Period(period1.StartTime.AddDays(1), Duration.FromDays(5)));

var period1Success = recDates.Remove(period1);
var period2Success = recDates.Remove(period2);
var period2Fail = !recDates.Remove(period2); // already removed

Assert.Multiple(() =>
{
Assert.That(period2Success, Is.True);
Assert.That(period1Success, Is.True);
Assert.That(period2Fail, Is.True);
Assert.That(evt.RecurrenceDates[0], Has.Count.EqualTo(1));
Assert.That(evt.RecurrenceDates[1], Is.Empty);
});
}

[Test]
public void Contains_ShouldReturnTrue_IfPeriodExists()
{
var evt = new CalendarEvent();
var recDates = new RecurrenceDates(evt.RecurrenceDates);

var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5));
var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6));

recDates.Add(period1).Add(period2);

Assert.Multiple(() =>
{
Assert.That(recDates.Contains(period1), Is.True);
Assert.That(recDates.Contains(period2), Is.True);
});
}

[Test]
public void Contains_ShouldReturnFalse_IfPeriodDoesNotExist()
{
var evt = new CalendarEvent();
var recDates = new RecurrenceDates(evt.RecurrenceDates);

var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5));
var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6));

recDates.AddRange([period1, period2]);

Assert.Multiple(() =>
{
Assert.That(recDates.Contains(new Period(period1.StartTime.AddDays(1), Duration.FromDays(5))), Is.False);
Assert.That(recDates.Contains(new Period(period2.StartTime.AddDays(1), Duration.FromHours(6))), Is.False);
});
}

#endregion

#region ** PeriodListWrapperBase **

[Test]
public void Clear_ShouldRemoveAllPeriods()
{
var evt = new CalendarEvent();
var exDates = new ExceptionDates(evt.ExceptionDates);

exDates
.Add(new CalDateTime(2025, 1, 1))
.Add(new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"));

exDates.Clear();

Assert.That(evt.ExceptionDates, Is.Empty);
}

[Test]
public void Contains_ShouldReturnTrue_IfDateExists()
{
var evt = new CalendarEvent();
var exDates = new ExceptionDates(evt.ExceptionDates);

var dateOnly = new CalDateTime(2025, 1, 1);
var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin");

exDates.Add(dateOnly).Add(dateTime);

Assert.Multiple(() =>
{
Assert.That(exDates.Contains(dateOnly), Is.True);
Assert.That(exDates.Contains(dateTime), Is.True);
});
}

[Test]
public void Contains_ShouldReturnFalse_IfDateDoesNotExist()
{
var evt = new CalendarEvent();
var exDates = new ExceptionDates(evt.ExceptionDates);

var dateOnly = new CalDateTime(2025, 1, 1);
var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin");

exDates.AddRange([dateOnly, dateTime]);

Assert.Multiple(() =>
{
Assert.That(exDates.Contains(dateOnly.AddDays(1)), Is.False);
Assert.That(exDates.Contains(dateTime.AddDays(1)), Is.False);
});
}

#endregion
}
54 changes: 54 additions & 0 deletions Ical.Net/DataTypes/ExceptionDates.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.Collections.Generic;
using System.Linq;

namespace Ical.Net.DataTypes;

/// <summary>
/// This class is used to manage ICalendar <c>EXDATE</c> properties, which can be date-time and date-only.
/// <remarks>
/// The class is a wrapper around a list of <c>PeriodList</c> objects.
/// Specifically, it is used to group periods by their <c>TzId</c>, <c>PeriodKind</c> and <c>date-time/date-only</c>
/// in way that serialization conforms to the RFC 5545 standard.
/// </remarks>
/// </summary>
public class ExceptionDates : PeriodListWrapperBase
{
internal ExceptionDates(IList<PeriodList> listOfPeriodList) : base(listOfPeriodList)
{ }

/// <summary>
/// Adds a date to the list, if it doesn't already exist.
/// </summary>
public ExceptionDates Add(IDateTime dt)
{
var periodList = GetOrCreate(dt);

// Don't add the same date twice.
if (periodList.FirstOrDefault(period => Equals(period.StartTime, dt)) != null)
return this;

var dtPeriod = new Period(dt);
periodList.Add(dtPeriod);

return this;
}

/// <summary>
/// Adds a range of dates to the list, if they don't already exist.
/// </summary>
public ExceptionDates AddRange(IEnumerable<IDateTime> dates)
{
foreach (var dt in dates)
{
Add(dt);
}

return this;
}
}
Loading

0 comments on commit fca144a

Please sign in to comment.