From eb0a1b30112361beb9c34f2d8b1d1ea83f0817c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 04:20:03 +0000 Subject: [PATCH 1/5] Initial plan From 23ad428748d9524bd876fbc4aaf78d6024b9bee1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 04:27:54 +0000 Subject: [PATCH 2/5] Add support for y, M, w time units to TimeUnit parser with comprehensive tests Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../TimeUnit.cs | 14 +++++ .../TimeUnitTests.cs | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs index 588a0c9..9b57077 100644 --- a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs +++ b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs @@ -35,6 +35,20 @@ public static bool TryParse(string value, out TimeSpan? time) // compare using the original value as uppercase M could mean months. string normalized = value.Trim(); + + // Handle years (y) - using average days in a year + if (value.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 (value.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 (value.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 (value.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int minutes)) return new TimeSpan(0, minutes, 0); diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs index 998b4ad..8c8407a 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs @@ -18,6 +18,15 @@ 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)], }; [Theory] @@ -50,6 +59,15 @@ 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)] [InlineData(null, false)] [InlineData("1.234h", false)] // fractional time [InlineData("1234", false)] // missing unit @@ -61,4 +79,49 @@ public void VerifyTryParse(string value, bool expected) bool success = TimeUnit.TryParse(value, out var result); Assert.Equal(expected, success); } + + [Fact] + public void VerifyMonthsVsMinutesCaseSensitive() + { + // Uppercase M should be months + var monthResult = TimeUnit.Parse("1M"); + var expectedMonthDays = (int)TimeSpanExtensions.AvgDaysInAMonth; + Assert.Equal(new TimeSpan(expectedMonthDays, 0, 0, 0), monthResult); + + // Lowercase m should be minutes + var minuteResult = TimeUnit.Parse("1m"); + Assert.Equal(new TimeSpan(0, 1, 0), minuteResult); + + // Verify they are different + Assert.NotEqual(monthResult, minuteResult); + } + + [Theory] + [InlineData("1y", 365)] // Approximately 365 days in a year + [InlineData("1M", 30)] // Approximately 30 days in a month + [InlineData("1w", 7)] // Exactly 7 days in a week + public void VerifyNewTimeUnitsConvertCorrectly(string input, int expectedApproxDays) + { + var result = TimeUnit.Parse(input); + + // For years and months, check approximate values due to fractional constants + if (input.EndsWith("y")) + { + Assert.True(Math.Abs(result.TotalDays - TimeSpanExtensions.AvgDaysInAYear) < 1, + $"Year conversion should be close to {TimeSpanExtensions.AvgDaysInAYear} days, got {result.TotalDays}"); + Assert.True(Math.Abs(result.TotalDays - expectedApproxDays) < 10, + $"Year conversion should be approximately {expectedApproxDays} days, got {result.TotalDays}"); + } + else if (input.EndsWith("M")) + { + Assert.True(Math.Abs(result.TotalDays - TimeSpanExtensions.AvgDaysInAMonth) < 1, + $"Month conversion should be close to {TimeSpanExtensions.AvgDaysInAMonth} days, got {result.TotalDays}"); + Assert.True(Math.Abs(result.TotalDays - expectedApproxDays) < 5, + $"Month conversion should be approximately {expectedApproxDays} days, got {result.TotalDays}"); + } + else if (input.EndsWith("w")) + { + Assert.Equal(expectedApproxDays, result.TotalDays); + } + } } From 7e6613ac86f3ed130321339d82f32c7c1bc0a0c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:51:47 +0000 Subject: [PATCH 3/5] Improve TimeUnit parsing: add early null/empty checks, use normalized values consistently, and add comprehensive whitespace/special character tests Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../TimeUnit.cs | 18 +++++++------ .../TimeUnitTests.cs | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs index 9b57077..ca40670 100644 --- a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs +++ b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs @@ -28,28 +28,32 @@ public static bool TryParse(string value, out TimeSpan? time) private static TimeSpan? ParseTime(string value) { + if (String.IsNullOrEmpty(value)) + return null; + + string normalized = value.Trim(); + if (String.IsNullOrEmpty(normalized)) + return null; + // bail if we have any weird characters foreach (char c in value) 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(); - // Handle years (y) - using average days in a year - if (value.EndsWith("y") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int years)) + 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 (value.EndsWith("M") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int months)) + 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 (value.EndsWith("w") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int weeks)) + 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 (value.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int 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/TimeUnitTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs index 8c8407a..734a161 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs @@ -27,6 +27,11 @@ public class TimeUnitTests ["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] @@ -42,6 +47,13 @@ public void CanParse(string value, TimeSpan expected) [InlineData("1234")] // missing unit [InlineData("12unknownunit")] [InlineData("12h.")] + [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 VerifyParseFailure(string value) { Assert.ThrowsAny(() => TimeUnit.Parse(value)); @@ -68,6 +80,19 @@ public void VerifyParseFailure(string value) [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 From 313d25f69ce6ee7a234524994ffc43207caef484 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:57:55 +0000 Subject: [PATCH 4/5] Refactor TimeUnit tests to follow best practices: use proper naming convention, improve AAA pattern, and simplify test logic Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../TimeUnitTests.cs | 109 ++++++++++++------ 1 file changed, 74 insertions(+), 35 deletions(-) diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs index 734a161..ba3ad46 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs @@ -36,7 +36,7 @@ public class TimeUnitTests [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)); } @@ -54,7 +54,7 @@ public void CanParse(string value, TimeSpan expected) [InlineData("1M!")] // special character after unit [InlineData("1w#")] // special character after unit [InlineData("1@y")] // special character in middle - public void VerifyParseFailure(string value) + public void Parse_InvalidInput_ThrowsException(string value) { Assert.ThrowsAny(() => TimeUnit.Parse(value)); } @@ -99,54 +99,93 @@ public void VerifyParseFailure(string value) [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 VerifyMonthsVsMinutesCaseSensitive() + public void Parse_UppercaseM_ParsesAsMonths() { - // Uppercase M should be months - var monthResult = TimeUnit.Parse("1M"); - var expectedMonthDays = (int)TimeSpanExtensions.AvgDaysInAMonth; - Assert.Equal(new TimeSpan(expectedMonthDays, 0, 0, 0), monthResult); + // 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); - // Lowercase m should be minutes + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Parse_MonthsAndMinutes_ProduceDifferentResults() + { + // Act + var monthResult = TimeUnit.Parse("1M"); var minuteResult = TimeUnit.Parse("1m"); - Assert.Equal(new TimeSpan(0, 1, 0), minuteResult); - // Verify they are different + // Assert Assert.NotEqual(monthResult, minuteResult); } [Theory] - [InlineData("1y", 365)] // Approximately 365 days in a year - [InlineData("1M", 30)] // Approximately 30 days in a month - [InlineData("1w", 7)] // Exactly 7 days in a week - public void VerifyNewTimeUnitsConvertCorrectly(string input, int expectedApproxDays) + [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); - - // For years and months, check approximate values due to fractional constants - if (input.EndsWith("y")) - { - Assert.True(Math.Abs(result.TotalDays - TimeSpanExtensions.AvgDaysInAYear) < 1, - $"Year conversion should be close to {TimeSpanExtensions.AvgDaysInAYear} days, got {result.TotalDays}"); - Assert.True(Math.Abs(result.TotalDays - expectedApproxDays) < 10, - $"Year conversion should be approximately {expectedApproxDays} days, got {result.TotalDays}"); - } - else if (input.EndsWith("M")) - { - Assert.True(Math.Abs(result.TotalDays - TimeSpanExtensions.AvgDaysInAMonth) < 1, - $"Month conversion should be close to {TimeSpanExtensions.AvgDaysInAMonth} days, got {result.TotalDays}"); - Assert.True(Math.Abs(result.TotalDays - expectedApproxDays) < 5, - $"Month conversion should be approximately {expectedApproxDays} days, got {result.TotalDays}"); - } - else if (input.EndsWith("w")) - { - Assert.Equal(expectedApproxDays, result.TotalDays); - } + 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); } } From 934ecb90023132c8ded551ae9e5779b8e30e7c1d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 22 Sep 2025 09:19:10 -0500 Subject: [PATCH 5/5] Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski ran dotnet format --- src/Exceptionless.DateTimeExtensions/TimeUnit.cs | 6 ++---- .../DateMathTests.cs | 12 ++++++------ .../TimeUnitTests.cs | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs index ca40670..c090c00 100644 --- a/src/Exceptionless.DateTimeExtensions/TimeUnit.cs +++ b/src/Exceptionless.DateTimeExtensions/TimeUnit.cs @@ -28,15 +28,13 @@ public static bool TryParse(string value, out TimeSpan? time) private static TimeSpan? ParseTime(string value) { - if (String.IsNullOrEmpty(value)) + if (String.IsNullOrWhiteSpace(value)) return null; string normalized = value.Trim(); - if (String.IsNullOrEmpty(normalized)) - return null; // 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; 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 ba3ad46..7a1cbdf 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs @@ -156,7 +156,7 @@ public void Parse_YearUnit_ReturnsExpectedDays(string input) var expectedDays = int.Parse(input.Substring(0, input.Length - 1)) * TimeSpanExtensions.AvgDaysInAYear; // Assert - Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1, + Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1, $"Year conversion should be close to {expectedDays} days, got {result.TotalDays}"); }