diff --git a/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs index 84e62cf..7026c0e 100644 --- a/benchmarks/DurationMancer.Benchmarks/Configuration.cs +++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Environments; -using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; namespace DurationMancer.Benchmarks; diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs index e722049..92b33c6 100644 --- a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs @@ -15,16 +15,28 @@ public class Parse public TimeSpan Standard_HHmmss() => DurationTimeParser.Parse("12:30:45"); + [Benchmark(Description = "Standard negative: -HH:mm:ss")] + public TimeSpan Standard_NegativeHHmmss() + => DurationTimeParser.Parse("-12:30:45"); + [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] public TimeSpan Standard_Full() => DurationTimeParser.Parse("3.08:15:30.250"); + [Benchmark(Description = "Standard negative: -d.HH:mm:ss.fff")] + public TimeSpan Standard_NegativeFull() + => DurationTimeParser.Parse("-3.08:15:30.250"); + // --- Human-readable format inputs --- [Benchmark(Description = "Human: single unit (5m)")] public TimeSpan Human_SingleUnit() => DurationTimeParser.Parse("5m"); + [Benchmark(Description = "Human: single unit negative (-5m)")] + public TimeSpan Human_NegativeSingleUnit() + => DurationTimeParser.Parse("-5m"); + [Benchmark(Description = "Human: two units (1h 30m)")] public TimeSpan Human_TwoUnits() => DurationTimeParser.Parse("1h 30m"); @@ -33,6 +45,10 @@ public TimeSpan Human_TwoUnits() public TimeSpan Human_FullCombo() => DurationTimeParser.Parse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds"); + [Benchmark(Description = "Human: full combo negative")] + public TimeSpan Human_NegativeFullCombo() + => DurationTimeParser.Parse("-2 days 4 hours 15 minutes 30 seconds 500 milliseconds"); + [Benchmark(Description = "Human: decimals (1.5h 100ms)")] public TimeSpan Human_Decimals() => DurationTimeParser.Parse("1.5h 100ms"); diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs index e219c86..49e8aff 100644 --- a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs @@ -17,12 +17,24 @@ public bool Standard_HHmmss() return DurationTimeParser.TryParse("12:30:45", out _); } + [Benchmark(Description = "Standard negative: -HH:mm:ss")] + public bool Standard_NegativeHHmmss() + { + return DurationTimeParser.TryParse("-12:30:45", out _); + } + [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] public bool Standard_Full() { return DurationTimeParser.TryParse("3.08:15:30.250", out _); } + [Benchmark(Description = "Standard negative: -d.HH:mm:ss.fff")] + public bool Standard_NegativeFull() + { + return DurationTimeParser.TryParse("-3.08:15:30.250", out _); + } + // --- Human-readable format inputs --- [Benchmark(Description = "Human: single unit (5m)")] @@ -31,6 +43,12 @@ public bool Human_SingleUnit() return DurationTimeParser.TryParse("5m", out _); } + [Benchmark(Description = "Human: single unit negative (-5m)")] + public bool Human_NegativeSingleUnit() + { + return DurationTimeParser.TryParse("-5m", out _); + } + [Benchmark(Description = "Human: two units (1h 30m)")] public bool Human_TwoUnits() { @@ -43,6 +61,12 @@ public bool Human_FullCombo() return DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _); } + [Benchmark(Description = "Human: full combo negative")] + public bool Human_NegativeFullCombo() + { + return DurationTimeParser.TryParse("-2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _); + } + [Benchmark(Description = "Human: decimals (1.5h 100ms)")] public bool Human_Decimals() { diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs index c8e8301..7cc4277 100644 --- a/src/DurationMancer/DurationTimeParser.cs +++ b/src/DurationMancer/DurationTimeParser.cs @@ -8,6 +8,7 @@ namespace DurationMancer; /// public static partial class DurationTimeParser { + private const string MinusPattern = @"(?-)?\s*"; private const string DaysPattern = @"(?\d+(?:\.\d+)?)\s*(d|day|days)\b"; private const string HoursPattern = @"(?\d+(?:\.\d+)?)\s*(h|hour|hours)\b"; private const string MillisecondsPattern = @"(?\d+)\s*(ms|millisecond|milliseconds)\b"; @@ -15,14 +16,20 @@ public static partial class DurationTimeParser private const string SecondsPattern = @"(?\d+(?:\.\d+)?)\s*(s|sec|second|seconds)\b"; private const string StandardDurationTimeFormat = - @"^(?:(?\d+)\.)?(?[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d)(?:\.(?\d{0,3}))?$"; + @$"^{MinusPattern}(?:(?\d+)\.)?(?[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d)(?:\.(?\d{{0,3}}))?$"; - private const string TimePattern = $"{StandardDurationTimeFormat}|" + - @$"^(?:{DaysPattern})?\s*" + - @$"(?:{HoursPattern})?\s*" + - @$"(?:{MinutesPattern})?\s*" + - @$"(?:{SecondsPattern})?\s*" + - $"(?:{MillisecondsPattern})?$"; + private const string AnyHumanReadableUnitPattern = + @"\d+(?:\.\d+)?\s*(?:d|day|days|h|hour|hours|m|min|minute|minutes|s|sec|second|seconds)\b|\d+\s*(?:ms|millisecond|milliseconds)\b"; + + private const string HumanReadableDurationTimeFormat = + @$"^{MinusPattern}(?=.*(?:{AnyHumanReadableUnitPattern}))" + + @$"(?:{DaysPattern})?\s*" + + @$"(?:{HoursPattern})?\s*" + + @$"(?:{MinutesPattern})?\s*" + + @$"(?:{SecondsPattern})?\s*" + + @$"(?:{MillisecondsPattern})?$"; + + private const string TimePattern = $"{StandardDurationTimeFormat}|{HumanReadableDurationTimeFormat}"; private static readonly Regex TimeRegex = CreateTimeFormatRegex(); @@ -40,8 +47,8 @@ public static partial class DurationTimeParser /// Supports two format types: /// /// 1. Standard duration format:
- /// - [d.]HH:mm:ss[.fff]
- /// 2. Human-readable format with combinations of:
+ /// - [-][d.]HH:mm:ss[.fff]
+ /// 2. Human-readable format with combinations of the following elements, optionally preceded by "-":
/// - days: "1d", "2 days"
/// - hours: "1h", "2 hours"
/// - minutes: "1m", "5 minutes"
@@ -51,6 +58,7 @@ public static partial class DurationTimeParser /// Examples of valid human-readable formats:
/// - "1s"
/// - "5 minutes"
+ /// - "-7 minutes"
/// - "3m 10s"
/// - "1 hour 30 minutes"
/// - "2 days 4 hours 15m 30s"
@@ -73,12 +81,24 @@ public static bool TryParse(string? input, out TimeSpan timeSpan) return false; } - return TryParseDurationFormat(match, out timeSpan) || - TryParseHumanReadableDurationFormat(match, out timeSpan); + if (!TryParseDurationFormat(match, out timeSpan) && + !TryParseHumanReadableDurationFormat(match, out timeSpan)) + { + return false; + } + + var isNegative = match.Groups["minus"].Success; + if (isNegative) + { + timeSpan = -timeSpan; + } + + return true; } /// /// Parses a given input string into a representation. + /// Supports standard duration format and human-readable duration format. /// Throws a if the input string is not in a valid duration format. /// /// The input string that represents a duration in a valid format. @@ -91,7 +111,8 @@ public static TimeSpan Parse(string? input) return TryParse(input, out var result) ? result - : throw new FormatException($"Input string '{input}' was not in a correct duration format."); + : throw new FormatException( + $"The input string '{input}' was not recognized as a valid duration. Expected either standard format '[-][d.]HH:mm:ss[.fff]' or human-readable format (e.g. '[-]1d 2h 30m 15s 100ms')."); } /// @@ -193,8 +214,12 @@ private static bool TryParseHumanReadableDurationFormat(Match match, out TimeSpa /// The new rounded instance. private static TimeSpan RoundOnMilliseconds(TimeSpan timeSpan) { - var roundedTicks = - (long)(Math.Round((double)timeSpan.Ticks / TimeSpan.TicksPerMillisecond) * TimeSpan.TicksPerMillisecond); + var milliseconds = (double)timeSpan.Ticks / TimeSpan.TicksPerMillisecond; + + // Round to the nearest millisecond + var roundedMilliseconds = Math.Round(milliseconds); + + var roundedTicks = (long)roundedMilliseconds * TimeSpan.TicksPerMillisecond; return new TimeSpan(roundedTicks); } diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs index cf768ec..19a44c7 100644 --- a/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs @@ -107,9 +107,9 @@ public void Parse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) #region Invalid input tests [Theory] - [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidMixedFormatInputs), + [MemberData(nameof(DurationTimeParserInvalidTestData.AllInvalidInputs), MemberType = typeof(DurationTimeParserInvalidTestData))] - public void Parse_MixedInputFormats_ReturnsFalseAndTimeSpanZero(string input) + public void Parse_InvalidInputs_ReturnsFalseAndTimeSpanZero(string input) { // Arrange // Act @@ -119,19 +119,6 @@ public void Parse_MixedInputFormats_ReturnsFalseAndTimeSpanZero(string input) parse.Should().Throw(); } - [Theory] - [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidInputs), - MemberType = typeof(DurationTimeParserInvalidTestData))] - public void Parse_InvalidInput_ReturnsFalseAndTimeSpanZero(string? input) - { - // Arrange - // Act - Action parse = () => DurationTimeParser.Parse(input!); - - // Assert - parse.Should().Throw(); - } - #endregion #region Value rolling over tests that result in next bigger unit @@ -246,6 +233,140 @@ public void Parse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAndTi parsedResult.Should().Be(new TimeSpan(1, 1, 0, 0)); } + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOverWithNonZeroHigherComponentInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverWithNonZeroHigherComponents_ReturnsCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidMultiLevelCascadingRollingOverInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_MultiLevelCascadingRollingOver_ReturnsCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Negative rolling over tests + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver1000MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverThousandMilliseconds_ReturnsNegativeOneSecond(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, -1)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver60SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverSixtySeconds_ReturnsNegativeOneMinute(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, -1, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver60MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverSixtyMinutes_ReturnsNegativeOneHour(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(-1, 0, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver24HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverTwentyFourHours_ReturnsNegativeOneDay(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(-1, 0, 0, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver25HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverTwentyFiveHours_ReturnsNegativeOneDayAndOneHour(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(-1, -1, 0, 0)); + } + + #endregion + + #region Valid negative input tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeStandardDurationFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_NegativeStandardDurationFormat_ShouldParseCorrectly(string input, int days, int hours, + int minutes, int seconds, int milliseconds) + { + // Arrange + var expected = new TimeSpan(days, hours, minutes, seconds, milliseconds); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expected); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeHumanReadableFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_NegativeHumanReadableFormat_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + #endregion #region Value unrolling tests that result in current and next smaller unit @@ -278,6 +399,190 @@ public void Parse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValue(s parsedResult.Should().Be(expectedResult); } + #endregion + +#region Case insensitivity tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidCaseInsensitiveInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_CaseInsensitiveInput_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Whitespace-padded input tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidWhitespacePaddedInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_WhitespacePaddedInput_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Standalone fractional unit tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidStandaloneFractionalUnitInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_StandaloneFractionalUnit_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Negative unrolling tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeUnrollingInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_NegativeUnrollingInput_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeMixedUnrollingAndRollingOverInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_NegativeMixedUnrollingAndRollingOverInput_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Negative rolling over additional tests + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver1001MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverThousandAndOneMilliseconds_ReturnsNegativeOneSecondAndOneMillisecond(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, 0, -1, -1)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver65SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverSixtyFiveSeconds_ReturnsNegativeOneMinuteAndFiveSeconds(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, -1, -5)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver65MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverSixtyFiveMinutes_ReturnsNegativeOneHourAndFiveMinutes(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(-1, -5, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOverWithNonZeroHigherComponentInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeRollingOverWithNonZeroHigherComponents_ReturnsCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeMultiLevelCascadingRollingOverInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_NegativeMultiLevelCascadingRollingOver_ReturnsCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + + #region Boundary value tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidBoundaryInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_BoundaryValues_ShouldParseCorrectly(string input, int days, int hours, int minutes, + int seconds, int milliseconds) + { + // Arrange + var expected = new TimeSpan(days, hours, minutes, seconds, milliseconds); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expected); + } + #endregion } } diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs index 3116750..5f6c0d7 100644 --- a/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs @@ -114,23 +114,9 @@ public void TryParse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) #region Invalid input tests [Theory] - [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidMixedFormatInputs), + [MemberData(nameof(DurationTimeParserInvalidTestData.AllInvalidInputs), MemberType = typeof(DurationTimeParserInvalidTestData))] - public void TryParse_MixedInputFormats_ReturnsFalseAndTimeSpanZero(string input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(TimeSpan.Zero); - parsedResult.Should().BeFalse(); - } - - [Theory] - [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidInputs), - MemberType = typeof(DurationTimeParserInvalidTestData))] - public void TryParse_InvalidInput_ReturnsFalseAndTimeSpanZero(string? input) + public void TryParse_InvalidInputs_ReturnsFalseAndTimeSpanZero(string input) { // Arrange // Act @@ -263,6 +249,149 @@ public void TryParse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAn parsedResult.Should().BeTrue(); } + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOverWithNonZeroHigherComponentInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverWithNonZeroHigherComponents_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidMultiLevelCascadingRollingOverInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_MultiLevelCascadingRollingOver_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Negative rolling over tests + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver1000MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverThousandMilliseconds_ReturnsTrueAndNegativeOneSecond(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, -1)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver60SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverSixtySeconds_ReturnsTrueAndNegativeOneMinute(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, -1, 0)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver60MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverSixtyMinutes_ReturnsTrueAndNegativeOneHour(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(-1, 0, 0)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver24HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverTwentyFourHours_ReturnsTrueAndNegativeOneDay(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(-1, 0, 0, 0)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver25HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverTwentyFiveHours_ReturnsTrueAndNegativeOneDayAndOneHour(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(-1, -1, 0, 0)); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Valid negative input tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeStandardDurationFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_NegativeStandardDurationFormat_ShouldParseCorrectly(string input, int days, int hours, + int minutes, int seconds, int milliseconds) + { + // Arrange + var expected = new TimeSpan(days, hours, minutes, seconds, milliseconds); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expected); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeHumanReadableFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_NegativeHumanReadableFormat_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + #endregion #region Value unrolling tests that result in current and next smaller unit @@ -298,5 +427,201 @@ public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValu } #endregion + + + #region Case insensitivity tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidCaseInsensitiveInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_CaseInsensitiveInput_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Whitespace-padded input tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidWhitespacePaddedInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_WhitespacePaddedInput_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Standalone fractional unit tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidStandaloneFractionalUnitInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_StandaloneFractionalUnit_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Negative unrolling tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeUnrollingInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_NegativeUnrollingInput_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidNegativeMixedUnrollingAndRollingOverInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_NegativeMixedUnrollingAndRollingOverInput_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Negative rolling over additional tests + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver1001MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverThousandAndOneMilliseconds_ReturnsTrueAndNegativeOneSecondAndOneMillisecond(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, 0, -1, -1)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver65SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverSixtyFiveSeconds_ReturnsTrueAndNegativeOneMinuteAndFiveSeconds(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, -1, -5)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOver65MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverSixtyFiveMinutes_ReturnsTrueAndNegativeOneHourAndFiveMinutes(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(-1, -5, 0)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeRollingOverWithNonZeroHigherComponentInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeRollingOverWithNonZeroHigherComponents_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidNegativeMultiLevelCascadingRollingOverInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_NegativeMultiLevelCascadingRollingOver_ReturnsTrueAndCorrectTimeSpan(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + #endregion + + #region Boundary value tests + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidBoundaryInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_BoundaryValues_ReturnsTrueAndCorrectTimeSpan(string input, int days, int hours, int minutes, + int seconds, int milliseconds) + { + // Arrange + var expected = new TimeSpan(days, hours, minutes, seconds, milliseconds); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(expected); + parsedResult.Should().BeTrue(); + } + + #endregion } } diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs index ad15c8d..135635c 100644 --- a/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs @@ -3,15 +3,20 @@ public sealed class DurationTimeParserInvalidTestData { /// - /// Represents a collection of mixed input formats considered as invalid inputs for - /// testing the duration time parsing functionality. + /// All invalid inputs combined (mixed formats, malformed, out-of-range, and negative). /// - /// - /// This property provides a diverse dataset of string inputs, including combinations of days, - /// hours, minutes, seconds, and milliseconds in various notations and sequences. - /// It ensures the parsing method rejects unsuitable input values. - /// - public static TheoryData InvalidMixedFormatInputs => + public static TheoryData AllInvalidInputs => + [ + .. InvalidMixedFormatInputs, + .. InvalidMalformedInputs, + .. InvalidOutOfRangeInputs, + .. InvalidNegativeFormatInputs + ]; + + /// + /// Inputs that mix human-readable units with standard time format (e.g. "1d 00:00:00"). + /// + private static TheoryData InvalidMixedFormatInputs => [ // Human-readable days + standard time "1d 00:00:00", @@ -134,51 +139,93 @@ public sealed class DurationTimeParserInvalidTestData // Full standard format + full human-readable format "1.23:45:56.789 1d 2h 3m 4s 500ms", "00:00:01 1 second", - "1d 1.23:45:56.789 500ms" + "1d 1.23:45:56.789 500ms", + + // Negative mixed formats (still invalid — mixing human-readable + standard) + "-1d 00:00:00", + "-1 day 00:00:00", + "-00:00:00 1s", + "-00:00:00 1 day", + "-1d 00:00:00 1s", + "-1.23:45:56.789 1d 2h 3m 4s 500ms" ]; /// - /// Represents a collection of strings that are considered invalid inputs for - /// testing the duration time parsing functionality. + /// Malformed inputs: non-parseable text, unrecognized units, wrong order, missing spaces, + /// comma decimals, incomplete standard formats, plus signs, and bad numeric formats. /// - /// - /// This property provides a dataset of invalid strings to ensure the duration - /// parsing method correctly identifies and handles erroneous or malformed input values. - /// - public static TheoryData InvalidInputs => + private static TheoryData InvalidMalformedInputs => [ + // Non-parseable or completely invalid text + "invalid", "Foo", " Bar", + + // Valid value with invalid surrounding text "Foo 3s", "3s Bar", "Foo 00:00:03", "00:00:03 Bar", "Foo 00:00:03 Bar", + + // Number followed by unrecognized unit "3house", "3 house", "3houses", "3 houses", - "invalid", - "0h0m3s", + "1y", + "1d 2x", + + // Missing spaces between human-readable components + "2s1ms", + "3m2s", + "4h3m", + "5d3h", + "3m2s1ms", + "4h3m2s", + "5d4h3m2s1ms", + + // Human-readable components in the wrong order or duplicated "0m 3s 1m", "3s 1h", - "+3s", - "-3s", + + // Comma decimal separator (not supported; use dot) "3,5s", "3,005s", "3,005.0s", "3,5 s", - "1y", - "1d 2x", + + // Incomplete or malformed standard time format "00:00", "00:00.0", "0.00:00", + + // Human-readable values that cause numeric parsing errors + "1.2.3 days", // The double.Parse will throw FormatException due to multiple decimal points + "1 day 2.5ms", // int.Parse will throw FormatException due to an invalid integer format + + // Input with plus sign (not supported; only minus is allowed) + "+00:00:03", + "+3s", + "+3h", + "+00:00:03", + "+1d 2h" + ]; + + /// + /// Out-of-range values: components exceeding valid bounds (e.g. "00:00:60", overflow). + /// + private static TheoryData InvalidOutOfRangeInputs => + [ + // Standard time format with out-of-range components (without days prefix) "00:00:60", "00:00:61", "00:60:00", "00:61:00", "24:00:00", "25:00:00", + + // Standard time format with out-of-range components (with fractional seconds) "00:00:00.0001", "00:00:60.0", "00:00:61.0", @@ -186,15 +233,103 @@ public sealed class DurationTimeParserInvalidTestData "00:61:00.0", "24:00:00.0", "25:00:00.0", + + // Standard time format with out-of-range components (with days prefix) "0.00:00:60", "0.00:00:61", "0.00:60:00", "0.00:61:00", "0.24:00:00", "0.25:00:00", + + // Human-readable values that cause numeric parsing errors "1E+999999999 days", // The value is too large for double.Parse and will throw OverflowException - "1.2.3 days", // The double.Parse will throw FormatException due to multiple decimal points "999999999 days 23 hours 59 minutes 59 seconds", // This will cause TimeSpan overflow when components are added together - "1 day 2.5ms" // int.Parse will throw FormatException due to an invalid integer format + + // --- Negative values --- + + // Standard time format with out-of-range components (without days prefix) + "-00:00:60", + "-00:00:61", + "-00:60:00", + "-00:61:00", + "-24:00:00", + "-25:00:00", + + // Standard time format with out-of-range components (with fractional seconds) + "-00:00:00.0001", + "-00:00:60.0", + "-00:00:61.0", + "-00:60:00.0", + "-00:61:00.0", + "-24:00:00.0", + "-25:00:00.0", + + // Standard time format with out-of-range components (with days prefix) + "-0.00:00:60", + "-0.00:00:61", + "-0.00:60:00", + "-0.00:61:00", + "-0.24:00:00", + "-0.25:00:00", + + // Human-readable values that cause numeric parsing errors + "-1E+999999999 days", // This value is too small for double.Parse and will throw OverflowException + "-999999999 days 23 hours 59 minutes 59 seconds", // This will cause TimeSpan overflow when components are added together + ]; + + /// + /// Invalid negative inputs: bare minus, doubled/misplaced/trailing minus signs, + /// and negative values combined with other malformed elements (bad units, commas, bad numerics). + /// + private static TheoryData InvalidNegativeFormatInputs => + [ + // Input with minus sign only + "-", + " -", + "- ", + + // Double minus sign + "--00:00:03", + "-- 00:00:05", + "--3s", + "-- 7s", + + // Pre-component minus sign + "3h -30m", + "3h - 30m", + "-3h -30m", + "- 3h - 30m", + "--5m --3s", + "-- 5m -- 3s", + "-3h -30m -5s", + + // Trailing minus sign + "3s-", + "3s -", + "3s--", + "3s --", + "00:00:03-", + "00:00:03 -", + + // Mid-position minus sign + "3h-30m", + "3h- 30m", + "3h-30m-7s", + "3h-30m- 7s", + "3h- 30m- 7s", + + // Negative with invalid unit + "-1y", + "-3 house", + + // Comma decimal + "-3,5s", + + // Negative invalid double + "-1.2.3 days", // The double.Parse will throw FormatException due to multiple decimal points + + // Negative invalid double + "-1 day 2.5ms", // int.Parse will throw FormatException due to an invalid integer format ]; } diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs index 9eff267..e7ce07c 100644 --- a/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs @@ -3,16 +3,8 @@ public sealed class DurationTimeParserRollingOverTestData { /// - /// Represents a collection of human-readable valid input strings that describe durations adding up to one second - /// through the use of milliseconds or combinations of time units, intended for testing duration parsing functionality. + /// Duration inputs where 1000ms should roll over into 1 second. /// - /// - /// This property includes test data with various formats where durations expressed in milliseconds may - /// roll over into a full second. Examples include standalone millisecond declarations such as "1000ms" and - /// combinations of time units like "0s 1000ms" or "0 hours 0 minutes 0 seconds 1000 milliseconds." - /// It ensures the parsing method correctly recognizes such scenarios and converts them into a consistent - /// one-second time span representation. - /// public static TheoryData ValidRollingOver1000MillisecondsInputs => [ "1000ms", @@ -56,16 +48,27 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a collection of human-readable valid inputs that span over one second and include an - /// additional one millisecond, formatted in various valid representations. + /// Negative duration inputs where -1000ms should roll over into -1 second. + /// + public static TheoryData ValidNegativeRollingOver1000MillisecondsInputs => + [ + "-1000ms", + "-1000 ms", + "-1000milliseconds", + "-1000 milliseconds", + "-0s 1000ms", + "-0 s 1000 ms", + "-0m 0s 1000ms", + "-0 minutes 0 seconds 1000 milliseconds", + "-0h 0m 0s 1000ms", + "-0 hours 0 minutes 0 seconds 1000 milliseconds", + "-0d 0h 0m 0s 1000ms", + "-0 days 0 hours 0 minutes 0 seconds 1000 milliseconds" + ]; + + /// + /// Duration inputs where 1001ms should roll over into 1 second and 1 millisecond. /// - /// - /// This property provides data to test the parsing of duration values that translate - /// to one second and one millisecond combined. The inputs include multiple formats - /// with different units (e.g., milliseconds, seconds, minutes, hours, days) and their - /// abbreviations. It ensures that the parsing functionality correctly interprets - /// diverse duration strings containing exact values of 1001 milliseconds. - /// public static TheoryData ValidRollingOver1001MillisecondsInputs => [ "1001ms", @@ -109,14 +112,8 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Contains valid human-readable input strings that represent durations rolling over 60 seconds - /// to test if the duration parser accurately normalizes such inputs to one minute. + /// Duration inputs where 60 seconds should roll over into 1 minute. /// - /// - /// This property includes diverse formats of input strings representing 60 seconds or equivalent, - /// such as "60s", "0m 60s", "0h 0m 60s", and their variations with different spacings and unit formats. - /// It ensures the parsing functionality handles these cases correctly and interprets them as one minute. - /// public static TheoryData ValidRollingOver60SecondsInputs => [ "60s", @@ -160,16 +157,25 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a collection of valid human-readable input strings that specify durations - /// rolling over 65 seconds, used for testing duration parsing functionality. + /// Negative duration inputs where -60s should roll over into -1 minute. + /// + public static TheoryData ValidNegativeRollingOver60SecondsInputs => + [ + "-60s", + "-60 s", + "-60sec", + "-60 seconds", + "-0m 60s", + "-0 minutes 60 seconds", + "-0h 0m 60s", + "-0 hours 0 minutes 60 seconds", + "-0d 0h 0m 60s", + "-0 days 0 hours 0 minutes 60 seconds" + ]; + + /// + /// Duration inputs where 65 seconds should roll over into 1 minute and 5 seconds. /// - /// - /// This property provides test data containing various formats for expressing durations - /// that consolidate or exceed 65 seconds. It includes representations with - /// seconds alone or combined with other units such as minutes, hours, and days. - /// The data ensures that the parsing method correctly interprets diverse input formats - /// while respecting unit specifications and spacing variations. - /// public static TheoryData ValidRollingOver65SecondsInputs => [ "65s", @@ -213,15 +219,8 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a set of valid human-readable input strings that specify durations rolling over 60 minutes, - /// used for testing duration parsing functionality. + /// Duration inputs where 60 minutes should roll over into 1 hour. /// - /// - /// This property contains various input formats for durations equal to or exceeding 60 minutes - /// but represented within a minute-based syntax. Examples include "60m", "60 minutes", or complex - /// combinations such as "0h 60m 0s". It validates that the parsing logic properly normalizes these - /// cases and interprets them as equivalent to one hour. - /// public static TheoryData ValidRollingOver60MinutesInputs => [ "60m", @@ -265,18 +264,23 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a collection of valid human-readable input strings that encapsulate - /// durations rolling over 65 minutes. These inputs are used to verify - /// time parsing functionality that normalizes such cases into valid - /// hour-and-minute formats. + /// Negative duration inputs where -60m should roll over into -1 hour. + /// + public static TheoryData ValidNegativeRollingOver60MinutesInputs => + [ + "-60m", + "-60 m", + "-60min", + "-60 minutes", + "-0h 60m", + "-0 hours 60 minutes", + "-0d 0h 60m", + "-0 days 0 hours 60 minutes" + ]; + + /// + /// Duration inputs where 65 minutes should roll over into 1 hour and 5 minutes. /// - /// - /// This property includes varied string representations of durations - /// that specify 65 minutes, often accompanied by additional time segments - /// like hours, seconds, and milliseconds. The test cases ensure the parsing - /// method correctly converts these inputs into the appropriate TimeSpan - /// representation, typically equivalent to 1 hour and 5 minutes. - /// public static TheoryData ValidRollingOver65MinutesInputs => [ "65m", @@ -320,16 +324,16 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a collection of human-readable input strings that signify a time duration - /// rolling over 24 hours, formatted in various valid notations. + /// Duration inputs where 24 hours should roll over into 1 day. /// - /// - /// This property is used to test parsing functionality for duration strings - /// that normalize to a single day (24 hours). It includes variations with - /// different formats, units, and separators to validate flexible input handling. - /// public static TheoryData ValidRollingOver24HoursInputs => [ + "24h", + "24hour", + "24hours", + "24 h", + "24 hour", + "24 hours", "24h 0m 0s", "24hour 0min 0sec", "24hour 0minute 0second", @@ -355,17 +359,31 @@ public sealed class DurationTimeParserRollingOverTestData ]; /// - /// Represents a collection of human-readable input strings that signify a time duration - /// rolling over 25 hours, formatted in various valid notations. + /// Negative duration inputs where -24h should roll over into -1 day. + /// + public static TheoryData ValidNegativeRollingOver24HoursInputs => + [ + "-24h", + "-24 h", + "-24hours", + "-24 hours", + "-24h 0m 0s", + "-24 hours 0 minutes 0 seconds", + "-0d 24h 0m 0s", + "-0 days 24 hours 0 minutes 0 seconds" + ]; + + /// + /// Duration inputs where 25 hours should roll over into 1 day and 1 hour. /// - /// - /// This property provides test data with various syntactical representations of durations - /// exceeding 24 hours, formatted in different combinations of hours, minutes, seconds, - /// days, and optional milliseconds. It ensures the parsing method correctly identifies - /// and processes these extended time inputs without errors or inaccuracies. - /// public static TheoryData ValidRollingOver25HoursInputs => [ + "25h", + "25hour", + "25hours", + "25 h", + "25 hour", + "25 hours", "25h 0m 0s", "25hour 0min 0sec", "25hour 0minute 0second", @@ -389,4 +407,197 @@ public sealed class DurationTimeParserRollingOverTestData "0day 25 hour 0 minute 0second 0millisecond", "0days 25hours 0minutes 0 seconds 0milliseconds" ]; + + /// + /// Negative duration inputs where -25h should roll over into -1 day and -1 hour. + /// + public static TheoryData ValidNegativeRollingOver25HoursInputs => + [ + "-25h", + "-25 h", + "-25hours", + "-25 hours", + "-25h 0m 0s", + "-25 hours 0 minutes 0 seconds", + "-0d 25h 0m 0s", + "-0 days 25 hours 0 minutes 0 seconds" + ]; + + + /// + /// Negative duration inputs where -1001ms should roll over into -1 second and -1 millisecond. + /// + public static TheoryData ValidNegativeRollingOver1001MillisecondsInputs => + [ + "-1001ms", + "-1001 ms", + "-1001milliseconds", + "-1001 milliseconds", + "-0s 1001ms", + "-0 s 1001 ms", + "-0m 0s 1001ms", + "-0 minutes 0 seconds 1001 milliseconds", + "-0h 0m 0s 1001ms", + "-0 hours 0 minutes 0 seconds 1001 milliseconds", + "-0d 0h 0m 0s 1001ms", + "-0 days 0 hours 0 minutes 0 seconds 1001 milliseconds" + ]; + + /// + /// Negative duration inputs where -65s should roll over into -1 minute and -5 seconds. + /// + public static TheoryData ValidNegativeRollingOver65SecondsInputs => + [ + "-65s", + "-65 s", + "-65sec", + "-65 seconds", + "-0m 65s", + "-0 minutes 65 seconds", + "-0h 0m 65s", + "-0 hours 0 minutes 65 seconds", + "-0d 0h 0m 65s", + "-0 days 0 hours 0 minutes 65 seconds" + ]; + + /// + /// Negative duration inputs where -65m should roll over into -1 hour and -5 minutes. + /// + public static TheoryData ValidNegativeRollingOver65MinutesInputs => + [ + "-65m", + "-65 m", + "-65min", + "-65 minutes", + "-0h 65m", + "-0 hours 65 minutes", + "-0d 0h 65m", + "-0 days 0 hours 65 minutes" + ]; + + /// + /// Negative rolling over with non-zero higher components (e.g. "-1m 60s" → -2m, "-1d 24h" → -2d). + /// + public static TheoryData ValidNegativeRollingOverWithNonZeroHigherComponentInputs => new() + { + // Milliseconds rolling into non-zero seconds + { "-5s 1000ms", "-00:00:06" }, + { "-5 seconds 1000 milliseconds", "-00:00:06" }, + { "-59s 1000ms", "-00:01:00" }, + + // Seconds rolling into non-zero minutes + { "-1m 60s", "-00:02:00" }, + { "-1 minute 60 seconds", "-00:02:00" }, + { "-5m 65s", "-00:06:05" }, + { "-5 minutes 65 seconds", "-00:06:05" }, + + // Minutes rolling into non-zero hours + { "-1h 60m", "-02:00:00" }, + { "-1 hour 60 minutes", "-02:00:00" }, + { "-2h 65m", "-03:05:00" }, + { "-2 hours 65 minutes", "-03:05:00" }, + + // Hours rolling into non-zero days + { "-1d 24h", "-2.00:00:00" }, + { "-1 day 24 hours", "-2.00:00:00" }, + { "-1d 25h", "-2.01:00:00" }, + { "-1 day 25 hours", "-2.01:00:00" }, + { "-2d 48h", "-4.00:00:00" }, + { "-2 days 48 hours", "-4.00:00:00" } + }; + + /// + /// Negative multi-level cascading rolling over (e.g. "-59m 60s" → -1h, "-23h 59m 60s" → -1d). + /// + public static TheoryData ValidNegativeMultiLevelCascadingRollingOverInputs => new() + { + // Seconds cascade through minutes to hours + { "-59m 60s", "-01:00:00" }, + { "-59 minutes 60 seconds", "-01:00:00" }, + { "-59m 61s", "-01:00:01" }, + + // Seconds cascade through minutes and hours to days + { "-23h 59m 60s", "-1.00:00:00" }, + { "-23 hours 59 minutes 60 seconds", "-1.00:00:00" }, + + // Milliseconds cascade through seconds to minutes + { "-0m 59s 1000ms", "-00:01:00" }, + { "-0 minutes 59 seconds 1000 milliseconds", "-00:01:00" }, + + // Full cascade: milliseconds → seconds → minutes → hours → days + { "-23h 59m 59s 1000ms", "-1.00:00:00" }, + { "-23 hours 59 minutes 59 seconds 1000 milliseconds", "-1.00:00:00" }, + + // Multiple levels with non-zero higher components + { "-1d 23h 59m 60s", "-2.00:00:00" }, + { "-1 day 23 hours 59 minutes 60 seconds", "-2.00:00:00" }, + { "-1d 23h 60m", "-2.00:00:00" }, + { "-1 day 23 hours 60 minutes", "-2.00:00:00" } + }; + + /// + /// Rolling over with non-zero higher components (e.g. "1m 60s" → 2m, "1d 24h" → 2d). + /// + public static TheoryData ValidRollingOverWithNonZeroHigherComponentInputs => new() + { + // Milliseconds rolling into non-zero seconds + { "5s 1000ms", "00:00:06" }, + { "5 seconds 1000 milliseconds", "00:00:06" }, + { "59s 1000ms", "00:01:00" }, + { "59 seconds 1001 milliseconds", "00:01:00.001" }, + + // Seconds rolling into non-zero minutes + { "1m 60s", "00:02:00" }, + { "1 minute 60 seconds", "00:02:00" }, + { "5m 65s", "00:06:05" }, + { "5 minutes 65 seconds", "00:06:05" }, + + // Minutes rolling into non-zero hours + { "1h 60m", "02:00:00" }, + { "1 hour 60 minutes", "02:00:00" }, + { "2h 65m", "03:05:00" }, + { "2 hours 65 minutes", "03:05:00" }, + + // Hours rolling into non-zero days + { "1d 24h", "2.00:00:00" }, + { "1 day 24 hours", "2.00:00:00" }, + { "1d 25h", "2.01:00:00" }, + { "1 day 25 hours", "2.01:00:00" }, + { "2d 48h", "4.00:00:00" }, + { "2 days 48 hours", "4.00:00:00" } + }; + + /// + /// Multi-level cascading rolling over (e.g. "59m 60s" → 1h, "23h 59m 60s" → 1d). + /// + public static TheoryData ValidMultiLevelCascadingRollingOverInputs => new() + { + // Seconds cascade through minutes to hours + { "59m 60s", "01:00:00" }, + { "59 minutes 60 seconds", "01:00:00" }, + { "59m 61s", "01:00:01" }, + + // Seconds cascade through minutes and hours to days + { "23h 59m 60s", "1.00:00:00" }, + { "23 hours 59 minutes 60 seconds", "1.00:00:00" }, + + // Milliseconds cascade through seconds to minutes + { "0m 59s 1000ms", "00:01:00" }, + { "0 minutes 59 seconds 1000 milliseconds", "00:01:00" }, + { "0m 59s 1001ms", "00:01:00.001" }, + + // Milliseconds cascade through seconds and minutes to hours + { "59m 59s 1000ms", "01:00:00" }, + { "59 minutes 59 seconds 1000 milliseconds", "01:00:00" }, + + // Full cascade: milliseconds → seconds → minutes → hours → days + { "23h 59m 59s 1000ms", "1.00:00:00" }, + { "23 hours 59 minutes 59 seconds 1000 milliseconds", "1.00:00:00" }, + + // Multiple levels with non-zero higher components + { "1d 23h 59m 60s", "2.00:00:00" }, + { "1 day 23 hours 59 minutes 60 seconds", "2.00:00:00" }, + { "1d 23h 60m", "2.00:00:00" }, + { "1 day 23 hours 60 minutes", "2.00:00:00" } + }; } diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs index 0cce147..6074bfb 100644 --- a/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs @@ -97,7 +97,6 @@ public sealed class DurationTimeParserUnrollingTestData /// public static TheoryData ValidUnrollingFractionInputs => new() { - { "1.25d 1.5h 1.5m 1.5s 7ms", "1.07:31:31.507" }, { "1.25d 1.5h 1.5m 1.5s 7ms", "1.07:31:31.507" }, { "1.25d 1.5m 7ms", "1.06:01:30.007" }, { "1.5m 13ms", "00:01:30.013" } diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs index 93e422b..7a1aaf7 100644 --- a/tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs @@ -199,6 +199,20 @@ public sealed class DurationTimeParserValidTestData { "1.23:45:56.789", 1, 23, 45, 56, 789 } // Complex case }; + /// + /// Valid negative inputs in standard duration format that should parse to the corresponding negative TimeSpan. + /// + public static TheoryData ValidNegativeStandardDurationFormatInputs => new() + { + { "-00:00:00", 0, 0, 0, 0, 0 }, + { "-00:00:03", 0, 0, 0, -3, 0 }, + { "-01:00:00", 0, -1, 0, 0, 0 }, + { "-00:30:00", 0, 0, -30, 0, 0 }, + { "-1.00:00:00", -1, 0, 0, 0, 0 }, + { "-00:00:00.500", 0, 0, 0, 0, -500 }, + { "-1.23:45:56.789", -1, -23, -45, -56, -789 } + }; + /// /// Represents a collection of valid human-readable duration format input strings /// mapped to their equivalent standardized time span representations. @@ -224,6 +238,25 @@ public sealed class DurationTimeParserValidTestData { "1d 2h 3m 4s 500ms", "1.02:03:04.500" } }; + /// + /// Valid negative inputs in human-readable format that should parse to the corresponding negative TimeSpan. + /// + public static TheoryData ValidNegativeHumanReadableFormatInputs => new() + { + { "-1d", "-1.00:00:00" }, + { "-2 days", "-2.00:00:00" }, + { "-3h", "-03:00:00" }, + { "-4 hours", "-04:00:00" }, + { "-5m", "-00:05:00" }, + { "-6 minutes", "-00:06:00" }, + { "-7s", "-00:00:07" }, + { "-8 seconds", "-00:00:08" }, + { "-100ms", "-00:00:00.100" }, + { "-1d 2h 3m 4s 500ms", "-1.02:03:04.500" }, + { "- 3s", "-00:00:03" }, + { "- 1 day 2 hours 3 minutes 4 seconds 500 milliseconds", "-1.02:03:04.500" } + }; + /// /// Represents a collection of valid human-readable input strings that include combinations of partial /// rolling-over and unrolling duration formats for testing duration parsing functionality. @@ -241,4 +274,144 @@ public sealed class DurationTimeParserValidTestData { "0.01h 100.007s", "00:02:16.007" }, { "0.0003d", "00:00:25.920" } }; + + + /// + /// Valid inputs with case-insensitive unit abbreviations (uppercase, mixed case). + /// The regex uses , + /// so these should all parse correctly. + /// + public static TheoryData ValidCaseInsensitiveInputs => new() + { + // Uppercase abbreviations + { "3S", "00:00:03" }, + { "5M", "00:05:00" }, + { "2H", "02:00:00" }, + { "1D", "1.00:00:00" }, + { "100MS", "00:00:00.100" }, + + // Mixed case abbreviations + { "3Sec", "00:00:03" }, + { "5Min", "00:05:00" }, + { "2Hour", "02:00:00" }, + { "1Day", "1.00:00:00" }, + { "100Milliseconds", "00:00:00.100" }, + + // Full uppercase + { "3SEC", "00:00:03" }, + { "5MINUTES", "00:05:00" }, + { "2HOURS", "02:00:00" }, + { "1DAY", "1.00:00:00" }, + { "100MILLISECONDS", "00:00:00.100" }, + + // Mixed case with spaces + { "1D 2H 3M 4S 500MS", "1.02:03:04.500" }, + { "1 Day 2 Hours 3 Minutes 4 Seconds 500 Milliseconds", "1.02:03:04.500" } + }; + + /// + /// Valid inputs with leading/trailing whitespace that should be trimmed before parsing. + /// + public static TheoryData ValidWhitespacePaddedInputs => new() + { + // Leading whitespace + { " 3s", "00:00:03" }, + { " 00:00:03", "00:00:03" }, + + // Trailing whitespace + { "3s ", "00:00:03" }, + { "00:00:03 ", "00:00:03" }, + + // Both leading and trailing whitespace + { " 3s ", "00:00:03" }, + { " 00:00:03 ", "00:00:03" }, + { " 1d 2h 3m 4s 500ms ", "1.02:03:04.500" }, + { " 1.23:45:56.789 ", "1.23:45:56.789" } + }; + + /// + /// Valid standalone fractional unit inputs (e.g. "0.5d" → 12h, "0.5h" → 30m). + /// + public static TheoryData ValidStandaloneFractionalUnitInputs => new() + { + // Fractional days + { "0.5d", "12:00:00" }, + { "0.5 days", "12:00:00" }, + { "1.5d", "1.12:00:00" }, + + // Fractional hours + { "0.5h", "00:30:00" }, + { "0.5 hours", "00:30:00" }, + { "1.5h", "01:30:00" }, + + // Fractional minutes + { "0.5m", "00:00:30" }, + { "0.5 minutes", "00:00:30" }, + { "1.5m", "00:01:30" }, + + // Fractional seconds + { "0.5s", "00:00:00.500" }, + { "0.5 seconds", "00:00:00.500" }, + { "1.5s", "00:00:01.500" } + }; + + /// + /// Valid negative inputs with fractional/unrolling values combined with the minus prefix. + /// + public static TheoryData ValidNegativeUnrollingInputs => new() + { + // Negative fractional seconds + { "-3.5s", "-00:00:03.500" }, + { "-3.5 seconds", "-00:00:03.500" }, + { "-0.5s", "-00:00:00.500" }, + + // Negative fractional minutes + { "-1.5m", "-00:01:30" }, + { "-1.5 minutes", "-00:01:30" }, + { "-0.5m", "-00:00:30" }, + + // Negative fractional hours + { "-1.5h", "-01:30:00" }, + { "-1.5 hours", "-01:30:00" }, + { "-0.5h", "-00:30:00" }, + + // Negative fractional days + { "-0.5d", "-12:00:00" }, + { "-0.5 days", "-12:00:00" }, + { "-1.5d", "-1.12:00:00" }, + + // Negative combined fractional + { "-1.25d 1.5h 1.5m 1.5s 7ms", "-1.07:31:31.507" }, + { "-1.5m 13ms", "-00:01:30.013" } + }; + + /// + /// Valid negative inputs with mixed unrolling and rolling over values. + /// + public static TheoryData ValidNegativeMixedUnrollingAndRollingOverInputs => new() + { + { "-69.5m 0.5s", "-01:09:30.500" }, + { "-0.3m 100.07s", "-00:01:58.070" }, + { "-0.01h 100.007s", "-00:02:16.007" }, + { "-0.0003d", "-00:00:25.920" } + }; + + /// + /// Valid inputs near the and boundaries. + /// + public static TheoryData ValidBoundaryInputs => new() + { + // Large but valid standard format + { "10675199.02:48:05.477", 10675199, 2, 48, 5, 477 }, + + // Large but valid human-readable + { "10675199d 2h 48m 5s 477ms", 10675199, 2, 48, 5, 477 }, + + // Negative large but valid standard format + { "-10675199.02:48:05.477", -10675199, -2, -48, -5, -477 }, + + // Single day boundary + { "365d", 365, 0, 0, 0, 0 }, + { "365 days", 365, 0, 0, 0, 0 } + }; }