Skip to content
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
242 changes: 242 additions & 0 deletions Ical.Net.Tests/MatchTimeZoneTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
//
// Copyright ical.net project maintainers and contributors.
// Licensed under the MIT license.
//

using System;
using System.Linq;
using Ical.Net.DataTypes;
using NUnit.Framework;

namespace Ical.Net.Tests;

[TestFixture]
public class MatchTimeZoneTests
{
[Test, Category("Recurrence")]
public void MatchTimeZone_LocalTimeUsaWithTimeZone()
{
// DTSTART with local time and time zone reference (negative offset), UNTIL as UTC
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example1
SUMMARY:Event with local time and time zone
DTSTART;TZID=America/New_York:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T130000Z
DTEND;TZID=America/New_York:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 13, 00, 00, DateTimeKind.Utc);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(4));
/*
Should have 4 occurrences:
November 1, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 2, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 3, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 4, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 5, 2023: 09:00 AM - 10:00 AM (UTC-0500) (America/New_York)
must NOT be included, because 20231105T130000Z => November 5, 2023: 08:00 AM (America/New_York)
(Daylight Saving Time in America/New_York ended on Sunday, November 5, 2023, at 2:00 AM)
*/
});
}

[Test, Category("Recurrence")]
[TestCase("20241005T140000Z", 5)]
[TestCase("20241005T150000Z", 6)]
public void MatchTimeZone_LocalTimeAustraliaWithTimeZone(string inputUntil, int expectedOccurrences)
{
// DTSTART with local time and time zone reference (positive offset), UNTIL as UTC
var ical =
$"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example1
SUMMARY:Event with local time and time zone
DTSTART;TZID=Australia/Sydney:20241001T010000
RRULE:FREQ=DAILY;UNTIL={inputUntil}
DTEND;TZID=Australia/Sydney:20241001T020000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = DateTime.ParseExact(inputUntil, "yyyyMMddTHHmmssZ",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal |
System.Globalization.DateTimeStyles.AdjustToUniversal);
var occurrences = evt.GetOccurrences(new CalDateTime(2024, 10, 01), new CalDateTime(2024, 10, 07));
Copy link
Collaborator

Choose a reason for hiding this comment

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

In this case DTSTART has a tzid but the startTime is floating. This is quite a special case and we should considere to disallow the case. Anyhow, I think it would be preferable to specify a tzid for the start/endTime too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hm, CalDateTime(2024, 10, 01) resolves to a DATE value, which cannot have a timezone (RFC 5545)?

Copy link
Collaborator Author

@axunonb axunonb Dec 7, 2024

Choose a reason for hiding this comment

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

So you mean, it should always be a zoned DATE-TIME?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Just a comment, not too relevant. Generally it would be advisable for users of the app, not to mix DATE vs DATE-TIME and floating vs non-floating, because there's always some ambiguity that causes additional complexity. In this case it really doesn't matter, so no need to change it. But as its advisable for users, not to mix it, we generally might want to avoid mixing them in the tests either (except for those cases where we explicitly want to test mixing them).


Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(expectedOccurrences));
/*
Should have 5 occurrences with UNTIL=20241005T140000Z...
October 1, 2024: 01:00 AM - 02:00 AM (UTC+1000) (Australia/Sydney)
October 2, 2024: 01:00 AM - 02:00 AM (UTC+1000) (Australia/Sydney)
October 3, 2024: 01:00 AM - 02:00 AM (UTC+1000) (Australia/Sydney)
October 4, 2024: 01:00 AM - 02:00 AM (UTC+1000) (Australia/Sydney)
October 5, 2024: 01:00 AM - 02:00 AM (UTC+1000) (Australia/Sydney)
... and 6 occurrences with UNTIL=20241005T150000Z, i.e. plus one more
October 6, 2024: 01:00 AM - 02:00 AM (UTC+1100) (Australia/Sydney)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, actually I think, this should be included, because UNTIL must be considered inclusive.

If the value
specified by UNTIL is synchronized with the specified recurrence,
this DATE or DATE-TIME becomes the last instance of the
recurrence.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, the conclusion came from the description I assume. It was not updated.
Now there are 2 test cases, one when DST occurs.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, I was looking at the comments. But now reconsidering the test, I still think its not correct.

October 6, 2024: 01:00 AM Sydney time = 20241005T140000Z, so it should be included in the first test and the number of occurrences should therefore be 6 in both cases.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Isn't UTC Time: 2024-10-05 14:00:00 => Sydney Time (AEST, UTC+10:00): 2024-10-06 00:00:00?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh yes, of course, sorry for the confusion!

(Daylight Saving Time in Australia/Sydney starts on Sunday, October 6, 2024, at 2:00 AM)
*/
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_UTCTime()
{
// DTSTART and UNTIL with UTC time
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example2
SUMMARY:Event with UTC time
DTSTART:20231101T090000Z
RRULE:FREQ=DAILY;UNTIL=20231105T090000Z
DTEND:20231101T100000Z
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Utc);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_FloatingTime()
{
// DTSTART AND UNTIL with floating time
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example3
SUMMARY:Event with floating time
DTSTART:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T090000
DTEND:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});

}

