diff --git a/Ical.Net.Tests/CalendarEventTest.cs b/Ical.Net.Tests/CalendarEventTest.cs index 20787e8f..4f599d26 100644 --- a/Ical.Net.Tests/CalendarEventTest.cs +++ b/Ical.Net.Tests/CalendarEventTest.cs @@ -17,7 +17,7 @@ namespace Ical.Net.Tests; [TestFixture] public class CalendarEventTest { - private static readonly DateTime _now = DateTime.UtcNow; + private static readonly DateTime _now = DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified); private static readonly DateTime _later = _now.AddHours(1); private static readonly string _uid = Guid.NewGuid().ToString(); @@ -516,7 +516,7 @@ public void GetEffectiveDurationTests() { Assert.That(evt.DtStart.Value, Is.EqualTo(dt.Date)); Assert.That(evt.Duration, Is.Null); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromDays(1))); + Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(DataTypes.Duration.Zero)); }); evt = new CalendarEvent diff --git a/Ical.Net.Tests/CollectionHelpersTests.cs b/Ical.Net.Tests/CollectionHelpersTests.cs index 7f13ab1e..6741c710 100644 --- a/Ical.Net.Tests/CollectionHelpersTests.cs +++ b/Ical.Net.Tests/CollectionHelpersTests.cs @@ -14,7 +14,7 @@ namespace Ical.Net.Tests; internal class CollectionHelpersTests { - private static readonly DateTime _now = DateTime.UtcNow; + private static readonly DateTime _now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); private static List GetExceptionDates() => new List { new PeriodList { new Period(new CalDateTime(_now.AddDays(1).Date)) } }; diff --git a/Ical.Net.Tests/EqualityAndHashingTests.cs b/Ical.Net.Tests/EqualityAndHashingTests.cs index f40113dd..1a7a54ad 100644 --- a/Ical.Net.Tests/EqualityAndHashingTests.cs +++ b/Ical.Net.Tests/EqualityAndHashingTests.cs @@ -18,7 +18,7 @@ namespace Ical.Net.Tests; public class EqualityAndHashingTests { - private const string _someTz = "America/Los_Angeles"; + private const string TzId = "America/Los_Angeles"; private static readonly DateTime _nowTime = DateTime.Parse("2016-07-16T16:47:02.9310521-04:00"); private static readonly DateTime _later = _nowTime.AddHours(1); @@ -39,8 +39,8 @@ public static IEnumerable CalDateTime_TestCases() var nowCalDt = new CalDateTime(_nowTime); yield return new TestCaseData(nowCalDt, new CalDateTime(_nowTime)).SetName("Now, no time zone"); - var nowCalDtWithTz = new CalDateTime(_nowTime, _someTz); - yield return new TestCaseData(nowCalDtWithTz, new CalDateTime(_nowTime, _someTz)).SetName("Now, with time zone"); + var nowCalDtWithTz = new CalDateTime(_nowTime, TzId); + yield return new TestCaseData(nowCalDtWithTz, new CalDateTime(_nowTime, TzId)).SetName("Now, with time zone"); } [Test] @@ -281,7 +281,7 @@ public void Resources_Tests() }); } - internal static (byte[] original, byte[] copy) GetAttachments() + private static (byte[] original, byte[] copy) GetAttachments() { var payload = Encoding.UTF8.GetBytes("This is an attachment!"); var payloadCopy = new byte[payload.Length]; @@ -347,112 +347,51 @@ public void PeriodListTests() new DateTime(2017, 03, 02, 06, 00, 00), new DateTime(2017, 03, 03, 06, 00, 00), new DateTime(2017, 03, 06, 06, 00, 00), - new DateTime(2017, 03, 07, 06, 00, 00), - new DateTime(2017, 03, 08, 06, 00, 00), - new DateTime(2017, 03, 09, 06, 00, 00), - new DateTime(2017, 03, 10, 06, 00, 00), - new DateTime(2017, 03, 13, 06, 00, 00), - new DateTime(2017, 03, 14, 06, 00, 00), - new DateTime(2017, 03, 17, 06, 00, 00), - new DateTime(2017, 03, 20, 06, 00, 00), - new DateTime(2017, 03, 21, 06, 00, 00), - new DateTime(2017, 03, 22, 06, 00, 00), - new DateTime(2017, 03, 23, 06, 00, 00), - new DateTime(2017, 03, 24, 06, 00, 00), - new DateTime(2017, 03, 27, 06, 00, 00), - new DateTime(2017, 03, 28, 06, 00, 00), - new DateTime(2017, 03, 29, 06, 00, 00), - new DateTime(2017, 03, 30, 06, 00, 00), - new DateTime(2017, 03, 31, 06, 00, 00), - new DateTime(2017, 04, 03, 06, 00, 00), - new DateTime(2017, 04, 05, 06, 00, 00), - new DateTime(2017, 04, 06, 06, 00, 00), - new DateTime(2017, 04, 07, 06, 00, 00), - new DateTime(2017, 04, 10, 06, 00, 00), - new DateTime(2017, 04, 11, 06, 00, 00), - new DateTime(2017, 04, 12, 06, 00, 00), - new DateTime(2017, 04, 13, 06, 00, 00), - new DateTime(2017, 04, 17, 06, 00, 00), - new DateTime(2017, 04, 18, 06, 00, 00), - new DateTime(2017, 04, 19, 06, 00, 00), - new DateTime(2017, 04, 20, 06, 00, 00), - new DateTime(2017, 04, 21, 06, 00, 00), - new DateTime(2017, 04, 24, 06, 00, 00), - new DateTime(2017, 04, 25, 06, 00, 00), - new DateTime(2017, 04, 27, 06, 00, 00), new DateTime(2017, 04, 28, 06, 00, 00), - new DateTime(2017, 05, 01, 06, 00, 00), + new DateTime(2017, 05, 01, 06, 00, 00) } .Select(dt => new Period(new CalDateTime(dt))).ToList(); + var a = new PeriodList(); foreach (var period in startTimesA) { a.Add(period); } - //Difference from A: first element became the second, and last element became the second-to-last element + // Difference from A: first element became the second, + // and last element became the second-to-last element var startTimesB = new List { new DateTime(2017, 03, 03, 06, 00, 00), new DateTime(2017, 03, 02, 06, 00, 00), new DateTime(2017, 03, 06, 06, 00, 00), - new DateTime(2017, 03, 07, 06, 00, 00), - new DateTime(2017, 03, 08, 06, 00, 00), - new DateTime(2017, 03, 09, 06, 00, 00), - new DateTime(2017, 03, 10, 06, 00, 00), - new DateTime(2017, 03, 13, 06, 00, 00), - new DateTime(2017, 03, 14, 06, 00, 00), - new DateTime(2017, 03, 17, 06, 00, 00), - new DateTime(2017, 03, 20, 06, 00, 00), - new DateTime(2017, 03, 21, 06, 00, 00), - new DateTime(2017, 03, 22, 06, 00, 00), - new DateTime(2017, 03, 23, 06, 00, 00), - new DateTime(2017, 03, 24, 06, 00, 00), - new DateTime(2017, 03, 27, 06, 00, 00), - new DateTime(2017, 03, 28, 06, 00, 00), - new DateTime(2017, 03, 29, 06, 00, 00), - new DateTime(2017, 03, 30, 06, 00, 00), - new DateTime(2017, 03, 31, 06, 00, 00), - new DateTime(2017, 04, 03, 06, 00, 00), - new DateTime(2017, 04, 05, 06, 00, 00), - new DateTime(2017, 04, 06, 06, 00, 00), - new DateTime(2017, 04, 07, 06, 00, 00), - new DateTime(2017, 04, 10, 06, 00, 00), - new DateTime(2017, 04, 11, 06, 00, 00), - new DateTime(2017, 04, 12, 06, 00, 00), - new DateTime(2017, 04, 13, 06, 00, 00), - new DateTime(2017, 04, 17, 06, 00, 00), - new DateTime(2017, 04, 18, 06, 00, 00), - new DateTime(2017, 04, 19, 06, 00, 00), - new DateTime(2017, 04, 20, 06, 00, 00), - new DateTime(2017, 04, 21, 06, 00, 00), - new DateTime(2017, 04, 24, 06, 00, 00), - new DateTime(2017, 04, 25, 06, 00, 00), - new DateTime(2017, 04, 27, 06, 00, 00), new DateTime(2017, 05, 01, 06, 00, 00), - new DateTime(2017, 04, 28, 06, 00, 00), + new DateTime(2017, 04, 28, 06, 00, 00) } .Select(dt => new Period(new CalDateTime(dt))).ToList(); + var b = new PeriodList(); + foreach (var period in startTimesB) { b.Add(period); } var collectionEqual = CollectionHelpers.Equals(a, b); - Assert.Multiple(() => - { - Assert.That(collectionEqual, Is.EqualTo(true)); - Assert.That(b.GetHashCode(), Is.EqualTo(a.GetHashCode())); - }); var listOfListA = new List { a }; var listOfListB = new List { b }; - Assert.That(CollectionHelpers.Equals(listOfListA, listOfListB), Is.True); var aThenB = new List { a, b }; var bThenA = new List { b, a }; - Assert.That(CollectionHelpers.Equals(aThenB, bThenA), Is.True); + + Assert.Multiple(() => + { + Assert.That(collectionEqual, Is.EqualTo(true)); + Assert.That(b.GetHashCode(), Is.EqualTo(a.GetHashCode())); + Assert.That(CollectionHelpers.Equals(listOfListA, listOfListB), Is.True); + Assert.That(CollectionHelpers.Equals(aThenB, bThenA), Is.True); + }); } [Test] @@ -489,8 +428,6 @@ private void TestComparison(Func calOp, Func. TestComparison((dt1, dt2) => dt1 == dt2, (i1, i2) => i1 == i2); TestComparison((dt1, dt2) => dt1 != dt2, (i1, i2) => i1 != i2); TestComparison((dt1, dt2) => dt1 > dt2, (i1, i2) => i1 > i2); diff --git a/Ical.Net.Tests/PeriodListTest.cs b/Ical.Net.Tests/PeriodListTest.cs new file mode 100644 index 00000000..916f2b44 --- /dev/null +++ b/Ical.Net.Tests/PeriodListTest.cs @@ -0,0 +1,140 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.IO; +using Ical.Net.DataTypes; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +public class PeriodListTests +{ + [Test] + public void RemovePeriod_ShouldDecreaseCount() + { + // Arrange + var periodList = new PeriodList(); + var period = new Period(new CalDateTime(2023, 1, 1, 0, 0, 0), Duration.FromHours(1)); + periodList.Add(period); + + // Act + periodList.Remove(period); + + // Assert + Assert.That(periodList, Has.Count.EqualTo(0)); + } + + [Test] + public void GetSet_Period_ShouldReturnCorrectPeriod() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 2, 1, 0, 0, 0), Duration.FromHours(1)); + + periodList.AddPeriod(period1).AddPeriod(period1); + + // Act + var retrievedPeriod = periodList[0]; + periodList[1] = period2; + + // Assert + Assert.Multiple(() => + { + Assert.That(period1, Is.EqualTo(retrievedPeriod)); + Assert.That(periodList.Contains(period1), Is.True); + Assert.That(periodList[periodList.IndexOf(period2)], Is.EqualTo(period2)); + }); + } + + [Test] + public void Clear_ShouldRemoveAllPeriods() + { + // Arrange + var periodList = new PeriodList(); + var pl = PeriodList + .FromDateTime(new CalDateTime(2025, 1, 2)) + .Add(new CalDateTime(2025, 1, 3)); + + var count = pl.Count; + + // Act + periodList.Clear(); + + // Assert + Assert.Multiple(() => + { + Assert.That(count, Is.EqualTo(2)); + Assert.That(periodList, Has.Count.EqualTo(0)); + }); + } + + [Test] + public void Create_FromStringReader_ShouldSucceed() + { + // Arrange + const string periodString = "20250101T000000Z/20250101T010000Z,20250102T000000Z/20250102T010000Z"; + using var reader = new StringReader(periodString); + + // Act + var periodList = PeriodList.FromStringReader(reader); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(2)); + Assert.That(periodList[0].StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "UTC"))); + Assert.That(periodList[0].EndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "UTC"))); + Assert.That(periodList[1].StartTime, Is.EqualTo(new CalDateTime(2025, 1, 2, 0, 0, 0, "UTC"))); + Assert.That(periodList[1].EndTime, Is.EqualTo(new CalDateTime(2025, 1, 2, 1, 0, 0, "UTC"))); + Assert.That(periodList.IsReadOnly, Is.EqualTo(false)); + }); + } + + [Test] + public void InsertAt_ShouldInsertPeriodAtCorrectPosition() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 1, 2, 0, 0, 0), Duration.FromHours(1)); + var period3 = new Period(new CalDateTime(2025, 1, 3, 0, 0, 0), Duration.FromHours(1)); + periodList.AddPeriod(period1).AddPeriod(period3); + + // Act + periodList.Insert(1, period2); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(3)); + Assert.That(periodList[1], Is.EqualTo(period2)); + }); + } + + [Test] + public void RemoveAt_ShouldRemovePeriodAtCorrectPosition() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 1, 2, 0, 0, 0), Duration.FromHours(1)); + var period3 = new Period(new CalDateTime(2025, 1, 3, 0, 0, 0), Duration.FromHours(1)); + periodList.AddPeriod(period1).AddPeriod(period2).AddPeriod(period3); + + // Act + periodList.RemoveAt(1); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(2)); + Assert.That(periodList[1], Is.EqualTo(period3)); + }); + } +} diff --git a/Ical.Net.Tests/PeriodTests.cs b/Ical.Net.Tests/PeriodTests.cs index 3ae3021d..484d6384 100644 --- a/Ical.Net.Tests/PeriodTests.cs +++ b/Ical.Net.Tests/PeriodTests.cs @@ -5,6 +5,8 @@ #nullable enable using System; +using System.ComponentModel.Design; +using System.Linq; using Ical.Net.DataTypes; using NUnit.Framework; @@ -27,12 +29,12 @@ public void CreatePeriodWithArguments() Assert.That(period.Duration, Is.Null); Assert.That(periodWithEndTime.StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.That(periodWithEndTime.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); - Assert.That(periodWithEndTime.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(1))); + Assert.That(periodWithEndTime.EffectiveEndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); + Assert.That(periodWithEndTime.EffectiveDuration, Is.EqualTo(Duration.FromHours(1))); Assert.That(periodWithDuration.StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.That(periodWithDuration.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); - Assert.That(periodWithDuration.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(1))); + Assert.That(periodWithDuration.EffectiveEndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); + Assert.That(periodWithDuration.EffectiveDuration, Is.EqualTo(Duration.FromHours(1))); }); } @@ -61,10 +63,10 @@ public void SetEndTime_GetDuration() var endTime = new CalDateTime(2025, 1, 31, 0, 0, 0); period.EndTime = endTime; - Assert.That(period.GetEffectiveEndTime(), Is.EqualTo(endTime)); Assert.That(period.EndTime, Is.EqualTo(endTime)); Assert.That(period.Duration, Is.Null); - Assert.That(period.GetEffectiveDuration(), Is.EqualTo(Duration.FromDays(30))); + Assert.That(period.EffectiveDuration, Is.EqualTo(Duration.FromDays(30))); + Assert.That(period.EffectiveEndTime, Is.EqualTo(endTime)); } [Test] @@ -74,10 +76,32 @@ public void SetDuration_GetEndTime() var duration = Duration.FromHours(1); period.Duration = duration; - Assert.That(period.GetEffectiveDuration(), Is.EqualTo(duration)); Assert.That(period.Duration, Is.EqualTo(duration)); Assert.That(period.EndTime, Is.Null); - Assert.That(period.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0))); + Assert.That(period.EffectiveEndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0))); + Assert.That(period.EffectiveDuration, Is.EqualTo(duration)); + } + + [Test] + public void Timezones_StartTime_EndTime_MustBeEqual() + { + var periods = new[] + { + (new CalDateTime(2025, 1, 1, 0, 0, 0, "Europe/Vienna"), + new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId)), + (new CalDateTime(2025, 1, 1, 0, 0, 0, null), + new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId)), + (new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(2025, 1, 1, 0, 0, 0, null)) + }; + + Assert.Multiple(() => + { + foreach (var p in periods) + { + Assert.Throws(() => _ = new Period(p.Item1, p.Item2)); + } + }); } [Test] diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index ec12c482..73426949 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -66,7 +66,7 @@ int eventIndex expectedPeriods[i].AssociatedObject = cal; var period = expectedPeriods[i].Copy(); - period.EndTime = period.GetEffectiveEndTime(); + period.EndTime = period.EffectiveEndTime; Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period); if (timeZones != null) @@ -2567,7 +2567,7 @@ public void DurationOfRecurrencesOverDst(string dtStart, string dtEnd, string d1 foreach (var p in expectedPeriods) { p.StartTime = p.StartTime.ToTimeZone(start.TzId); - p.EndTime = p.StartTime.Add(p.Duration.Value); + p.EndTime = p.StartTime.Add(p.Duration!.Value); } // date only cannot have a time zone @@ -3303,16 +3303,14 @@ public void AddExDateToEventAfterGetOccurrencesShouldRecomputeResult() Assert.That(occurrences, Has.Count.EqualTo(5)); var exDate = _now.AddDays(1); - var period = new Period(new CalDateTime(exDate, false)); - var periodList = new PeriodList { period }; + var periodList = new PeriodList() { new CalDateTime(exDate, false) }; e.ExceptionDates.Add(periodList); occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(4)); //Specifying just a date should "black out" that date var excludeTwoDaysFromNow = _now.AddDays(2).Date; - period = new Period(new CalDateTime(excludeTwoDaysFromNow, false)); - periodList.Add(period); + periodList.Add(new CalDateTime(excludeTwoDaysFromNow, false)); occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(3)); } @@ -3337,7 +3335,7 @@ private static CalendarEvent GetEventWithRecurrenceRules() } [Test] - public void ExDateFold_Tests() + public void ExDatesShouldGetMergedInOutput() { var start = _now.AddYears(-1); var end = start.AddHours(1); @@ -3350,7 +3348,7 @@ public void ExDateFold_Tests() }; var firstExclusion = new CalDateTime(start.AddDays(4)); - e.ExceptionDates = new List { new PeriodList { new Period(firstExclusion) } }; + e.ExceptionDates = new List { new PeriodList() { new Period(firstExclusion) } }; var serialized = SerializationHelpers.SerializeToString(e); Assert.That(Regex.Matches(serialized, "EXDATE:"), Has.Count.EqualTo(1)); @@ -3375,15 +3373,14 @@ public void ExDateTimeZone_Tests() RecurrenceRules = new List { rrule }, }; - var exceptionDateList = new PeriodList { TzId = tzid }; - exceptionDateList.Add(new Period(new CalDateTime(_now.AddDays(1)))); + var exceptionDateList = new PeriodList() { new Period(new CalDateTime(_now.AddDays(1), tzid)) }; e.ExceptionDates.Add(exceptionDateList); var serialized = SerializationHelpers.SerializeToString(e); const string expected = "TZID=Europe/Stockholm"; Assert.That(Regex.Matches(serialized, expected), Has.Count.EqualTo(3)); - e.ExceptionDates.First().Add(new Period(new CalDateTime(_now.AddDays(2)))); + e.ExceptionDates.First().Add(new Period(new CalDateTime(_now.AddDays(2), tzid))); serialized = SerializationHelpers.SerializeToString(e); Assert.That(Regex.Matches(serialized, expected), Has.Count.EqualTo(3)); } @@ -3501,16 +3498,14 @@ public void RecurrenceRuleTests() VERSION:2.0 BEGIN:VEVENT DTEND;TZID=UTC:20170228T140000 - DTSTAMP;TZID=UTC:20170428T171444 + DTSTAMP:20170428T171444Z DTSTART;TZID=UTC:20170228T060000 - EXDATE;TZID=UTC:20170302T060000,20170303T060000,20170306T060000,20170307T0 - 60000,20170308T060000,20170309T060000,20170310T060000,20170313T060000,201 - 70314T060000,20170317T060000,20170320T060000,20170321T060000,20170322T060 - 000,20170323T060000,20170324T060000,20170327T060000,20170328T060000,20170 - 329T060000,20170330T060000,20170331T060000,20170403T060000,20170405T06000 - 0,20170406T060000,20170407T060000,20170410T060000,20170411T060000,2017041 - 2T060000 - EXDATE;TZID=UTC:20170417T060000,20170413T060000 + EXDATE;TZID=UTC:20170302T060000,20170303T060000,20170306T060000,20170307T060000, + 20170308T060000,20170309T060000,20170310T060000,20170313T060000,20170314T060000, + 20170317T060000,20170320T060000,20170321T060000,20170322T060000,20170323T060000, + 20170324T060000,20170327T060000,20170328T060000,20170329T060000,20170330T060000, + 20170331T060000,20170403T060000,20170405T060000,20170406T060000,20170407T060000, + 20170410T060000,20170411T060000,20170412T060000,20170413T060000,20170417T060000 IMPORTANCE:None RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR UID:001b7e43-98df-4fcc-b9ec-345a28a4fc14 @@ -3672,24 +3667,26 @@ public static IEnumerable UntilTimeZoneSerializationTestCases() [Test] public void InclusiveRruleUntil() { - const string icalText = @"BEGIN:VCALENDAR -BEGIN:VEVENT -DTSTART;VALUE=DATE:20180101 -DTEND;VALUE=DATE:20180102 -RRULE:FREQ=WEEKLY;UNTIL=20180105;BYDAY=MO,TU,WE,TH,FR -DTSTAMP:20170926T001103Z -UID:5kvks79u4nurqopt7qv4fi1jo8@google.com -CREATED:20170922T131958Z -DESCRIPTION: -LAST-MODIFIED:20170922T131958Z -LOCATION: -SEQUENCE:0 -STATUS:CONFIRMED -SUMMARY:Holiday Break - No School -TRANSP:TRANSPARENT -END:VEVENT -END:VCALENDAR -"; + const string icalText = + """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART;VALUE=DATE:20180101 + DTEND;VALUE=DATE:20180102 + RRULE:FREQ=WEEKLY;UNTIL=20180105;BYDAY=MO,TU,WE,TH,FR + DTSTAMP:20170926T001103Z + UID:5kvks79u4nurqopt7qv4fi1jo8@google.com + CREATED:20170922T131958Z + DESCRIPTION: + LAST-MODIFIED:20170922T131958Z + LOCATION: + SEQUENCE:0 + STATUS:CONFIRMED + SUMMARY:Holiday Break - No School + TRANSP:TRANSPARENT + END:VEVENT + END:VCALENDAR + """; const string timeZoneId = @"Eastern Standard Time"; var calendar = Calendar.Load(icalText); var firstEvent = calendar.Events.First(); diff --git a/Ical.Net.Tests/RecurrenceWithExDateTests.cs b/Ical.Net.Tests/RecurrenceWithExDateTests.cs new file mode 100644 index 00000000..c58dc90b --- /dev/null +++ b/Ical.Net.Tests/RecurrenceWithExDateTests.cs @@ -0,0 +1,233 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.Linq; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +/// +/// The class contains the tests for submitted issues from the GitHub repository, +/// slightly modified to fit the testing environment and the current version of the library. +/// +[TestFixture] +public class RecurrenceWithExDateTests +{ + [TestCase(true)] + [TestCase(false)] + public void ShouldNotOccurOnLocalExceptionDate(bool useExDateWithTime) + { + // Arrange + var id = Guid.NewGuid(); + const string timeZoneId = "Europe/London"; // IANA Time Zone ID + var start = new CalDateTime(2024, 10, 19, 18, 0, 0, timeZoneId); + var end = new CalDateTime(2024, 10, 19, 19, 0, 0, timeZoneId); + var exceptionDate = useExDateWithTime + ? new CalDateTime(2024, 10, 19, 21, 0, 0, timeZoneId) + : new CalDateTime(2024, 10, 19); + + var recurrencePattern = new RecurrencePattern(FrequencyType.Hourly) + { + Count = 2, + Interval = 3 + }; + + var recurringEvent = new CalendarEvent + { + Summary = "My Recurring Event", + Uid = id.ToString(), + Start = start, + End = end + }; + recurringEvent.RecurrenceRules.Add(recurrencePattern); + recurringEvent.ExceptionDates.Add(PeriodList.FromDateTime(exceptionDate)); + + var calendar = new Calendar(); + calendar.Events.Add(recurringEvent); + + // Act + var serializer = new CalendarSerializer(); + var ics = serializer.SerializeToString(calendar); + + var deserializedCalendar = Calendar.Load(ics); + var occurrences = deserializedCalendar.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + if (useExDateWithTime) + { + Assert.That(occurrences.Single().Period, Is.EqualTo(new Period(start, end))); + Assert.That(ics, Does.Contain("EXDATE;TZID=Europe/London:20241019T210000")); + } + else + { + Assert.That(occurrences, Has.Count.EqualTo(0)); + Assert.That(ics, Does.Contain("EXDATE:20241019")); + } + }); + + } + + [Test] + public void ShouldNotOccurOnUtcExceptionDate() + { + // Using Windows Time Zone ID + var ics = """ + BEGIN:VCALENDAR + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + VERSION:2.0 + BEGIN:VEVENT + DTEND;TZID=GMT Standard Time:20241019T190000 + DTSTAMP:20241018T083839Z + DTSTART;TZID=GMT Standard Time:20241019T180000 + EXDATE:20241019T190000Z + RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=4 + SEQUENCE:0 + SUMMARY:My Recurring Event + UID:c9f3a28d-97d6-43f7-872e-6cd79f67093d + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + var occurrences = cal.GetOccurrences().ToList(); + + var serializer = new CalendarSerializer(); + ics = serializer.SerializeToString(cal); + + // Start date: 2024-10-19 at 18:00 (GMT Standard Time) + // Recurrence: Every hour, 4 occurrences + // Occurrences: + // 2024-10-19 18:00 (UTC Offset: +0100) + // 2024-10-19 19:00 (UTC Offset: +0100) + // 2024-10-19 21:00 (UTC Offset: +0100) + // Excluded dates impact: + // 2024-10-19 at 19:00 UTC (= 2024-10-19 20:00 in "GMT Standard Time") + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(3)); + Assert.That( + occurrences.All( + o => !cal + .Events[0] + .ExceptionDates[0] + .Any(ex => ex.StartTime.Equals(o.Period.StartTime))), Is.True); + Assert.That(ics, Does.Contain("EXDATE:20241019T190000Z")); + }); + } + + [Test] + public void MultipleExclusionDatesSameTimeZoneShouldBeExcluded() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + BEGIN:VEVENT + UID:uid2@example.com + DTSTAMP:20231021T162159Z + DTSTART;TZID=Europe/Berlin:20231025T090000 + DTEND;TZID=Europe/Berlin:20231025T100000 + RRULE:FREQ=WEEKLY;COUNT=10 + EXDATE;TZID=Europe/Berlin:20231029T090000,20231105T090000,20231112T090000 + SUMMARY:Weekly Meeting + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + var occurrences = cal.GetOccurrences().ToList(); + + var serializer = new CalendarSerializer(); + ics = serializer.SerializeToString(cal); + + // Occurrences: + // 2023-10-25 09:00 (UTC Offset: +0200) + // 2023-11-01 09:00 (UTC Offset: +0100) + // 2023-11-08 09:00 (UTC Offset: +0100) + // 2023-11-15 09:00 (UTC Offset: +0100) + // 2023-11-22 09:00 (UTC Offset: +0100) + // 2023-11-29 09:00 (UTC Offset: +0100) + // 2023-12-06 09:00 (UTC Offset: +0100) + // 2023-12-13 09:00 (UTC Offset: +0100) + // 2023-12-20 09:00 (UTC Offset: +0100) + // 2023-12-27 09:00 (UTC Offset: +0100) + // Exclusion Dates impact: + // 2023-10-29 09:00 (UTC Offset: +0200) + // 2023-11-05 09:00 (UTC Offset: +0100) + // 2023-11-12 09:00 (UTC Offset: +0100) + // The recurrences are adjusted for the switch from Daylight Saving Time to + // Standard Time, which occurs on October 29, 2023, in the Europe/Berlin time zone. + + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(10)); + Assert.That(cal.Events[0].ExceptionDates[0], Has.Count.EqualTo(3)); + Assert.That( + occurrences.All( + o => !cal + .Events[0] + .ExceptionDates[0] + .Any(ex => ex.StartTime.Equals(o.Period.StartTime))), Is.True); + Assert.That(ics, Does.Contain("EXDATE;TZID=Europe/Berlin:20231029T090000,20231105T090000,20231112T090000")); + }); + } + + [Test] + public void MultipleExclusionDatesDifferentZoneShouldBeExcluded() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + BEGIN:VEVENT + UID:uid5@example.com + DTSTAMP:20231021T162159Z + DTSTART;TZID=America/New_York:20231025T090000 + DTEND;TZID=America/New_York:20231025T100000 + RRULE:FREQ=WEEKLY;COUNT=10 + EXDATE;TZID=America/New_York:20231029T090000 + EXDATE;TZID=Europe/London:20231101T130000 + SUMMARY:Weekly Meeting + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + var occurrences = cal.GetOccurrences().ToList(); + + // Occurrences: + // October 25, 2023, 09:00 AM (EDT, UTC-4) + // November 8, 2023, 09:00 AM (EST, UTC-5) + // November 15, 2023, 09:00 AM (EST, UTC-5) + // November 22, 2023, 09:00 AM (EST, UTC-5) + // November 29, 2023, 09:00 AM (EST, UTC-5) + // December 6, 2023, 09:00 AM (EST, UTC-5) + // December 13, 2023, 09:00 AM (EST, UTC-5) + // December 20, 2023, 09:00 AM (EST, UTC-5) + // December 27, 2023, 09:00 AM (EST, UTC-5) + // Exclusion Dates Impact + // October 29, 2023, 09:00 AM (America/New_York): Excluded + // November 1, 2023, 01:00 PM (Europe/London): Excluded - November 1, 2023, 09:00 AM (EDT, UTC-4) + + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(9)); + Assert.That(cal.Events[0].ExceptionDates[0], Has.Count.EqualTo(1)); + Assert.That(cal.Events[0].ExceptionDates[1], Has.Count.EqualTo(1)); + Assert.That( + occurrences.All( + o => !cal + .Events[0]. + ExceptionDates[0] + .Any(ex => ex.StartTime.Equals(o.Period.StartTime))), Is.True); + }); + } +} diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs new file mode 100644 index 00000000..d86139b6 --- /dev/null +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -0,0 +1,124 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Collections.Generic; +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 RecurrenceWithRDateTests +{ + [Test] + public void RDate_SingleDate_IsProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + PeriodList recurrenceDates = + [ + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0)) + ]; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + RecurrenceDates = new List { 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, 10, 0, 0))); + Assert.That(ics, Does.Contain("DURATION:PT1H")); + Assert.That(ics, Does.Contain("RDATE:20231002T100000")); + }); + } + + [Test] + public void RDate_MultipleDates_WithTimeZones_AreProcessedCorrectly() + { + const string tzId = "America/New_York"; + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, tzId); + var recurrenceDates = new PeriodList + { + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, tzId)), + new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId)) + }; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 2), + RecurrenceDates = new List { 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(3)); + 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")); + }); + } + + [Test] + public void RDate_Periods_AreProcessedCorrectly() + { + const string tzId = "America/New_York"; + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, tzId); + var recurrenceDates = new PeriodList + { + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, tzId), new Duration(hours: 4)), + new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId), new Duration(hours: 5)) + }; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 2), + RecurrenceDates = new List { 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(3)); + 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(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")); + }); + } +} diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index b1c1f117..64f6d59b 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -358,9 +358,9 @@ public void RecurrenceDates1() Assert.Multiple(() => { - Assert.That(iCal.Events.First().RecurrenceDates[0][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1997, 7, 14, 12, 30, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 2, 0, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].EndTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 4, 0, 0, DateTimeKind.Utc))); + Assert.That(iCal.Events.First().RecurrenceDates[0][0].StartTime, Is.EqualTo(new CalDateTime(1997, 7, 14, 12, 30, 0, CalDateTime.UtcTzId))); + Assert.That(iCal.Events.First().RecurrenceDates[1][0].StartTime, Is.EqualTo(new CalDateTime(1996, 4, 3, 2, 0, 0, CalDateTime.UtcTzId))); + Assert.That(iCal.Events.First().RecurrenceDates[1][0].EndTime, Is.EqualTo(new CalDateTime(1996, 4, 3, 4, 0, 0, CalDateTime.UtcTzId))); Assert.That(iCal.Events.First().RecurrenceDates[2][0].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 1))); Assert.That(iCal.Events.First().RecurrenceDates[2][1].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 20))); Assert.That(iCal.Events.First().RecurrenceDates[2][2].StartTime, Is.EqualTo(new CalDateTime(1997, 2, 17))); diff --git a/Ical.Net/Calendar.cs b/Ical.Net/Calendar.cs index d4b00e46..b833167e 100644 --- a/Ical.Net/Calendar.cs +++ b/Ical.Net/Calendar.cs @@ -275,20 +275,14 @@ public virtual IEnumerable GetOccurrences(IDateTime startTime = n /// An object of the type specified public T Create() where T : ICalendarComponent { - var obj = Activator.CreateInstance(typeof(T)) as ICalendarObject; - if (obj is T) + if (Activator.CreateInstance(typeof(T), true) is ICalendarObject cal) { - this.AddChild(obj); - return (T) obj; + this.AddChild(cal); + return (T) cal; } return default(T); } - public void Dispose() - { - Children.Clear(); - } - public virtual void MergeWith(IMergeable obj) { var c = obj as Calendar; diff --git a/Ical.Net/CalendarComponents/Alarm.cs b/Ical.Net/CalendarComponents/Alarm.cs index 531e1747..8bc2277b 100644 --- a/Ical.Net/CalendarComponents/Alarm.cs +++ b/Ical.Net/CalendarComponents/Alarm.cs @@ -3,10 +3,10 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.Collections.Generic; using Ical.Net.DataTypes; -using Ical.Net.Utility; namespace Ical.Net.CalendarComponents; @@ -58,7 +58,7 @@ public virtual string Summary set => Properties.Set("SUMMARY", value); } - public virtual Trigger Trigger + public virtual Trigger? Trigger { get => Properties.Get(TriggerRelation.Key); set => Properties.Set(TriggerRelation.Key, value); @@ -73,7 +73,7 @@ public Alarm() /// Gets a list of alarm occurrences for the given recurring component, /// that occur between and . /// - public virtual IList GetOccurrences(IRecurringComponent rc, IDateTime fromDate, IDateTime toDate) + public virtual IList GetOccurrences(IRecurringComponent rc, IDateTime? fromDate, IDateTime? toDate) { if (Trigger == null) { @@ -93,7 +93,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa fromDate = rc.Start.Copy(); } - Duration? d = null; + Duration? duration = null; foreach (var o in rc.GetOccurrences(fromDate, toDate)) { var dt = o.Period.StartTime; @@ -102,15 +102,15 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa if (o.Period.EndTime != null) { dt = o.Period.EndTime; - if (d == null) + if (duration == null) { - d = o.Period.Duration!.Value; // the getter always returns a value + duration = o.Period.EffectiveDuration; } } // Use the "last-found" duration as a reference point - else if (d != null) + else if (duration != null) { - dt = o.Period.StartTime.Add(d.Value); + dt = o.Period.StartTime.Add(duration.Value); } else { @@ -119,7 +119,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa } } - occurrences.Add(new AlarmOccurrence(this, dt.Add(Trigger.Duration.Value), rc)); + occurrences.Add(new AlarmOccurrence(this, dt.Add(Trigger.Duration!.Value), rc)); } } else @@ -142,6 +142,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa /// is null, all triggered alarms will be returned. /// /// The earliest date/time to poll trigered alarms for. + /// /// A list of objects, each containing a triggered alarm. public virtual IList Poll(IDateTime start, IDateTime end) { diff --git a/Ical.Net/CalendarComponents/CalendarEvent.cs b/Ical.Net/CalendarComponents/CalendarEvent.cs index e22d6746..0e42be53 100644 --- a/Ical.Net/CalendarComponents/CalendarEvent.cs +++ b/Ical.Net/CalendarComponents/CalendarEvent.cs @@ -138,15 +138,13 @@ calculation from the zoned start time to the zoned end time. return DtEnd.Subtract(dtStart); } - if (!dtStart.HasTime) - { - // RFC 5545 3.6.1: - // For cases where a "VEVENT" calendar component - // specifies a "DTSTART" property with a DATE value type but no - // "DTEND" nor "DURATION" property, the event’s duration is taken to - // be one day. - return DataTypes.Duration.FromDays(1); - } + // RFC 5545 3.6.1: + // For cases where a "VEVENT" calendar component + // specifies a "DTSTART" property with a DATE value type but no + // "DTEND" nor "DURATION" property, the event’s duration is taken to + // be one day. + // This is taken care of in the PeriodSerializer class, + // so we don't use magic numbers here. // For DtStart.HasTime but no DtEnd - also the default case // diff --git a/Ical.Net/CalendarComponents/Todo.cs b/Ical.Net/CalendarComponents/Todo.cs index 86e38815..0078f2e5 100644 --- a/Ical.Net/CalendarComponents/Todo.cs +++ b/Ical.Net/CalendarComponents/Todo.cs @@ -148,7 +148,7 @@ public virtual bool IsCompleted(IDateTime currDt) // Evaluate to the previous occurrence. var periods = _mEvaluator.EvaluateToPreviousOccurrence(Completed, currDt); - return periods.Cast().All(p => !p.StartTime.GreaterThan(Completed) || !currDt.GreaterThanOrEqual(p.StartTime)); + return periods.All(p => !p.StartTime.GreaterThan(Completed) || !currDt.GreaterThanOrEqual(p.StartTime)); } return false; } diff --git a/Ical.Net/CalendarObjectBase.cs b/Ical.Net/CalendarObjectBase.cs index 8d3cbcda..3d111d11 100644 --- a/Ical.Net/CalendarObjectBase.cs +++ b/Ical.Net/CalendarObjectBase.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +#nullable enable using System; namespace Ical.Net; @@ -25,24 +26,21 @@ public virtual void CopyFrom(ICopyable obj) /// Creates a deep copy of the object. /// /// The copy of the object. - public virtual T Copy() + public virtual T? Copy() { - var type = GetType(); - var obj = Activator.CreateInstance(type) as ICopyable; + if (Activator.CreateInstance(GetType(), true) is not T objOfT) return default; - if (obj is not T objOfT) return default(T); - - obj.CopyFrom(this); + (objOfT as ICopyable)?.CopyFrom(this); return objOfT; } public virtual bool IsLoaded => _mIsLoaded; - public event EventHandler Loaded; + public event EventHandler? Loaded; public virtual void OnLoaded() { _mIsLoaded = true; Loaded?.Invoke(this, EventArgs.Empty); } -} \ No newline at end of file +} diff --git a/Ical.Net/Collections/GroupedValueList.cs b/Ical.Net/Collections/GroupedValueList.cs index e50caa63..c8568167 100644 --- a/Ical.Net/Collections/GroupedValueList.cs +++ b/Ical.Net/Collections/GroupedValueList.cs @@ -30,10 +30,12 @@ public virtual void Set(TGroup group, IEnumerable values) } // No matching item was found, add a new item to the list - var obj = Activator.CreateInstance(typeof(TItem)) as TInterface; - obj.Group = group; - obj.SetValue(values); - Add(obj); + if (Activator.CreateInstance(typeof(TItem), true) is TInterface obj) + { + obj.Group = group; + obj.SetValue(values); + Add(obj); + } } public virtual TType Get(TGroup group) @@ -50,4 +52,4 @@ public virtual TType Get(TGroup group) } public virtual IList GetMany(TGroup group) => new GroupedValueListProxy(this, group); -} \ No newline at end of file +} diff --git a/Ical.Net/DataTypes/CalendarDataType.cs b/Ical.Net/DataTypes/CalendarDataType.cs index 0d75e2fc..71bd3340 100644 --- a/Ical.Net/DataTypes/CalendarDataType.cs +++ b/Ical.Net/DataTypes/CalendarDataType.cs @@ -154,7 +154,7 @@ public virtual void CopyFrom(ICopyable obj) public virtual T Copy() { var type = GetType(); - var obj = Activator.CreateInstance(type) as ICopyable; + var obj = Activator.CreateInstance(type, true) as ICopyable; if (obj is not T o) return default(T); diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index 31527302..d6ce3a44 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -11,17 +11,27 @@ namespace Ical.Net.DataTypes; /// /// Represents an iCalendar period of time. +/// +/// A period can be defined
+/// 1. by a start time and an end time,
+/// 2. by a start time and a duration,
+/// 3. by a start time only, with the duration unspecified. ///
public class Period : EncodableDataType, IComparable { - public Period() { } + private IDateTime _startTime = null!; + private IDateTime? _endTime; + private Duration? _duration; + + // Needed for the serialization factory + internal Period() { } /// /// Creates a new instance starting at the given time - /// and ending at the given time. The latter may be null. + /// and ending at the given time. /// - /// A that has a date-only , no - /// and no duration set, is considered to last for one day. + /// If time is not provided, the period will be considered as starting at the given time, + /// while the duration is unspecified. /// /// /// @@ -31,16 +41,23 @@ public Period(IDateTime start, IDateTime? end = null) { if (end != null && end.LessThanOrEqual(start)) { - throw new ArgumentException("End time must be greater than end time.", nameof(end)); + throw new ArgumentException("End time must be greater than start time.", nameof(end)); } - - StartTime = start ?? throw new ArgumentNullException(nameof(start)); - EndTime = end; + + if (end?.TzId != null && start.TzId != end.TzId) throw new ArgumentException("Start and end time must have the same timezone.", nameof(end)); + _startTime = start ?? throw new ArgumentNullException(nameof(start)); + _endTime = end; } /// /// Creates a new instance starting at the given time /// and lasting for the given duration. + /// + /// If is not provided, the period will be considered as starting at the given time, + /// while the duration is unspecified. + /// + /// For a that lasts full days, add a date-only , + /// and a of with the number of days. /// /// /// @@ -50,8 +67,8 @@ public Period(IDateTime start, Duration duration) if (duration.Sign < 0) throw new ArgumentException("Duration must be greater than or equal to zero.", nameof(duration)); - StartTime = start; - Duration = duration; + _startTime = start; + _duration = duration; } /// @@ -61,9 +78,9 @@ public override void CopyFrom(ICopyable obj) if (obj is not Period p) return; - StartTime = p.StartTime.Copy(); - EndTime = p.EndTime?.Copy(); - Duration = p.Duration; + _startTime = p._startTime.Copy(); + _endTime = p._endTime?.Copy(); + _duration = p._duration; } protected bool Equals(Period other) => Equals(StartTime, other.StartTime) && Equals(EndTime, other.EndTime) && Duration.Equals(other.Duration); @@ -89,7 +106,7 @@ public override int GetHashCode() } /// - public override string ToString() + public override string? ToString() { var periodSerializer = new PeriodSerializer(); return periodSerializer.SerializeToString(this); @@ -98,7 +115,11 @@ public override string ToString() /// /// Gets or sets the start time of the period. /// - public virtual IDateTime StartTime { get; set; } = null!; + public virtual IDateTime StartTime //NOSONAR + { + get => _startTime; + set => _startTime = value; + } /// /// Gets either the end time of the period that was set, @@ -106,43 +127,71 @@ public override string ToString() /// /// Sets the end time of the period. /// Either the or the can be set at a time. - /// The last one set will be stored, and the other will be calculated. + /// The last one set with a value not null will prevail, while the other will become . + /// + public virtual IDateTime? EndTime + { + get => _endTime; + set + { + _endTime = value; + if (_endTime != null) + { + _duration = null; + } + } + } + + /// + /// Gets the nominal duration of the period that was set, or - if this is - + /// calculates the exact duration based on the duration. /// - public virtual IDateTime? EndTime { get; set; } + public virtual IDateTime? EffectiveEndTime => _endTime ?? (_duration != null ? GetEffectiveEndTime() : null); /// - /// Gets either the nominal duration of the period that was set, - /// or calculates the exact duration based on the end time. + /// Gets the original duration of the period as it was set.
+ /// See also . /// /// Sets the duration of the period. /// Either the or the can be set at a time. - /// The last one set will be stored, and the other will be calculated. - /// - /// A that has a date-only , no - /// and no duration set, is considered to last for one day. + /// The last one set with a value not null will prevail, while the other will become . + ///
+ public virtual Duration? Duration + { + get => _duration; + set + { + _duration = value; + if (_duration != null) + { + _endTime = null; + } + } + } + + /// + /// Gets the duration of the period that was set, or - if this is - + /// calculates the exact duration based on the end time. /// - public virtual Duration? Duration { get; set; } + public virtual Duration? EffectiveDuration => _duration ?? (_endTime != null ? GetEffectiveDuration() : null); - internal Duration GetEffectiveDuration() + private Duration GetEffectiveDuration() { - if (Duration is { } d) + if (_duration is { } d) return d; - if (EndTime is { } endTime) - return endTime.Subtract(StartTime); - - if (!StartTime.HasTime) - return DataTypes.Duration.FromDays(1); + if (_endTime is { } endTime) + return endTime.Subtract(_startTime); return DataTypes.Duration.Zero; } - internal IDateTime GetEffectiveEndTime() + private IDateTime GetEffectiveEndTime() { - if (EndTime is { } endTime) + if (_endTime is { } endTime) return endTime; - return StartTime.Add(GetEffectiveDuration()); + return _startTime.Add(GetEffectiveDuration()); } /// @@ -155,14 +204,14 @@ internal IDateTime GetEffectiveEndTime() public virtual bool Contains(IDateTime? dt) { // Start time is inclusive - if (dt == null || !StartTime.LessThanOrEqual(dt)) + if (dt == null || !_startTime.LessThanOrEqual(dt)) { return false; } var endTime = GetEffectiveEndTime(); // End time is exclusive - return endTime == null || endTime.GreaterThan(dt); + return endTime.GreaterThan(dt); } /// @@ -181,6 +230,7 @@ public virtual bool CollidesWith(Period period) || Contains(period.GetEffectiveEndTime()) || period.Contains(GetEffectiveEndTime()); + /// public int CompareTo(Period? other) { if (other == null) @@ -188,11 +238,11 @@ public int CompareTo(Period? other) return 1; } - if (StartTime.Equals(other.StartTime)) + if (StartTime.AsUtc.Equals(other.StartTime.AsUtc)) { return 0; } - if (StartTime.LessThan(other.StartTime)) + if (StartTime.AsUtc <= other.StartTime.AsUtc) { return -1; } @@ -201,4 +251,3 @@ public int CompareTo(Period? other) return 1; } } - diff --git a/Ical.Net/DataTypes/PeriodList.cs b/Ical.Net/DataTypes/PeriodList.cs index 44fd5979..08bfa6bd 100644 --- a/Ical.Net/DataTypes/PeriodList.cs +++ b/Ical.Net/DataTypes/PeriodList.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.Collections; using System.Collections.Generic; @@ -15,24 +16,64 @@ namespace Ical.Net.DataTypes; /// -/// An iCalendar list of recurring dates (or date exclusions) +/// An iCalendar list used to represent a list of objects +/// for EXDATE and RDATE properties. /// public class PeriodList : EncodableDataType, IList { - public string TzId { get; set; } + /// + /// Gets the timezone ID of the .
+ /// The timezone of the first item added determines the timezone for the list. + ///
+ public string? TzId { get; private set; } + + /// + /// Gets the number of s of the list. + /// public int Count => Periods.Count; - protected IList Periods { get; set; } = new List(); + /// + /// Gets the list of s of the list. + /// + protected IList Periods { get; } = new List(); + // Also needed for the serialization factory public PeriodList() { SetService(new PeriodListEvaluator(this)); + TzId = null; } - public PeriodList(string value) : this() + /// + /// Creates a new instance of the class from the . + /// + /// + private PeriodList(StringReader value) { var serializer = new PeriodListSerializer(); - CopyFrom(serializer.Deserialize(new StringReader(value)) as ICopyable); + if (serializer.Deserialize(value) is ICopyable deserialized) + { + CopyFrom(deserialized); + } + SetService(new PeriodListEvaluator(this)); + } + + /// + /// Creates a new instance of the class from the object. + /// + /// + public static PeriodList FromStringReader(StringReader value) => new PeriodList(value); + + /// + /// Creates a new instance of a class from an object, + /// using the timezone from the . + /// + /// + /// A new instance of the . + public static PeriodList FromDateTime(IDateTime value) + { + var pl = new PeriodList().Add(value); + return pl; } /// @@ -48,16 +89,18 @@ public override void CopyFrom(ICopyable obj) { Add(p.Copy()); } - - // String assignments create new instances - TzId = list.TzId; } - public override string ToString() => new PeriodListSerializer().SerializeToString(this); - - public void Add(IDateTime dt) => Periods.Add(new Period(dt)); - - public static Dictionary> GetGroupedPeriods(IList periodLists) + /// + /// Gets the string representation of the list. + /// + /// + public override string? ToString() => new PeriodListSerializer().SerializeToString(this); + + /// + /// Used for equality comparison of two lists of periods. + /// + public static Dictionary> GetGroupedPeriods(IList periodLists) { // In order to know if two events are equal, a semantic understanding of exdates, rdates, rrules, and exrules is required. This could be done by // computing the complete recurrence set (expensive) while being time-zone sensitive, or by comparing each List in each IPeriodList. @@ -73,32 +116,37 @@ public static Dictionary> GetGroupedPeriods(IList>(StringComparer.OrdinalIgnoreCase); foreach (var periodList in periodLists) { - var defaultBucket = string.IsNullOrWhiteSpace(periodList.TzId) ? "" : periodList.TzId; - + // Dictionary key cannot be null, so an empty string is used for the default bucket + var defaultBucket = string.IsNullOrWhiteSpace(periodList.TzId) ? string.Empty : periodList.TzId; foreach (var period in periodList) { - var actualBucket = string.IsNullOrWhiteSpace(period.StartTime.TzId) ? defaultBucket : period.StartTime.TzId; + var bucketTzId = period.StartTime.TzId ?? defaultBucket; - if (!grouped.ContainsKey(actualBucket)) + if (!grouped.TryGetValue(bucketTzId, out var periods)) { - grouped.Add(actualBucket, new HashSet()); + periods = new HashSet(); + grouped.Add(bucketTzId, periods); } - grouped[actualBucket].Add(period); + + periods.Add(period); } } - return grouped.ToDictionary(k => k.Key, v => v.Value.OrderBy(d => d.StartTime).ToList()); + + return grouped.ToDictionary(k => k.Key, IList (v) => v.Value.OrderBy(d => d.StartTime).ToList()); } protected bool Equals(PeriodList other) => string.Equals(TzId, other.TzId, StringComparison.OrdinalIgnoreCase) && CollectionHelpers.Equals(Periods, other.Periods); - public override bool Equals(object obj) + /// + public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; return obj.GetType() == GetType() && Equals((PeriodList) obj); } + /// public override int GetHashCode() { unchecked @@ -109,21 +157,91 @@ public override int GetHashCode() } } + /// public Period this[int index] { get => Periods[index]; set => Periods[index] = value; } + /// public bool Remove(Period item) => Periods.Remove(item); + + /// public bool IsReadOnly => Periods.IsReadOnly; + + /// public int IndexOf(Period item) => Periods.IndexOf(item); + + /// public void Insert(int index, Period item) => Periods.Insert(index, item); + + /// public void RemoveAt(int index) => Periods.RemoveAt(index); - public void Add(Period item) => Periods.Add(item); + + /// + /// Adds a for an 'RDATE' to the list.
+ /// The timezone of the first value added determines the timezone for the list. + /// + /// To add an 'EXDATE', use the method instead, + /// because s are not permitted for 'EXDATE' according to + /// RFC 5545 section 3.8.5.1. + ///
+ /// The for an 'RDATE'. + public void Add(Period item) + { + EnsureConsistentTimezones(item.StartTime, item.EndTime); + Periods.Add(item); + } + + /// + /// Adds a DATE or DATE-TIME value for an 'EXDATE' or 'RDATE' to the list.
+ /// The timezone of the first value added determines the timezone for the list. + /// + /// To add an 'RDATE' , use the method instead. + ///
+ /// + /// This instance of the . + public PeriodList Add(IDateTime dt) + { + EnsureConsistentTimezones(dt, null); + Periods.Add(new Period(dt)); + return this; + } + + /// + /// Adds a for an 'RDATE' to the list.
+ /// The timezone of the first value added determines the timezone for the list. + ///
+ /// + /// + public PeriodList AddPeriod(Period item) + { + EnsureConsistentTimezones(item.StartTime, item.EndTime); + Add(item); + return this; + } + + /// public void Clear() => Periods.Clear(); + + /// public bool Contains(Period item) => Periods.Contains(item); + + /// public void CopyTo(Period[] array, int arrayIndex) => Periods.CopyTo(array, arrayIndex); + + /// public IEnumerator GetEnumerator() => Periods.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => Periods.GetEnumerator(); -} \ No newline at end of file + + private void EnsureConsistentTimezones(IDateTime dt1, IDateTime? dt2) + { + if (Count != 0 && (dt1.TzId != TzId || (dt2 != null && dt2.TzId != TzId))) + { + throw new ArgumentException("All Periods of a PeriodList must have the same timezone"); + } + + if (Count == 0) TzId = dt1.TzId; + } +} diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index 89de4ca4..b2bcb3ff 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -90,9 +90,13 @@ and it may differ from the time span added to the period start time. endTime = endDt; } - // Return the Period object with the calculated end time and duration. - period.Duration = endTime.Subtract(period.StartTime); // exact duration - period.EndTime = endTime; // Only EndTime is relevant for further processing. + // Return the Period object with the calculated end time. + // Only EndTime is relevant for further processing, + // so we have to set it. + // If the period duration is not null here, it is an RDATE period. + period.EndTime = period.Duration == null + ? endTime + : period.EffectiveEndTime; return period; } diff --git a/Ical.Net/Evaluation/TodoEvaluator.cs b/Ical.Net/Evaluation/TodoEvaluator.cs index 17e268f3..24cc4d09 100644 --- a/Ical.Net/Evaluation/TodoEvaluator.cs +++ b/Ical.Net/Evaluation/TodoEvaluator.cs @@ -83,27 +83,7 @@ public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? if (Todo.Start == null) return []; - Period PeriodWithDuration(Period p) - { - if (p.EndTime != null) - return p; - - var period = p.Copy(); - - var d = Todo.Duration; - if (d != null) - { - period.EndTime = period.StartTime.Add(d.Value); - } - else - { - period.Duration = default; - } - - return period; - } - return base.Evaluate(referenceDate, periodStart, periodEnd, includeReferenceDateInResults) - .Select(PeriodWithDuration); + .Select(p => p); } } diff --git a/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs b/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs index d82e5183..6bc28e43 100644 --- a/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs @@ -17,7 +17,7 @@ protected DataTypeSerializer(SerializationContext ctx) : base(ctx) { } protected virtual ICalendarDataType CreateAndAssociate() { // Create an instance of the object - if (!(Activator.CreateInstance(TargetType) is ICalendarDataType dt)) + if (Activator.CreateInstance(TargetType, true) is not ICalendarDataType dt) { return null; } @@ -29,4 +29,4 @@ protected virtual ICalendarDataType CreateAndAssociate() return dt; } -} \ No newline at end of file +} diff --git a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs index 7d45ec06..838e1a58 100644 --- a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs @@ -43,12 +43,9 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } // the time value. The "TZID" property parameter MUST NOT be applied to DATE-TIME // properties whose time values are specified in UTC. - var kind = dt.IsUtc - ? DateTimeKind.Utc - : DateTimeKind.Unspecified; - if (dt.IsUtc) { + // 'Z' is used as the UTC designator dt.Parameters.Remove("TZID"); } else if (!string.IsNullOrWhiteSpace(dt.TzId)) @@ -56,16 +53,6 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } dt.Parameters.Set("TZID", dt.TzId); } - var dateWithNewKind = DateTime.SpecifyKind(dt.Value, kind); - // We can't use 'Copy' because we need to change the value - dt = dt.HasTime - ? new CalDateTime(dateWithNewKind, dt.TzId, true) { AssociatedObject = dt.AssociatedObject } - : new CalDateTime(dateWithNewKind, dt.TzId, false) { AssociatedObject = dt.AssociatedObject }; - - // FIXME: what if DATE is the default value type for this? - // Also, what if the DATE-TIME value type is specified on something - // where DATE-TIME is the default value type? It should be removed - // during serialization, as it's redundant... if (!dt.HasTime) { dt.SetValueType("DATE"); @@ -73,13 +60,12 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } var value = new StringBuilder(512); value.Append($"{dt.Year:0000}{dt.Month:00}{dt.Day:00}"); - if (dt.HasTime) + if (!dt.HasTime) return Encode(dt, value.ToString()); + + value.Append($"T{dt.Hour:00}{dt.Minute:00}{dt.Second:00}"); + if (dt.IsUtc) { - value.Append($"T{dt.Hour:00}{dt.Minute:00}{dt.Second:00}"); - if (dt.IsUtc) - { - value.Append("Z"); - } + value.Append("Z"); } // Encode the value as necessary diff --git a/Ical.Net/Serialization/DataTypes/DurationSerializer.cs b/Ical.Net/Serialization/DataTypes/DurationSerializer.cs index 7f86f807..d17800b0 100644 --- a/Ical.Net/Serialization/DataTypes/DurationSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DurationSerializer.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.IO; using System.Text; @@ -19,7 +20,7 @@ public DurationSerializer(SerializationContext ctx) : base(ctx) { } public override Type TargetType => typeof(Duration); - public override string SerializeToString(object obj) + public override string? SerializeToString(object obj) => (obj is not Duration duration) ? null : SerializeToString(duration); private static string SerializeToString(Duration ts) @@ -53,20 +54,24 @@ private static string SerializeToString(Duration ts) return sb.ToString(); } - internal static readonly Regex TimespanMatch = + internal static readonly Regex DurationMatch = new Regex(@"^(?\+|-)?P(((?\d+)W)|(?
((?\d+)D)?(?