Skip to content

Commit

Permalink
Refactor CalDateTime and DateTimeSerializer
Browse files Browse the repository at this point in the history
CalDateTime
* to use DateOnly and TimeOnly types
* Added SetValue(DateOnly date, TimeOnly? time, DateTimeKind kind) to set the value of the date and time properly.
* Refactored `ToString` overloads to convert the date and time to a string representation.
* marked some properties as obsolete.
* Updated related tests and methods to accommodate these changes.
* Increase code coverage > 90%

DateTimeSerializer
* Added validation in the `SerializeToString` method to ensure the TZID parameter is correctly handled based on whether the date-time is in UTC.
* Improved the Deserialize method to handle both full date-time and date-only strings, initializing the DateOnly and TimeOnly parts accordingly.
* Increase code coverage > 90%

Added new package reference `Portable.System.DateTimeOnly` for netstandard2.0 and netstandard2.1 for compatibility with net6.0 and later.

Fixes ical-org#630
  • Loading branch information
axunonb committed Nov 4, 2024
1 parent d27dd56 commit f2f651e
Show file tree
Hide file tree
Showing 14 changed files with 345 additions and 263 deletions.
2 changes: 1 addition & 1 deletion Ical.Net.Tests/AlarmTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Ical.Net.DataTypes;
using Ical.Net.DataTypes;
using NUnit.Framework;
using System;
using System.Collections.Generic;
Expand Down
97 changes: 68 additions & 29 deletions Ical.Net.Tests/CalDateTimeTests.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
using System.Collections.Generic;
using System.Globalization;
using Ical.Net.CalendarComponents;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using NUnit.Framework;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;

namespace Ical.Net.Tests
{
public class CalDateTimeTests
{
private static readonly DateTime _now = DateTime.Now;
private static readonly DateTime _later = _now.AddHours(1);

private static CalendarEvent GetEventWithRecurrenceRules(string tzId)
{
var dailyForFiveDays = new RecurrencePattern(FrequencyType.Daily, 1)
Expand All @@ -34,7 +35,7 @@ private static CalendarEvent GetEventWithRecurrenceRules(string tzId)
public void ToTimeZoneTests(CalendarEvent calendarEvent, string targetTimeZone)
{
var startAsUtc = calendarEvent.Start.AsUtc;

var convertedStart = calendarEvent.Start.ToTimeZone(targetTimeZone);
var convertedAsUtc = convertedStart.AsUtc;

Expand Down Expand Up @@ -91,13 +92,15 @@ public static IEnumerable AsDateTimeOffsetTestCases()
var convertedToNySummer = new CalDateTime(summerDate, "UTC");
convertedToNySummer.TzId = nyTzId;
yield return new TestCaseData(convertedToNySummer)
.SetName("Summer UTC DateTime converted to NY time zone by setting TzId returns a DateTimeOffset with UTC-4")
.SetName(
"Summer UTC DateTime converted to NY time zone by setting TzId returns a DateTimeOffset with UTC-4")
.Returns(new DateTimeOffset(summerDate, nySummerOffset));

var noTz = new CalDateTime(summerDate);
var currentSystemOffset = TimeZoneInfo.Local.GetUtcOffset(summerDate);
yield return new TestCaseData(noTz)
.SetName($"Summer DateTime with no time zone information returns the system-local's UTC offset ({currentSystemOffset})")
.SetName(
$"Summer DateTime with no time zone information returns the system-local's UTC offset ({currentSystemOffset})")
.Returns(new DateTimeOffset(summerDate, currentSystemOffset));
}

Expand Down Expand Up @@ -154,34 +157,70 @@ public static IEnumerable DateTimeKindOverrideTestCases()
}

[Test, TestCaseSource(nameof(ToStringTestCases))]
public string ToStringTests(DateTime localDateTime, string format, IFormatProvider formatProvider)
=> new CalDateTime(localDateTime, "Pacific/Auckland").ToString(format, formatProvider);
public string ToStringTests(CalDateTime calDateTime, string format, IFormatProvider formatProvider)
=> calDateTime.ToString(format, formatProvider);

public static IEnumerable<ITestCaseData> ToStringTestCases()
public static IEnumerable ToStringTestCases()
{
yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), null, null)
.Returns($"{new DateTime(2022, 8, 30, 10, 30, 0)} Pacific/Auckland")
.SetName("Date and time with current culture formatting returns string using BCL current culture formatted date and time");

yield return new TestCaseData(new DateTime(2022, 8, 30), null, null)
.Returns($"{new DateTime(2022, 8, 30):d} Pacific/Auckland")
.SetName("Date only with current culture formatting returns string using BCL current culture formatted date");

yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), "o", null)
.Returns("2022-08-30T10:30:00.0000000+12:00 Pacific/Auckland")
.SetName("Date and time formatted using format string with no culture returns string using BCL formatter");
yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), "O", null)
.Returns("2024-08-30T10:30:00.0000000+12:00 Pacific/Auckland")
.SetName("Date and time with 'O' format arg, default culture");

yield return new TestCaseData(new CalDateTime(2024, 8, 30, tzId: "Pacific/Auckland"), "O", null)
.Returns("08/30/2024 Pacific/Auckland")
.SetName("Date only with 'O' format arg, default culture");

yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), "O",
CultureInfo.GetCultureInfo("fr-FR"))
.Returns("2024-08-30T10:30:00.0000000+12:00 Pacific/Auckland")
.SetName("Date and time with 'O' format arg, French culture");

yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"),
"yyyy-MM-dd", CultureInfo.InvariantCulture)
.Returns("2024-08-30 Pacific/Auckland")
.SetName("Date and time with custom format, default culture");

yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"),
"MM/dd/yyyy HH:mm:ss", CultureInfo.GetCultureInfo("FR"))
.Returns("08/30/2024 10:30:00 Pacific/Auckland")
.SetName("Date and time with format and 'FR' CultureInfo");

yield return new TestCaseData(new CalDateTime(2024, 8, 30, tzId: "Pacific/Auckland"), null,
CultureInfo.GetCultureInfo("IT"))
.Returns("30/08/2024 Pacific/Auckland")
.SetName("Date only with 'IT' CultureInfo and default format arg");
}

yield return new TestCaseData(new DateTime(2022, 8, 30), "o", null)
.Returns("2022-08-30T00:00:00.0000000+12:00 Pacific/Auckland")
.SetName("Date and time formatted using format string with no culture returns string using BCL formatter");
[Test]
public void Simple_PropertyAndMethod_Tests()
{
var dt = new DateTime(2025, 1, 2, 10, 20, 30, DateTimeKind.Utc);
var c = new CalDateTime(dt, tzId: "Europe/Berlin");

yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), null, CultureInfo.InvariantCulture)
.Returns("08/30/2022 10:30:00 Pacific/Auckland")
.SetName("Date and time with format provider");
var c2 = new CalDateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, c.TzId, null);
var c3 = new CalDateTime(new DateOnly(dt.Year, dt.Month, dt.Day),
new TimeOnly(dt.Hour, dt.Minute, dt.Second), c.TzId);

yield return new TestCaseData(new DateTime(2022, 8, 30), null, CultureInfo.InvariantCulture)
.Returns("08/30/2022 Pacific/Auckland")
.SetName("Date only with current format provider");
Assert.Multiple(() =>
{
Assert.That(c2.Ticks, Is.EqualTo(c3.Ticks));
Assert.That(c2.TzId, Is.EqualTo(c3.TzId));
Assert.That(CalDateTime.UtcNow.Value.Kind, Is.EqualTo(DateTimeKind.Utc));
Assert.That(c.Millisecond, Is.EqualTo(0));
Assert.That(c.Ticks, Is.EqualTo(dt.Ticks));
Assert.That(c.DayOfYear, Is.EqualTo(dt.DayOfYear));
Assert.That(c.TimeOfDay, Is.EqualTo(dt.TimeOfDay));
Assert.That(c.Subtract(TimeSpan.FromSeconds(dt.Second)).Value.Second, Is.EqualTo(0));
Assert.That(c.AddYears(1).Value, Is.EqualTo(dt.AddYears(1)));
Assert.That(c.AddMonths(1).Value, Is.EqualTo(dt.AddMonths(1)));
Assert.That(c.AddMinutes(1).Value, Is.EqualTo(dt.AddMinutes(1)));
Assert.That(c.AddSeconds(15).Value, Is.EqualTo(dt.AddSeconds(15)));
Assert.That(c.AddMilliseconds(100).Value, Is.EqualTo(dt.AddMilliseconds(0))); // truncated
Assert.That(c.AddMilliseconds(1000).Value, Is.EqualTo(dt.AddMilliseconds(1000)));
Assert.That(c.AddTicks(1).Value, Is.EqualTo(dt.AddTicks(0))); // truncated
Assert.That(c.AddTicks(TimeSpan.FromMinutes(1).Ticks).Value, Is.EqualTo(dt.AddTicks(TimeSpan.FromMinutes(1).Ticks)));
Assert.That(c.ToString("dd.MM.yyyy"), Is.EqualTo("02.01.2025 Europe/Berlin"));
});
}
}
}
19 changes: 5 additions & 14 deletions Ical.Net.Tests/DeserializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -448,22 +448,13 @@ public void Transparency2()
Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent));
}

/// <summary>
/// Tests that DateTime values that are out-of-range are still parsed correctly
/// and set to the closest representable date/time in .NET.
/// </summary>
[Test]
public void DateTime1()
public void DateTime1_Unrepresentable_DateTimeArgs_ShouldThrow()
{
var iCal = Calendar.Load(IcsFiles.DateTime1);
Assert.That(iCal.Events, Has.Count.EqualTo(6));

var evt = iCal.Events["nc2o66s0u36iesitl2l0b8inn8@google.com"];
Assert.That(evt, Is.Not.Null);

// The "Created" date is out-of-bounds. It should be coerced to the
// closest representable date/time.
Assert.That(evt.Created.Value, Is.EqualTo(DateTime.MinValue));
Assert.That(() =>
{
_ = Calendar.Load(IcsFiles.DateTime1);
}, Throws.Exception.TypeOf<ArgumentOutOfRangeException>());
}

