diff --git a/Ical.Net.Tests/AlarmTest.cs b/Ical.Net.Tests/AlarmTest.cs index 1974e2d0..fc91c8b8 100644 --- a/Ical.Net.Tests/AlarmTest.cs +++ b/Ical.Net.Tests/AlarmTest.cs @@ -1,4 +1,4 @@ -using Ical.Net.DataTypes; +using Ical.Net.DataTypes; using NUnit.Framework; using System; using System.Collections.Generic; diff --git a/Ical.Net.Tests/CalDateTimeTests.cs b/Ical.Net.Tests/CalDateTimeTests.cs index ac77ffe6..fa54a90d 100644 --- a/Ical.Net.Tests/CalDateTimeTests.cs +++ b/Ical.Net.Tests/CalDateTimeTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; namespace Ical.Net.Tests { @@ -11,6 +12,7 @@ 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) @@ -32,7 +34,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; @@ -89,13 +91,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)); } @@ -150,5 +154,165 @@ public static IEnumerable DateTimeKindOverrideTestCases() .Returns(DateTimeKind.Unspecified) .SetName("DateTime with Kind = Unspecified and null tzid returns DateTimeKind.Unspecified"); } + + [Test, TestCaseSource(nameof(ToStringTestCases))] + public string ToStringTests(CalDateTime calDateTime, string format, IFormatProvider formatProvider) + => calDateTime.ToString(format, formatProvider); + + public static IEnumerable ToStringTestCases() + { + 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"); + } + + [Test] + public void SetValue_AppliesSameRulesAsWith_CTOR() + { + var dateTime = new DateTime(2024, 8, 30, 10, 30, 0, DateTimeKind.Unspecified); + var tzId = "Europe/Berlin"; + + var dt1 = new CalDateTime(dateTime, tzId); + var dt2 = new CalDateTime(DateTime.Now, tzId); + dt2.Value = dateTime; + + Assert.Multiple(() => + { + // TzId changes the DateTimeKind to Local + Assert.That(dt1.Value.Kind, Is.Not.EqualTo(dateTime.Kind)); + Assert.That(dt1.Value.Kind, Is.EqualTo(dt2.Value.Kind)); + Assert.That(dt1.TzId, Is.EqualTo(dt2.TzId)); + }); + } + + [Test] + public void SetValue_LeavesExistingPropertiesUnchanged() + { + var cal = new Calendar(); + var dateTime = new DateTime(2024, 8, 30, 10, 30, 0, DateTimeKind.Unspecified); + var tzId = "Europe/Berlin"; + + var dt = new CalDateTime(dateTime, tzId, false) + { + AssociatedObject = cal + }; + var hasTimeInitial = dt.HasTime; + + dt.Value = DateTime.MinValue; + + // Properties should remain unchanged + Assert.Multiple(() => + { + Assert.That(dt.HasTime, Is.EqualTo(hasTimeInitial)); + Assert.That(dt.TzId, Is.EqualTo(tzId)); + Assert.That(dt.Calendar, Is.SameAs(cal)); + }); + } + + [Test] + public void Simple_PropertyAndMethod_HasTime_Tests() + { + var dt = new DateTime(2025, 1, 2, 10, 20, 30, DateTimeKind.Utc); + var c = new CalDateTime(dt, tzId: "Europe/Berlin"); + + 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), dt.Kind, c.TzId); + + 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.AddDays(1).Value, Is.EqualTo(dt.AddDays(1))); + Assert.That(c.AddHours(1).Value, Is.EqualTo(dt.AddHours(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.DateOnlyValue, Is.EqualTo(new DateOnly(dt.Year, dt.Month, dt.Day))); + Assert.That(c.TimeOnlyValue, Is.EqualTo(new TimeOnly(dt.Hour, dt.Minute, dt.Second))); + Assert.That(c.ToString("dd.MM.yyyy"), Is.EqualTo("02.01.2025 Europe/Berlin")); + }); + } + + [Test] + public void Simple_PropertyAndMethod_NotHasTime_Tests() + { + var dt = new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var c = new CalDateTime(dt, tzId: "Europe/Berlin", hasTime: false); + + // Adding time to a date-only value should not change the HasTime property + Assert.Multiple(() => + { + var result = c.AddHours(1); + Assert.That(result.HasTime, Is.EqualTo(true)); + + result = c.AddMinutes(1); + Assert.That(result.HasTime, Is.EqualTo(true)); + + result = c.AddSeconds(1); + Assert.That(result.HasTime, Is.EqualTo(true)); + + result = c.AddMilliseconds(1000); + Assert.That(result.HasTime, Is.EqualTo(true)); + + result = c.AddTicks(TimeSpan.FromMinutes(1).Ticks); + Assert.That(result.HasTime, Is.EqualTo(true)); + }); + } + + [Test] + public void Toggling_HasDate_ShouldSucceed() + { + var dateTime = new DateTime(2025, 1, 2, 10, 20, 30, DateTimeKind.Utc); + var dt = new CalDateTime(dateTime); + Assert.Multiple(() => + { + Assert.That(dt.HasTime, Is.True); + Assert.That(dt.HasDate, Is.True); + + dt.HasDate = false; + Assert.That(dt.HasDate, Is.False); + Assert.That(dt.DateOnlyValue.HasValue, Is.False); + Assert.That(() => dt.Value, Throws.InstanceOf()); + + dt.HasDate = true; + Assert.That(dt.HasDate, Is.True); + }); + } } } diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index e32d6fb0..b4bf0403 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -448,22 +448,13 @@ public void Transparency2() Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent)); } - /// - /// Tests that DateTime values that are out-of-range are still parsed correctly - /// and set to the closest representable date/time in .NET. - /// [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()); } [Test] diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 862a5bec..463c4479 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -2471,26 +2471,28 @@ public void Bug3007244() { var iCal = Calendar.Load(IcsFiles.Bug3007244); + // CalDateTimes.HasTime = false EventOccurrenceTest( cal: iCal, - fromDate: new CalDateTime(2010, 7, 18, 0, 0, 0), - toDate: new CalDateTime(2010, 7, 26, 0, 0, 0), - dateTimes: new[] { new CalDateTime(2010, 05, 23, 0, 0, 0), }, + fromDate: new CalDateTime(2010, 7, 18), + toDate: new CalDateTime(2010, 7, 26), + dateTimes: new[] { new CalDateTime(2010, 05, 23) }, timeZones: null, eventIndex: 0 ); + // CalDateTimes.HasTime = false EventOccurrenceTest( cal: iCal, - fromDate: new CalDateTime(2011, 7, 18, 0, 0, 0), - toDate: new CalDateTime(2011, 7, 26, 0, 0, 0), - dateTimes: new[] { new CalDateTime(2011, 05, 23, 0, 0, 0), }, + fromDate: new CalDateTime(2011, 7, 18), + toDate: new CalDateTime(2011, 7, 26), + dateTimes: new[] { new CalDateTime(2011, 05, 23) }, timeZones: null, eventIndex: 0 ); } - /// + /// /// Tests bug BYWEEKNO not working /// [Test, Category("Recurrence")] @@ -2500,7 +2502,7 @@ public void BugByWeekNoNotWorking() var end = new DateTime(2019, 12, 31); var rpe = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=WEEKLY;BYDAY=MO;BYWEEKNO=2")); - var recurringPeriods = rpe.Evaluate(new CalDateTime(start), start, end, false); + var recurringPeriods = rpe.Evaluate(new CalDateTime(start, false), start, end, false); Assert.That(recurringPeriods, Has.Count.EqualTo(1)); Assert.That(recurringPeriods.First().StartTime, Is.EqualTo(new CalDateTime(2019, 1, 7))); @@ -2516,7 +2518,7 @@ public void BugByMonthWhileFreqIsWeekly() var end = new DateTime(2020, 12, 31); var rpe = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=WEEKLY;BYDAY=MO;BYMONTH=1")); - var recurringPeriods = rpe.Evaluate(new CalDateTime(start), start, end, false).OrderBy(x => x).ToList(); + var recurringPeriods = rpe.Evaluate(new CalDateTime(start, false), start, end, false).OrderBy(x => x).ToList(); Assert.That(recurringPeriods, Has.Count.EqualTo(4)); Assert.Multiple(() => @@ -2559,7 +2561,7 @@ public void BugByMonthWhileFreqIsMonthly() var end = new DateTime(2020, 12, 31); var rpe = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=MONTHLY;BYDAY=MO;BYMONTH=1")); - var recurringPeriods = rpe.Evaluate(new CalDateTime(start), start, end, false).OrderBy(x => x).ToList(); + var recurringPeriods = rpe.Evaluate(new CalDateTime(start, false), start, end, false).OrderBy(x => x).ToList(); Assert.That(recurringPeriods, Has.Count.EqualTo(4)); Assert.Multiple(() => @@ -2766,7 +2768,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.UtcNow.Date, 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. @@ -2774,7 +2776,7 @@ public void Evaluate1(string freq, int secsPerInterval, bool hasTime) #pragma warning disable 0618 evt.RecurrenceRules[0].RestrictionType = RecurrenceRestrictionType.NoRestriction; #pragma warning restore 0618 - + var occurrences = evt.GetOccurrences(CalDateTime.Today.AddDays(-1), CalDateTime.Today.AddDays(100)) .OrderBy(x => x) .ToList(); @@ -3112,7 +3114,7 @@ public void OccurrenceMustBeCompletelyContainedWithinSearchRange() /// Evaluate relevancy and validity of the request. /// Find a solution for issue #120 or close forever /// - [Test, Ignore("Turn on in v3", Until = "2024-12-31")] + [Test, Ignore("No solution for issue #120 yet", Until = "2024-12-31")] public void EventsWithShareUidsShouldGenerateASingleRecurrenceSet() { //https://github.com/rianjs/ical.net/issues/120 dated Sep 5, 2016 @@ -3211,21 +3213,21 @@ public void AddExDateToEventAfterGetOccurrencesShouldRecomputeResult() var searchEnd = _now.AddDays(7); var e = GetEventWithRecurrenceRules(); var occurrences = e.GetOccurrences(searchStart, searchEnd); - Assert.That(occurrences.Count == 5, Is.True); + Assert.That(occurrences, Has.Count.EqualTo(5)); var exDate = _now.AddDays(1); - var period = new Period(new CalDateTime(exDate)); + var period = new Period(new CalDateTime(exDate, false)); var periodList = new PeriodList { period }; e.ExceptionDates.Add(periodList); occurrences = e.GetOccurrences(searchStart, searchEnd); - Assert.That(occurrences.Count == 4, Is.True); + Assert.That(occurrences, Has.Count.EqualTo(4)); //Specifying just a date should "black out" that date var excludeTwoDaysFromNow = _now.AddDays(2).Date; - period = new Period(new CalDateTime(excludeTwoDaysFromNow)); + period = new Period(new CalDateTime(excludeTwoDaysFromNow, false)); periodList.Add(period); occurrences = e.GetOccurrences(searchStart, searchEnd); - Assert.That(occurrences.Count == 3, Is.True); + Assert.That(occurrences, Has.Count.EqualTo(3)); } private static readonly DateTime _now = DateTime.Now; @@ -3495,27 +3497,18 @@ public void ManyExclusionDatesEqualityTesting() END:VEVENT END:VCALENDAR"; - //The only textual difference between A and B is a different DTSTAMP, which is not considered significant for equality or hashing + // The only textual difference between A and B + // is a different DTSTAMP, which is not considered significant for equality or hashing - //Tautologies... var collectionA = CalendarCollection.Load(icalA); - Assert.That(collectionA, Is.EqualTo(collectionA)); - Assert.That(collectionA.GetHashCode(), Is.EqualTo(collectionA.GetHashCode())); - var calendarA = collectionA.First(); - Assert.That(calendarA, Is.EqualTo(calendarA)); - Assert.That(calendarA.GetHashCode(), Is.EqualTo(calendarA.GetHashCode())); - var eventA = calendarA.Events.First(); - Assert.That(eventA, Is.EqualTo(eventA)); - Assert.That(eventA.GetHashCode(), Is.EqualTo(eventA.GetHashCode())); - var collectionB = CalendarCollection.Load(icalB); - Assert.That(collectionB, Is.EqualTo(collectionB)); - Assert.That(collectionB.GetHashCode(), Is.EqualTo(collectionB.GetHashCode())); + var calendarA = collectionA.First(); var calendarB = collectionB.First(); - Assert.That(calendarB, Is.EqualTo(calendarB)); - Assert.That(calendarB.GetHashCode(), Is.EqualTo(calendarB.GetHashCode())); + var eventA = calendarA.Events.First(); var eventB = calendarB.Events.First(); - + var exDatesA = eventA.ExceptionDates; + var exDatesB = eventB.ExceptionDates; + Assert.Multiple(() => { //Comparing the two... @@ -3525,17 +3518,12 @@ public void ManyExclusionDatesEqualityTesting() Assert.That(calendarB.GetHashCode(), Is.EqualTo(calendarA.GetHashCode())); Assert.That(eventB, Is.EqualTo(eventA)); Assert.That(eventB.GetHashCode(), Is.EqualTo(eventA.GetHashCode())); + Assert.That(exDatesB, Is.EqualTo(exDatesA)); }); - - - var exDatesA = eventA.ExceptionDates; - var exDatesB = eventB.ExceptionDates; - Assert.That(exDatesB, Is.EqualTo(exDatesA)); - } [Test, TestCaseSource(nameof(UntilTimeZoneSerializationTestCases))] - public void UntilTimeZoneSerializationTests(string tzid, DateTimeKind expectedKind) + public void UntilTimeZoneSerializationTests(string tzId, DateTimeKind expectedKind) { var now = DateTime.SpecifyKind(DateTime.Parse("2017-11-08 10:30:00"), expectedKind); var later = now.AddHours(1); @@ -3548,8 +3536,8 @@ public void UntilTimeZoneSerializationTests(string tzid, DateTimeKind expectedKi }; var e = new CalendarEvent { - Start = new CalDateTime(now, tzid), - End = new CalDateTime(later, tzid) + Start = new CalDateTime(now, tzId), + End = new CalDateTime(later, tzId) }; e.RecurrenceRules.Add(rrule); var calendar = new Calendar diff --git a/Ical.Net.Tests/SerializationTests.cs b/Ical.Net.Tests/SerializationTests.cs index a633c642..7b20c697 100644 --- a/Ical.Net.Tests/SerializationTests.cs +++ b/Ical.Net.Tests/SerializationTests.cs @@ -110,11 +110,11 @@ public static string InspectSerializedSection(string serialized, string sectionN { const string notFound = "expected '{0}' not found"; var searchFor = "BEGIN:" + sectionName; - var begin = serialized.IndexOf(searchFor); + var begin = serialized.IndexOf(searchFor, StringComparison.Ordinal); Assert.That(begin, Is.Not.EqualTo(-1), () => string.Format(notFound, searchFor)); searchFor = "END:" + sectionName; - var end = serialized.IndexOf(searchFor, begin); + var end = serialized.IndexOf(searchFor, begin, StringComparison.Ordinal); Assert.That(end, Is.Not.EqualTo(-1), () => string.Format(notFound, searchFor)); var searchRegion = serialized.Substring(begin, end - begin + searchFor.Length); @@ -158,7 +158,6 @@ private static Dictionary GetValues(string serialized, string na [Test, Category("Serialization"), Ignore("TODO: standard time, for NZ standard time (current example)")] public void TimeZoneSerialize() { - //ToDo: This test is broken as of 2016-07-13 var cal = new Calendar { Method = "PUBLISH", @@ -192,10 +191,10 @@ public void TimeZoneSerialize() InspectSerializedSection(vTimezone, "DAYLIGHT", new[] { "TZNAME:" + tzi.DaylightName, "TZOFFSETFROM:" + o }); } + [Test, Category("Serialization")] public void SerializeDeserialize() { - //ToDo: This test is broken as of 2016-07-13 var cal1 = new Calendar { Method = "PUBLISH", @@ -227,7 +226,6 @@ public void SerializeDeserialize() [Test, Category("Serialization")] public void EventPropertiesSerialized() { - //ToDo: This test is broken as of 2016-07-13 var cal = new Calendar { Method = "PUBLISH", diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index ee72332a..a7ebbe1a 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -451,22 +451,15 @@ public void Transparency2() Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent)); } - /// - /// Tests that DateTime values that are out-of-range are still parsed correctly - /// and set to the closest representable date/time in .NET. - /// [Test, Category("Deserialization")] - public void DateTime1() + public void DateTime1_Unrepresentable_DateTimeArgs_ShouldThrow() { - var iCal = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.DateTime1)).Cast().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() + .Single(); + }, Throws.Exception.TypeOf()); } [Test, Category("Deserialization"), Ignore("Ignore until @thoemy commits the EventStatus.ics file")] diff --git a/Ical.Net/CalendarComponents/CalendarEvent.cs b/Ical.Net/CalendarComponents/CalendarEvent.cs index 272c9f9a..e83f5915 100644 --- a/Ical.Net/CalendarComponents/CalendarEvent.cs +++ b/Ical.Net/CalendarComponents/CalendarEvent.cs @@ -126,11 +126,33 @@ public virtual bool IsAllDay // has a time value. if (Start != null) { - Start.HasTime = !value; + if (value) + { + // Ensure time part is not set + Start = new CalDateTime(Start.Value, Start.TzId, false) + { AssociatedObject = Start.AssociatedObject }; + } + else + { + // Ensure time part is set + Start = new CalDateTime(Start.Value, Start.TzId, true) + { AssociatedObject = Start.AssociatedObject }; + } } if (End != null) { - End.HasTime = !value; + if (value) + { + // Ensure time part is not set + End = new CalDateTime(End.Value, End.TzId, false) + { AssociatedObject = End.AssociatedObject }; + } + else + { + // Ensure time part is set + End = new CalDateTime(End.Value, End.TzId, true) + { AssociatedObject = End.AssociatedObject }; + } } if (value && Start != null && End != null && Equals(Start.Date, End.Date)) diff --git a/Ical.Net/CalendarComponents/UniqueComponent.cs b/Ical.Net/CalendarComponents/UniqueComponent.cs index c8b03ffe..0907b819 100644 --- a/Ical.Net/CalendarComponents/UniqueComponent.cs +++ b/Ical.Net/CalendarComponents/UniqueComponent.cs @@ -42,7 +42,7 @@ private void EnsureProperties() { // icalendar RFC doesn't care about sub-second time resolution, so shave off everything smaller than seconds. var utcNow = DateTime.UtcNow.Truncate(TimeSpan.FromSeconds(1)); - DtStamp = new CalDateTime(utcNow, "UTC"); + DtStamp = CalDateTime.UtcNow; } } diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 24b3ca5f..a1a01a62 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -1,4 +1,4 @@ -using Ical.Net.DataTypes; +using Ical.Net.DataTypes; using Ical.Net.Proxies; using Ical.Net.Utility; using NodaTime; @@ -177,7 +177,7 @@ private static VTimeZoneInfo CreateTimeZoneInfo(List matchedInterv timeZoneInfo.TimeZoneName = oldestInterval.Name; var start = oldestInterval.IsoLocalStart.ToDateTimeUnspecified() + delta; - timeZoneInfo.Start = new CalDateTime(start) { HasTime = true }; + timeZoneInfo.Start = new CalDateTime(start, true); if (isRRule) { @@ -238,13 +238,12 @@ private static void PopulateTimeZoneInfoRecurrenceDates(VTimeZoneInfo tzi, List< { var periodList = new PeriodList(); var time = interval.IsoLocalStart.ToDateTimeUnspecified(); - var date = new CalDateTime(time).Add(delta) as CalDateTime; + var date = new CalDateTime(time, true).Add(delta) as CalDateTime; if (date == null) { continue; } - date.HasTime = true; periodList.Add(date); tzi.RecurrenceDates.Add(periodList); } diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index c149d779..7213dc81 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -1,7 +1,9 @@ -using Ical.Net.Serialization.DataTypes; +#nullable enable +using Ical.Net.Serialization.DataTypes; using Ical.Net.Utility; using NodaTime; using System; +using System.Globalization; using System.IO; namespace Ical.Net.DataTypes @@ -10,116 +12,167 @@ namespace Ical.Net.DataTypes /// The iCalendar equivalent of the .NET class. /// /// In addition to the features of the class, the - /// class handles time zone differences, and integrates seamlessly into the iCalendar framework. + /// class handles time zones, and integrates seamlessly into the iCalendar framework. /// /// public sealed class CalDateTime : EncodableDataType, IDateTime { + // The date and time parts that were used to initialize the instance + // or by the Value setter. + private DateTime _value; + // The date part that is used to return the Value property. + private DateOnly? _dateOnly; + // The time part that is used to return the Value property. + private TimeOnly? _timeOnly; + public static CalDateTime Now => new CalDateTime(DateTime.Now); public static CalDateTime Today => new CalDateTime(DateTime.Today); - private bool _hasDate; - private bool _hasTime; + public static CalDateTime UtcNow => new CalDateTime(DateTime.UtcNow); + /// + /// This constructor is required for the SerializerFactory to work. + /// public CalDateTime() { } + /// + /// Creates a new instance of the class + /// respecting the setting. + /// + /// public CalDateTime(IDateTime value) { - Initialize(value.Value, value.TzId, null); + Initialize(value.Value, value.HasTime, value.TzId, value.Calendar); } - public CalDateTime(DateTime value) : this(value, null) { } + /// + /// Creates a new instance of the class + /// and sets the to "UTC" if the + /// has a of . + /// + /// + /// Set to (default), if the must be included. + public CalDateTime(DateTime value, bool hasTime = true) : this(value, value.Kind == DateTimeKind.Utc ? "UTC" : null, hasTime) + { } /// - /// Specifying a `tzId` value will override `value`'s `DateTimeKind` property. If the time zone specified is UTC, the underlying `DateTimeKind` will be - /// `Utc`. If a non-UTC time zone is specified, the underlying `DateTimeKind` property will be `Local`. If no time zone is specified, the `DateTimeKind` - /// property will be left untouched. + /// Creates a new instance of the class using the specified time zone. /// - public CalDateTime(DateTime value, string tzId) + /// + /// The specified value will override value's property. + /// If the time zone specified is UTC, the underlying will be + /// . If a non-UTC time zone is specified, the underlying + /// property will be . + /// If no time zone is specified, the property will be left untouched. + /// Set to (default), if the must be included. + public CalDateTime(DateTime value, string? tzId, bool hasTime = true) { - Initialize(value, tzId, null); + Initialize(value, hasTime, tzId, null); } - public CalDateTime(int year, int month, int day, int hour, int minute, int second) - { - Initialize(year, month, day, hour, minute, second, null, null); - HasTime = true; + /// + /// Creates a new instance of the class using the specified time zone. + /// Sets for the property. + /// + /// + /// The specified value will override value's property. + /// If a non-UTC time zone is specified, the underlying property will be . + /// If no time zone is specified, the property will be left untouched. + /// + /// + /// + /// + /// + /// + /// + public CalDateTime(int year, int month, int day, int hour, int minute, int second, string? tzId = null, Calendar? cal = null) //NOSONAR - must keep this signature + { + Initialize(new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified), true, tzId, cal); } - public CalDateTime(int year, int month, int day, int hour, int minute, int second, string tzId) + /// + /// Creates a new instance of the class using the specified time zone. + /// Sets for the property. + /// + /// The specified value will override value's property. + /// If a non-UTC time zone is specified, the underlying property will be . + /// If no time zone is specified, the property will be left untouched. + /// + /// + /// + /// + public CalDateTime(int year, int month, int day, string? tzId = null) { - Initialize(year, month, day, hour, minute, second, tzId, null); - HasTime = true; + Initialize(new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified), false, tzId, null); } - public CalDateTime(int year, int month, int day, int hour, int minute, int second, string tzId, Calendar cal) - { - Initialize(year, month, day, hour, minute, second, tzId, cal); - HasTime = true; + /// + /// Creates a new instance of the class using the specified time zone. + /// + /// If , is used. + /// The specified value will override value's property. + /// If a non-UTC time zone is specified, the underlying property will be . + /// If no time zone is specified, the property will be left untouched. + /// + /// + /// + /// + public CalDateTime(DateOnly date, TimeOnly? time, DateTimeKind kind, string? tzId = null, Calendar? cal = null) + { + if (time.HasValue) + Initialize(new DateTime(date.Year, date.Month, date.Day, time.Value.Hour, time.Value.Minute, time.Value.Second, kind), true , tzId, cal); + else + Initialize(new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, kind), false, tzId, cal); } - public CalDateTime(int year, int month, int day) : this(year, month, day, 0, 0, 0) { } - public CalDateTime(int year, int month, int day, string tzId) : this(year, month, day, 0, 0, 0, tzId) { } - + /// + /// Creates a new instance of the class by parsing + /// using the . + /// + /// An iCalendar-compatible date or date-time string. public CalDateTime(string value) { var serializer = new DateTimeSerializer(); - CopyFrom(serializer.Deserialize(new StringReader(value)) as ICopyable); + CopyFrom(serializer.Deserialize(new StringReader(value)) as ICopyable + ?? throw new InvalidOperationException("Failure deserializing value")); } - private void Initialize(int year, int month, int day, int hour, int minute, int second, string tzId, Calendar cal) + private void Initialize(DateTime dateTime, bool hasTime, string? tzId, Calendar? cal) { - Initialize(CoerceDateTime(year, month, day, hour, minute, second, DateTimeKind.Local), tzId, cal); - } + DateTime initialValue; - private void Initialize(DateTime value, string tzId, Calendar cal) - { - if (!string.IsNullOrWhiteSpace(tzId) && !tzId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) + if ((tzId != null && !string.IsNullOrWhiteSpace(tzId) && !tzId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) + || (string.IsNullOrWhiteSpace(tzId) && dateTime.Kind == DateTimeKind.Local)) { // Definitely local - value = DateTime.SpecifyKind(value, DateTimeKind.Local); - TzId = tzId; + _tzId = tzId; + + initialValue = DateTime.SpecifyKind(dateTime, DateTimeKind.Local); + } - else if (string.Equals("UTC", tzId, StringComparison.OrdinalIgnoreCase) || value.Kind == DateTimeKind.Utc) + else if (string.Equals("UTC", tzId, StringComparison.OrdinalIgnoreCase) || dateTime.Kind == DateTimeKind.Utc) { - // Probably UTC - value = DateTime.SpecifyKind(value, DateTimeKind.Utc); - TzId = "UTC"; - } - - Value = new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind); - HasDate = true; - HasTime = value.Second != 0 || value.Minute != 0 || value.Hour != 0; - AssociatedObject = cal; - } + // It is UTC + _tzId = "UTC"; - private DateTime CoerceDateTime(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind) - { - var dt = DateTime.MinValue; - - // NOTE: determine if a date/time value exceeds the representable date/time values in .NET. - // If so, let's automatically adjust the date/time to compensate. - // FIXME: should we have a parsing setting that will throw an exception - // instead of automatically adjusting the date/time value to the - // closest representable date/time? - try + initialValue = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + else { - if (year > 9999) - { - dt = DateTime.MaxValue; - } - else if (year > 0) - { - dt = new DateTime(year, month, day, hour, minute, second, kind); - } + // Unspecified + _tzId = null; + + initialValue = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified); } - catch { } - return dt; + SynchronizeDateTimeFields(initialValue, hasTime); + + AssociatedObject = cal; } - public override ICalendarObject AssociatedObject + /// + public override ICalendarObject? AssociatedObject { get => base.AssociatedObject; set @@ -136,17 +189,22 @@ public override void CopyFrom(ICopyable obj) { base.CopyFrom(obj); - var dt = obj as IDateTime; - if (dt == null) + if (obj is not IDateTime dt) + { { return; } - _value = dt.Value; - _hasDate = dt.HasDate; - _hasTime = dt.HasTime; - // String assignments create new instances - TzId = dt.TzId; + if (dt is CalDateTime calDt) + { + // Maintain the private date/time backing fields + _dateOnly = calDt._dateOnly; + _timeOnly = calDt._timeOnly; + + // Copy the underlying DateTime value and time zone + _value = calDt._value; + _tzId = calDt._tzId; + } AssociateWith(dt); } @@ -154,9 +212,11 @@ public override void CopyFrom(ICopyable obj) public bool Equals(CalDateTime other) => this == other; - public override bool Equals(object other) - => other is IDateTime && (CalDateTime)other == this; + /// + public override bool Equals(object? obj) + => obj is IDateTime && (CalDateTime)obj == this; + /// public override int GetHashCode() { unchecked @@ -169,56 +229,77 @@ public override int GetHashCode() } } - public static bool operator <(CalDateTime left, IDateTime right) + public static bool operator <(CalDateTime? left, IDateTime? right) => left != null && right != null && left.AsUtc < right.AsUtc; - public static bool operator >(CalDateTime left, IDateTime right) + public static bool operator >(CalDateTime? left, IDateTime? right) => left != null && right != null && left.AsUtc > right.AsUtc; - public static bool operator <=(CalDateTime left, IDateTime right) + public static bool operator <=(CalDateTime? left, IDateTime? right) => left != null && right != null && left.AsUtc <= right.AsUtc; - public static bool operator >=(CalDateTime left, IDateTime right) + public static bool operator >=(CalDateTime? left, IDateTime? right) => left != null && right != null && left.AsUtc >= right.AsUtc; - public static bool operator ==(CalDateTime left, IDateTime right) + public static bool operator ==(CalDateTime? left, IDateTime? right) { return ReferenceEquals(left, null) || ReferenceEquals(right, null) ? ReferenceEquals(left, right) - : right is CalDateTime - && left.Value.Equals(right.Value) - && left.HasDate == right.HasDate - && left.AsUtc.Equals(right.AsUtc) - && string.Equals(left.TzId, right.TzId, StringComparison.OrdinalIgnoreCase); + : right is CalDateTime calDateTime + && left.Value.Equals(calDateTime.Value) + && left.HasDate == calDateTime.HasDate + && left.HasTime == calDateTime.HasTime + && left.AsUtc.Equals(calDateTime.AsUtc) + && string.Equals(left.TzId, calDateTime.TzId, StringComparison.OrdinalIgnoreCase); } - public static bool operator !=(CalDateTime left, IDateTime right) + public static bool operator !=(CalDateTime? left, IDateTime? right) => !(left == right); - public static TimeSpan operator -(CalDateTime left, IDateTime right) - { - left.AssociateWith(right); - return left.AsUtc - right.AsUtc; - } - + /// + /// Subtracts a from the . + /// + /// + /// This will also set to , + /// if the is not a multiple of 24 hours. + /// public static IDateTime operator -(CalDateTime left, TimeSpan right) { - var copy = left.Copy(); + var copy = left.Copy(); + if (right.TotalDays % 1 != 0) + { + copy.HasTime = true; + } copy.Value -= right; return copy; } + /// + /// Adds a to the . + /// + /// + /// This will also set to , + /// if the is not a multiple of 24 hours. + /// public static IDateTime operator +(CalDateTime left, TimeSpan right) { - var copy = left.Copy(); + var copy = left.Copy(); + if (right.TotalDays % 1 != 0) + { + copy.HasTime = true; + } copy.Value += right; return copy; } + /// + /// Creates a new instance of with for + /// public static implicit operator CalDateTime(DateTime left) => new CalDateTime(left); /// - /// Converts the date/time to the date/time of the computer running the program. If the DateTimeKind is Unspecified, it's assumed that the underlying + /// Converts the date/time to the date/time of the computer running the program. + /// If the DateTimeKind is Unspecified, it's assumed that the underlying /// Value already represents the system's datetime. /// public DateTime AsSystemLocal @@ -238,77 +319,124 @@ public DateTime AsSystemLocal } } - private DateTime _asUtc = DateTime.MinValue; /// - /// Returns a representation of the DateTime in Coordinated Universal Time (UTC) + /// Returns a representation of the in UTC. /// public DateTime AsUtc { get { - if (_asUtc == DateTime.MinValue) + // In order of weighting: + // 1) Specified TzId + // 2) Value having a DateTimeKind.Utc + // 3) Use the OS's time zone + DateTime asUtc; + + if (!string.IsNullOrWhiteSpace(TzId)) { - // In order of weighting: - // 1) Specified TzId - // 2) Value having a DateTimeKind.Utc - // 3) Use the OS's time zone - - if (!string.IsNullOrWhiteSpace(TzId)) - { - var asLocal = DateUtil.ToZonedDateTimeLeniently(Value, TzId); - _asUtc = asLocal.ToDateTimeUtc(); - } - else if (IsUtc || Value.Kind == DateTimeKind.Utc) - { - _asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Utc); - } - else - { - _asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); - } + var asLocal = DateUtil.ToZonedDateTimeLeniently(Value, TzId); + return asLocal.ToDateTimeUtc(); } - return _asUtc; + + if (IsUtc || Value.Kind == DateTimeKind.Utc) + { + asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Utc); + return asUtc; + } + + asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); + return asUtc; } } - private DateTime _value; + /// + /// Gets the underlying of . + /// + public DateOnly? DateOnlyValue => _dateOnly; + + /// + /// Gets the underlying of . + /// + public TimeOnly? TimeOnlyValue => _timeOnly; + + /// + /// Gets the underlying . + /// Depending on setting, + /// the returned has + /// set to midnight or the time from initialization. The precision of the time part is up to seconds. + /// + /// See also and for the date and time parts. + /// public DateTime Value { - get => _value; + get + { + // HasDate and HasTime both have setters, so they can be changed. + if (_dateOnly.HasValue && _timeOnly.HasValue) + { + return new DateTime(_dateOnly.Value.Year, _dateOnly.Value.Month, + _dateOnly.Value.Day, _timeOnly.Value.Hour, _timeOnly.Value.Minute, _timeOnly.Value.Second, + _value.Kind); + } + + if (_dateOnly.HasValue && !_timeOnly.HasValue) + return new DateTime(_dateOnly.Value.Year, _dateOnly.Value.Month, _dateOnly.Value.Day, + 0, 0, 0, + _value.Kind); + + throw new InvalidOperationException($"Cannot create DateTime when {nameof(HasDate)} is false."); + } + set { + // Kind must be checked in addition to the value, + // as the value can be the same but the Kind different. if (_value == value && _value.Kind == value.Kind) { return; } - _asUtc = DateTime.MinValue; - _value = value; + // Initialize with the new value, keeping current 'HasTime' setting + Initialize(value, _timeOnly.HasValue, TzId, Calendar); } } + /// + /// Returns true if the underlying is in UTC. + /// public bool IsUtc => _value.Kind == DateTimeKind.Utc; + /// + /// Toggles the part of the underlying . + /// if the underlying has a 'date' part (year, month, day). + /// public bool HasDate { - get => _hasDate; - set => _hasDate = value; + get => _dateOnly.HasValue; + set => _dateOnly = value ? DateOnly.FromDateTime(_value) : null; } + /// + /// Toggles the part of the underlying . + /// if the underlying has a 'time' part (hour, minute, second). + /// public bool HasTime { - get => _hasTime; - set => _hasTime = value; + get => _timeOnly.HasValue; + set => _timeOnly = value ? TimeOnly.FromDateTime(_value) : null; } - private string _tzId = string.Empty; + private string? _tzId = string.Empty; /// - /// Setting the TzId to a local time zone will set Value.Kind to Local. Setting TzId to UTC will set Value.Kind to Utc. If the incoming value is null - /// or whitespace, Value.Kind will be set to Unspecified. Setting the TzId will NOT incur a UTC offset conversion under any circumstances. To convert - /// to another time zone, use the ToTimeZone() method. + /// Setting the to a local time zone will set to . + /// Setting to UTC will set to . + /// If the value is set to or whitespace, will be . + /// + /// Setting the will initialize in the same way aw with the .
+ /// To convert to another time zone, use . ///
- public string TzId + public string? TzId { get { @@ -325,63 +453,65 @@ public string TzId return; } - _asUtc = DateTime.MinValue; - - var isEmpty = string.IsNullOrWhiteSpace(value); - if (isEmpty) + if (string.IsNullOrWhiteSpace(value)) { + Initialize(_value, _timeOnly.HasValue, value, Calendar); Parameters.Remove("TZID"); - _tzId = null; - Value = DateTime.SpecifyKind(Value, DateTimeKind.Local); return; } - var kind = string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase) - ? DateTimeKind.Utc - : DateTimeKind.Local; - - Value = DateTime.SpecifyKind(Value, kind); - Parameters.Set("TZID", value); - _tzId = value; + Initialize(_value, _timeOnly.HasValue, value, Calendar); + Parameters.Set("TZID", _tzId); // Use the value after the initialization } } - public string TimeZoneName => TzId; + /// + /// Gets the time zone name, if it references a time zone. + /// This is an alias for . + /// + public string? TimeZoneName => TzId; + /// public int Year => Value.Year; + /// public int Month => Value.Month; + /// public int Day => Value.Day; + /// public int Hour => Value.Hour; + /// public int Minute => Value.Minute; + /// public int Second => Value.Second; + /// public int Millisecond => Value.Millisecond; + /// public long Ticks => Value.Ticks; + /// public DayOfWeek DayOfWeek => Value.DayOfWeek; + /// public int DayOfYear => Value.DayOfYear; + /// public DateTime Date => Value.Date; + /// public TimeSpan TimeOfDay => Value.TimeOfDay; /// - /// Returns a representation of the IDateTime in the specified time zone + /// Returns a representation of the in the time zone /// - public IDateTime ToTimeZone(string tzId) + public IDateTime ToTimeZone(string? tzId) { - if (string.IsNullOrWhiteSpace(tzId)) - { - throw new ArgumentException("You must provide a valid time zone id", nameof(tzId)); - } - // If TzId is empty, it's a system-local datetime, so we should use the system time zone as the starting point. var originalTzId = string.IsNullOrWhiteSpace(TzId) ? TimeZoneInfo.Local.Id @@ -396,7 +526,8 @@ public IDateTime ToTimeZone(string tzId) } /// - /// Returns a DateTimeOffset representation of the Value. If a TzId is specified, it will use that time zone's UTC offset, otherwise it will use the + /// Returns a representation of the . + /// If a TzId is specified, it will use that time zone's UTC offset, otherwise it will use the /// system-local time zone. /// public DateTimeOffset AsDateTimeOffset => @@ -404,36 +535,66 @@ public IDateTime ToTimeZone(string tzId) ? new DateTimeOffset(AsSystemLocal) : DateUtil.ToZonedDateTimeLeniently(Value, TzId).ToDateTimeOffset(); + /// + /// + /// This will also set to , + /// if the hours are not a multiple of 24. + /// public IDateTime Add(TimeSpan ts) => this + ts; + + /// Returns a new by subtracting the specified from the value of this instance. + /// A interval. + /// An object whose value is the difference of the date and time represented by this instance and the time interval represented by . + /// + /// This will also set to , + /// if the hours are not a multiple of 24. + /// public IDateTime Subtract(TimeSpan ts) => this - ts; - public TimeSpan Subtract(IDateTime dt) => this - dt; + [Obsolete("This operator will be removed in a future version.", true)] + public static TimeSpan? operator -(CalDateTime? left, IDateTime? right) + { + left?.AssociateWith(right); // Should not be done in operator overloads + return left?.AsUtc - right?.AsUtc; + } + + /// Returns a new from subtracting the specified from to the value of this instance. + /// + public TimeSpan Subtract(IDateTime value) => (AsUtc - value.AsUtc)!; + /// public IDateTime AddYears(int years) { - var dt = Copy(); + var dt = Copy(); dt.Value = Value.AddYears(years); return dt; } + /// public IDateTime AddMonths(int months) { - var dt = Copy(); + var dt = Copy(); dt.Value = Value.AddMonths(months); return dt; } + /// public IDateTime AddDays(int days) { - var dt = Copy(); + var dt = Copy(); dt.Value = Value.AddDays(days); return dt; } + /// + /// + /// This will also set to , + /// if the hours are not a multiple of 24. + /// public IDateTime AddHours(int hours) { - var dt = Copy(); + var dt = Copy(); if (!dt.HasTime && hours % 24 > 0) { dt.HasTime = true; @@ -442,9 +603,14 @@ public IDateTime AddHours(int hours) return dt; } + /// + /// + /// This will also set to , + /// if the minutes are not a multiple of 1440. + /// public IDateTime AddMinutes(int minutes) { - var dt = Copy(); + var dt = Copy(); if (!dt.HasTime && minutes % 1440 > 0) { dt.HasTime = true; @@ -453,9 +619,14 @@ public IDateTime AddMinutes(int minutes) return dt; } + /// + /// + /// This will also set to , + /// if the seconds are not a multiple of 86400. + /// public IDateTime AddSeconds(int seconds) { - var dt = Copy(); + var dt = Copy(); if (!dt.HasTime && seconds % 86400 > 0) { dt.HasTime = true; @@ -464,9 +635,16 @@ public IDateTime AddSeconds(int seconds) return dt; } + /// + /// + /// This will also set to + /// if the milliseconds are not a multiple of 86400000. + /// + /// Milliseconds less than full seconds get truncated. + /// public IDateTime AddMilliseconds(int milliseconds) { - var dt = Copy(); + var dt = Copy(); if (!dt.HasTime && milliseconds % 86400000 > 0) { dt.HasTime = true; @@ -475,76 +653,124 @@ public IDateTime AddMilliseconds(int milliseconds) return dt; } + /// + /// + /// This will also set to . + /// if ticks do not result in multiple of full days. + /// + /// Ticks less than full seconds get truncated. + /// public IDateTime AddTicks(long ticks) { - var dt = Copy(); - dt.HasTime = true; + var dt = Copy(); + if (!dt.HasTime && TimeSpan.FromTicks(ticks).TotalDays % 1 != 0) + { + dt.HasTime = true; + } + dt.Value = Value.AddTicks(ticks); return dt; } + /// + /// Returns if the current instance is less than . + /// public bool LessThan(IDateTime dt) => this < dt; + /// + /// Returns if the current instance is greater than . + /// public bool GreaterThan(IDateTime dt) => this > dt; + /// + /// Returns if the current instance is less than or equal to . + /// public bool LessThanOrEqual(IDateTime dt) => this <= dt; + /// + /// Returns if the current instance is greater than or equal to . + /// public bool GreaterThanOrEqual(IDateTime dt) => this >= dt; - public void AssociateWith(IDateTime dt) + + /// + /// Associates the current instance with the specified object. + /// + /// The object to associate with. + public void AssociateWith(IDateTime? dt) { - if (AssociatedObject == null && dt.AssociatedObject != null) + if (AssociatedObject == null && dt?.AssociatedObject != null) { AssociatedObject = dt.AssociatedObject; } - else if (AssociatedObject != null && dt.AssociatedObject == null) + else if (AssociatedObject != null && dt?.AssociatedObject == null && dt != null) { dt.AssociatedObject = AssociatedObject; } } - public int CompareTo(IDateTime dt) + /// + /// Compares the current instance with another object and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other IDateTime. + /// + /// The object to compare with this instance. + /// A value that indicates the relative order of the objects being compared. The return value has these meanings: + /// Less than zero: This instance is less than . + /// Zero: This instance is equal to . + /// Greater than zero: This instance is greater than . + /// + public int CompareTo(IDateTime? dt) { if (Equals(dt)) { return 0; } - if (this < dt) + + if (dt == null) { - return -1; + return 1; } - if (this > dt) + + if (this < dt) { - return 1; + return -1; } - throw new Exception("An error occurred while comparing two IDateTime values."); + + // Meaning "this > dt" + return 1; } + /// public override string ToString() => ToString(null, null); - public string ToString(string format) => ToString(format, null); + /// + public string ToString(string? format) => ToString(format, null); - public string ToString(string format, IFormatProvider formatProvider) + /// + public string ToString(string? format, IFormatProvider? formatProvider) { - var tz = TimeZoneName; - if (!string.IsNullOrEmpty(tz)) - { - tz = " " + tz; - } + formatProvider ??= CultureInfo.InvariantCulture; + var dateTimeOffset = AsDateTimeOffset; - if (format != null) + // Use the .NET format options to format the DateTimeOffset + + if (HasTime && !HasDate) { - return Value.ToString(format, formatProvider) + tz; + return $"{dateTimeOffset.TimeOfDay.ToString(format, formatProvider)} {_tzId}"; } + if (HasTime && HasDate) { - return Value + tz; - } - if (HasTime) - { - return Value.TimeOfDay + tz; + return $"{dateTimeOffset.ToString(format, formatProvider)} {_tzId}"; } - return Value.ToString("d") + tz; + + return $"{dateTimeOffset.ToString("d", formatProvider)} {_tzId}"; + } + + private void SynchronizeDateTimeFields(DateTime dateTime, bool hasTime) + { + _value = dateTime; + _dateOnly = DateOnly.FromDateTime(_value); + _timeOnly = hasTime ? TimeOnly.FromDateTime(_value) : null; } } } \ No newline at end of file diff --git a/Ical.Net/DataTypes/IDateTime.cs b/Ical.Net/DataTypes/IDateTime.cs index bead53f5..c2377e5f 100644 --- a/Ical.Net/DataTypes/IDateTime.cs +++ b/Ical.Net/DataTypes/IDateTime.cs @@ -1,4 +1,5 @@ -using System; +#nullable enable +using System; namespace Ical.Net.DataTypes { @@ -29,7 +30,7 @@ public interface IDateTime : IEncodableDataType, IComparable, IFormat /// /// Gets the time zone name this time is in, if it references a time zone. /// - string TimeZoneName { get; } + string? TimeZoneName { get; } /// /// Gets/sets the underlying DateTime value stored. This should always @@ -52,7 +53,7 @@ public interface IDateTime : IEncodableDataType, IComparable, IFormat /// /// Gets/sets the time zone ID for this date/time value. /// - string TzId { get; set; } + string? TzId { get; set; } /// /// Gets the year for this date/time value. diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index f5fefa33..eeecc722 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -82,6 +82,9 @@ public override string ToString() return periodSerializer.SerializeToString(this); } + /// + /// Infers or calculates values based on existing time data. + /// private void ExtrapolateTimes() { if (EndTime == null && StartTime != null && Duration != default(TimeSpan)) @@ -103,7 +106,7 @@ public virtual IDateTime StartTime { get => _startTime.HasTime ? _startTime - : new CalDateTime(new DateTime(_startTime.Value.Year, _startTime.Value.Month, _startTime.Value.Day, 0, 0, 0), _startTime.TzId); + : new CalDateTime(new DateTime(_startTime.Value.Year, _startTime.Value.Month, _startTime.Value.Day, 0, 0, 0), _startTime.TzId, false); set { if (Equals(_startTime, value)) @@ -137,7 +140,7 @@ public virtual TimeSpan Duration { if (StartTime != null && EndTime == null - && StartTime.Value.TimeOfDay == TimeSpan.Zero) + && !StartTime.HasTime) { return TimeSpan.FromDays(1); } diff --git a/Ical.Net/DataTypes/Trigger.cs b/Ical.Net/DataTypes/Trigger.cs index ee9e8b66..1c608357 100644 --- a/Ical.Net/DataTypes/Trigger.cs +++ b/Ical.Net/DataTypes/Trigger.cs @@ -31,7 +31,7 @@ public virtual IDateTime DateTime // DateTime and Duration are mutually exclusive Duration = null; - // Do not allow timeless date/time values + // Ensure date/time has a time part _mDateTime.HasTime = true; } } diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index c949f1c8..9ed08ee2 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -230,7 +230,7 @@ private void EnforceEvaluationRestrictions(RecurrencePattern pattern) } } } -#pragma warning 0618 restore +#pragma warning restore 0618 /// /// Returns a list of start dates in the specified period represented by this recurrence pattern. /// This method includes a base date argument, which indicates the start of the first occurrence of this recurrence. @@ -242,6 +242,7 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim bool includeReferenceDateInResults) { var dates = new HashSet(); + // In the first step, we work with DateTime values, so we need to convert the IDateTime to DateTime var originalDate = DateUtil.GetSimpleDateTimeData(seed); var seedCopy = DateUtil.GetSimpleDateTimeData(seed); @@ -892,22 +893,25 @@ private List GetSecondVariants(List dates, RecurrencePattern goto Next; } } + // Remove unmatched dates dates.RemoveAt(i); - Next: - ; + Next: ; } + return dates; } - private Period CreatePeriod(DateTime dt, IDateTime referenceDate) + /// + /// Creates a new period from the specified date/time, + /// where the is taken into account. + /// when initializing the new period with a new . + /// + private Period CreatePeriod(DateTime dateTime, IDateTime referenceDate) { // Turn each resulting date/time into an IDateTime and associate it // with the reference date. - IDateTime newDt = new CalDateTime(dt, referenceDate.TzId); - - // NOTE: fixes bug #2938007 - hasTime missing - newDt.HasTime = referenceDate.HasTime; + IDateTime newDt = new CalDateTime(dateTime, referenceDate.TzId, referenceDate.HasTime); newDt.AssociateWith(referenceDate); diff --git a/Ical.Net/Evaluation/RecurringEvaluator.cs b/Ical.Net/Evaluation/RecurringEvaluator.cs index 1460f562..f87df4a1 100644 --- a/Ical.Net/Evaluation/RecurringEvaluator.cs +++ b/Ical.Net/Evaluation/RecurringEvaluator.cs @@ -157,7 +157,7 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio private HashSet FindDateOverlaps(HashSet dates) { - var datesWithoutTimes = new HashSet(dates.Where(d => d.StartTime.Value.TimeOfDay == TimeSpan.Zero).Select(d => d.StartTime.Value)); + var datesWithoutTimes = new HashSet(dates.Where(d => !d.StartTime.HasTime).Select(d => d.StartTime.Value)); var overlaps = new HashSet(Periods.Where(p => datesWithoutTimes.Contains(p.StartTime.Value.Date))); return overlaps; } diff --git a/Ical.Net/Ical.Net.csproj b/Ical.Net/Ical.Net.csproj index 335d33f4..0a514633 100644 --- a/Ical.Net/Ical.Net.csproj +++ b/Ical.Net/Ical.Net.csproj @@ -8,6 +8,9 @@ + + + <_Parameter1>Ical.Net.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a1f790f70176d52efbd248577bdb292be2d0acc62f3227c523e267d64767f207f81536c77bb91d17031a5afbc2d69cd3b5b3b9c98fa8df2cd363ec90a08639a1213ad70079eff666bcc14cf6574b899f4ad0eac672c8f763291cb1e0a2304d371053158cb398b2e6f9eeb45db7d1b4d2bbba1f985676c5ca4602fab3671d34bf diff --git a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs index f96fbbff..1e1c9e72 100644 --- a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs @@ -1,4 +1,5 @@ -using Ical.Net.DataTypes; +#nullable enable +using Ical.Net.DataTypes; using System; using System.IO; using System.Text; @@ -6,43 +7,27 @@ namespace Ical.Net.Serialization.DataTypes { + /// + /// A serializer for the data type. + /// public class DateTimeSerializer : EncodableDataTypeSerializer { + /// + /// This constructor is required for the SerializerFactory to work. + /// public DateTimeSerializer() { } + /// + /// Creates a new instance of the class. + /// + /// public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } - private DateTime CoerceDateTime(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind) - { - var dt = DateTime.MinValue; - - // NOTE: determine if a date/time value exceeds the representable date/time values in .NET. - // If so, let's automatically adjust the date/time to compensate. - // FIXME: should we have a parsing setting that will throw an exception - // instead of automatically adjusting the date/time value to the - // closest representable date/time? - try - { - if (year > 9999) - { - dt = DateTime.MaxValue; - } - else if (year > 0) - { - dt = new DateTime(year, month, day, hour, minute, second, kind); - } - } - catch { } - - return dt; - } - public override Type TargetType => typeof(CalDateTime); - public override string SerializeToString(object obj) + public override string? SerializeToString(object obj) { - var dt = obj as IDateTime; - if (dt == null) + if (obj is not IDateTime dt) { return null; } @@ -66,7 +51,11 @@ public override string SerializeToString(object obj) dt.Parameters.Set("TZID", dt.TzId); } - DateTime.SpecifyKind(dt.Value, kind); + var dateWithNewKind = DateTime.SpecifyKind(dt.Value, kind); + // We can't use 'Copy' because we need to change the value + dt = dt.HasTime + ? new CalDateTime(dateWithNewKind, dt.TzId, true) { AssociatedObject = dt.AssociatedObject } + : new CalDateTime(dateWithNewKind, dt.TzId, false) { AssociatedObject = dt.AssociatedObject }; // FIXME: what if DATE is the default value type for this? // Also, what if the DATE-TIME value type is specified on something @@ -77,7 +66,7 @@ public override string SerializeToString(object obj) dt.SetValueType("DATE"); } - var value = new StringBuilder(); + var value = new StringBuilder(512); value.Append($"{dt.Year:0000}{dt.Month:00}{dt.Day:00}"); if (dt.HasTime) { @@ -92,16 +81,16 @@ public override string SerializeToString(object obj) return Encode(dt, value.ToString()); } - private const RegexOptions _ciCompiled = RegexOptions.Compiled | RegexOptions.IgnoreCase; - internal static readonly Regex DateOnlyMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))?$", _ciCompiled, RegexDefaults.Timeout); - internal static readonly Regex FullDateTimePatternMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))T((\d{2})(\d{2})(\d{2})(Z)?)$", _ciCompiled, RegexDefaults.Timeout); + private const RegexOptions Options = RegexOptions.Compiled | RegexOptions.IgnoreCase; + internal static readonly Regex DateOnlyMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))?$", Options, RegexDefaults.Timeout); + internal static readonly Regex FullDateTimePatternMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))T((\d{2})(\d{2})(\d{2})(Z)?)$", Options, RegexDefaults.Timeout); - public override object Deserialize(TextReader tr) + public override object? Deserialize(TextReader tr) { var value = tr.ReadToEnd(); - var dt = CreateAndAssociate() as IDateTime; - if (dt == null) + // CalDateTime is defined as the Target type + if (CreateAndAssociate() is not CalDateTime dt) { return null; } @@ -119,28 +108,21 @@ public override object Deserialize(TextReader tr) { return null; } - var now = DateTime.Now; - var year = now.Year; - var month = now.Month; - var date = now.Day; - var hour = 0; - var minute = 0; - var second = 0; + var datePart = new DateOnly(); // Initialize. At this point, we know that the date part is present + TimeOnly? timePart = null; if (match.Groups[1].Success) { - dt.HasDate = true; - year = Convert.ToInt32(match.Groups[2].Value); - month = Convert.ToInt32(match.Groups[3].Value); - date = Convert.ToInt32(match.Groups[4].Value); + datePart = new DateOnly(Convert.ToInt32(match.Groups[2].Value), + Convert.ToInt32(match.Groups[3].Value), + Convert.ToInt32(match.Groups[4].Value)); } if (match.Groups.Count >= 6 && match.Groups[5].Success) { - dt.HasTime = true; - hour = Convert.ToInt32(match.Groups[6].Value); - minute = Convert.ToInt32(match.Groups[7].Value); - second = Convert.ToInt32(match.Groups[8].Value); + timePart = new TimeOnly(Convert.ToInt32(match.Groups[6].Value), + Convert.ToInt32(match.Groups[7].Value), + Convert.ToInt32(match.Groups[8].Value)); } var isUtc = match.Groups[9].Success; @@ -152,9 +134,13 @@ public override object Deserialize(TextReader tr) { dt.TzId = "UTC"; } + + dt.Value = timePart.HasValue + ? new DateTime(datePart.Year, datePart.Month, datePart.Day, timePart.Value.Hour, timePart.Value.Minute, timePart.Value.Second, kind) + : new DateTime(datePart.Year, datePart.Month, datePart.Day, 0, 0, 0, kind); + dt.HasTime = timePart.HasValue; - dt.Value = CoerceDateTime(year, month, date, hour, minute, second, kind); return dt; } } -} \ No newline at end of file +} diff --git a/Ical.Net/Serialization/DataTypes/PeriodSerializer.cs b/Ical.Net/Serialization/DataTypes/PeriodSerializer.cs index 3340a1a3..4c6c84b3 100644 --- a/Ical.Net/Serialization/DataTypes/PeriodSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/PeriodSerializer.cs @@ -98,7 +98,7 @@ public override object Deserialize(TextReader tr) } // Only return an object if it has been deserialized correctly. - if (p.StartTime != null && p.Duration != null) + if (p.StartTime != null) { return p; }