Skip to content

Commit c05fbf4

Browse files
Copilotniemyjski
andauthored
Add support for years (y), months (M), and weeks (w) time units to TimeUnit parser (#115)
* Initial plan * Add support for y, M, w time units to TimeUnit parser with comprehensive tests Co-authored-by: niemyjski <[email protected]> * Improve TimeUnit parsing: add early null/empty checks, use normalized values consistently, and add comprehensive whitespace/special character tests Co-authored-by: niemyjski <[email protected]> * Refactor TimeUnit tests to follow best practices: use proper naming convention, improve AAA pattern, and simplify test logic Co-authored-by: niemyjski <[email protected]> * Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski Apply suggestion from @niemyjski ran dotnet format --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: niemyjski <[email protected]> Co-authored-by: Blake Niemyjski <[email protected]>
1 parent 21bc0eb commit c05fbf4

File tree

3 files changed

+156
-13
lines changed

3 files changed

+156
-13
lines changed

src/Exceptionless.DateTimeExtensions/TimeUnit.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,30 @@ public static bool TryParse(string value, out TimeSpan? time)
2828

2929
private static TimeSpan? ParseTime(string value)
3030
{
31+
if (String.IsNullOrWhiteSpace(value))
32+
return null;
33+
34+
string normalized = value.Trim();
35+
3136
// bail if we have any weird characters
32-
foreach (char c in value)
37+
foreach (char c in normalized)
3338
if (!Char.IsLetterOrDigit(c) && c != '-' && c != '+' && !Char.IsWhiteSpace(c))
3439
return null;
3540

36-
// compare using the original value as uppercase M could mean months.
37-
string normalized = value.Trim();
38-
if (value.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int minutes))
41+
// Handle years (y) - using average days in a year
42+
if (normalized.EndsWith("y") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int years))
43+
return new TimeSpan((int)(years * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0);
44+
45+
// Handle months (M) - using average days in a month, case-sensitive uppercase M
46+
if (normalized.EndsWith("M") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int months))
47+
return new TimeSpan((int)(months * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0);
48+
49+
// Handle weeks (w)
50+
if (normalized.EndsWith("w") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int weeks))
51+
return new TimeSpan(weeks * 7, 0, 0, 0);
52+
53+
// Handle minutes (m) - lowercase m for minutes
54+
if (normalized.EndsWith("m") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int minutes))
3955
return new TimeSpan(0, minutes, 0);
4056

4157
if (normalized.EndsWith("h") && Int32.TryParse(normalized.Substring(0, normalized.Length - 1), out int hours))

tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone()
571571
Assert.Equal(14, result.Hour);
572572
Assert.Equal(30, result.Minute);
573573
Assert.Equal(0, result.Second);
574-
574+
575575
// Should use the timezone offset from Eastern Time
576576
var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0));
577577
Assert.Equal(expectedOffset, result.Offset);
@@ -596,7 +596,7 @@ public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone()
596596
Assert.Equal(14, result.Hour);
597597
Assert.Equal(30, result.Minute);
598598
Assert.Equal(0, result.Second);
599-
599+
600600
// Should preserve the original +05:00 timezone, not use Pacific
601601
Assert.Equal(TimeSpan.FromHours(5), result.Offset);
602602
}
@@ -609,7 +609,7 @@ public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone()
609609
public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, int hours)
610610
{
611611
var utcTimeZone = TimeZoneInfo.Utc;
612-
612+
613613
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}",
614614
expression, utcTimeZone.Id, hours);
615615

@@ -635,7 +635,7 @@ public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression,
635635
public void ParseTimeZone_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit)
636636
{
637637
var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central");
638-
638+
639639
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}",
640640
expression, centralTimeZone.Id, isUpperLimit);
641641

@@ -693,7 +693,7 @@ public void TryParseTimeZone_ValidExpression_ReturnsTrue()
693693

