Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/Exceptionless.DateTimeExtensions/TimeUnit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 6 additions & 6 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 130 additions & 3 deletions tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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<Exception>(() => TimeUnit.Parse(value));
}
Expand All @@ -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);
}
}