Skip to content

Commit

Permalink
Correct handling of iCalendar PERIOD
Browse files Browse the repository at this point in the history
PeriodKind
- Add new enum

Period:
- Add internal `property string TzId`
- Add internal method `PeriodKind GetPeriodKind()`
- Ensure timeone consistence for setters of StartTime / EndTime

PeriodList:
- Add internal `property string TzId`
- Add internal property ``PeriodKind PeriodListKind`m
- Ensure added `Period`s have the same `TzId`and `PeriodKind` as the first one added

PeriodListSerialilzer
- Serializes TZID
- Serializes value type PERIOD
  • Loading branch information
axunonb committed Dec 30, 2024
1 parent 42c2384 commit 8532029
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 61 deletions.
162 changes: 115 additions & 47 deletions Ical.Net.Tests/RecurrenceWithRDateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Ical.Net.Tests;
public class RecurrenceWithRDateTests
{
[Test]
public void RDate_SingleDate_IsProcessedCorrectly()
public void RDate_SingleDateTime_IsProcessedCorrectly()
{
var cal = new Calendar();
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0);
Expand All @@ -44,8 +44,38 @@ public void RDate_SingleDate_IsProcessedCorrectly()
Assert.That(occurrences, Has.Count.EqualTo(2));
Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0)));
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0)));
Assert.That(ics, Does.Contain("DURATION:PT1H"));
Assert.That(ics, Does.Contain("RDATE:20231002T100000"));
Assert.That(ics, Does.Contain("DURATION:PT1H"));
});
}

[Test]
public void RDate_SingleDateOnly_IsProcessedCorrectly()
{
var cal = new Calendar();
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0);

var recurrenceDates = PeriodList.FromDateTime(new CalDateTime(2023, 10, 2));

var calendarEvent = new CalendarEvent
{
Start = eventStart,
RecurrenceDates = new List<PeriodList> { recurrenceDates }
};

cal.Events.Add(calendarEvent);
var serializer = new CalendarSerializer();
var ics = serializer.SerializeToString(cal);

var occurrences = calendarEvent.GetOccurrences().ToList();

Assert.Multiple(() =>
{
Assert.That(occurrences, Has.Count.EqualTo(2));
Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0)));
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2)));
Assert.That(ics, Does.Contain("RDATE:20231002"));
Assert.That(ics, Does.Not.Contain("DURATION:"));
});
}

Expand Down Expand Up @@ -80,13 +110,13 @@ public void RDate_MultipleDates_WithTimeZones_AreProcessedCorrectly()
Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0, tzId)));
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0, tzId)));
Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId)));
Assert.That(ics, Does.Contain("DURATION:PT2H"));
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York:20231002T100000,20231003T100000"));
Assert.That(ics, Does.Contain("DURATION:PT2H"));
});
}

[Test]
public void RDate_Periods_AreProcessedCorrectly()
public void RDate_PeriodsWithTimezone_AreProcessedCorrectly()
{
const string tzId = "America/New_York";
var cal = new Calendar();
Expand Down Expand Up @@ -121,7 +151,8 @@ public void RDate_Periods_AreProcessedCorrectly()
Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId)));
Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4)));
Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5)));
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York:20231002T100000/PT4H,20231003T100000/PT5H"));
// Line folding is used for long lines
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T100\r\n 000/PT5H"));
});

// Deserialization
Expand All @@ -145,17 +176,20 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly()
{
var cal = new Calendar();
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0);
var recurrenceDates = new PeriodList
var recurrenceDates1 = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2, 10, 0, 0)),
};
var recurrenceDates2 = new PeriodList
{
new Period(new CalDateTime(2023, 10, 3, 10, 0, 0), new Duration(hours: 3))
};

var calendarEvent = new CalendarEvent
{
Start = eventStart,
Duration = new Duration(hours: 1),
RecurrenceDates = new List<PeriodList> { recurrenceDates }
RecurrenceDates = new List<PeriodList> { recurrenceDates1, recurrenceDates2 }
};

cal.Events.Add(calendarEvent);
Expand All @@ -171,7 +205,8 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly()
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0)));
Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0)));
Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 3)));
Assert.That(ics, Does.Contain("RDATE:20231002T100000,20231003T100000/PT3H"));
Assert.That(ics, Does.Contain("RDATE:20231002T100000"));
Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231003T100000/PT3H"));
});
}

Expand Down Expand Up @@ -217,34 +252,24 @@ public void RDate_DifferentTimeZones_AreProcessedCorrectly()
}

[Test]
public void AddingDifferentTimeZonesToPeriodList_ShouldThrow()
{
Assert.That(() =>
{
_ = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles")),
new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London"))
};
}, Throws.ArgumentException);
}

[Test]
public void RDate_DateOnlyAndDateTime_AreProcessedCorrectly()
public void RDate_DateOnlyWithDurationAndDateTime_AreProcessedCorrectly()
{
var cal = new Calendar();
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0);
var recurrenceDates = new PeriodList
var recurrenceDates1 = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2), new Duration(days: 1)),
};
var recurrenceDates2 = new PeriodList
{
new Period(new CalDateTime(2023, 10, 3, 10, 0, 0))
};

var calendarEvent = new CalendarEvent
{
Start = eventStart,
Duration = new Duration(days: 1),
RecurrenceDates = new List<PeriodList> { recurrenceDates }
Duration = new Duration(days: 2),
RecurrenceDates = new List<PeriodList> { recurrenceDates1, recurrenceDates2 }
};

cal.Events.Add(calendarEvent);
Expand All @@ -260,8 +285,9 @@ public void RDate_DateOnlyAndDateTime_AreProcessedCorrectly()
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2)));
Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(days: 1)));
Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0)));
Assert.That(ics, Does.Contain("RDATE:20231002/P1D,20231003T100000"));
Assert.That(ics, Does.Contain("DURATION:P1D"));
Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231002/P1D"));
Assert.That(ics, Does.Contain("RDATE:20231003T100000"));
Assert.That(ics, Does.Contain("DURATION:P2D"));
});
}

Expand Down Expand Up @@ -295,29 +321,10 @@ public void RDate_OverlappingPeriods_AreProcessedCorrectly()
Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0)));
Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0)));
Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 11, 0, 0)));
Assert.That(ics, Does.Contain("RDATE:20231002T100000/PT2H,20231002T110000/PT2H"));
Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231002T100000/PT2H,20231002T110000/PT2H"));
});
}

[Test]
public void RDate_DateOnly_WithExactDuration_ShouldThrow()
{
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York");
var recurrenceDates = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2)),
};

var calendarEvent = new CalendarEvent
{
Start = eventStart,
Duration = new Duration(hours: 1), // Exact duration cannot be added to date-only recurrence
RecurrenceDates = new List<PeriodList> { recurrenceDates }
};

Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException);
}

[Test]
public void RDate_LargeNumberOfDates_ShouldBeLineFolded()
{
Expand Down Expand Up @@ -355,4 +362,65 @@ public void RDate_LargeNumberOfDates_ShouldBeLineFolded()
Assert.That(ics, Does.Contain(" T100000,20240106T100000,20240107T100000,20240108T100000,20240109T100000"));
});
}

[Test]
public void AddingDifferentTimeZonesToPeriodList_ShouldThrow()
{
Assert.That(() =>
{
_ = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles")),
new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London"))
};
}, Throws.ArgumentException);
}

[Test]
public void AddingDifferentPeriodTypes_ShouldThrow()
{
Assert.Multiple(() =>
{
Assert.That(() =>
{
_ = new PeriodList
{
// date-only
new Period(new CalDateTime(2023, 10, 2)),
// date-time
new Period(new CalDateTime(2023, 10, 3, 10, 0, 0))
};
}, Throws.ArgumentException);

Assert.That(() =>
{
_ = new PeriodList
{
// period
new Period(new CalDateTime(2023, 10, 3), Duration.FromDays(1)),
// date-only
new Period(new CalDateTime(2023, 10, 2))
};
}, Throws.ArgumentException);
});
}

[Test]
public void RDate_DateOnly_WithExactDuration_ShouldThrow()
{
var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York");
var recurrenceDates = new PeriodList
{
new Period(new CalDateTime(2023, 10, 2)),
};

var calendarEvent = new CalendarEvent
{
Start = eventStart,
Duration = new Duration(hours: 1), // Exact duration cannot be added to date-only recurrence
RecurrenceDates = new List<PeriodList> { recurrenceDates }
};

Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException);
}
}
27 changes: 24 additions & 3 deletions Ical.Net/DataTypes/Period.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Ical.Net.DataTypes;
/// A period can be defined<br/>
/// 1. by a start time and an end time,<br/>
/// 2. by a start time and a duration,<br/>
/// 3. by a start time only, with the duration unspecified.
/// 3. by a start date/time or date-only, with the duration unspecified.
/// </summary>
public class Period : EncodableDataType, IComparable<Period>
{
Expand Down Expand Up @@ -44,7 +44,7 @@ public Period(IDateTime start, IDateTime? end = null)
throw new ArgumentException($"End time ({end}) must be greater than start time ({start}).", nameof(end));
}

if (end?.TzId != null && start.TzId != end.TzId) throw new ArgumentException($"Start time ({start}) and end time ({end}) must have the same timezone.", nameof(end));
EnsureConsistentTimezones(start, end);
_startTime = start ?? throw new ArgumentNullException(nameof(start), "Start time cannot be null.");
_endTime = end;
}
Expand Down Expand Up @@ -118,7 +118,11 @@ public override int GetHashCode()
public virtual IDateTime StartTime //NOSONAR
{
get => _startTime;
set => _startTime = value;
set
{
EnsureConsistentTimezones(value, _endTime);
_startTime = value;
}
}

/// <summary>
Expand All @@ -134,6 +138,7 @@ public virtual IDateTime? EndTime
get => _endTime;
set
{
EnsureConsistentTimezones(_startTime, value);
_endTime = value;
if (_endTime != null)
{
Expand Down Expand Up @@ -175,6 +180,22 @@ public virtual Duration? Duration
/// </summary>
public virtual Duration? EffectiveDuration => _duration ?? (_endTime != null ? GetEffectiveDuration() : null);

private static void EnsureConsistentTimezones(IDateTime start, IDateTime? end)
{
if (end?.TzId != null && start.TzId != end.TzId) throw new ArgumentException($"Start time ({start}) and end time ({end}) must have the same timezone.");
}

internal string? TzId => _startTime.TzId; // same timezone for start and end

internal PeriodKind GetPeriodKind()
{
if (EffectiveDuration != null)
{
return PeriodKind.Period;
}
return StartTime.HasTime ? PeriodKind.DateTime : PeriodKind.DateOnly;
}

private Duration GetEffectiveDuration()
{
if (_duration is { } d)
Expand Down
28 changes: 28 additions & 0 deletions Ical.Net/DataTypes/PeriodKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright ical.net project maintainers and contributors.
// Licensed under the MIT license.

#nullable enable
namespace Ical.Net.DataTypes;

/// <summary>
/// The kind of <see cref="DataTypes.Period"/>s that can be added to a <see cref="PeriodList"/>.
/// </summary>
internal enum PeriodKind
{
/// <summary>
/// The period kind is undefined.
/// </summary>
Undefined,
/// <summary>
/// A date-time kind.
/// </summary>
DateTime,
/// <summary>
/// A date-only kind.
/// </summary>
DateOnly,
/// <summary>
/// A period that has a <see cref="Duration"/>.
/// </summary>
Period
}
Loading

0 comments on commit 8532029

Please sign in to comment.