[Test, Category("Recurrence")]
public void MatchTimeZone_LocalTimeNoTimeZone()
{
// DTSTART with local time and no time zone reference
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example4
SUMMARY:Event with local time and no time zone reference
DTSTART:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T090000
DTEND:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_DateOnly()
{
// DTSTART with date-only value
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example5
SUMMARY:Event with date-only value
DTSTART;VALUE=DATE:20231101
RRULE:FREQ=DAILY;UNTIL=20231105
DTEND;VALUE=DATE:20231102
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 00, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}
}
49 changes: 33 additions & 16 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Licensed under the MIT license.
//

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -30,7 +31,7 @@ private RecurrencePattern ProcessRecurrencePattern(IDateTime referenceDate)
// Convert the UNTIL value to one that matches the same time information as the reference date
if (r.Until != DateTime.MinValue)
{
r.Until = MatchTimeZone(referenceDate, new CalDateTime(r.Until, referenceDate.TzId)).Value;
r.Until = MatchTimeZone(referenceDate, r.Until);
}

if (referenceDate.HasTime)
Expand Down Expand Up @@ -968,24 +969,40 @@ public override HashSet<Period> Evaluate(IDateTime referenceDate, DateTime perio
return new HashSet<Period>(periodQuery);
}

private static IDateTime MatchTimeZone(IDateTime dt1, IDateTime dt2)
private static DateTime MatchTimeZone(IDateTime reference, DateTime until)
{
// Associate the date/time with the first.
var copy = dt2;
copy.AssociateWith(dt1);

// If the dt1 time does not occur in the same time zone as the
// dt2 time, then let's convert it so they can be used in the
// same context (i.e. evaluation).
if (dt1.TzId != null)
/*
The value of the "UNTIL" rule part MUST have the same value type as the
"DTSTART" property. Furthermore, if the "DTSTART" property is
specified as a date with local time, then the UNTIL rule part MUST
also be specified as a date with local time.

If the "DTSTART" property is specified as a date with UTC time or a date with local
time and time zone reference, then the UNTIL rule part MUST be
specified as a date with UTC time.
*/
string? untilTzId;
if (reference.IsFloating)
{
// If 'reference' is floating, then 'until' must be floating
untilTzId = null;
}
else
{
return string.Equals(dt1.TzId, copy.TzId, StringComparison.OrdinalIgnoreCase)
? copy
: copy.ToTimeZone(dt1.TzId);
// If 'reference' has a timezone, 'until' MUST be UTC,
// but in case of UTC rule violation we fall back to the 'reference' timezone
untilTzId = until.Kind == DateTimeKind.Utc
? CalDateTime.UtcTzId
: reference.TzId;
}

return dt1.IsUtc
? new CalDateTime(copy.AsUtc)
: copy;
var untilCalDt = new CalDateTime(until, untilTzId, reference.HasTime);
untilCalDt.AssociateWith(reference);

// If 'reference' is floating, then 'until' is floating, too
return reference.TzId is null
? untilCalDt.Value
// convert to the reference timezone
: untilCalDt.ToTimeZone(reference.TzId).Value;
}
}
Loading