694694
Assert.True(success);
695695
Assert.NotEqual(default(DateTimeOffset), result);
696-
696+
697697
// Should use Mountain Time offset
698698
var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow);
699699
Assert.Equal(expectedOffset, result.Offset);
@@ -731,7 +731,7 @@ public void ParseTimeZone_ComplexExpression_WorksCorrectly()
731731

732732
// Should be UTC
733733
Assert.Equal(TimeSpan.Zero, result.Offset);
734-
734+
735735
// Should be rounded to start of hour
736736
Assert.Equal(0, result.Minute);
737737
Assert.Equal(0, result.Second);

tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,25 @@ public class TimeUnitTests
1818
["10m", new TimeSpan(0, 10, 0)],
1919
["10h", new TimeSpan(10, 0, 0)],
2020
["10d", new TimeSpan(10, 0, 0, 0)],
21+
["1w", new TimeSpan(7, 0, 0, 0)],
22+
["2w", new TimeSpan(14, 0, 0, 0)],
23+
["-1w", new TimeSpan(-7, 0, 0, 0)],
24+
["1M", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAMonth, 0, 0, 0)],
25+
["2M", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)],
26+
["-1M", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)],
27+
["1y", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAYear, 0, 0, 0)],
28+
["2y", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)],
29+
["-1y", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)],
30+
// Whitespace trimming tests
31+
[" 1y ", new TimeSpan((int)TimeSpanExtensions.AvgDaysInAYear, 0, 0, 0)],
32+
[" 2M ", new TimeSpan((int)(2 * TimeSpanExtensions.AvgDaysInAMonth), 0, 0, 0)],
33+
["\t3w\t", new TimeSpan(21, 0, 0, 0)],
34+
[" -1y ", new TimeSpan((int)(-1 * TimeSpanExtensions.AvgDaysInAYear), 0, 0, 0)],
2135
};
2236

