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
Next Next commit
Correct handling of iCalendar PERIOD
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 Jan 8, 2025
commit fe2f2a8894ab61a35e528f64c06ac6ba5b413152
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.");
Copy link
Collaborator

Choose a reason for hiding this comment

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

We'd have seen a NPE if start was null, at least if end was not nulll.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed

_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