Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make CalendarEvent.EffectiveDuration and some conversion functions public. #733

Merged
merged 2 commits into from
Feb 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions Ical.Net.Tests/CalDateTimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,25 +323,33 @@ public void Simple_PropertyAndMethod_HasTime_Tests()
});
}

public static IEnumerable<TestCaseData> 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)));
});
}

Expand Down
14 changes: 7 additions & 7 deletions Ical.Net.Tests/CalendarEventTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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)));
});
}

Expand Down
107 changes: 55 additions & 52 deletions Ical.Net/CalendarComponents/CalendarEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,71 +91,74 @@
}

/// <summary>
/// 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.
/// <para/>
/// If the <see cref="CalendarEvent.Duration"/> property is not null, its value will be returned.<br/>
/// If <see cref="RecurringComponent.DtStart"/> and <see cref="CalendarEvent.DtEnd"/> are set, it will return <see cref="CalendarEvent.DtEnd"/> minus <see cref="CalendarEvent.DtStart"/>.<br/>
/// Otherwise, it will return <see cref="Duration.Zero"/>.
/// Otherwise, if DtStart is date-only, it will return a duration of 1d, otherwise it will return <see cref="Duration.Zero"/>.
/// </summary>
/// <remarks>
/// Note: For recurring events, the <b>exact duration</b> of individual occurrences may vary due to DST transitions
/// of the given <see cref="RecurringComponent.DtStart"/> and <see cref="CalendarEvent.DtEnd"/> timezones.
/// </remarks>
/// <returns>The time span that gets added to the period start time to get the period end time.</returns>
internal Duration GetEffectiveDuration()
/// <returns>The duration that gets added to the period start time to get the period end time.</returns>
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.");

Check warning on line 121 in Ical.Net/CalendarComponents/CalendarEvent.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/CalendarComponents/CalendarEvent.cs#L121

Added line #L121 was not covered by tests
}

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;
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion Ical.Net/DataTypes/CalDateTime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
25 changes: 22 additions & 3 deletions Ical.Net/DataTypes/Duration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public static Duration FromSeconds(int seconds) =>
/// <remarks>
/// According to RFC5545 the weeks and day fields of a duration are considered nominal durations while the time fields are considered exact values.
/// </remarks>
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));
Expand Down Expand Up @@ -171,15 +171,34 @@ internal TimeSpan DateAsTimeSpan
}

/// <summary>
/// Convert the instance to a <see cref="TimeSpan"/>.
/// Convert the instance to a <see cref="TimeSpan"/>, ignoring potential
/// DST changes.
/// </summary>
internal TimeSpan ToTimeSpan()
/// <remarks>
/// A duration's days and weeks are considered nominal durations, while the time fields are
/// considered exact values.
/// To convert a duration to a <see cref="TimeSpan"/> while considering the days and weeks as
/// nominal durations, use <see cref="ToTimeSpan"/>.
/// </remarks>
public TimeSpan ToTimeSpanUnspecified()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep, this was a missing part, agree.

=> new TimeSpan(
(Weeks ?? 0) * 7 + (Days ?? 0),
Hours ?? 0,
Minutes ?? 0,
Seconds ?? 0);

/// <summary>
/// Convert the instance to a <see cref="TimeSpan"/>, treating the days as nominal duration and
/// the time part as exact.
/// </summary>
/// <remarks>
/// A duration's days and weeks are considered nominal durations, while the time fields are considered exact values.
/// To convert a duration to a <see cref="TimeSpan"/> while considering the days and weeks as nominal durations,
/// use <see cref="ToTimeSpan"/>.
/// </remarks>
public TimeSpan ToTimeSpan(CalDateTime start)
=> start.Add(this).SubtractExact(start);

/// <summary>
/// Gets a value indicating whether the duration is zero, that is, all fields are null or 0.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion Ical.Net/Evaluation/EventEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading