diff --git a/Ical.Net.Tests/CalDateTimeTests.cs b/Ical.Net.Tests/CalDateTimeTests.cs index 8b2d4bd6d..5afdaefa5 100644 --- a/Ical.Net.Tests/CalDateTimeTests.cs +++ b/Ical.Net.Tests/CalDateTimeTests.cs @@ -323,25 +323,33 @@ public void Simple_PropertyAndMethod_HasTime_Tests() }); } - public static IEnumerable AddAndSubtractTestCases() + private static TestCaseData[] AddAndSubtractTestCases => [ + new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: null), 0), + new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: CalDateTime.UtcTzId), 0), + new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: "Europe/Paris"), 1) + ]; + + [Test, TestCaseSource(nameof(AddAndSubtractTestCases))] + public void AddAndSubtract_ShouldBeReversible(CalDateTime t, int tzOffs) { - yield return new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: null), Duration.FromHours(4)) - .SetName("Floating"); + var d = Duration.FromHours(4); + var expectedTimeSpan = d.ToTimeSpanUnspecified(); - yield return new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: CalDateTime.UtcTzId), Duration.FromHours(4)) - .SetName("UTC"); + Assert.Multiple(() => + { + Assert.That(t.Add(d).Add(-d), Is.EqualTo(t)); + Assert.That(t.Add(d).SubtractExact(t), Is.EqualTo(expectedTimeSpan)); + Assert.That(t.Add(d).SubtractExact(t), Is.EqualTo(d.ToTimeSpan(t))); + }); - yield return new TestCaseData(new CalDateTime(2024, 10, 27, 0, 0, 0, tzId: "Europe/Paris"), Duration.FromHours(4)) - .SetName("Zoned Date/Time with DST change"); - } + d = Duration.FromDays(1); + expectedTimeSpan = d.ToTimeSpanUnspecified().Add(TimeSpan.FromHours(tzOffs)); - [Test, TestCaseSource(nameof(AddAndSubtractTestCases))] - public void AddAndSubtract_ShouldBeReversible(CalDateTime t, Duration d) - { Assert.Multiple(() => { Assert.That(t.Add(d).Add(-d), Is.EqualTo(t)); - Assert.That(t.Add(d).SubtractExact(t), Is.EqualTo(d.ToTimeSpan())); + Assert.That(t.Add(d).SubtractExact(t), Is.EqualTo(expectedTimeSpan)); + Assert.That(t.Add(d).SubtractExact(t), Is.EqualTo(d.ToTimeSpan(t))); }); } diff --git a/Ical.Net.Tests/CalendarEventTest.cs b/Ical.Net.Tests/CalendarEventTest.cs index 5a27a39b3..01157066a 100644 --- a/Ical.Net.Tests/CalendarEventTest.cs +++ b/Ical.Net.Tests/CalendarEventTest.cs @@ -486,12 +486,12 @@ public void GetEffectiveDurationTests() DtEnd = new CalDateTime(DateOnly.FromDateTime(dt.AddHours(1)), TimeOnly.FromDateTime(dt.AddHours(1)), tzIdEnd) }; - var ed = evt.GetEffectiveDuration(); + var ed = evt.EffectiveDuration; Assert.Multiple(() => { Assert.That(evt.DtStart.Value, Is.EqualTo(dt)); Assert.That(evt.DtEnd.Value, Is.EqualTo(dt.AddHours(1))); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(-4))); + Assert.That(evt.EffectiveDuration, Is.EqualTo(Duration.FromHours(-4))); }); evt = new CalendarEvent @@ -503,7 +503,7 @@ public void GetEffectiveDurationTests() Assert.Multiple(() => { Assert.That(evt.DtStart.Value, Is.EqualTo(dt.Date)); - Assert.That(evt.GetEffectiveDuration().IsZero, Is.True); + Assert.That(evt.EffectiveDuration.IsZero, Is.True); }); evt = new CalendarEvent @@ -515,7 +515,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(DataTypes.Duration.FromDays(1))); + Assert.That(evt.EffectiveDuration, Is.EqualTo(DataTypes.Duration.FromDays(1))); }); evt = new CalendarEvent @@ -527,7 +527,7 @@ public void GetEffectiveDurationTests() Assert.Multiple(() => { Assert.That(evt.DtStart.Value, Is.EqualTo(dt)); Assert.That(evt.DtEnd, Is.Null); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(2))); + Assert.That(evt.EffectiveDuration, Is.EqualTo(Duration.FromHours(2))); }); evt = new CalendarEvent() @@ -538,7 +538,7 @@ public void GetEffectiveDurationTests() Assert.Multiple(() => { Assert.That(evt.DtStart.Value, Is.EqualTo(dt.Date)); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(2))); + Assert.That(evt.EffectiveDuration, Is.EqualTo(Duration.FromHours(2))); }); evt = new CalendarEvent() @@ -549,7 +549,7 @@ public void GetEffectiveDurationTests() Assert.Multiple(() => { Assert.That(evt.DtStart.Value, Is.EqualTo(dt.Date)); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromDays(1))); + Assert.That(evt.EffectiveDuration, Is.EqualTo(Duration.FromDays(1))); }); } diff --git a/Ical.Net/CalendarComponents/CalendarEvent.cs b/Ical.Net/CalendarComponents/CalendarEvent.cs index 694ceacda..f3c51cba2 100644 --- a/Ical.Net/CalendarComponents/CalendarEvent.cs +++ b/Ical.Net/CalendarComponents/CalendarEvent.cs @@ -91,71 +91,74 @@ public virtual Duration? Duration } /// - /// Gets the time span that gets added to the period start time to get the period end time. + /// Gets the duration that gets added to the period start time to get the period end time. /// /// If the property is not null, its value will be returned.
/// If and are set, it will return minus .
- /// Otherwise, it will return . + /// Otherwise, if DtStart is date-only, it will return a duration of 1d, otherwise it will return . ///
/// /// Note: For recurring events, the exact duration of individual occurrences may vary due to DST transitions /// of the given and timezones. /// - /// The time span that gets added to the period start time to get the period end time. - internal Duration GetEffectiveDuration() + /// The duration that gets added to the period start time to get the period end time. + public Duration EffectiveDuration { - // 3.8.5.3. Recurrence Rule - // If the duration of the recurring component is specified with the - // "DURATION" property, then the same NOMINAL duration will apply to - // all the members of the generated recurrence set and the exact - // duration of each recurrence instance will depend on its specific - // start time. - if (Duration is not null) - return Duration.Value; - - if (DtStart is not { } dtStart) - { - // Mustn't happen - throw new InvalidOperationException("DtStart must be set."); - } - - if (DtEnd is not null) - { - /* - The 'DTEND' property for a 'VEVENT' calendar component specifies the - non-inclusive end of the event. - - 3.8.5.3. Recurrence Rule: - If the duration of the recurring component is specified with the - "DTEND" or "DUE" property, then the same EXACT duration will apply - to all the members of the generated recurrence set. - - We use the difference from DtStart to DtEnd (neglecting timezone), - because the caller will set the period end time to the - same timezone as the event end time. This finally leads to an exact duration - calculation from the zoned start time to the zoned end time. - */ - return DtEnd.Subtract(dtStart); - } - - if (!dtStart.HasTime) + get { + // 3.8.5.3. Recurrence Rule + // If the duration of the recurring component is specified with the + // "DURATION" property, then the same NOMINAL duration will apply to + // all the members of the generated recurrence set and the exact + // duration of each recurrence instance will depend on its specific + // start time. + if (Duration is not null) + return Duration.Value; + + if (DtStart is not { } dtStart) + { + // Mustn't happen + throw new InvalidOperationException("DtStart must be set."); + } + + if (DtEnd is { } dtEnd) + { + /* + The 'DTEND' property for a 'VEVENT' calendar component specifies the + non-inclusive end of the event. + + 3.8.5.3. Recurrence Rule: + If the duration of the recurring component is specified with the + "DTEND" or "DUE" property, then the same EXACT duration will apply + to all the members of the generated recurrence set. + + We use the difference from DtStart to DtEnd (neglecting timezone), + because the caller will set the period end time to the + same timezone as the event end time. This finally leads to an exact duration + 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); + } + + // For DtStart.HasTime but no DtEnd - also the default case + // // 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); + // specifies a "DTSTART" property with a DATE-TIME value type but no + // "DTEND" property, the event ends on the same calendar date and + // time of day specified by the "DTSTART" property. + return DataTypes.Duration.Zero; } - - // For DtStart.HasTime but no DtEnd - also the default case - // - // RFC 5545 3.6.1: - // For cases where a "VEVENT" calendar component - // specifies a "DTSTART" property with a DATE-TIME value type but no - // "DTEND" property, the event ends on the same calendar date and - // time of day specified by the "DTSTART" property. - return DataTypes.Duration.Zero; } /// diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index 7d4f74f33..33d471813 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -517,7 +517,7 @@ public CalDateTime Add(Duration d) (TimeSpan? nominalPart, TimeSpan? exactPart) dt; if (TzId is null) - dt = (d.ToTimeSpan(), null); + dt = (d.ToTimeSpanUnspecified(), null); else dt = (d.HasDate ? d.DateAsTimeSpan : null, d.HasTime ? d.TimeAsTimeSpan : null); diff --git a/Ical.Net/DataTypes/Duration.cs b/Ical.Net/DataTypes/Duration.cs index 2748df4a9..d0313c4c3 100644 --- a/Ical.Net/DataTypes/Duration.cs +++ b/Ical.Net/DataTypes/Duration.cs @@ -126,7 +126,7 @@ public static Duration FromSeconds(int seconds) => /// /// According to RFC5545 the weeks and day fields of a duration are considered nominal durations while the time fields are considered exact values. /// - internal static Duration FromTimeSpanExact(TimeSpan t) + public static Duration FromTimeSpanExact(TimeSpan t) // As a TimeSpan always refers to exact time, we specify days as part of the hours field, // because time is added as exact values rather than nominal according to RFC 5545. => new Duration(hours: NullIfZero(t.Days * 24 + t.Hours), minutes: NullIfZero(t.Minutes), seconds: NullIfZero(t.Seconds)); @@ -171,15 +171,34 @@ internal TimeSpan DateAsTimeSpan } /// - /// Convert the instance to a . + /// Convert the instance to a , ignoring potential + /// DST changes. /// - internal TimeSpan ToTimeSpan() + /// + /// A duration's days and weeks are considered nominal durations, while the time fields are + /// considered exact values. + /// To convert a duration to a while considering the days and weeks as + /// nominal durations, use . + /// + public TimeSpan ToTimeSpanUnspecified() => new TimeSpan( (Weeks ?? 0) * 7 + (Days ?? 0), Hours ?? 0, Minutes ?? 0, Seconds ?? 0); + /// + /// Convert the instance to a , treating the days as nominal duration and + /// the time part as exact. + /// + /// + /// A duration's days and weeks are considered nominal durations, while the time fields are considered exact values. + /// To convert a duration to a while considering the days and weeks as nominal durations, + /// use . + /// + public TimeSpan ToTimeSpan(CalDateTime start) + => start.Add(this).SubtractExact(start); + /// /// Gets a value indicating whether the duration is zero, that is, all fields are null or 0. /// diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index d2ba3eff1..040497225 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -69,7 +69,7 @@ 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.GetEffectiveDuration(); + var tsToAdd = CalendarEvent.EffectiveDuration; CalDateTime endTime; if (tsToAdd.IsZero)