diff --git a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs index 588a0c9..c090c00 100644 --- a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs +++ b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs @@ -28,14 +28,30 @@ public static bool TryParse(string value, out TimeSpan? time) private static TimeSpan? ParseTime(string value) { + if (String.IsNullOrWhiteSpace(value)) + return null; + + string normalized = value.Trim(); + // bail if we have any weird characters - foreach (char c in value) + foreach (char c in normalized) if (!Char.IsLetterOrDigit(c) && c != '-' && c != '+' && !Char.IsWhiteSpace(c)) return null; - // compare using the original value as uppercase M could mean months. - string normalized = value.Trim(); - if (value.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int minutes)) + // Handle years (y) - using average days in a year + if (normalized.EndsWith("y") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int years)) + return new TimeSpan((int)(years * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0); + + // Handle months (M) - using average days in a month, case-sensitive uppercase M + if (normalized.EndsWith("M") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int months)) + return new TimeSpan((int)(months * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0); + + // Handle weeks (w) + if (normalized.EndsWith("w") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int weeks)) + return new TimeSpan(weeks * 7, 0, 0, 0); + + // Handle minutes (m) - lowercase m for minutes + if (normalized.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int minutes)) return new TimeSpan(0, minutes, 0); if (normalized.EndsWith("h") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int hours)) diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 866bcd2..667f648 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -571,7 +571,7 @@ public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() Assert.Equal(14, result.Hour); Assert.Equal(30, result.Minute); Assert.Equal(0, result.Second); - + // Should use the timezone offset from Eastern Time var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0)); Assert.Equal(expectedOffset, result.Offset); @@ -596,7 +596,7 @@ public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone() Assert.Equal(14, result.Hour); Assert.Equal(30, result.Minute); Assert.Equal(0, result.Second); - + // Should preserve the original +05:00 timezone, not use Pacific Assert.Equal(TimeSpan.FromHours(5), result.Offset); } @@ -609,7 +609,7 @@ public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone() public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, int hours) { var utcTimeZone = TimeZoneInfo.Utc; - + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}", expression, utcTimeZone.Id, hours); @@ -635,7 +635,7 @@ public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, public void ParseTimeZone_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) { var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central"); - + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}", expression, centralTimeZone.Id, isUpperLimit); @@ -693,7 +693,7 @@ public void TryParseTimeZone_ValidExpression_ReturnsTrue() Assert.True(success); Assert.NotEqual(default(DateTimeOffset), result); - + // Should use Mountain Time offset var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow); Assert.Equal(expectedOffset, result.Offset); @@ -731,7 +731,7 @@ public void ParseTimeZone_ComplexExpression_WorksCorrectly() // Should be UTC Assert.Equal(TimeSpan.Zero, result.Offset); - + // Should be rounded to start of hour Assert.Equal(0, result.Minute); Assert.Equal(0, result.Second); diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs index 998b4ad..7a1cbdf 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs @@ -18,11 +18,25 @@ public class TimeUnitTests ["10m", new TimeSpan(0, 10, 0)], ["10h", new TimeSpan(10, 0, 0)], ["10d", new TimeSpan(10, 0, 0, 0)], + ["1w", new TimeSpan(7, 0, 0, 0)], + ["2w", new TimeSpan(14, 0, 0, 0)], + ["-1w", new TimeSpan(-7, 0, 0, 0)], + ["1M", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAMonth, 0, 0, 0)], + ["2M", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)], + ["-1M", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)], + ["1y", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAYear, 0, 0, 0)], + ["2y", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)], + ["-1y", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)], + // Whitespace trimming tests + [" 1y ", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAYear, 0, 0, 0)], + [" 2M ", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)], + ["\t3w\t", new TimeSpan(21, 0, 0, 0)], + [" -1y ", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)], }; [Theory] [MemberData(nameof(TestData))] - public void CanParse(string value, TimeSpan expected) + public void Parse_ValidInput_ReturnsExpectedTimeSpan(string value, TimeSpan expected) { Assert.Equal(expected, TimeUnit.Parse(value)); } @@ -33,7 +47,14 @@ public void CanParse(string value, TimeSpan expected) [InlineData("1234")] // missing unit [InlineData("12unknownunit")] [InlineData("12h.")] - public void VerifyParseFailure(string value) + [InlineData("")] // empty string + [InlineData(" ")] // whitespace only + [InlineData("\t\t")] // tabs only + [InlineData("1y@")] // special character after unit + [InlineData("1M!")] // special character after unit + [InlineData("1w#")] // special character after unit + [InlineData("1@y")] // special character in middle + public void Parse_InvalidInput_ThrowsException(string value) { Assert.ThrowsAny(() => TimeUnit.Parse(value)); } @@ -50,15 +71,121 @@ public void VerifyParseFailure(string value) [InlineData("10m", true)] [InlineData("10h", true)] [InlineData("10d", true)] + [InlineData("1w", true)] + [InlineData("2w", true)] + [InlineData("-1w", true)] + [InlineData("1M", true)] + [InlineData("2M", true)] + [InlineData("-1M", true)] + [InlineData("1y", true)] + [InlineData("2y", true)] + [InlineData("-1y", true)] + // Whitespace tests + [InlineData(" 1y ", true)] + [InlineData(" 2M ", true)] + [InlineData("\t3w\t", true)] + [InlineData(" -1M ", true)] + // Special character and edge case tests + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("\t\t", false)] + [InlineData("1y@", false)] + [InlineData("1M!", false)] + [InlineData("1w#", false)] + [InlineData("1@y", false)] [InlineData(null, false)] [InlineData("1.234h", false)] // fractional time [InlineData("1234", false)] // missing unit [InlineData("12unknownunit", false)] [InlineData("12h.", false)] [InlineData("Blah/Blahs", false)] - public void VerifyTryParse(string value, bool expected) + public void TryParse_VariousInputs_ReturnsExpectedResult(string value, bool expected) { bool success = TimeUnit.TryParse(value, out var result); Assert.Equal(expected, success); } + + [Fact] + public void Parse_UppercaseM_ParsesAsMonths() + { + // Arrange + var input = "1M"; + var expectedDays = (int)TimeSpanExtensions.AvgDaysInAMonth; + var expected = new TimeSpan(expectedDays, 0, 0, 0); + + // Act + var result = TimeUnit.Parse(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Parse_LowercaseM_ParsesAsMinutes() + { + // Arrange + var input = "1m"; + var expected = new TimeSpan(0, 1, 0); + + // Act + var result = TimeUnit.Parse(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Parse_MonthsAndMinutes_ProduceDifferentResults() + { + // Act + var monthResult = TimeUnit.Parse("1M"); + var minuteResult = TimeUnit.Parse("1m"); + + // Assert + Assert.NotEqual(monthResult, minuteResult); + } + + [Theory] + [InlineData("1y")] + [InlineData("2y")] + [InlineData("-1y")] + public void Parse_YearUnit_ReturnsExpectedDays(string input) + { + // Act + var result = TimeUnit.Parse(input); + var expectedDays = int.Parse(input.Substring(0, input.Length - 1)) * TimeSpanExtensions.AvgDaysInAYear; + + // Assert + Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1, + $"Year conversion should be close to {expectedDays} days, got {result.TotalDays}"); + } + + [Theory] + [InlineData("1M")] + [InlineData("3M")] + [InlineData("-1M")] + public void Parse_MonthUnit_ReturnsExpectedDays(string input) + { + // Act + var result = TimeUnit.Parse(input); + var expectedDays = int.Parse(input.Substring(0, input.Length - 1)) * TimeSpanExtensions.AvgDaysInAMonth; + + // Assert + Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1, + $"Month conversion should be close to {expectedDays} days, got {result.TotalDays}"); + } + + [Theory] + [InlineData("1w", 7)] + [InlineData("2w", 14)] + [InlineData("4w", 28)] + [InlineData("-1w", -7)] + public void Parse_WeekUnit_ReturnsExpectedDays(string input, int expectedDays) + { + // Act + var result = TimeUnit.Parse(input); + + // Assert + Assert.Equal(expectedDays, result.TotalDays); + } }