[Test]
Expand Down
4 changes: 2 additions & 2 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2828,7 +2828,7 @@ public void UsHolidays()
[TestCase("SECONDLY", 1, true)]
[TestCase("MINUTELY", 60, true)]
[TestCase("HOURLY", 3600, true)]
[TestCase("DAILY", 24*3600, false)]
[TestCase("DAILY", 24*3600, true)]
public void Evaluate1(string freq, int secsPerInterval, bool hasTime)
{
Calendar cal = new Calendar();
Expand All @@ -2837,7 +2837,7 @@ public void Evaluate1(string freq, int secsPerInterval, bool hasTime)
evt.Summary = "Event summary";

// Start at midnight, UTC time
evt.Start = new CalDateTime(DateTime.SpecifyKind(DateTime.Today, DateTimeKind.Utc)) { HasTime = false };
evt.Start = new CalDateTime(DateTime.SpecifyKind(DateTime.Today, DateTimeKind.Utc)); // { HasTime = false };

// This case (DTSTART of type DATE and FREQ=MINUTELY) is undefined in RFC 5545.
// ical.net handles the case by pretending DTSTART has the time set to midnight.
Expand Down
21 changes: 7 additions & 14 deletions Ical.Net.Tests/SimpleDeserializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -451,22 +451,15 @@ public void Transparency2()
Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent));
}

/// <summary>
/// Tests that DateTime values that are out-of-range are still parsed correctly
/// and set to the closest representable date/time in .NET.
/// </summary>
[Test, Category("Deserialization")]
public void DateTime1()
public void DateTime1_Unrepresentable_DateTimeArgs_ShouldThrow()
{
var iCal = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.DateTime1)).Cast<Calendar>().Single();
Assert.That(iCal.Events, Has.Count.EqualTo(6));

var evt = iCal.Events["nc2o66s0u36iesitl2l0b8inn8@google.com"];
Assert.That(evt, Is.Not.Null);

// The "Created" date is out-of-bounds. It should be coerced to the
// closest representable date/time.
Assert.That(evt.Created.Value, Is.EqualTo(DateTime.MinValue));
Assert.That(() =>
{
_ = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.DateTime1))
.Cast<Calendar>()
.Single();
}, Throws.Exception.TypeOf<ArgumentOutOfRangeException>());
}

[Test, Category("Deserialization"), Ignore("Ignore until @thoemy commits the EventStatus.ics file")]
Expand Down
30 changes: 28 additions & 2 deletions Ical.Net/CalendarComponents/CalendarEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,37 @@ public virtual bool IsAllDay
// has a time value.
if (Start != null)
{
Start.HasTime = !value;
if (value)
{
// Ensure time part is not set
var dt = new CalDateTime(Start);
dt.SetValue(DateOnly.FromDateTime(Start.Value), null, Start.Value.Kind);
Start = dt;
}
else
{
// Ensure time part is set
var dt = new CalDateTime(Start);
dt.SetValue(DateOnly.FromDateTime(Start.Value), TimeOnly.FromDateTime(Start.Value), Start.Value.Kind);
Start = dt;
}
}
if (End != null)
{
End.HasTime = !value;
if (value)
{
// Ensure time part is not set
var dt = new CalDateTime(End);
dt.SetValue(DateOnly.FromDateTime(End.Value), null, End.Value.Kind);
End = dt;
}
else
{
// Ensure time part is set
var dt = new CalDateTime(End);
dt.SetValue(DateOnly.FromDateTime(End.Value), TimeOnly.FromDateTime(End.Value), End.Value.Kind);
End = dt;
}
}

if (value && Start != null && End != null && Equals(Start.Date, End.Date))
Expand Down
5 changes: 2 additions & 3 deletions Ical.Net/CalendarComponents/VTimeZone.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Ical.Net.DataTypes;
using Ical.Net.DataTypes;
using Ical.Net.Proxies;
using Ical.Net.Utility;
using NodaTime;
Expand Down Expand Up @@ -177,7 +177,7 @@ private static VTimeZoneInfo CreateTimeZoneInfo(List<ZoneInterval> matchedInterv
timeZoneInfo.TimeZoneName = oldestInterval.Name;

var start = oldestInterval.IsoLocalStart.ToDateTimeUnspecified() + delta;
timeZoneInfo.Start = new CalDateTime(start) { HasTime = true };
timeZoneInfo.Start = new CalDateTime(start);

if (isRRule)
{
Expand Down Expand Up @@ -244,7 +244,6 @@ private static void PopulateTimeZoneInfoRecurrenceDates(VTimeZoneInfo tzi, List<
continue;
}

date.HasTime = true;
periodList.Add(date);
tzi.RecurrenceDates.Add(periodList);
}
Expand Down
Loading

0 comments on commit f2f651e

Please sign in to comment.