diff --git a/Ical.Net.Tests/Calendars/Alarm/ALARM4.ics b/Ical.Net.Tests/Calendars/Alarm/ALARM4.ics index e20baa61..2b972e86 100644 --- a/Ical.Net.Tests/Calendars/Alarm/ALARM4.ics +++ b/Ical.Net.Tests/Calendars/Alarm/ALARM4.ics @@ -8,7 +8,6 @@ DTSTAMP:20060717T210718Z UID:uuid1153170430406 SUMMARY:Test event DTSTART;TZID=US-Eastern:19970902T090000 -DTEND;TZID=US-Eastern:19970902T100000 EXDATE;TZID=US-Eastern:19970902T090000 RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13 BEGIN:VALARM diff --git a/Ical.Net.Tests/Calendars/Alarm/ALARM6.ics b/Ical.Net.Tests/Calendars/Alarm/ALARM6.ics index 6773cc29..558721fa 100644 --- a/Ical.Net.Tests/Calendars/Alarm/ALARM6.ics +++ b/Ical.Net.Tests/Calendars/Alarm/ALARM6.ics @@ -8,7 +8,6 @@ DTSTAMP:20060717T210718Z UID:uuid1153170430406 SUMMARY:Test event DTSTART;TZID=US-Eastern:19970902T090000 -DTEND;TZID=US-Eastern:19970902T100000 EXDATE;TZID=US-Eastern:19970902T090000 RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13 BEGIN:VALARM diff --git a/Ical.Net.Tests/Calendars/Recurrence/DailyInterval1.ics b/Ical.Net.Tests/Calendars/Recurrence/DailyInterval1.ics index 6420f426..63c39690 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/DailyInterval1.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/DailyInterval1.ics @@ -6,7 +6,6 @@ CREATED:20070405T013153Z DTEND;TZID=US-Eastern:20070410T070000 DTSTAMP:20070405T013153Z DTSTART;TZID=US-Eastern:20070409T070000 -DURATION:P1D RRULE:FREQ=DAILY;INTERVAL=3 SUMMARY:Every third day UID:baff9b6a-a161-4057-bd9c-d76359dc90b1 diff --git a/Ical.Net.Tests/Calendars/Recurrence/DailyInterval2.ics b/Ical.Net.Tests/Calendars/Recurrence/DailyInterval2.ics index 924029a1..c60843ac 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/DailyInterval2.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/DailyInterval2.ics @@ -2,7 +2,7 @@ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ddaysoftware.com//NONSGML DDay.iCal 1.0//EN BEGIN:VEVENT -DTEND:20070410T070000 +DURATION:P1D DTSTART:20070409T070000 RRULE:FREQ=DAILY;INTERVAL=2 SUMMARY:Every 2 days diff --git a/Ical.Net.Tests/Calendars/Recurrence/HourlyInterval1.ics b/Ical.Net.Tests/Calendars/Recurrence/HourlyInterval1.ics index 9fff80b5..fd4822e9 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/HourlyInterval1.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/HourlyInterval1.ics @@ -6,7 +6,6 @@ CREATED:20070405T013153Z DTEND;TZID=US-Eastern:20070410T070000 DTSTAMP:20070405T013153Z DTSTART;TZID=US-Eastern:20070409T070000 -DURATION:P1D RRULE:FREQ=HOURLY;INTERVAL=18 SUMMARY:Every 18 hours UID:baff9b6a-a161-4057-bd9c-d76359dc90b1 diff --git a/Ical.Net.Tests/Calendars/Recurrence/MonthlyInterval1.ics b/Ical.Net.Tests/Calendars/Recurrence/MonthlyInterval1.ics index 55fccabd..22cb3279 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/MonthlyInterval1.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/MonthlyInterval1.ics @@ -6,7 +6,6 @@ CREATED:20070405T013153Z DTEND;TZID=US-Eastern:20070410T070000 DTSTAMP:20070405T013153Z DTSTART;TZID=US-Eastern:20070409T070000 -DURATION:P1D RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=MO,TU;BYMONTHDAY=8,9,10,11,12,13,14 SUMMARY:Every other month, on Monday and Tuesday on the 2nd week of the month. UID:baff9b6a-a161-4057-bd9c-d76359dc90b1 diff --git a/Ical.Net.Tests/Calendars/Recurrence/YearlyInterval1.ics b/Ical.Net.Tests/Calendars/Recurrence/YearlyInterval1.ics index 7d43ec43..cf8c0e1d 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/YearlyInterval1.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/YearlyInterval1.ics @@ -6,7 +6,6 @@ CREATED:20050405T013153Z DTEND;TZID=US-Eastern:20050410T070000 DTSTAMP:20050405T013153Z DTSTART;TZID=US-Eastern:20050409T070000 -DURATION:P1D RRULE:FREQ=YEARLY;INTERVAL=2;BYDAY=MO,TU;BYMONTHDAY=8,9,10,11,12,13,14 SUMMARY:Every other year, on Monday and Tuesday on the 2nd week of each month. UID:baff9b6a-a161-4057-bd9c-d76359dc90b1 diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index c034155a..5a4a03ac 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -128,7 +128,8 @@ public void Bug2938007() Assert.Multiple(() => { Assert.That(o.Period.StartTime.HasTime, Is.EqualTo(true)); - Assert.That(o.Period.EndTime.HasTime, Is.EqualTo(true)); + Assert.That(o.Period.EndTime, Is.Null); + Assert.That(o.Period.EffectiveEndTime, Is.Not.Null); }); } } diff --git a/Ical.Net.Tests/GetOccurrenceTests.cs b/Ical.Net.Tests/GetOccurrenceTests.cs index 95c6e52b..6fee5eec 100644 --- a/Ical.Net.Tests/GetOccurrenceTests.cs +++ b/Ical.Net.Tests/GetOccurrenceTests.cs @@ -43,7 +43,7 @@ public void WrongDurationTest() Assert.Multiple(() => { Assert.That(firstOccurrence.Period.StartTime, Is.EqualTo(firstStartCopy)); - Assert.That(firstOccurrence.Period.EndTime, Is.EqualTo(firstEndCopy)); + Assert.That(firstOccurrence.Period.EffectiveEndTime, Is.EqualTo(firstEndCopy)); }); var secondOccurrence = occurrences.Last(); @@ -52,7 +52,7 @@ public void WrongDurationTest() Assert.Multiple(() => { Assert.That(secondOccurrence.Period.StartTime, Is.EqualTo(secondStartCopy)); - Assert.That(secondOccurrence.Period.EndTime, Is.EqualTo(secondEndCopy)); + Assert.That(secondOccurrence.Period.EffectiveEndTime, Is.EqualTo(secondEndCopy)); }); } diff --git a/Ical.Net.Tests/PeriodTests.cs b/Ical.Net.Tests/PeriodTests.cs index db3c5a76..3e0f0ace 100644 --- a/Ical.Net.Tests/PeriodTests.cs +++ b/Ical.Net.Tests/PeriodTests.cs @@ -85,19 +85,4 @@ public void Timezones_StartTime_EndTime_MustBeEqual() } }); } - - [Test] - public void CollidesWithPeriod() - { - var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); - var period2 = new Period(new CalDateTime(2025, 1, 1, 0, 30, 0), Duration.FromHours(1)); - var period3 = new Period(new CalDateTime(2025, 1, 1, 1, 30, 0), Duration.FromHours(1)); - - Assert.Multiple(() => - { - Assert.That(period1.CollidesWith(period2), Is.True); - Assert.That(period1.CollidesWith(period3), Is.False); - Assert.That(period2.CollidesWith(period3), Is.True); - }); - } } diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index bf53a0c7..afa47239 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -55,7 +55,7 @@ int eventIndex for (var i = 0; i < expectedPeriods.Length; i++) { - var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveEndTime); + var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveDuration!.Value); Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period); if (timeZones != null) @@ -2539,14 +2539,14 @@ public void Bug3007244() // specified via DTEND: exact [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DTEND;TZID=Europe/Vienna:20241021T040000", "20241020T010000/PT27H", "20241027T010000/PT27H", "20241103T010000/PT27H")] // First days are applied nominal, then time exact - [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:P1DT3H", "20241020T010000/PT27H", "20241027T010000/PT28H", "20241103T010000/PT27H")] + [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:P1DT3H", "20241020T010000/P1DT3H", "20241027T010000/P1DT3H", "20241103T010000/P1DT3H")] // Exact, because duration is time-only [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:PT27H", "20241020T010000/PT27H", "20241027T010000/PT27H", "20241103T010000/PT27H")] // specified via DTEND: exact [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DTEND;TZID=Europe/Vienna:20241027T040000", "20241020T010000/PT172H", "20241027T010000/PT172H", "20241103T010000/PT172H")] // First days are applied nominal, then time exact - [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:P7DT3H", "20241020T010000/PT171H", "20241027T010000/PT172H", "20241103T010000/PT171H")] + [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:P7DT3H", "20241020T010000/P7DT3H", "20241027T010000/P7DT3H", "20241103T010000/P7DT3H")] // Exact, because duration is time-only [TestCase("DTSTART;TZID=Europe/Vienna:20241020T010000", "DURATION:PT171H", "20241020T010000/PT171H", "20241027T010000/PT171H", "20241103T010000/PT171H")] @@ -2575,8 +2575,8 @@ public void Bug3007244() // // see https://github.com/ical-org/ical.net/issues/681 [TestCase("DTSTART;TZID=Europe/Vienna:20250316T023000", "DTEND;TZID=Europe/Vienna:20250323T023000", "20250316T023000/PT168H", "20250323T023000/PT168H", "20250330T033000/PT168H")] - [TestCase("DTSTART;TZID=Europe/Vienna:20250316T023000", "DURATION:P1W", "20250316T023000/PT168H", "20250323T023000/PT168H", "20250330T033000/PT168H")] - [TestCase("DTSTART;TZID=Europe/Vienna:20250316T023000", "DURATION:P7D", "20250316T023000/PT168H", "20250323T023000/PT168H", "20250330T033000/PT168H")] + [TestCase("DTSTART;TZID=Europe/Vienna:20250316T023000", "DURATION:P1W", "20250316T023000/P1W", "20250323T023000/P1W", "20250330T033000/P1W")] + [TestCase("DTSTART;TZID=Europe/Vienna:20250316T023000", "DURATION:P7D", "20250316T023000/P7D", "20250323T023000/P7D", "20250330T033000/P7D")] public void DurationOfRecurrencesOverDst(string dtStart, string dtEnd, string? d1, string? d2, string? d3) { @@ -2603,7 +2603,7 @@ public void DurationOfRecurrencesOverDst(string dtStart, string dtEnd, string? d { var p = expectedPeriods[index]; var newStart = p.StartTime.ToTimeZone(start!.TzId); - expectedPeriods[index] = Period.Create(newStart, end: newStart.Add(p.Duration!.Value)); + expectedPeriods[index] = Period.Create(newStart, duration: p.EffectiveDuration); } // date only cannot have a time zone @@ -3097,7 +3097,7 @@ public void Test4() RDATE;TZID=America/Chicago:99991231T221000 END:VEVENT END:VCALENDAR - """)] + """, true)] // y10k exceeded due to the event duration [TestCase(""" @@ -3108,7 +3108,7 @@ public void Test4() RRULE:FREQ=DAILY;BYHOUR=22,23;COUNT=2 END:VEVENT END:VCALENDAR - """)] + """, false)] // Events are merged in different places than individual RRULES of a single event [TestCase(""" @@ -3120,11 +3120,11 @@ public void Test4() DTSTART;TZID=America/Chicago:99991231T221000 END:VEVENT END:VCALENDAR - """)] - public void Recurrence_WithOutOfBoundsUtc_ShouldFailWithCorrectException(string ical) + """, true)] + public void Recurrence_WithOutOfBoundsUtc_ShouldFailWithCorrectException(string ical, bool shouldThrow) { var cal = Calendar.Load(ical)!; - Assert.That(() => cal.GetOccurrences().ToList(), Throws.InstanceOf()); + Assert.That(() => cal.GetOccurrences().ToList(), shouldThrow ? Throws.InstanceOf() : Throws.Nothing); } [Test, Category("Recurrence")] @@ -3327,7 +3327,7 @@ public void EventsWithShareUidsShouldGenerateASingleRecurrenceSet() Assert.Multiple(() => { Assert.That(orderedOccurrences[3].StartTime, Is.EqualTo(expectedSept1Start)); - Assert.That(orderedOccurrences[3].EndTime, Is.EqualTo(expectedSept1End)); + Assert.That(orderedOccurrences[3].EffectiveEndTime, Is.EqualTo(expectedSept1End)); }); var expectedSept3Start = new CalDateTime(DateTime.Parse("2016-09-03T07:00:00", CultureInfo.InvariantCulture), "Europe/Bucharest"); @@ -3335,7 +3335,7 @@ public void EventsWithShareUidsShouldGenerateASingleRecurrenceSet() Assert.Multiple(() => { Assert.That(orderedOccurrences[5].StartTime, Is.EqualTo(expectedSept3Start)); - Assert.That(orderedOccurrences[5].EndTime, Is.EqualTo(expectedSept3End)); + Assert.That(orderedOccurrences[5].EffectiveEndTime, Is.EqualTo(expectedSept3End)); }); } @@ -3790,7 +3790,7 @@ Type LoadType(string name) => if (testCase.ExceptionStep == RecurrenceTestExceptionStep.Enumeration) { - Assert.That(() => EnumerateOccurrences(), throwsConstraint); + Assert.That(() => EnumerateOccurrences().Last().Period.EffectiveEndTime, throwsConstraint); return; } @@ -4031,4 +4031,37 @@ public void Disallowed_Recurrence_RangeChecks_Should_Throw() Assert.That(() => serializer.CheckRange("a", (int?) 0, 1, 2, false), Throws.TypeOf()); }); } + + [Test] + public void AmbiguousLocalTime_WithShortDurationOfRecurrence() + { + // Short recurrence falls into an ambiguous local time + // for the end time of the second occurrence because + // of DST transition on 2025-10-25 03:00 + // See also: https://github.com/ical-org/ical.net/issues/737 + var ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART;TZID=Europe/Vienna:20201024T023000 + DURATION:PT45M + RRULE:FREQ=DAILY;UNTIL=20201025T013000Z + END:VEVENT + END:VCALENDAR + """; + var cal = Calendar.Load(ics)!; + var occ = cal.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + Assert.That(occ.Count, Is.EqualTo(2)); + + Assert.That(occ[0].Period.StartTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 2, 30, 0, "Europe/Vienna"))); + Assert.That(occ[0].Period.EffectiveEndTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 3, 15, 0, "Europe/Vienna"))); + Assert.That(occ[0].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0))); + + Assert.That(occ[1].Period.StartTime, Is.EqualTo(new CalDateTime(2020, 10, 25, 2, 30, 0, "Europe/Vienna"))); + Assert.That(occ[1].Period.EffectiveEndTime, Is.EqualTo(new CalDateTime(2020, 10, 25, 2, 15, 0, "Europe/Vienna"))); + Assert.That(occ[1].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0))); + }); + } } diff --git a/Ical.Net.Tests/RecurrenceTests_From_Issues.cs b/Ical.Net.Tests/RecurrenceTests_From_Issues.cs index a8241157..6000cf0c 100644 --- a/Ical.Net.Tests/RecurrenceTests_From_Issues.cs +++ b/Ical.Net.Tests/RecurrenceTests_From_Issues.cs @@ -85,7 +85,7 @@ public void ClockGoingForwardTest() Assert.That(occurrences.Count(), Is.EqualTo(1)); Assert.That(occurrence.Source, Is.SameAs(myEvent)); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } @@ -119,7 +119,7 @@ public void ClockGoingBackTest() Assert.That(occurrences.Count(), Is.EqualTo(1)); Assert.That(occurrence.Source, Is.SameAs(myEvent)); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } @@ -156,8 +156,8 @@ public void ClockGoingForwardAllDayTest() Assert.That(occurrence.Source, Is.SameAs(myEvent)); Assert.That(occurrence.Period.StartTime.HasTime, Is.False); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime?.HasTime, Is.False); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EndTime, Is.Null); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } @@ -195,8 +195,8 @@ public void ClockGoingBackAllDayTest() Assert.That(occurrence.Source, Is.SameAs(myEvent)); Assert.That(occurrence.Period.StartTime.HasTime, Is.False); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime?.HasTime, Is.False); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EndTime, Is.Null); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } @@ -233,8 +233,8 @@ public void ClockGoingBackAllDayNonLocalTest() Assert.That(occurrence.Source, Is.SameAs(myEvent)); Assert.That(occurrence.Period.StartTime.HasTime, Is.False); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime?.HasTime, Is.False); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EndTime, Is.Null); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } @@ -271,8 +271,8 @@ public void ClockGoingForwardAllDayNonLocalTest() Assert.That(myEvent.IsAllDay, Is.True); Assert.That(occurrence.Period.StartTime.HasTime, Is.False); Assert.That(occurrence.Period.StartTime, Is.EqualTo(myEvent.Start)); - Assert.That(occurrence.Period.EndTime?.HasTime, Is.False); - Assert.That(occurrence.Period.EndTime, Is.EqualTo(myEvent.End)); + Assert.That(occurrence.Period.EndTime, Is.Null); + Assert.That(occurrence.Period.EffectiveEndTime, Is.EqualTo(myEvent.End)); }); } diff --git a/Ical.Net.Tests/RecurrenceWithExDateTests.cs b/Ical.Net.Tests/RecurrenceWithExDateTests.cs index 706ab55f..2df3170d 100644 --- a/Ical.Net.Tests/RecurrenceWithExDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithExDateTests.cs @@ -64,7 +64,7 @@ public void ShouldNotOccurOnLocalExceptionDate(bool useExDateWithTime) { if (useExDateWithTime) { - Assert.That(occurrences.Single().Period, Is.EqualTo(new Period(start, end))); + Assert.That(occurrences.Single().Period, Is.EqualTo(new Period(start, end.Subtract(start)))); Assert.That(ics, Does.Contain("EXDATE;TZID=Europe/London:20241019T210000")); } else diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs index fe072e98..fc2fcab5 100644 --- a/Ical.Net.Tests/RecurrenceWithRDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -395,6 +395,6 @@ public void RDate_DateOnly_WithExactDuration_ShouldThrow() }; calendarEvent.RecurrenceDates.AddRange(recurrenceDates); - Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException); + Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.ArgumentException); } } diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index 19699ffa..5c67f41d 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -123,8 +123,8 @@ public void Bug2938007() var evt = iCal.Events.First(); Assert.Multiple(() => { - Assert.That(evt.Start.HasTime, Is.EqualTo(true)); - Assert.That(evt.End.HasTime, Is.EqualTo(true)); + Assert.That(evt.Start.HasTime, Is.True); + Assert.That(evt.End.HasTime, Is.True); }); foreach (var o in evt.GetOccurrences(new CalDateTime(2010, 1, 17, 0, 0, 0)).TakeWhileBefore(new CalDateTime(2010, 2, 1, 0, 0, 0))) @@ -132,7 +132,8 @@ public void Bug2938007() Assert.Multiple(() => { Assert.That(o.Period.StartTime.HasTime, Is.EqualTo(true)); - Assert.That(o.Period.EndTime.HasTime, Is.EqualTo(true)); + Assert.That(o.Period.EndTime, Is.Null); + Assert.That(o.Period.EffectiveEndTime, Is.Not.Null); }); } } diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index 842880a1..6d041c07 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -4,6 +4,7 @@ // using System; +using Ical.Net.Evaluation; using Ical.Net.Serialization.DataTypes; namespace Ical.Net.DataTypes; @@ -99,6 +100,9 @@ public Period(CalDateTime start, Duration duration) if (duration.Sign < 0) throw new ArgumentException($"Duration ({duration}) must be greater than or equal to zero.", nameof(duration)); + if (!start.HasTime && duration.HasTime) + throw new ArgumentException($"Exact Duration '{duration}' cannot be added to date-only value '{start}'", nameof(start)); + _startTime = start; _duration = duration; } @@ -161,17 +165,27 @@ public override int GetHashCode() /// public virtual CalDateTime? EffectiveEndTime { - get - { - var effectiveDuration = EffectiveDuration; - return _endTime switch + get => + _endTime switch { null when _duration is null => null, { } endTime => endTime, - _ => effectiveDuration is not null - ? _startTime.Add(effectiveDuration.Value) - : null + _ => CalculateEndTime() }; + } + + private CalDateTime CalculateEndTime() + { + try + { + // When invoked, _duration is not null, so EffectiveDuration + // is guaranteed to be non-null + return _startTime.Add(EffectiveDuration!.Value); + } + catch (ArgumentOutOfRangeException) + { + // intentionally don't include the outer exception + throw new EvaluationOutOfRangeException($"Calculating the end time of the period from start time '{_startTime}' and effective duration '{EffectiveDuration}' resulted in an out-of-range value."); } } @@ -188,9 +202,8 @@ public virtual CalDateTime? EffectiveEndTime /// public virtual Duration? EffectiveDuration { - get - { - return _duration switch + get => + _duration switch { null when _endTime is null => null, { } d => d, @@ -198,7 +211,6 @@ public virtual Duration? EffectiveDuration ? endTime.Subtract(_startTime) : null }; - } } internal string? TzId => _startTime.TzId; // same timezone for start and end diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index 193c9771..7bcef71d 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -41,7 +41,7 @@ public override IEnumerable Evaluate(CalDateTime referenceDate, CalDateT { // Evaluate recurrences normally var periods = base.Evaluate(referenceDate, periodStart, options) - .Select(WithEndTime); + .Select(WithFinalDuration); return periods; } @@ -50,15 +50,15 @@ public override IEnumerable Evaluate(CalDateTime referenceDate, CalDateT /// The to evaluate has the set, /// but neither nor are set. /// - /// The period where will be set. - /// Returns the with and exact set. - private Period WithEndTime(Period period) + /// The period where will be set. + /// Returns the with and exact set. + private Period WithFinalDuration(Period period) { try { /* - We use a time span to calculate the end time of the event. - It evaluates the event's definition of DtStart and either DtEnd or Duration. + The period's Duration evaluates the event's definition of DtStart + and Duration, or the timespan from DtStart to DtEnd. The time span is used, because the period end time gets the same timezone as the event end time. This ensures that the end time is correct, even for DST transitions. @@ -66,38 +66,20 @@ It evaluates the event's definition of DtStart and either DtEnd or Duration. The exact duration is calculated from the zoned end time and the zoned start time, and it may differ from the time span added to the period start time. */ - var tsToAdd = CalendarEvent.EffectiveDuration; - CalDateTime endTime; - if (tsToAdd.IsZero) - { - // For a zero-duration event, the end time is the same as the start time. - endTime = period.StartTime; - } - else - { - // Calculate the end time of the event as a DateTime - var endDt = period.StartTime.Add(tsToAdd); - if ((CalendarEvent.End is { } end) && (end.TzId != period.StartTime.TzId) && (end.TzId is { } tzid)) - { - // Ensure the end time has the same timezone as the event end time. - endDt = endDt.ToTimeZone(tzid); - } + // The preliminary duration was set in a previous evaluation step + var duration = period.Duration; - endTime = endDt; + if (duration == null) + { + duration = CalendarEvent.EffectiveDuration; } - // 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 - // and has priority over the calculated end time. - - return new Period( + var newPeriod = new Period( start: period.StartTime, - end: period.Duration == null - ? endTime - : period.EffectiveEndTime); + duration: duration.Value); + + return newPeriod; } catch (ArgumentOutOfRangeException) { diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs index 288af539..e5e9c01f 100644 --- a/Ical.Net/Evaluation/RecurrenceUtil.cs +++ b/Ical.Net/Evaluation/RecurrenceUtil.cs @@ -36,10 +36,10 @@ public static IEnumerable GetOccurrences(IRecurrable recurrable, Cal { periods = from p in periods - let endTime = p.EndTime ?? p.StartTime + let effectiveEndTime = p.EffectiveEndTime where p.StartTime.GreaterThanOrEqual(periodStart) - || endTime.GreaterThan(periodStart) + || effectiveEndTime.GreaterThan(periodStart) select p; }