2337
[Theory]
2438
[MemberData(nameof(TestData))]
25-
public void CanParse(string value, TimeSpan expected)
39+
public void Parse_ValidInput_ReturnsExpectedTimeSpan(string value, TimeSpan expected)
2640
{
2741
Assert.Equal(expected, TimeUnit.Parse(value));
2842
}
@@ -33,7 +47,14 @@ public void CanParse(string value, TimeSpan expected)
3347
[InlineData("1234")] // missing unit
3448
[InlineData("12unknownunit")]
3549
[InlineData("12h.")]
36-
public void VerifyParseFailure(string value)
50+
[InlineData("")] // empty string
51+
[InlineData(" ")] // whitespace only
52+
[InlineData("\t\t")] // tabs only
53+
[InlineData("1y@")] // special character after unit
54+
[InlineData("1M!")] // special character after unit
55+
[InlineData("1w#")] // special character after unit
56+
[InlineData("1@y")] // special character in middle
57+
public void Parse_InvalidInput_ThrowsException(string value)
3758
{
3859
Assert.ThrowsAny<Exception>(() => TimeUnit.Parse(value));
3960
}
@@ -50,15 +71,121 @@ public void VerifyParseFailure(string value)
5071
[InlineData("10m", true)]
5172
[InlineData("10h", true)]
5273
[InlineData("10d", true)]
74+
[InlineData("1w", true)]
75+
[InlineData("2w", true)]
76+
[InlineData("-1w", true)]
77+
[InlineData("1M", true)]
78+
[InlineData("2M", true)]
79+
[InlineData("-1M", true)]
80+
[InlineData("1y", true)]
81+
[InlineData("2y", true)]
82+
[InlineData("-1y", true)]
83+
// Whitespace tests
84+
[InlineData(" 1y ", true)]
85+
[InlineData(" 2M ", true)]
86+
[InlineData("\t3w\t", true)]
87+
[InlineData(" -1M ", true)]
88+
// Special character and edge case tests
89+
[InlineData("", false)]
90+
[InlineData(" ", false)]
91+
[InlineData("\t\t", false)]
92+
[InlineData("1y@", false)]
93+
[InlineData("1M!", false)]
94+
[InlineData("1w#", false)]
95+
[InlineData("1@y", false)]
5396
[InlineData(null, false)]
5497
[InlineData("1.234h", false)] // fractional time
5598
[InlineData("1234", false)] // missing unit
5699
[InlineData("12unknownunit", false)]
57100
[InlineData("12h.", false)]
58101
[InlineData("Blah/Blahs", false)]
59-
public void VerifyTryParse(string value, bool expected)
102+
public void TryParse_VariousInputs_ReturnsExpectedResult(string value, bool expected)
60103
{
61104
bool success = TimeUnit.TryParse(value, out var result);
62105
Assert.Equal(expected, success);
63106
}
107+
108+
[Fact]
109+
public void Parse_UppercaseM_ParsesAsMonths()
110+
{
111+
// Arrange
112+
var input = "1M";
113+
var expectedDays = (int)TimeSpanExtensions.AvgDaysInAMonth;
114+
var expected = new TimeSpan(expectedDays, 0, 0, 0);
115+
116+
// Act
117+
var result = TimeUnit.Parse(input);
118+
119+
// Assert
120+
Assert.Equal(expected, result);
121+
}
122+
123+
[Fact]
124+
public void Parse_LowercaseM_ParsesAsMinutes()
125+
{
126+
// Arrange
127+
var input = "1m";
128+
var expected = new TimeSpan(0, 1, 0);
129+
130+
// Act
131+
var result = TimeUnit.Parse(input);
132+
133+
// Assert
134+
Assert.Equal(expected, result);
135+
}
136+
137+
[Fact]
138+
public void Parse_MonthsAndMinutes_ProduceDifferentResults()
139+
{
140+
// Act
141+
var monthResult = TimeUnit.Parse("1M");
142+
var minuteResult = TimeUnit.Parse("1m");
143+
144+
// Assert
145+
Assert.NotEqual(monthResult, minuteResult);
146+
}
147+
148+
[Theory]
149+
[InlineData("1y")]
150+
[InlineData("2y")]
151+
[InlineData("-1y")]
152+
public void Parse_YearUnit_ReturnsExpectedDays(string input)
153+
{
154+
// Act
155+
var result = TimeUnit.Parse(input);
156+
var expectedDays = int.Parse(input.Substring(0, input.Length - 1)) * TimeSpanExtensions.AvgDaysInAYear;
157+
158+
// Assert
159+
Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1,
160+
$"Year conversion should be close to {expectedDays} days, got {result.TotalDays}");
161+
}
162+
163+
[Theory]
164+
[InlineData("1M")]
165+
[InlineData("3M")]
166+
[InlineData("-1M")]
167+
public void Parse_MonthUnit_ReturnsExpectedDays(string input)
168+
{
169+
// Act
170+
var result = TimeUnit.Parse(input);
171+
var expectedDays = int.Parse(input.Substring(0, input.Length - 1)) * TimeSpanExtensions.AvgDaysInAMonth;
172+
173+
// Assert
174+
Assert.True(Math.Abs(result.TotalDays - expectedDays) < 1,
175+
$"Month conversion should be close to {expectedDays} days, got {result.TotalDays}");
176+
}
177+
178+
[Theory]
179+
[InlineData("1w", 7)]
180+
[InlineData("2w", 14)]
181+
[InlineData("4w", 28)]
182+
[InlineData("-1w", -7)]
183+
public void Parse_WeekUnit_ReturnsExpectedDays(string input, int expectedDays)
184+
{
185+
// Act
186+
var result = TimeUnit.Parse(input);
187+
188+
// Assert
189+
Assert.Equal(expectedDays, result.TotalDays);
190+
}
64191
}

0 commit comments

Comments
 (0)