From 0f96208cecd71181f5e3e339c6acc3a038a915f5 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:33:42 +0100 Subject: [PATCH 01/17] Initial `DurationTimeParser` impl. + tests Implemented the class `DurationTimeParser`, which provides methods to parse formatted duration strings into `TimeSpan`. Added extensive unit tests to validate functionality. --- Directory.Build.props | 2 +- Directory.Packages.props | 9 + DurationMancer.slnx | 3 +- src/Class1.cs | 6 - src/DurationMancer/DurationTimeParser.cs | 167 ++++ .../DurationMancer.Tests.csproj | 43 + .../DurationTimeParserTests.cs | 799 ++++++++++++++++++ 7 files changed, 1021 insertions(+), 8 deletions(-) delete mode 100644 src/Class1.cs create mode 100644 src/DurationMancer/DurationTimeParser.cs create mode 100644 tests/DurationMancer.Tests/DurationMancer.Tests.csproj create mode 100644 tests/DurationMancer.Tests/DurationTimeParserTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 62abc08..51d10a2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@  - net6.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 enable enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c1d675..871dfbc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,5 +4,14 @@ true + + + + + + + + + diff --git a/DurationMancer.slnx b/DurationMancer.slnx index f2525a4..40c5c69 100644 --- a/DurationMancer.slnx +++ b/DurationMancer.slnx @@ -1,3 +1,4 @@ - + + diff --git a/src/Class1.cs b/src/Class1.cs deleted file mode 100644 index 09601fc..0000000 --- a/src/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DurationMancer -{ - public class Class1 - { - } -} diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs new file mode 100644 index 0000000..c5a16f0 --- /dev/null +++ b/src/DurationMancer/DurationTimeParser.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace DurationMancer; + +/// +/// Provides methods for parsing various duration string formats into a . +/// +public static partial class DurationTimeParser +{ + 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"; + private const string MinutesPattern = @"(?\d+(?:\.\d+)?)\s*(m|min|minute|minutes)\b"; + 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}))?$"; + + private const string TimePattern = StandardDurationTimeFormat + @"|" + + @"^(?:" + DaysPattern + @")?\s*" + + @"(?:" + HoursPattern + @")?\s*" + + @"(?:" + MinutesPattern + @")?\s*" + + @"(?:" + SecondsPattern + @")?\s*" + + @"(?:" + MillisecondsPattern + @")?$"; + + private static readonly Regex TimeRegex = CreateTimeFormatRegex(); + + [GeneratedRegex(TimePattern, RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex CreateTimeFormatRegex(); + + /// + /// Tries to parse a given input string into a representation. + /// Supports standard duration format and human-readable duration format. + /// + /// The input string that represents a duration in a valid format. + /// The parsed value if parsing is successful; otherwise . + /// true if the input string was successfully parsed into a valid ; otherwise false. + /// + /// Supports two format types: + /// + /// 1. Standard duration format:
+ /// - [d.]HH:mm:ss[.fff]
+ /// 2. Human-readable format with combinations of:
+ /// - days: "1d", "2 days"
+ /// - hours: "1h", "2 hours"
+ /// - minutes: "1m", "5 minutes"
+ /// - seconds: "1s", "30 seconds"
+ /// - milliseconds: "100ms", "500 milliseconds"
+ ///
+ /// Examples of valid human-readable formats:
+ /// - "1s"
+ /// - "5 minutes"
+ /// - "3m 10s"
+ /// - "1 hour 30 minutes"
+ /// - "2 days 4 hours 15m 30s"
+ ///
+ ///
+ public static bool TryParse(string? input, out TimeSpan timeSpan) + { + timeSpan = TimeSpan.Zero; + + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + input = input.Trim(); + + var match = TimeRegex.Match(input); + if (!match.Success) + { + return false; + } + + return TryParseDurationFormat(match, out timeSpan) || + TryParseHumanReadableDurationFormat(match, out timeSpan); + } + + /// + /// Tries to parse a possibly matched time in duration format [d.]HH:mm:ss[.fff]. + /// + /// Possibly RegEx match that contains values to process. + /// The parsed result object, if successfully parsed; otherwise . + /// true if successfully parsed; otherwise false + private static bool TryParseDurationFormat(Match match, out TimeSpan result) + { + result = TimeSpan.Zero; + + if (!match.Groups["hh"].Success) + { + return false; + } + + _ = int.TryParse(match.Groups["d"].Value, out var d); + _ = int.TryParse(match.Groups["hh"].Value, out var hh); + _ = int.TryParse(match.Groups["mm"].Value, out var mm); + _ = int.TryParse(match.Groups["ss"].Value, out var ss); + _ = int.TryParse(match.Groups["ms"].Value, out var ms); + + result = new TimeSpan(d, hh, mm, ss, ms); + + return true; + } + + /// + /// Parses a possibly matched time in human-readable duration format. For instance, like "1h 13m 2s", "5 sec" or "10min 30s". + /// + /// Possibly RegEx match that contains values to process. + /// The parsed result object, if successfully parsed; otherwise . + /// The parsed result object. + private static bool TryParseHumanReadableDurationFormat(Match match, out TimeSpan result) + { + result = TimeSpan.Zero; + + try + { + var days = match.Groups["days"].Success + ? double.Parse(match.Groups["days"].Value, CultureInfo.InvariantCulture) + : 0; + + var hours = match.Groups["hours"].Success + ? double.Parse(match.Groups["hours"].Value, CultureInfo.InvariantCulture) + : 0; + + var minutes = match.Groups["minutes"].Success + ? double.Parse(match.Groups["minutes"].Value, CultureInfo.InvariantCulture) + : 0; + + var seconds = match.Groups["seconds"].Success + ? double.Parse(match.Groups["seconds"].Value, CultureInfo.InvariantCulture) + : 0; + + var milliSeconds = match.Groups["milliseconds"].Success + ? int.Parse(match.Groups["milliseconds"].Value, CultureInfo.InvariantCulture) + : 0; + + var timeSpan = TimeSpan.FromDays(days) + + TimeSpan.FromHours(hours) + + TimeSpan.FromMinutes(minutes) + + TimeSpan.FromSeconds(seconds) + + TimeSpan.FromMilliseconds(milliSeconds); + + // As we do not support nanoseconds or ticks, we have to round to the nearest millisecond + result = RoundOnMilliseconds(timeSpan); + } + catch (Exception) + { + return false; + } + + return true; + } + + /// + /// Rounds a given to the nearest milliseconds with a precision of 1 millisecond. + /// + /// The instance to round. + /// The new rounded instance. + private static TimeSpan RoundOnMilliseconds(TimeSpan timeSpan) + { + var roundedTicks = + (long)(Math.Round((double)timeSpan.Ticks / TimeSpan.TicksPerMillisecond) * TimeSpan.TicksPerMillisecond); + + return new TimeSpan(roundedTicks); + } +} diff --git a/tests/DurationMancer.Tests/DurationMancer.Tests.csproj b/tests/DurationMancer.Tests/DurationMancer.Tests.csproj new file mode 100644 index 0000000..5eccec8 --- /dev/null +++ b/tests/DurationMancer.Tests/DurationMancer.Tests.csproj @@ -0,0 +1,43 @@ + + + + false + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.cs new file mode 100644 index 0000000..66815c0 --- /dev/null +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.cs @@ -0,0 +1,799 @@ +using AwesomeAssertions; + +namespace DurationMancer.Tests; + +public sealed class DurationTimeParserTests +{ + [Theory] + [InlineData("00:00:00.0")] + [InlineData("00:00:00.00")] + [InlineData("00:00:00")] + [InlineData("0.00:00:00")] + [InlineData("00.00:00:00")] + [InlineData("0ms")] + [InlineData("0millisecond")] + [InlineData("0milliseconds")] + [InlineData("0 ms")] + [InlineData("0 millisecond")] + [InlineData("0 milliseconds")] + [InlineData("0s")] + [InlineData("0sec")] + [InlineData("0second")] + [InlineData("0seconds")] + [InlineData("0 s")] + [InlineData("0 sec")] + [InlineData("0 second")] + [InlineData("0 seconds")] + [InlineData("0m")] + [InlineData("0min")] + [InlineData("0minute")] + [InlineData("0minutes")] + [InlineData("0 m")] + [InlineData("0 min")] + [InlineData("0 minute")] + [InlineData("0 minutes")] + [InlineData("0h")] + [InlineData("0hour")] + [InlineData("0hours")] + [InlineData("0 h")] + [InlineData("0 hour")] + [InlineData("0 hours")] + [InlineData("0d")] + [InlineData("0day")] + [InlineData("0days")] + [InlineData("0 d")] + [InlineData("0 day")] + [InlineData("0 days")] + [InlineData("0d 0h 0m 0s 0ms")] + [InlineData("0day 0hour 0min 0sec 0ms")] + [InlineData("0day 0hour 0minute 0second 0millisecond")] + [InlineData("0days 0hours 0minutes 0seconds 0milliseconds")] + [InlineData("0 d 0 h 0 m 0 s 0 ms")] + [InlineData("0 day 0 hour 0 min 0 sec 0 ms")] + [InlineData("0 day 0 hour 0 minute 0 second 0 millisecond")] + [InlineData("0 days 0 hours 0 minutes 0 seconds 0 milliseconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 0m 0 s 0ms")] + [InlineData("0day 0 hour 0min 0 sec 0ms")] + [InlineData("0day 0 hour 0minute 0 second 0millisecond")] + [InlineData("0days 0hours 0minutes 0 seconds 0milliseconds")] + public void TryParse_ZeroInput_ReturnsTrueAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeTrue(); + } + + [Theory] + [InlineData("00:00:03")] + [InlineData("00:00:03.0")] + [InlineData("00:00:03.00")] + [InlineData("0.00:00:03")] + [InlineData("00.00:00:03")] + [InlineData("3s")] + [InlineData("3sec")] + [InlineData("3second")] + [InlineData("3seconds")] + [InlineData("3 s")] + [InlineData("3 sec")] + [InlineData("3 second")] + [InlineData("3 seconds")] + [InlineData("3s 0ms")] + [InlineData("3sec 0ms")] + [InlineData("3second 0millisecond")] + [InlineData("3seconds 0milliseconds")] + [InlineData("3 s 0 ms")] + [InlineData("3 sec 0 ms")] + [InlineData("3 second 0 millisecond")] + [InlineData("3 seconds 0 milliseconds")] + [InlineData("0m 3s")] + [InlineData("0min 3sec")] + [InlineData("0minute 3second")] + [InlineData("0minutes 3seconds")] + [InlineData("0 m 3 s")] + [InlineData("0 min 3 sec")] + [InlineData("0 minute 3 second")] + [InlineData("0 minutes 3 seconds")] + [InlineData("0m 3s 0ms")] + [InlineData("0min 3sec 0ms")] + [InlineData("0minute 3second 0millisecond")] + [InlineData("0minutes 3seconds 0milliseconds")] + [InlineData("0 m 3 s 0 ms")] + [InlineData("0 min 3 sec 0 ms")] + [InlineData("0 minute 3 second 0 millisecond")] + [InlineData("0 minutes 3 seconds 0 milliseconds")] + [InlineData("0h 0m 3s")] + [InlineData("0hour 0min 3sec")] + [InlineData("0hour 0minute 3second")] + [InlineData("0hours 0minutes 3seconds")] + [InlineData("0 h 0 m 3 s")] + [InlineData("0 hour 0 min 3 sec")] + [InlineData("0 hour 0 minute 3 second")] + [InlineData("0 hours 0 minutes 3 seconds")] + [InlineData("0h 0m 3s 0ms")] + [InlineData("0hour 0min 3sec 0ms")] + [InlineData("0hour 0minute 3second 0millisecond")] + [InlineData("0hours 0minutes 3seconds 0milliseconds")] + [InlineData("0 h 0 m 3 s 0 ms")] + [InlineData("0 hour 0 min 3 sec 0 ms")] + [InlineData("0 hour 0 minute 3 second 0 millisecond")] + [InlineData("0 hours 0 minutes 3 seconds 0 milliseconds")] + [InlineData("0d 0h 0m 3s")] + [InlineData("0day 0hour 0min 3sec")] + [InlineData("0day 0hour 0minute 3second")] + [InlineData("0days 0hours 0minutes 3seconds")] + [InlineData("0 d 0 h 0 m 3 s")] + [InlineData("0 day 0 hour 0 min 3 sec")] + [InlineData("0 day 0 hour 0 minute 3 second")] + [InlineData("0 days 0 hours 0 minutes 3 seconds")] + [InlineData("0d 0h 0m 3s 0ms")] + [InlineData("0day 0hour 0min 3sec 0ms")] + [InlineData("0day 0hour 0minute 3second 0millisecond")] + [InlineData("0days 0hours 0minutes 3seconds 0milliseconds")] + [InlineData("0 d 0 h 0 m 3 s 0 ms")] + [InlineData("0 day 0 hour 0 min 3 sec 0 ms")] + [InlineData("0 day 0 hour 0 minute 3 second 0 millisecond")] + [InlineData("0 days 0 hours 0 minutes 3 seconds 0 milliseconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 0m 3 s 0ms")] + [InlineData("0day 0hour 0min 3 sec 0ms")] + [InlineData("0day 0 hour 0 minute 3second 0millisecond")] + [InlineData("0days 0hours 0minutes 3 seconds 0milliseconds")] + public void TryParse_ThreeSecondsInput_ReturnsTrueAndTimeSpanWithThreeSeconds(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, 3)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [InlineData("00:00:00", 0, 0, 0, 0)] // Zero time + [InlineData("01:00:00", 0, 1, 0, 0)] // Hours only + [InlineData("00:30:00", 0, 0, 30, 0)] // Minutes only + [InlineData("00:00:45", 0, 0, 0, 45)] // Seconds only + [InlineData("1.00:00:00", 1, 0, 0, 0)] // Days only + [InlineData("00:00:00.500", 0, 0, 0, 0, 500)] // With milliseconds + [InlineData("1.23:45:56.789", 1, 23, 45, 56, 789)] // Complex case + public void TryParse_StandardDurationFormat_ShouldParseCorrectly(string input, int days, int hours, int minutes, int seconds, + int milliseconds = 0) + { + // 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] + [InlineData("1d", "1.00:00:00")] + [InlineData("2 days", "2.00:00:00")] + [InlineData("3h", "03:00:00")] + [InlineData("4 hours", "04:00:00")] + [InlineData("5m", "00:05:00")] + [InlineData("6 minutes", "00:06:00")] + [InlineData("7s", "00:00:07")] + [InlineData("8 seconds", "00:00:08")] + [InlineData("100ms", "00:00:00.100")] + [InlineData("1d 2h 3m 4s 500ms", "1.02:03:04.500")] + public void TryParse_HumanReadableFormat_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Arrange + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + [Theory] + [InlineData("69.5m 0.5s", "01:09:30.500")] + [InlineData("0.3m 100.07s", "00:01:58.070")] + [InlineData("0.01h 100.007s", "00:02:16.007")] + [InlineData("0.0003d", "00:00:25.920")] + public void TryParse_MixedUnrollingAndRollingOverInput_ReturnsTrueAndTimeSpanWithCorrectValue(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(); + } + + #region Invalid input tests + + [Fact] + public void TryParse_NullInput_ReturnsFalseAndTimeSpanZero() + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(null, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + [InlineData(" \n ")] + public void TryParse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + [Theory] + [InlineData("Foo")] + [InlineData(" Bar")] + [InlineData("Foo 3s")] + [InlineData("3s Bar")] + [InlineData("Foo 00:00:03")] + [InlineData("00:00:03 Bar")] + [InlineData("Foo 00:00:03 Bar")] + [InlineData("3house")] + [InlineData("3 house")] + [InlineData("3houses")] + [InlineData("3 houses")] + [InlineData("invalid")] + [InlineData("0h0m3s")] + [InlineData("0m 3s 1m")] + [InlineData("3s 1h")] + [InlineData("+3s")] + [InlineData("-3s")] + [InlineData("3,5s")] + [InlineData("3,005s")] + [InlineData("3,005.0s")] + [InlineData("3,5 s")] + [InlineData("1y")] + [InlineData("1d 2x")] + [InlineData("00:00")] + [InlineData("00:00.0")] + [InlineData("0.00:00")] + [InlineData("00:00:60")] + [InlineData("00:00:61")] + [InlineData("00:60:00")] + [InlineData("00:61:00")] + [InlineData("24:00:00")] + [InlineData("25:00:00")] + [InlineData("00:00:00.0001")] + [InlineData("00:00:60.0")] + [InlineData("00:00:61.0")] + [InlineData("00:60:00.0")] + [InlineData("00:61:00.0")] + [InlineData("24:00:00.0")] + [InlineData("25:00:00.0")] + [InlineData("0.00:00:60")] + [InlineData("0.00:00:61")] + [InlineData("0.00:60:00")] + [InlineData("0.00:61:00")] + [InlineData("0.24:00:00")] + [InlineData("0.25:00:00")] + [InlineData("1E+999999999 days")] // The value is too large for double.Parse and will throw OverflowException + [InlineData("1.2.3 days")] // The double.Parse will throw FormatException due to multiple decimal points + [InlineData( + "999999999 days 23 hours 59 minutes 59 seconds")] // This will cause TimeSpan overflow when components are added together + [InlineData("1 day 2.5ms")] // int.Parse will throw FormatException due to an invalid integer format + public void TryParse_InvalidInput_ReturnsFalseAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + #endregion + + #region Value rolling over tests that result in next bigger unit + + [Theory] + [InlineData("1000ms")] + [InlineData("1000millisecond")] + [InlineData("1000milliseconds")] + [InlineData("1000 ms")] + [InlineData("1000 millisecond")] + [InlineData("1000 milliseconds")] + [InlineData("0s 1000ms")] + [InlineData("0sec 1000ms")] + [InlineData("0second 1000millisecond")] + [InlineData("0seconds 1000milliseconds")] + [InlineData("0 s 1000 ms")] + [InlineData("0 sec 1000 ms")] + [InlineData("0 second 1000 millisecond")] + [InlineData("0 seconds 1000 milliseconds")] + [InlineData("0m 0s 1000ms")] + [InlineData("0min 0sec 1000ms")] + [InlineData("0minute 0second 1000millisecond")] + [InlineData("0minutes 0seconds 1000milliseconds")] + [InlineData("0 m 0 s 1000 ms")] + [InlineData("0 min 0 sec 1000 ms")] + [InlineData("0 minute 0 second 1000 millisecond")] + [InlineData("0 minutes 0 seconds 1000 milliseconds")] + [InlineData("0h 0m 0s 1000ms")] + [InlineData("0hour 0min 0sec 1000ms")] + [InlineData("0hour 0minute 0second 1000millisecond")] + [InlineData("0hours 0minutes 0seconds 1000milliseconds")] + [InlineData("0 h 0 m 0 s 1000 ms")] + [InlineData("0 hour 0 min 0 sec 1000 ms")] + [InlineData("0 hour 0 minute 0 second 1000 millisecond")] + [InlineData("0 hours 0 minutes 0 seconds 1000 milliseconds")] + [InlineData("0d 0h 0m 0s 1000ms")] + [InlineData("0day 0hour 0min 0sec 1000ms")] + [InlineData("0day 0hour 0minute 0second 1000millisecond")] + [InlineData("0days 0hours 0minutes 0seconds 1000milliseconds")] + [InlineData("0 d 0 h 0 m 0 s 1000 ms")] + [InlineData("0 day 0 hour 0 min 0 sec 1000 ms")] + [InlineData("0 day 0 hour 0 minute 0 second 1000 millisecond")] + [InlineData("0 days 0 hours 0 minutes 0 seconds 1000 milliseconds")] + public void TryParse_RollingOverThousandMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecond(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] + [InlineData("1001ms")] + [InlineData("1001millisecond")] + [InlineData("1001milliseconds")] + [InlineData("1001 ms")] + [InlineData("1001 millisecond")] + [InlineData("1001 milliseconds")] + [InlineData("0s 1001ms")] + [InlineData("0sec 1001ms")] + [InlineData("0second 1001millisecond")] + [InlineData("0seconds 1001milliseconds")] + [InlineData("0 s 1001 ms")] + [InlineData("0 sec 1001 ms")] + [InlineData("0 second 1001 millisecond")] + [InlineData("0 seconds 1001 milliseconds")] + [InlineData("0m 0s 1001ms")] + [InlineData("0min 0sec 1001ms")] + [InlineData("0minute 0second 1001millisecond")] + [InlineData("0minutes 0seconds 1001milliseconds")] + [InlineData("0 m 0 s 1001 ms")] + [InlineData("0 min 0 sec 1001 ms")] + [InlineData("0 minute 0 second 1001 millisecond")] + [InlineData("0 minutes 0 seconds 1001 milliseconds")] + [InlineData("0h 0m 0s 1001ms")] + [InlineData("0hour 0min 0sec 1001ms")] + [InlineData("0hour 0minute 0second 1001millisecond")] + [InlineData("0hours 0minutes 0seconds 1001milliseconds")] + [InlineData("0 h 0 m 0 s 1001 ms")] + [InlineData("0 hour 0 min 0 sec 1001 ms")] + [InlineData("0 hour 0 minute 0 second 1001 millisecond")] + [InlineData("0 hours 0 minutes 0 seconds 1001 milliseconds")] + [InlineData("0d 0h 0m 0s 1001ms")] + [InlineData("0day 0hour 0min 0sec 1001ms")] + [InlineData("0day 0hour 0minute 0second 1001millisecond")] + [InlineData("0days 0hours 0minutes 0seconds 1001milliseconds")] + [InlineData("0 d 0 h 0 m 0 s 1001 ms")] + [InlineData("0 day 0 hour 0 min 0 sec 1001 ms")] + [InlineData("0 day 0 hour 0 minute 0 second 1001 millisecond")] + [InlineData("0 days 0 hours 0 minutes 0 seconds 1001 milliseconds")] + public void + TryParse_RollingOverThousandAndOneMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecondAndOneMillisecond( + 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] + [InlineData("60s")] + [InlineData("60sec")] + [InlineData("60second")] + [InlineData("60seconds")] + [InlineData("60 s")] + [InlineData("60 sec")] + [InlineData("60 second")] + [InlineData("60 seconds")] + [InlineData("0m 60s")] + [InlineData("0min 60sec")] + [InlineData("0minute 60second")] + [InlineData("0minutes 60seconds")] + [InlineData("0 m 60 s")] + [InlineData("0 min 60 sec")] + [InlineData("0 minute 60 second")] + [InlineData("0 minutes 60 seconds")] + [InlineData("0h 0m 60s")] + [InlineData("0hour 0min 60sec")] + [InlineData("0hour 0minute 60second")] + [InlineData("0hours 0minutes 60seconds")] + [InlineData("0 h 0 m 60 s")] + [InlineData("0 hour 0 min 60 sec")] + [InlineData("0 hour 0 minute 60 second")] + [InlineData("0 hours 0minutes 60seconds")] + [InlineData("0d 0h 0m 60s")] + [InlineData("0day 0hour 0min 60sec")] + [InlineData("0day 0hour 0minute 60second")] + [InlineData("0days 0hours 0minutes 60seconds")] + [InlineData("0 d 0 h 0 m 60 s")] + [InlineData("0 day 0 hour 0 min 60 sec")] + [InlineData("0 day 0 hour 0 minute 60 second")] + [InlineData("0 days 0 hours 0minutes 60 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 0m 60 s 0ms")] + [InlineData("0day 0hour 0min 60 sec 0ms")] + [InlineData("0day 0 hour 0 minute 60second 0millisecond")] + [InlineData("0days 0hours 0minutes 60 seconds 0milliseconds")] + public void TryParse_RollingOverSixtySecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinute(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] + [InlineData("65s")] + [InlineData("65sec")] + [InlineData("65second")] + [InlineData("65seconds")] + [InlineData("65 s")] + [InlineData("65 sec")] + [InlineData("65 second")] + [InlineData("65 seconds")] + [InlineData("0m 65s")] + [InlineData("0min 65sec")] + [InlineData("0minute 65second")] + [InlineData("0minutes 65seconds")] + [InlineData("0 m 65 s")] + [InlineData("0 min 65 sec")] + [InlineData("0 minute 65 second")] + [InlineData("0 minutes 65 seconds")] + [InlineData("0h 0m 65s")] + [InlineData("0hour 0min 65sec")] + [InlineData("0hour 0minute 65second")] + [InlineData("0hours 0minutes 65seconds")] + [InlineData("0 h 0 m 65 s")] + [InlineData("0 hour 0 min 65 sec")] + [InlineData("0 hour 0 minute 65 second")] + [InlineData("0 hours 0minutes 65seconds")] + [InlineData("0d 0h 0m 65s")] + [InlineData("0day 0hour 0min 65sec")] + [InlineData("0day 0hour 0minute 65second")] + [InlineData("0days 0hours 0minutes 65seconds")] + [InlineData("0 d 0 h 0 m 65 s")] + [InlineData("0 day 0 hour 0 min 65 sec")] + [InlineData("0 day 0 hour 0 minute 65 second")] + [InlineData("0 days 0 hours 0minutes 65 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 0m 65 s 0ms")] + [InlineData("0day 0hour 0min 65 sec 0ms")] + [InlineData("0day 0 hour 0 minute 65second 0millisecond")] + [InlineData("0days 0hours 0minutes 65 seconds 0milliseconds")] + public void + TryParse_RollingOverSixtyFiveSecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinuteAndFiveSeconds( + 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] + [InlineData("60m")] + [InlineData("60min")] + [InlineData("60minute")] + [InlineData("60minutes")] + [InlineData("60 m")] + [InlineData("60 min")] + [InlineData("60 minute")] + [InlineData("60 minutes")] + [InlineData("60m 0s")] + [InlineData("60min 0sec")] + [InlineData("60minute 0second")] + [InlineData("60minutes 0seconds")] + [InlineData("60 m 0 s")] + [InlineData("60 min 0 sec")] + [InlineData("60 minute 0 second")] + [InlineData("60 minutes 0 seconds")] + [InlineData("0h 60m 0s")] + [InlineData("0hour 60min 0sec")] + [InlineData("0hour 60minute 0second")] + [InlineData("0hours 60minutes 0seconds")] + [InlineData("0 h 60 m 0 s")] + [InlineData("0 hour 60 min 0 sec")] + [InlineData("0 hour 60 minute 0 second")] + [InlineData("0 hours 60 minutes 0 seconds")] + [InlineData("0d 0h 60m 0s")] + [InlineData("0day 0hour 60min 0sec")] + [InlineData("0day 0hour 60minute 0second")] + [InlineData("0days 0hours 60minutes 0seconds")] + [InlineData("0 d 0 h 60 m 0 s")] + [InlineData("0 day 0 hour 60 min 0 sec")] + [InlineData("0 day 0 hour 60 minute 0 second")] + [InlineData("0 days 0 hours 60 minutes 0 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 60m 0 s 0ms")] + [InlineData("0day 0hour 60min 0 sec 0ms")] + [InlineData("0day 0 hour 60 minute 0second 0millisecond")] + [InlineData("0days 0hours 60minutes 0 seconds 0milliseconds")] + public void TryParse_RollingOverSixtyMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHour(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] + [InlineData("65m")] + [InlineData("65min")] + [InlineData("65minute")] + [InlineData("65minutes")] + [InlineData("65 m")] + [InlineData("65 min")] + [InlineData("65 minute")] + [InlineData("65 minutes")] + [InlineData("65m 0s")] + [InlineData("65min 0sec")] + [InlineData("65minute 0second")] + [InlineData("65minutes 0seconds")] + [InlineData("65 m 0 s")] + [InlineData("65 min 0 sec")] + [InlineData("65 minute 0 second")] + [InlineData("65 minutes 0 seconds")] + [InlineData("0h 65m 0s")] + [InlineData("0hour 65min 0sec")] + [InlineData("0hour 65minute 0second")] + [InlineData("0hours 65minutes 0seconds")] + [InlineData("0 h 65 m 0 s")] + [InlineData("0 hour 65 min 0 sec")] + [InlineData("0 hour 65 minute 0 second")] + [InlineData("0 hours 65 minutes 0 seconds")] + [InlineData("0d 0h 65m 0s")] + [InlineData("0day 0hour 65min 0sec")] + [InlineData("0day 0hour 65minute 0second")] + [InlineData("0days 0hours 65minutes 0seconds")] + [InlineData("0 d 0 h 65 m 0 s")] + [InlineData("0 day 0 hour 65 min 0 sec")] + [InlineData("0 day 0 hour 65 minute 0 second")] + [InlineData("0 days 0 hours 65 minutes 0 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 65m 0 s 0ms")] + [InlineData("0day 0hour 65min 0 sec 0ms")] + [InlineData("0day 0 hour 65 minute 0second 0millisecond")] + [InlineData("0days 0hours 65minutes 0 seconds 0milliseconds")] + public void TryParse_RollingOverSixtyFiveMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHourAndFiveMinutes( + 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] + [InlineData("24h 0m 0s")] + [InlineData("24hour 0min 0sec")] + [InlineData("24hour 0minute 0second")] + [InlineData("24hours 0minutes 0seconds")] + [InlineData("24 h 0 m 0 s")] + [InlineData("24 hour 0 min 0 sec")] + [InlineData("24 hour 0 minute 0 second")] + [InlineData("24 hours 0 minutes 0 seconds")] + [InlineData("0d 24h 0m 0s")] + [InlineData("0day 24hour 0min 0sec")] + [InlineData("0day 24hour 0minute 0second")] + [InlineData("0days 24hours 0minutes 0seconds")] + [InlineData("0 d 24 h 0 m 0 s")] + [InlineData("0 day 24 hour 0 min 0 sec")] + [InlineData("0 day 24 hour 0 minute 0 second")] + [InlineData("0 days 24 hours 0 minutes 0 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 24 h 0m 0 s 0ms")] + [InlineData("0day 24hour 0min 0 sec 0ms")] + [InlineData("0day 24 hour 0 minute 0second 0millisecond")] + [InlineData("0days 24hours 0minutes 0 seconds 0milliseconds")] + public void TryParse_RollingOverTwentyFourHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDay(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] + [InlineData("25h 0m 0s")] + [InlineData("25hour 0min 0sec")] + [InlineData("25hour 0minute 0second")] + [InlineData("25hours 0minutes 0seconds")] + [InlineData("25 h 0 m 0 s")] + [InlineData("25 hour 0 min 0 sec")] + [InlineData("25 hour 0 minute 0 second")] + [InlineData("25 hours 0 minutes 0 seconds")] + [InlineData("0d 25h 0m 0s")] + [InlineData("0day 25hour 0min 0sec")] + [InlineData("0day 25hour 0minute 0second")] + [InlineData("0days 25hours 0minutes 0seconds")] + [InlineData("0 d 25 h 0 m 0 s")] + [InlineData("0 day 25 hour 0 min 0 sec")] + [InlineData("0 day 25 hour 0 minute 0 second")] + [InlineData("0 days 25 hours 0 minutes 0 seconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 25 h 0m 0 s 0ms")] + [InlineData("0day 25hour 0min 0 sec 0ms")] + [InlineData("0day 25 hour 0 minute 0second 0millisecond")] + [InlineData("0days 25hours 0minutes 0 seconds 0milliseconds")] + public void TryParse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDayAndOneHour( + 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 Value unrolling tests that result in current and next smaller unit + + [Theory] + [InlineData("3.5s")] + [InlineData("3.5sec")] + [InlineData("3.5second")] + [InlineData("3.5seconds")] + [InlineData("3.5 s")] + [InlineData("3.5 sec")] + [InlineData("3.5 second")] + [InlineData("3.5 seconds")] + [InlineData("3.5s 0ms")] + [InlineData("3.5sec 0ms")] + [InlineData("3.5second 0millisecond")] + [InlineData("3.5seconds 0milliseconds")] + [InlineData("3.5 s 0 ms")] + [InlineData("3.5 sec 0 ms")] + [InlineData("3.5 second 0 millisecond")] + [InlineData("3.5 seconds 0 milliseconds")] + [InlineData("0m 3.5s")] + [InlineData("0min 3.5sec")] + [InlineData("0minute 3.5second")] + [InlineData("0minutes 3.5seconds")] + [InlineData("0 m 3.5 s")] + [InlineData("0 min 3.5 sec")] + [InlineData("0 minute 3.5 second")] + [InlineData("0 minutes 3.5 seconds")] + [InlineData("0m 3.5s 0ms")] + [InlineData("0min 3.5sec 0ms")] + [InlineData("0minute 3.5second 0millisecond")] + [InlineData("0minutes 3.5seconds 0milliseconds")] + [InlineData("0 m 3.5 s 0 ms")] + [InlineData("0 min 3.5 sec 0 ms")] + [InlineData("0 minute 3.5 second 0 millisecond")] + [InlineData("0 minutes 3.5 seconds 0 milliseconds")] + [InlineData("0h 0m 3.5s")] + [InlineData("0hour 0min 3.5sec")] + [InlineData("0hour 0minute 3.5second")] + [InlineData("0hours 0minutes 3.5seconds")] + [InlineData("0 h 0 m 3.5 s")] + [InlineData("0 hour 0 min 3.5 sec")] + [InlineData("0 hour 0 minute 3.5 second")] + [InlineData("0 hours 0 minutes 3.5 seconds")] + [InlineData("0h 0m 3.5s 0ms")] + [InlineData("0hour 0min 3.5sec 0ms")] + [InlineData("0hour 0minute 3.5second 0millisecond")] + [InlineData("0hours 0minutes 3.5seconds 0milliseconds")] + [InlineData("0 h 0 m 3.5 s 0 ms")] + [InlineData("0 hour 0 min 3.5 sec 0 ms")] + [InlineData("0 hour 0 minute 3.5 second 0 millisecond")] + [InlineData("0 hours 0 minutes 3.5 seconds 0 milliseconds")] + [InlineData("0d 0h 0m 3.5s")] + [InlineData("0day 0hour 0min 3.5sec")] + [InlineData("0day 0hour 0minute 3.5second")] + [InlineData("0days 0hours 0minutes 3.5seconds")] + [InlineData("0 d 0 h 0 m 3.5 s")] + [InlineData("0 day 0 hour 0 min 3.5 sec")] + [InlineData("0 day 0 hour 0 minute 3.5 second")] + [InlineData("0 days 0 hours 0 minutes 3.5 seconds")] + [InlineData("0d 0h 0m 3.5s 0ms")] + [InlineData("0day 0hour 0min 3.5sec 0ms")] + [InlineData("0day 0hour 0minute 3.5second 0millisecond")] + [InlineData("0days 0hours 0minutes 3.5seconds 0milliseconds")] + [InlineData("0 d 0 h 0 m 3.5 s 0 ms")] + [InlineData("0 day 0 hour 0 min 3.5 sec 0 ms")] + [InlineData("0 day 0 hour 0 minute 3.5 second 0 millisecond")] + [InlineData("0 days 0 hours 0 minutes 3.5 seconds 0 milliseconds")] + + // Mixed w/ spaces and w/o spaces between units + [InlineData("0d 0 h 0m 3.5 s 0ms")] + [InlineData("0day 0hour 0min 3.5 sec 0ms")] + [InlineData("0day 0 hour 0 minute 3.5second 0millisecond")] + [InlineData("0days 0hours 0minutes 3.5 seconds 0milliseconds")] + public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithThreeSecondsAndFiveHundredMilliseconds(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, 0, 3, 500)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [InlineData("1.25d 1.5h 1.5m 1.5s 7ms", "1.07:31:31.507")] + [InlineData("1.25d 1.5m 7ms", "1.06:01:30.007")] + [InlineData("1.5m 13ms", "00:01:30.013")] + public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValue(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 +} From f7616893fbceca82d69fd3591d6866a60f189d3c Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:41:02 +0100 Subject: [PATCH 02/17] Fix folder structure --- src/{ => DurationMancer}/DurationMancer.csproj | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{ => DurationMancer}/DurationMancer.csproj (100%) diff --git a/src/DurationMancer.csproj b/src/DurationMancer/DurationMancer.csproj similarity index 100% rename from src/DurationMancer.csproj rename to src/DurationMancer/DurationMancer.csproj From ec468a903e83a38f40798adac1b46a2acfbfd9ef Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:55:21 +0100 Subject: [PATCH 03/17] Add assets and documentation for NuGet packaging --- README.md | 85 ++++++++++++++++++++ assets/images/package-icon.png | Bin 0 -> 82170 bytes assets/images/package-icon.svg | 97 +++++++++++++++++++++++ src/DurationMancer/DurationMancer.csproj | 20 +++++ 4 files changed, 202 insertions(+) create mode 100644 README.md create mode 100644 assets/images/package-icon.png create mode 100644 assets/images/package-icon.svg diff --git a/README.md b/README.md new file mode 100644 index 0000000..b95d745 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# DurationMancer + +DurationMancer is a simle library that provides a flexible time parser that understands both technical and natural language inputs. You can use these formats wherever a time duration is required and you don't want to deal with the hassle of parsing strings yourself. + +ℹ️ Negative values are not supported (yet)! + +## Usage + +The usage very simple, like shown in the examples below. + +### Humand-readable Format +```csharp +// Example 1: Mix of full words and shorthand +string input1 = "2days 4h 15m 30s"; +TimeSpan result1 = DurationParser.Parse(input1); +// result1 == TimeSpan.FromDays(2) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(15) + TimeSpan.FromSeconds(30) + +// Example 2: Decimal values with flexible spacing +string input2 = "1.5 hours 100ms"; +TimeSpan result2 = DurationParser.Parse(input2); +// result2 == TimeSpan.FromHours(1.5) + TimeSpan.FromMilliseconds(100) +``` + +### Standard Duration Format +```csharp +// Example 1: Days, hours, minutes, and seconds +string input1 = "1.05:00:00"; +TimeSpan result1 = DurationParser.Parse(input1); +// result1 == new TimeSpan(days: 1, hours: 5, minutes: 0, seconds: 0) + +// Example 2: Seconds with milliseconds +string input2 = "00:00:45.500"; +TimeSpan result2 = DurationParser.Parse(input2); +// result2 == TimeSpan.FromSeconds(45.5) +``` + +## Supported Formats + +### Standard Duration Format + +This format is best for precise, structured input. It follows the pattern: `[days.]HH:mm:ss[.milliseconds]` + +| Component | Format | Range / Description | +| :--------------- | :----- | :------------------------------------------------ | +| **Days** | `d.` | Optional. Any number of digits followed by a dot. | +| **Hours** | `HH` | **Required.** 00 to 23. | +| **Minutes** | `mm` | **Required.** 00 to 59. | +| **Seconds** | `ss` | **Required.** 00 to 59. | +| **Milliseconds** | `.fff` | Optional. Up to 3 digits following a dot. | + +**Examples:** + +- `12:30:00` → 12 hours, 30 minutes +- `1.05:00:00` → 1 day, 5 hours +- `00:00:45.500` → 45.5 seconds + +--- + +### Human-Readable Format + +This format is ideal for quick CLI input. You can mix and match units using full names or shorthand. + +#### Supported Units + +| Unit | Keywords (Case-Insensitive) | +| :--------------- | :---------------------------------- | +| **Days** | `d`, `day`, `days` | +| **Hours** | `h`, `hour`, `hours` | +| **Minutes** | `m`, `min`, `minute`, `minutes` | +| **Seconds** | `s`, `sec`, `second`, `seconds` | +| **Milliseconds** | `ms`, `millisecond`, `milliseconds` | + +#### Key Features + +- **Decimal Support:** You can use decimals for larger units (e.g., `1.5h` for 1 hour and 30 minutes). +- **Flexible Spacing:** `1h30m` and `1 h 30 m` are both valid. +- **Automatic Rounding:** All inputs are rounded to the nearest millisecond. + +**Examples:** + +- `1d 12h` +- `5m 30s` +- `1.5 hours` +- `100ms` +- `2days 4h 15m 30s` diff --git a/assets/images/package-icon.png b/assets/images/package-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..682d35a19024dda61fe3b011f3142827c0503199 GIT binary patch literal 82170 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

haBp$>f;Fyum3)-%fw6!jj{FZAj6a zC!uRDjX=I1vt~E<(QIFnOS60W8tbw@_>|qyO~i}5JJetb+7a~j*F!`tpTxYd@b12A zt#my0%2Vab6j)2jrM3A-S5R&B7|Jwc!5O!K-RYnrX-xUa+m@p#Q9W|Q9tV5Y+fUC) zGWzcIq~oMhCU%OiWzU)g)BChZcL?0)OG^I7mQ>>7! ztY54KF8^KU)i=kM%Vf110xy+z=P@UrqN%WHQGFd&hGPrkP+~Ko6Q^PZKfc zhTW`&VB8$KeMg*&mIO{ezqo5ByezBQnV*F{bI(O*VM&1)6{uo3kzgD`7r8ra+$Ld@ z>MdQmHC4QC;oO>FAEsYF>KS3{w0(K~vT83AoeX!?8_;2=v2QNq;H%@0Ym9C>dbjl^mz5R%K0@A$gQ(LI1w9nGrmD5=h(C&S%4j>0T zDrNBe(0LV!)BO)YNXaqDud4Utx9gZ_dAp7YS1&`#&O4p$iK1n{IJvDqAfqlNK^X8a z_QWbWhtd?oUK(q%C-V$<(s-2!c+h2F%1${Y4&5A-xNyh<{qCp#Yj=ip>z?0d88Ne0Rh~TU|P;bv~)bV=pUb6;@BXvw)Ng zx|Kl102w1MNv9{@vV%4oMo^Bx>*d_04RF12#4(lbeswNccJ^MJ*X8zsyIE=kWFLNj zC$nEq%xH85JN{@Umbo-YI-}uN?KGP5IS$}B&nZ!occ0Eq2kfn6p8cfU^{}zR?e6e~ zJWadJo0m*H4TPe9DF1*_)e0S`vQ1604%lmvC0rt9r^pp-n2X4a3Vun+nDt!peHUZ& zw(bWn&aL4!bj_zCAFMLn!teYIutjQ$B+Ky*hMX9Z`L*IUlOe!2v~&RVcaj9#Ycb@7 z7lcfuY#0qywZ)HJ?jd;YL5oaO!Pzv8428Y**PqZGXb1{_y>{4N@{Zm}@HhMZ=_Kc8 zcx>}SgGkNR!;j-YE2a_>D5LwDln;t5p$9IL^7VfCbLIK1cQ7X80hxE(ALl{PBBRbb z^|ism)l0?6?`&}|GT&^8pM3sNrKcF7_XoBW6-{ud2soesC)KM$;YI~nG}l(b-E06E zs4kDUYi(Y3i;;6+aM$@t*psYT|uJ)T*VV?d zZuZBSp+|i;SVas*0^JMImTrbY*F5$DJCA+sJ-_!buUqa&)a(W0new*l2gWLpQ}$L^ zhST&v8MK1NLtGgjfr4csdPj@mKgGdumqF5_5Af#|ymPt8;pXKjPjv3kA9n4!*0p~C zzPI18zb!>WDVE|&;?y)DqR&LHE+^;Ya-B6$FHRj{JYoErzmZ1-M7h4?J4;6at_rT* z0e)yp0xiieuiz`;wDDJXGI@iRD_0Rjm$wK0G|`WtJrvwLTU=D_^ElPl{=1XG1xfhV z2N7^#>JF+k9FZ4NAPx-?pF4B=m4r-Y!6bD|n6ECbLi9bvT_D8g<*&&I?i4vq`$xsK z1KL25pmKDusAg2EZS>hUqpdeR=*h7VSu1arXse^|Fwbb%-m*a5NHecv1hY1=b7<#t zSnnY}L`k8tPnN36PQS4>Skz98+%AV$a!P5ty2@ogd?Q068uy;dGLxpYb8VdW@dTg7 zfRY0Ob9Kc>^)@4O&V;_X@bB+Qs5w1Fe<(j`2hy(BcBFT!VGoWUbq#jnMU9KPKWYaC zR%mOBLu$bxEH@1YMbMQzCwI;sK!4+aK=9L>*eRji*Ja>)ISIe`bT#jycd_ovL)!QC z`k@p|&pPmbprFYN6~@S-gr&cF7J-Ju9?gJ(jiimIfJ=x}LKXL%2}xO$m`@og*^A98 z8HsL3vzHF}?**xizS9UR$V=@i zj2GhdNJ2Z<7R)2+Tw(v`r^tI*bVl6-pL(B}7D)Jy@d|(q>uI}W=Uc*c6`#Sa?9u7< zv^-@)fW&!SMbE1HJ>%Y3?>7l-eRnRTnagA^KAEY#m+&ReOgJA>bUKSGys<_#lv%m-qaNJbJom*5rUaJ<-?Kf&)PR7zn{m?UM7(3}Y75a*F;K=fMyf@Xa1u^2Ag8~sp9nXXT+IFuBcmLTrpePMhZzL5vFB$%q30%) zX(Ps=-k(j&9gM+>Y*a?t(dP0gFwpR0VPqiqzrJ`WCN5m_;Gd0gVfFPd>>P3VSQ~*7 z9s!dt0`=ZlL64Xmit+414X+#?Q>PV71)v1F*4`3gzFxyVlNf+9AU|2bLbq;ZkY$!0 zl7(XZe1?~jAfKb@SZYX2GLg{L=n(oe)H9s%85%{DUdQ7qPz$IfvZWN2t4;Y{C|D2@-P!rfc9sNCA^jAxgb9WyS( z5cXX`b7jREYs%n{ZTVgB#l-m=e-v}C%XD*RBPIS(N#>l-bwV_l3g_1ru@Y{d@B3<`WZUG1f8epl^M?WQua+iZTW+Qj1R0=6Nn z7pxyS`qouqiyU!0wf7|x793vvinR&3`jj}f`G!}#_L`!*Avs)~{pl3p9HJMsPuVKU z!(xA;EkeJPJeyIhRSfYU{M;?Br(3yXLb^&uE(z+;xP0OEA82YxFOy5oAj(;`N*^Z35}k|&+cFfwRUTT+GJZ~4QM%5tRis;HlDp}1*bKscWHRfK;!yg2|d z>d~eSVLu#aET_*O^3oR7vjqehyHu-LXoqh!3)o|1;@6t8~0eYE-EQ(8<{}Qu@EgpWkdcTt+SYieS-slWoxA z)HrB3Ww6lgTse6X>}nSRc*?8~Zy2_C1=EHO8uy6WAlUSSa@!;|@@qZ*r0%VkA1WIs z-Ks)6syp+laH5UI{Tv=wJ@$;KCO%jDb(?7r;acl|rR_3SeXSO+3Hja}z^=$J%9iOR zX`88SsxUv%r#q}_4iW;%Dz>?_*a5mpll?SmFO#gk^6?O=2FGxwFje{qN!!UYvE^Vo zE9%&C9dKx!l>(bh6*goj>JC|f3+k~&rNKQBJ~}PD6(#gq2yd%$_t?RUjD=!AyMwbF zKZ7~zkc5qr3GFSwtEFW^tL^-2-M~#IKJ*uzYraeUst7))CYzP3TtrH5nGr+A<)qCM zaQMQ;N7@iX-Bs90lM&6d#8cODwV5;gnsg?!dOGi-DSu&QAxY6%-n4lVNOaKPrM0N- zMw4zw)jY!eaONj_A7M413F%X3-{`;OOM6q8akuu{ZVBzfhQ-$VV@cYgM(r6MP?gs? zbn)+c)jiBQvt%9-dPBW8p$~6{O=L8?7^%E}v$hBi92`wMyAhhRMhJAFz+tCGR>nBy zpk^`zO;_0&Ay6`$6V5@_DtUF@ucdZIQQvZX{=pOfH_PsFCf{^-Ru8@lZ+EY@6wi(o zwaddT#l%S%9RU_oi400q9TC5bV&jf8+X5ERj)<%6wG}l+cmT4Hei)N*);guCmZY25Jn37UoopMNO!6z(T9?kbM z@6wg^Fj64XgLNq3BhT1;Qt60MGH{L#ndZ0GW}nYftGOcO40x1#5`AC2{%sy2DlLkF z0z1nmBrK6{=Z`VXSAYMHR{vv&ZHx~I3qPD%o|ShQhiAJO6>@?WaK6IrSl2451*E%i zJSsvItF(@t@$iJ)WPZF2Uw#ixXwW7eunV3%&!qG;%3b(on!M$7MVV--4HYtNF>9RW zfgE%o{n|afw%@yUf6QCV5+=UkUH;&;%J*aC9E4Pu-xQi{k@oc_$5Qkmf!Z+}KK752o8sVP z0#mRwknm6ga_hJCM(*mxZb&w#9=?SGWtt_%thz)JED?_rH-L^gOzxX(B}|8I^*+wFp-`tDFpU>^pl%wY9sW4F1QaG7Bn=0 zn23X~icOu@%Q8kcO(E<}1mE8AohYDMJ2kc@He9&f-JL(L{Otw#Hlw)PaLkoA&Z#w$ zyb~lnRMFb{7^a>gb%*Mj;SAw0L573|b+cTLJ>efUX+FF&uvM6y&zaixFE;-e7j}eN zZ{l_E#|k=IzPWikTp{9vFk6Vnmb~Z12&sWG?G^?azEJmFi6UL-^IdR$%*X<{&Q*jU z$y^t^d`Eh#r7l%)&AaXjzs(*S>jw|#jDGR>0&SIgXZJcd{%t`C**v$Vl*K+A*d<$; zq67N|LV5ROYzT8`qG=<@dNZ)J4t^f0L87DFVLg19YXF+B7htl9N3P%cU z8&?|p>dY7GH`ND6e;BsDGhA=b(6)8K=6^RdZV~P;#`&2I*}PCiaelPBIREod98d$z z!lzjF3T^D=P~aYB9ss%Z+WM~Jk1Y+c9TG<~X%jQj?v${-rvcVopZnZ%=iUVLA#yhR zb%Mjm+IDbG1Fm!KV^Q}SDr`Q>byti&XwJUvNc*nu&>0rAwEP6ts*bS!Gf#f9I5*9Q z8F9P#WJ?7;b`iLryLLSFh(WqCi4-Y!j87x~DskjlcydG-GEhtHqM6(?$bx23{@)&2 zI0=1PuS)z>lsDpzy=8;2LLwZaPyr7T&YVl9IfV(sj#BFB+6$=QDYKx@ktWgvKU;vVppNSvBj{4#)wmlhZT}z-~ z4wXO#i&}2!zxx7~p4~LtZzb-6B=lpc6VtU`y3(tE&0IdqTe+M`M0=sAAKRm(I|ntj zCGRozXP%9!H=EM2jSO*k@O>1!e0CXOyW5h})7kx*Y}h==Lwg%FgRNazP|H$5I1R{~ z9O^)4kE53Sn0*vAn#e#~EoHTR>kD=CaNaZAak$O<7>kzH4ge~WS zb#aY9zP^}W#?>RGiyC=mn1MGNy%Zi%|MgWIgG((Oi)JWTCXPWUg%*^HeYP{qHJ<=H~n>uO@MDvjAMCM=&-z5bxUT>KngLo*M#!%8sFB0L9Pe8w%q&Bc{A(o{;5-tmkQU=vjKnSaeVX%I#4;{(vnbe#! zbwKAn+U^#JN?ajxu+^-o!R4*5Xu+-uu1CmU%y!tTFZdO~dzN~%Cb_wa)$!r8=UY8y zA(jZV-EY}BYgT2vLS0iR988s8wBs*%?OFSC^iqv{yxb{qj0$Od^#)vJMg9bR;j#NY zGv3}~%(p8`=-oVYC!DDOA49pkw_@8g&eY$|&-o#PlR#9`PU}o0imTNH!gL^oD=-HiemQQm7Tk9?L?keHBOzdgh;D!0=pLNTXhmigaDPc4H zG}e${LkEIS?BVjQiabTv@`-t>B^HZ7}G3@ ztSa3z2Bp8HLUhc@Sk98?1;qb$QZ*}0`a341qQWk8tG$YqkNm2axtU%~d08qIfhuk3 zZr!U_ZTZpb6WdurC@l_ie|X0$`JgXLe^qpx>h&^uwMDAG9oXVq%9js4&f2=7n>kmj zcTOv6UAYR9pcGxhnaebHGA*Uu^__k1nzjM5W;1L7L$~zIzSDeuAB=O<^O* zKVU3O6pQ{d+zR7gxlybYTdi~|T7_`Y&cLH*h;vap>B1se^g=yLR6*S`{NWvH2WHIWD;v`z=rS&2=@ff78 z-L}K2ZO@1p8JNsF}pAq%e7w^r(6nQd%=lSAh*gQBxSb zzxw`-v3k%YP;sn0dOA0R{9M6V9DcPh`#ecPg+98nN2UcRv{qJBnNm(ZnhY;tMEQZC z!&5g|_DuuyQzIkeC6jJ^_z;>ZD|RZ|l}u4_+E@4{T>vs6ue{s=lkmrU4Brgq-ImI= z(ehGa@({ngk}w<}a!!R*jsbM2%248;F)Cpt+AWa>GsbO8p$@E`Jws;pe~!%5bZzBN zQhmSqha?*0y5PXSgG5uQ66IIgHiQ7uN)*|b@2EP0fC4q2ch^?AodQj(9iM3Rr2shQ zI@y=KuY-I6nRro_$F=RvmB3@9NGt!Rgsa>mUQ430Smp0;Pb7zz&SIr?%&a>!#$i{Z z=kXcsU_K4_LrWiyoWAg*By?6DOnfQ%CA4cmROWzJ+9e3J_^cYdZOQU!%6eeHYm}F> zXF{ym9Yp6K)U^&f)=b9IyS;57PO#g0W^D#`45efmCGy(0c%G5m1bXQcFnCbM9!bR$ zy3|fYKs`+4)e6h>XFWCZWjiD>oMD>2ebN=t)^=|ewr$Bs&jB+N<2BZ;m@H9lM&WfS zG3U>}L5WwfQ2=Y1|2m1aSq(+mcGAc$CFa)~v6Kz*x_n4GPGjpf*?!gcH4nbGa)29a zYWBEf#JNOgU?9!GNe_V)6@GK`5_5S%pSjBlHj-Zys~}&H!VTEs91JcYI4m#R%C=IP z*R%zVod#8WIKeFso-%9sI$Y>S5`s*2@GIe}gVy(+C4}J7@nkCaKrtyUn-ikKac^d% z=cny*@Pe=Ojm-H*Pg;MsPJfO&(i9JJ`LkzEH1FPXHq%Vae$>ePl-W(%cyIe5+>6Ol zsb!EN_WUcd+0Ymg^w=<^DJmm7$@+#_3ozTzc)7Q!DL=TmoIH>&Y zl2ukmW>nuHesqXXM|zTl2rMFtk!=S43_GrBuP{ZZ`L1Rmu0gE3TuX@^@Pikx>|8W~ zJ^+dM>#vP1{`$qqP-Wxl$vUaJBpTI}qxUO6>)MQn+P!IBK zcY|gD8`qWQm&Qpj=9TL=XY=ss&HyRW4e*c7j?eS2M%Kc7>tkp+YNKY!nr?}$;ZSSP zpu#L7D2j%pgpFsl`*R$y^D8PcocUU?Ir@qu^gdNik_^2| zoe=}fKf1jz(HaH!lIaM4!=R75_AuspVCvj_MhFE~f|ZhrWg9R-9Jcw)S9&n}C9)SQ z?P2dj727a#ujdNkd?$T+qb44$f!=%Wp0)P(Zo}G+4-`gC`*VcjZp&7${X_gr>LsUH zhA7#szf5;{>IE&cx~L=?CI0pl@OAhAg$fF9T29_2yT7>v%_Pm6`6qCjg0ofA=Yj1Z z;hkQ$G#41&DeZgDAUzsS&gTv!@%*}*oXcWh!^S*j`@NKZ`I9QedY|(5c(72y!FAwB zV;Pc}Kn<(UDj|%c23)t%bE5FUBNH&EoQRT7(B$7jCc5wKVy{0!Xe%*V6iH5=tjw$5 zd+kiJ2z553xgvONq90xlj?ar}eg@D*fnz&rPey+>?k6Ng)5ze>c z_#B)8HP#Q2>MOOg*QY)Z&&GyqhuT3cR}{BN7K@iYEgf^$2Kv$&=2B*E52d8?YP-2z zHX##bYQpUVI`=ro6HOb_)`^B_1|Vr@SxtU`VGgJ|cOA@aXbeixGZDqupDoT~`=o8l z(O>l7bzGg_6U%2RaDj5PLH|McVLYIqF7&vHudlu9cZwO9^G=V<_KZ2DT#s`(QOINAdINE~53v z-x>Y$Gb>0x!!3}abQ9&mwJDyMLSQk?l}s}vW;OWY^DVVZ4Zj38-Me&*u!(<~7q|@H znspKg?*+@vB4*9f|J@gmQ4}MWtfrim{?lH~kIwQqT3PF5B%M}6&4WeuUGw}5v@ z8$IXHx2L@Wh;n^C&BPU>@!JjGhD^+TBUBQZ4Chx8q${(kfgE~q?7|db0=}hgvBVAZclYZuudxi^fo=w72WsGy#qr|A35iHG4bdUL(M6nPJmA3T4ks|QJ_aRLHQ+6+!2M6 zQB6=mI`NFHjw!j!prw>^jC;==(zrGk-96kCxz{v#yUGZJz?14y+~uW!au>Uq;R z3bcsbDgMIFFFQaCrPT~? zSYOTF{hkwfR0y&55Aj_ia^al&Qa_QP*9U##nakjAiXUPw3=7G0SEPK{+&*pQ=5*|N zK*#}%Q#TqP-?Fx;?mCwKJCkQOLAtc2a1ihls$vef2Y5NA)12dCDWQYSdVtH$}iZ_ui% zl6WT}snEKQ!g4*!6#+x%M{+xOC!vQtDqD=f_WL8MkJ0o6f}A z`w2?z?fSTKLk07e@(c4b<%ESvAsjog8fCiYc9M&(c%#|24}C-0TDDC^MV$@t zMIm~+jr~g8z;l?kEYpv~c?$K3Ej1 z=$dbZhhAxmDtmqP^{n@jsu2amZB-xWQ5d5-$5M2891(d)i?i5aOjycn&+sO?1OWN3 z{og#FrTnSN#--v@c6lT~pTZ)Dl(Z#F|GL_l(y^jt0EbWpV>YZh9n~lMLb<#=@(jhG zQi92}4exu$HbK#+mfUjROHC}k;9!D+pr$!!fubgwY9VpzkZh{8%HNBv8I-Cry?Y%GWK`RmtfAt-7#TZl)`qUB(jDP#>`of#}c7Gc?`R8Tmh1a(~ zUZbz9oq_KmWoMVt!FbY-HQ%rvHohtUs20g&emnShJrC-AotKFa%db62&D9xVn9pB$ zt4w>vJ72Lzix}Vhq!B`nFAYV?6TfkFMe$)>@6X)vNBL9KuT}WyxuQ_hVhYyZ5~kE+ zf;Fi<#{2!j&qpjB`-K{h0S2DAFj2oha^#`X+qA1zH$W$`YPWDI*rwvEsLAX@8O&TK z7pk`b)k-(Rvorp}ve{%y(HP8dhs7@i_VW6yHKYrkeumftTvgNpd9ngaXh%~R z8$480(nZZjTREO+)*?&6&sZqt%d4AOqT|9j23i>ynCp;IqHB@@vu7EL(AmnLK5;U` z-Ij1cmMUMZ$qbnJI74&s5fh->R8kc)%d1+jd3;+1y|;Z3FQx7BM~cg}1^7m+cBKfm zas8N@Sge|3HNmezxv?Q3tl*H_mQ9~^(qO0%P2eHtia4?`bOqaRBj8V^l7+-XC-T!o zD|~!Q&{mA`1@y>>IwzMy+Z4>swdCnOZK7Y) z?dnFT?iF|S-<{4RW#s7_etFP_OdxatQL#3^kC#bapewsJFV1uK-TPrxm}4H~BFH(8 zq)u#x8mZ{=mpad)ljON(3CZnk73Ph_g_TZiaE-F*UM^ciMQb>F+_R+e^SXB`61 z7=Ja=G52PjV(!%!K{IpE9A@=iUFHy}FX^wM-~t$VIV?PW|7q78p0N85HyZhfVv&hf znr80V{L&?y{F=7Zo6Pd!n>;1b;5`TEDX;S}*i70^K_GjJ0m6FIfA1Yp4z58%B^b9g zIuLy8w|hR)<8xuK3_mt=%AG@NnY1Bd&6iw!%SzWxGyy8kS?#C#vQ);zDsBK>L3+$R z?~?daujialPGMNM<7N`*D0E>gT4T(+G|cCo@fAvg@&02{(-`>G>-bb+qmf6#ELEO- zea9+OyG-?aB_(q-Ys>s$o&);XKZITEsyvCDA>f1N$OmzyQgLMG0N`(vJz#8}nBRwI zI^I!)D2}VWIx6~X#B2ViVVTpT^q3U-#Iz=q^V2}zoOk=H7fIRICF(zd605aJrCKv) zlfFe6sWodv+5~=jnk#SIVBcm=5|7}D=5##Wg@9s&d#;ZIE}fbB<@5__#l0A7B)y0d z$!o_vb$kF@KQt$rLx~^e z;ZH)EVjg@=)>W4sA#O5SEq{74nsqWk@cRp=0QZ1QWI4W6iawb*$Z#~dB3cutO?!;C zTgaP2dIc$msa`*$;@v{Zs$KXWv=^x`d3ga-^duYkQt5;h81f&2e?#Z5wzX zHob?cs$d1=^{Rqo;Y3KCpJpaaV5xkW{n>#zYN6=!c0;ey^Tye@9fkEFwhn?5L zTRN{XnVo+@YTC%>%`YWpS_K0GhQ+E;-8rTYwJBb|Amr}*QLdfAFe?|=tG#azCmJp0 zNF8eWCi>Y;`u*@?8#6>pP)Q~y8$4F zB{1d6Z0(E7AS645qe%bH!ql;WqeGM}O%@F6z3idXS2r4ck(G=%UPRC(;jHTH>kjQ# z93@k)yQQ8yqOW*PZyJoenC^}ts>bBwe{dP!ZudFF;oTLiGCd#1}bmvIufPVme!%SV;wqL}0w(v-g0x#EG zctVuAEnA0-<4`Fv4p0vmKiiV)v{hf$6PL7h^Qmcu%H@_K;OL;XS8T2U=L%NrWeZ>( zHiL9!uV+u^m$QDg%}t-Ld=jW0(9NlglG!V#BK`mf*Kz)vaGfmdMR$)Tb?N^o>2|8! zeuC6$=t9a^JgSpmWziLQyF_Gt3#hga)T$ti@0uE=$^B6tL;(I|%`aEsTlKKAwk)Mh zp^#e;3F~8a>oHiJ7D4gRuT?$X>r+yN@Nn7iIrNP?lGOdD+pO)a)Z-{<>w8BjIK+Lp zdSJ)Ehojz9-1gDOj}-8sYSp{6SV}^VuMiD+onM8=e@*0_AeB?Xjx-~iasCmSDcmi5 zR0!%2LhkcNaqBs-y>orM+Q`qlL(ThRNlC`>-V)-!Li^^kvS#m#nyS$g;K=CFo}qVb z8J;@qzTRvV1RKzU$OLu+Whky`#9JbUjLo?CaNeoqnjTKKdywzC*vhpAEyD9+zJvpB zv=#4EI&Qw}%KTxWtB0wfst+IjlXml!H1A@#VbWX3=-Vc}r=6#+J(2S(fxLLsB0&lO zq4g7HeVHeLbwaE{ZHL|(DEO0i*6Qi|Ht7SrFn#Bv4=^o71*45Os8m5d>~TW#2n*yc zi5Myc5nOq4lH-$V*4F@|O7&L};v)X4^40w2;j0z=$FV_L!$ySHacvnG&}QwttD*g= z(S1z2q11h2e;!8`!g{DFP?ts?P->1E%Kdk&0NV7(-J->uahK8DUzNDJW*O2l$a0-v z8fBKy1!~&+`0u^|kK$t&3_R3Y#x4CIu+a-VC}`3S-4hH*38n zE{DHPW}GM4EDJ~(8#C+w6D)Rjj{{x}tR#yc;GB{6x42m_|Koj=v_-uJ_xFT1wJhkq zX2v*_6AupuG+VJ(p^{)}bH>o>-YnsL=;C)QKwFn=FAv~0$1A)Fg;$>;_&soeX8_FQ zD_ofpY4`p!MHg2X_wNW$2?%_2dHa6*p>I0o1=H3-mox7w>^<$kC(zxi$!frrUQYge zBn-sp9`ftnm-RdA6%-6g#7A5^s*UA;m)O;2<>=JFy}TdR5AMO+Nc~%2hJ`3&e}Br@ZIU#1cg$i33o5e#Eh1cgl4ZJ9;@S z8-QP@Rt3Jt7eJ6x>pmq5AYHaoot2q2&(kHL-k>S+&){B*U6?~fGjmU!K$oI^+6 zC30b_LbmS4-=e#9pY*kbK(#83eEm}_G^EG>39%gti+bZd65YTjGoJ&}U_t9#~uD+Vrg2Bg8! zi6i3Ts6;7-{#G{f0s(_fzisy~lCgt#TUmM1(SeT}$p}xUE7Sq%ou5XmoOT5@ zngSLWabzi)p6ig2zQdcn0+!U!Jg3m!FlDWyJtaycIZ9-b^7>ubVWfz8S#f7;o{kcf z2>5eNcRRl)WM9S#@B~o>Q913u*X9ChWLiDE*&T%kW=BCacamtn!RJ4kX}mC~rG?vWbVN5RxNnWU!L1^(ZF8S4{k}aoPbgT3DJxt@PkhU)<3R*NlO- zpXKqAwt&aZbHhPnv#&>Fv=2m|+dL%1R$NLR*M+5m1SwpsMji+8%hzgSu^{JCJMaB0 z#8;7Qbo$f_6?r30)GKzaLvC+3=cS{h@25`7`iCmaJv&%CF}W$pc8fm@@m#rRyq2uw z9t)du`nqWQ{+X?Ab59nC6ZFtuCgxB4X@R6C6mnBMT&-eQU|}}F4yZL1hd!GB5X*8( z{%q5g7MG!ON$~RP{h*bd8wKs-2>5$`$ob6_`o&D}sc1TH_a7lF(|%jbJ13LT?{$3Z zO^1RVrai!s;hhJHyRCZ!D$lsOzj-wGNN+Sk7lH=V)=lFa`HzjvrhMhCUuc2qPe!IQkXYJD-#V_^`kmi*%m#S*+Fsa*w-7iCsYM%L>+TGzQOM2Sh9?dD? z_A31~sl=6B>`XR{|CT;wOvW6G38bB&Ihk#H65brGuB~8+xGy7$(J1AqoiLgSQO&<$CJK_8&EF?BS{;KAerOCh zZ?s5V)Bm}dmF_({+8KjHF_p`vMHa~$LSiu)=#%RJ{H)B+C&4x&oygzWAL)@!Q&Bc@ zG-4;obzd-(LgU!vu?_gAljw1} z2WD~$hC}dwUfxS1hW$Y)chDs$#kKJX2(XpS?|bf)9#Qh)hf1Eie_{ce-4^6E@AOO} zKlHw3wZ%z_%nX0Tqf+*w1a0ZXceZX6XX+oQfUk5Eb1o{$Y!v&0ke_vDprD`d%`Z#4 zYlabb&QE=&5dPg|0SA?l4!Gid0;J;9=|hKT>q-&)ix6A1qFe9eE{;ce;4hS(r%bZ8 z%4J9@1V{%j3%yIGjaji#=yF+n5`EhFU|5QLlM-=NIW6n^`|))>o=+v#-i+2x8nEQL zjX+oU4~Wm&^Ty|!=7m&7M%2R9LR0CGrX1z=l*^jbfwWDrH~UlvjdLpRkd(`?a~X4a zD)L<%$UG#b&q0*^3+yt5a~Hll0P}oU_8y7sRL!--RKdHkF^2vGX`S=QZzqOQpl`KMfyWTvoeV<7LVaep57EMC?1!Wsttp zUTrqUG}=T1ls=~7?-+N|)CpZj4|V;X4D3CM?dGJ9r```5v-Zi3Vk)0&VgX$?r<{x< zFeXC9E~&%h8ii}1T)7k@Dx*r7@xI%|vv~uhci-&%UoD+}wFLcu^VndAm?zQk*xCbG ztkxoYH*`Er1^}c=2&MY7UJ@(ph?KucTe-S6OJmJWI1>Q75(U_uREZRdOQvy4+SlHh z^5yR~FbaZ~-%Eq9g%uTZ)~5RH)bFX1Q|1gW!wj*-v!a_%aFc(a{B~*ujekxO0%3vu zEqL+!7!%6}l{s5p&Fw@0M$EhVfRCVky}Hpmk%N3777%}|BY3)5|HR!Uz4C4eTY|tF zapWEhF`<1cG<@suCD$x2h=Q1pFx!dvpWs1YL?FHUb3ptqnTQCFV!hGkGH;Gnd zqt=q<8h6P(dC=_B@lZ(=BxNZMEyWbwBEw>3&98v~Kk*;1Lu{Iy1iqe+y2^aK!cz-T z_5D(}CrA%dL&qfFoqI|&V8V_=kI`q|oL(fM3 z%x`v}0-x3X(mC>x#%Xub{nC=AbO2zA!*#`vw;8DGPmHmcS=EyJaCuDb8R)0b>AHKH zEjZE3)3|h*m6tCcJYE_qy~2OlVr}le&It5{^gX)#*l+2IP08plvJ@&tclGN~qLD9Y zSS0%L*xus{Ttt0T`s>5=`7%1Ekp^SXbqMq~cdOfWsYvDWI>PP8>=eGBTbRc0w}Duu zvOQRE0`+wr~0z1VI}mbk;J5MP2tg*_`h`bol#9S zZM$IT2%$)kl7N6H3ZY1oKoF27MZf~mL8Xf{fe=C$6zNEZAVu^MsnWaj8UYC)odiNJ zK}rba#OGP>yUtnX&v(vR-#X{}ek6O(Ju`b|uRZ0u=bpK4fW^7bot4P;(Goa;!YBRS z+Fvv6>Mxb-cYFoA;9Zp#Fh~`85qHwAFbwB5`I?m0D^c=^tnFCqoV%_kUZ1Ww^~IM8 zTkr4Neb(?S^ea-?`@q5*TvvB6PM;OZrt3`Q45DxpcjW+eX_bD@NeJV1TM2Vu+z*gK zPGybyA6?{_>~Qng>U+X2)d0w{CYeO-wcN zkQYbb%XB_g9*n<(eyqEF3}h|BgCkoWzx3P^%!)TXJY@W6?%-kM%Rw`?x3_)gK$Gmm z#-ltfsPMplX6;3l!veA%#^vpdN`@AxXu-_qIEioTuL|FAYmsQF8h2L?YF7#}OHrOj zBlcO!DPPjy0Bn9jOW{M;q{6q~=Rhcf;SYRtmUD2tW-d;`d!Ht4YW*%g^QeV!jcq0W$}~}`Bz(ScEp@xpniP@& z4*lZ}$k9lc?74=YpP>rOuab3rPY`||llps)lB>p>3{Y!YvSRh69U`92HVTPUPukur zIG`0uNK1m+(|smNF+H|B{Z`5O{7F*UPfZ)Rin9A5yxDksd}G~*?J>Kq*Q0KM_7N<) zpJCcsJ=wR@r+99J#_kDvvWYTnL`S)oUhg=}{Mh^XP(|&DrUi@#y{!;d82Vt^_t77U?HOaqM+UTlD_(e+ zX(=~v4xzmf7@>$E{$O`y(i``l-*pq=*LsC#rFhw#53k)dQcBkQNTzy?l?6Y!v=29% zS-C-RNz|5{}m~G89qZ zteb3Sqe!Ir^+5pqIfJc`+qBXCtH{6B0t}i%UR~?a`s<^{b()kU_y0)t|Ew(c>&RO+ zH$dsZr*1Q{(s>qEL=Znig-_pgYZCoiPO^3T+LUU5QS*Vl%n{|TVzL^ZS#^RZ22JDK zjh(TMV#1Jy<^E$f)c=hf>fh$Q5qF3;>1M=i+=1EuWKsRUF(9WKq?1j|rKcW*Tl|2z zNK6mfg2X0)Hth1%yoxulc?3uMU?z~?mgJzpe-qsTa~!m3V zVt;a$Z_fdG`-1~u{+zkM0T?tp*#xJ3bnjQl7<5A$l)*jfRq-4uI!m6e19)gLF_DAF z?9Izl(d7x{ZZbK8%azT|TaIJfN$n)~m9uHo?zM;S`f%0t#7yWh0=*rKSb zz{|E$#Fj~=anrhz_2%<4FP$bwvuZ2kRfCP@M9$5~3*zq(PK;EJAr#b4j?`gvP85I$ z>RU1QtC({0ba+&07JEGADLg^3c^ayG04Y1I)KqW2R)4xz?HN%O1os_Eac=5@rbsY&^@7V6gV<;=-?0@4k;ACs1U4rb<0Ye)|~lY73@ zc7Y(uc70zMlW#x~2pDN&_fP!1W2jEMe@%FMWxM4S|nr7sQ{+6Q@H^onTO;qwr zv9TSxd7+`WwxqTUXd2ae34Y+S>h(hgaIPgcG6}C zH4xGMxrrbz;y%9*GBv=Gs6Yt(EnN2EGSG5k^Uo@X>_`g4SY*qan z30>gY%NgQHpQL!#&3F$P#N3GnF0n4j^J!K7XkV%|tK#mjZ-6GTSPV#~Cc=^=qcgd^ zoJtAoEgmQcCF7Xp|&9>XX3v2zM#vy$CF%&{s;b6 zFj8R4xXKbeg9FKwFT6B;>M1iWsvO7LSANR_7k1>yqIS+ z7mWIjZ*OrjYx$dLPD^5Y@O#J{D#5dnmp~o~V$x)klOx1sBJcc0Mc_ z=W^(`a8Rrwg@Xm##79`f5)dL#O zF>yO3zSGYW)x#|82`+4E0V;ZK^wItM5WJRNY#aQW{$4(IxFV9YG(O5GN6{S@^C295@3%i&{;cJ9(l9q)HEvA^#SOlRPI@u2JMsm+D{rXsy#__RTH1JaA<@5+ zb0TvuFU*36&*ZH@l@t%NCs44;m`IkBFeG zy2KOOI^lxnJ$KgyQp0~qFKL~mcV6m z1gRmYb5$O4!mf&VskGnqh0mV=@h7b0XCeE$Nz2yT({?AiXb2d!2U{M7r|e;+@w7<} zcH6PaCu#@POs#&1qbT0Dkh-sB3~I{3`foebsh0#5%b$aUg~882R2))}WQ3~_Sk$oP z2~Wtl0da}~qw9JGX@ch=7K7F|c#j4v`W(gaUk4b0Ty0v462!!(6~)x=_lkDPWD(l= zkj~k`sh%l0irpifBG_-O36Y>!A0@k(vF|DV2&k?~UclkW%gP)?8kGqvv}1;~)@xbo zq#tgmLgIGMQn_5SpPUWdm!9Jf%IxjHZw5?pEszfZ!Zh=Kd{w`)@>W zq0YvniSN+{E{`X8*KZ*+v3Ui=jP-99;xS#;?v4DZkZZfFy?nvk#(gRtVmMUs`(*T{ zVanr2;gt?^iz{!ofB5V1WMSp9P~XBAJdAo7uruAC;% zFl&L@Pc#Cc?K$?d*4nl03mVvk=4pOY1jDWS<=}oeVHh1j9*vZ2RXVJ90+w)xflA#Z z%3UrR!))Z>;S1cZ%6IULX!)fawHzkctDq}Y5I#IxV=EI_NSnNw0K;5 zw*NxgPzGW{PsK`+$W}@+Id^ey9QZ76xO|#FZ`@(Ka4}q4 zjrrn%06is*ny?@(O+b7&1wb${9uTIitri@@qSF)71`-x*RgDtHwb}p^S!@I}*gS2@ z{<}Wz=k950sw*}n;D^}tz3*Y!59@_h;t75nvm3>U_wOiuDIoW!SC5>qM=be~oG8*7 z)*{rxn7lWJgqf595=~|VGaK45A+-TR)2ELK^NI=3j-sTQm$1ei0=*S-)4#>RAH4ng z(4}VJ3l!~EhhpxWwm+-~T`M(D z91q6b`DF|mIvNALYf8VDeD4LtB9SW&d>k5(h1TMmvC5m=DLTgq`uC6ow?eaM6@i{0 zDuB5RupT|GtZ216vQfbkOkhi@o0IEmVROv&4m?=Tz41M-xc`>Gw7~6^gopmHS$j+o zam{~Kil~Du`@Lr?TfuS2Rd(z{n&z-&t(+6ZT;7gDAG^*bg7-Yb>$$uXJtvu37vF>h z&G;ODl~5u662r^|VYNCFNAa8NA+%pbuBYwi_909MZG|Jal)lWNaiu#pFT@TIw+4vp zCS6J0&aqe07q1iPb@gIpiPwd3CsEIQAYZ{V?wwW}U+Ql*ZO{lH7)*HA&7- znMQ8k;`(Yvo__bB!Z5wvg^rAqG^!i%hlb?7;(YtjZh6uX>wMC;9IcKATOAobNjB7p zDDr0<^DbV*ZW8@>>UlL=dO3PHWGM_`O*!gY_f3l}dJqAdxOO1@{_ z*`UlHr|^w7mEnHO4QGnQhuv89Iqu;ZNNw>rI?*0Ka;ritwF1^#{uUpG#a zxNAX%`Iq;E@!hUg17>Evi6~sRSDUcu;m|4e2GMn+uKdvIvzs>HzQJ+vFJB4@V84#W zod;@3)?SaR1?to*bu=|1R9WBLQq?F}5PmkprwstHB)m>aK1l;OwF0h;yd=MsmjIKW zss@jNF~Od>fD#y`-L6xQK%SUh2e-V|()htfj&S*Rgej6UswpKVfysp;C50I_^9EhN zhm=N=eET1we{A-7g8QCYii7y=P+hl4JxROhvS8a2+5K@j+Qq~l z;}y7@b#1T9>%+X{;lxjbhnpDs*dQH-bjaCAA-`DFZi#ESn<;=rwDQ}xd3OrUmJ4v1 zdLFgB$`7yP6B6s3v{lxibYcV4)zT2@qMdle1JJ8ZL{yS6dfbci-q4&th<+N-o#C`^ zWKR5MSxChPCe^I*#iM!rJJc4e?U;;p3;`^xDRUXUJ6whmWv#;uYwOO^;r`rr#!{9296Jd8&;Q+K6VVhP-mPiKUwST9|N z${Iigs>o0est)-rrkEgk#VrI+3 zfwUaGhGp@4f_U{zS`N=sM@IE9Tt4ft&Gud!xZ8>+z_~$4?vh*~Wt0pQZg-}%R&VWj zNYKV<2yxx<_NIO#sKiyqT^#T8Htph#UM<@;=%uRuES6Cl5&S=asS&6(WZdPg-!HSt zdD1TH@Tu`ep=+Y+695Z7VB_dROO_<@sg`fS$p&Xwn4v=9n`r2TOrJIOd0kM*V8;IZ zmmNnjh71lDg3^5cj&x5FN=@^J96HP98oUHxY{2ifJ0X==#?SIDo|= z$Q7{@jRXb_TmGo5=Js@WAh?J7Ajx9sN%f{pZ5I5`IRAJK>L+*5Hz9*1$WK)lP)0@X zP@jTU&KpL}lPw?mU%Mqm?D5aNO~1kMR5&B#FQr5v>Pb$jPT5u9)i(GuvX3OcxZ(g2 z^DE{UijJ|HAlMN_Hgc%NJ=y)j?de$#@gcLkf71S0WpE+buod@){UfL^^4rPzgt`&% z)HTKYsrfI84%tp>-Qyr_LwX}wDgUU?@p}UJHi>lFXMb_xYirlu_%^r3!k(=RW6{vvmqB3B5p}c%pY?CPEneJlA;^(#)?H;eX8;A7TL; zP&aD8+4~Lv>xIg>x^h-(T>E{@d4g+ot#`PzRzPpGa5?nmcYss}n*Ehlmsg3O()FUF zV9a82&2~56r8p>0#5=LH!-Qc};Hw|)utHtm9sMz3OzAX&-|Wajq5oAVb+p$LRTp3f zd7An3n|`Ue)xnA%GN*(zS+d^O!fmQ_v{OB_3>xwY@cs716ZU-BF9?B2YO%>h0l!G$ zwaO|Fn%DgQC~VS5ZdYiw#<_`5AK-H2U4Y7u=3aqiq&Ec8_hk-BCO-OUe%z-R^J7 zP9bX?XflIIy%0~Zjo_IBZ8SC_K-G6L=fNfHkUCi|K31C zinued^Hc|v7-<3aRGt=DZ&llHDzEWh{bf{-C715tWl=x%J1f_laFlgy~w7iH| z;hVM9LnB~H7#B7K^MX$VhfEn`}_LbgWdj5#NP=vb&v?lxP_)02W1oe6CWzs=R&e&`tVcMFFo?0wuA83t7Pul{K`U^I1-%|3i_BDx}V#v+Ij zXmZ${Fp+Op`c}DriNCZVkCKiPg@iABEyOambcM&o{WxWF=&PY*7Bg;;7JUTOyDK|T zAH3J7K!aCn{pArC(j5_8oC!#43uB2VPuqX4WuiwRm&XOEaQo^1gu97~SjI-!!N))4 z*roP3re@*$oFsA-j>fgR_k3SvBP-Sw9@HB@1~66K@QJU}DWd^4De5Ej)Cfkf&h)og zz^mk`VJI>Uki(FQgmr*$O^(9>#vA&(d=0&?ECD}~8vGDvjHIDWQfY2O1c+Twjb^f6 zTU~Yln)c~t6ifPLR}Pm4O>_w^6JnT8aMmm1WefPC9O0 zZ*f~b;U1tx$6i-~8h#|pLny6#(YL@uAD8>B7HD&J3#WMlVq4xcLk@C1KR5i`T%*aw zBRo9Lb~|^!E3?O(MNd^3GN!mnL?2I?-2LRZO&2Y+VA|m!V!X@Us?s&~QcSdigZ1HJ zE!)1LpcwXmx-H?YZ}{B~>;_|~5C=JR&+Rno&`7q=eiv=PSZ9Uz?2B)s#5^qC9A~k- z_aKGNYj-Mrzq07@uZ&Om2QIX!zj*+qDU0)v&FZ7~xU2DAp+qClAgZr6@XpJEMSfe> zU4?&Tlq;5i@!KsCuRM#tL8F0byE##&z!rOV$M_0I9OZ_+1yhlyngy$oNbC(G6nmcZ z3u_j>&wg5_8qlF;0R`jT*s`eTulW^4i26Mc7MOk5k%JYT*smAm2|lwleF8r+kH&_b zdube&8_Yl&05LBHpZTsG44pbbLI-9#Gor{k+s>^W%LJ$}uuf&j0*G3@MO=Dwn4CXr zZWs3^z2dQ}p)AE|CSilTd=h<+@!TbLILNpg4T!5BmkEEIH)S?iCc=;y`CXNeyYELn zo|DzL;6{GhsY7#>BIEw#4klKjD)kX3k zQm&BG0z0@^a-mL)CwPHWTdNRsG~stv^6V;X3+)bDirp5(CYJLp*V-&^g$-&DjQo3u z_y7Hy_`27I-;_m}l7hqzLMPbC>IR6A==*T>;W)+b?UJBTvnPDulWcozn6Ed_sgiFn z_)!0}p=*20{()+tNzDC)tcM+}oU#SB%XzwYWvv|3>OL78#c=UFm-y?!18l_Wsd$sS z)IW#9UZ+Qkag6zWScvWGJfyP168(ya-CcXWH{rmjrr<9RN`BGU93dg%$|paE3yJ84 z%SSqc*(9kq=X&FTeU^);I_ig4}EqO*+NQ=MbvXFA(exJIe-EBSl!GJ%uAgl=9 zP%r5{Ner)*&E+5T0JJtINGJDXE8Y02UVF`F)B)mH2BL;~E31q-FvZ$`jgxIrL62=# zQkw3cftBG$;i5gMvHY;3mKpxD%;E5$)#nGyKLT6FRU!8~nhK1ZSHiM>jq_=l7G!)u z^N!qS6I|PSeJfcb>bOymNrW{|#a&xnC;ZcYrKkrO`bnk}IxCjrjCqv;C0vyfX2-PO zUO%o%xkO|W3YetG!rMLf{@ron8!J8t`$lePBy!8@MrSFJ(3&GHJ`O~B*t|@&{}lU{ z5t_#&DdefZWo;U{_aPFPC#7{AzewrKfp&|s{;2U~)BL!uC!4ZLm@R$f808(@B!$Id z=Fzm5b>P`#VPs45A6P_24h^m_TN3N0`Pq-CVXZr0^_g=?na&>i+y*X1i z(PdSLPu(_mmX5!;p+`d~mP3V@0_??qXP9ET@~t4?n$8U3djmhFG0~}v<8sy{a>2YV zCE}Rb2F`9^(2(_N&}oBJru@10A?60z=`;n!`FS~JW`KM{^GB@*P{5x?+E(gtT_Z~< NbnY5xl&IN+{tp@}R|Wt8 literal 0 HcmV?d00001 diff --git a/assets/images/package-icon.svg b/assets/images/package-icon.svg new file mode 100644 index 0000000..fee7d69 --- /dev/null +++ b/assets/images/package-icon.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DurationMancer/DurationMancer.csproj b/src/DurationMancer/DurationMancer.csproj index c632161..e647237 100644 --- a/src/DurationMancer/DurationMancer.csproj +++ b/src/DurationMancer/DurationMancer.csproj @@ -1,3 +1,23 @@  + + DurationMancer + 1.0.0 + Christopher Wolf + DurationMancer parses human‑readable time expressions like “7h 13m”, “1:45:00”, or “5 days 7min 13ms” into TimeSpan values. It supports flexible formats, abbreviations, and natural‑language‑style inputs. It parses duration values only and does not extract them from full sentences or larger text. + MIT + https://github.com/HaGGi13/DurationMancer + https://github.com/HaGGi13/DurationMancer + git + Christopher Wolf and contributors + README.md + package-icon.png + duration;time;timespan;humad-readable;parser + + + + + + + From 6d5ca7e788a800ec2b38db51763b386cf2f9ec14 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:59:32 +0100 Subject: [PATCH 04/17] Update package description and path format --- src/DurationMancer/DurationMancer.csproj | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/DurationMancer/DurationMancer.csproj b/src/DurationMancer/DurationMancer.csproj index e647237..391e065 100644 --- a/src/DurationMancer/DurationMancer.csproj +++ b/src/DurationMancer/DurationMancer.csproj @@ -4,7 +4,10 @@ DurationMancer 1.0.0 Christopher Wolf - DurationMancer parses human‑readable time expressions like “7h 13m”, “1:45:00”, or “5 days 7min 13ms” into TimeSpan values. It supports flexible formats, abbreviations, and natural‑language‑style inputs. It parses duration values only and does not extract them from full sentences or larger text. + + DurationMancer parses human‑readable time expressions like “7h 13m”, “1:45:00”, or “5 days 7min 13ms” into TimeSpan values. It supports flexible formats, abbreviations, and natural‑language‑style inputs. + It parses duration values only and does not extract them from full sentences or larger text. + MIT https://github.com/HaGGi13/DurationMancer https://github.com/HaGGi13/DurationMancer @@ -12,12 +15,12 @@ Christopher Wolf and contributors README.md package-icon.png - duration;time;timespan;humad-readable;parser + parser;duration;time;timespan;humad-readable - - + + From 52c5bf0ea5c0982987c00306cd0e8311723e3dc2 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:00:31 +0100 Subject: [PATCH 05/17] Add pipeline with testing, analysis + packaging --- .github/workflows/build.yaml | 132 +++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..7ec37a6 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,132 @@ +name: Build and analyze + +on: + push: + branches: [develop, master] + pull_request: + types: [opened, synchronize, reopened] + branches: [develop, master] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +env: + solutionPath: './DurationMancer.slnx' + buildConfiguration: 'Release' + +jobs: + build: + name: Build and analyze + runs-on: windows-latest + steps: + - name: Code checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis and for GitVersion based versioning + + # --- GitVersion --- + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4.2.0 + with: + versionSpec: '6.5.x' + - name: Determine Version + id: version_step # step id used as reference for output values + uses: gittools/actions/gitversion/command@v4.2.0 + with: + arguments: '/output buildserver /l console /config GitVersion.yaml /updateprojectfiles' # have to self-define all arguments; /updateprojectfiles not impl. in execute task + + # --- Tool setup --- + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + + # --- SonarCloud caching & installation --- + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~\sonar\cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache SonarQube Cloud scanner + id: cache-sonar-scanner + uses: actions/cache@v4 + with: + path: .\.sonar\scanner + key: ${{ runner.os }}-sonar-scanner + restore-keys: ${{ runner.os }}-sonar-scanner + - name: Install SonarQube Cloud scanner + if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' + shell: powershell + run: | + New-Item -Path .\.sonar\scanner -ItemType Directory + dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner + + # --- Restore --- + - name: Restore ${{ env.solutionPath }} dependencies + run: dotnet restore ${{ env.solutionPath }} + + # --- SonarCloud begin --- + - name: Prepare SonarQube analyze + shell: powershell + run: > + .\.sonar\scanner\dotnet-sonarscanner begin /k:"HaGGi13_DurationMancer" /o:"haggi13" + /d:sonar.host.url="https://sonarcloud.io" + /v:"${{ env.GitVersion_FullSemVer }}" + /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage.opencover.xml + + # --- Build --- + - name: Build ${{ env.solutionPath }} ${{ env.GitVersion_FullSemVer }} + run: > + dotnet build ${{ env.solutionPath }} + --no-restore + -c ${{ env.buildConfiguration }} + + # --- Test --- + - name: Test ${{ env.solutionPath }} + run: > + dotnet test ${{ env.solutionPath }} --no-build -c ${{ env.buildConfiguration }} + --logger trx + --results-directory "./TestResults/" + --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + /p:CollectCoverage=true + /p:CoverletOutputFormat=opencover + /p:CoverletOutput="./../../TestResults/" + + - name: Upload dotnet test results + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: ./TestResults/ + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + + # --- SonarCloud end --- + - name: Run SonarQube analyze + shell: powershell + run: | + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + + # --- NuGet Pack, Upload & Publish --- + - name: Pack NuGet package + run: > + dotnet pack ${{ env.solutionPath }} + --no-build + -c ${{ env.buildConfiguration }} + -o ./nupkg + /p:PackageVersion=${{ env.GitVersion_NuGetVersionV2 }} + + - name: Upload NuGet package artifact + uses: actions/upload-artifact@v6 + with: + name: NuGetPackage + path: ./nupkg/*.nupkg + retention-days: 3 From ccd13dde925c107aaa96db305952366796a35781 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:20:45 +0100 Subject: [PATCH 06/17] Use FullSemVer for package versioning --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7ec37a6..94c4ce3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -122,7 +122,7 @@ jobs: --no-build -c ${{ env.buildConfiguration }} -o ./nupkg - /p:PackageVersion=${{ env.GitVersion_NuGetVersionV2 }} + /p:PackageVersion=${{ env.GitVersion_FullSemVer }} - name: Upload NuGet package artifact uses: actions/upload-artifact@v6 From 12cf5d84bfd6b3ca8d0461991725035c0ba1ed80 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:58:04 +0100 Subject: [PATCH 07/17] Fix SonarQube issue csharpsquid:S1192 (#1) This fixed the SonarQube issue csharpsquid:S1192 - String literals should not be duplicated. --- src/DurationMancer/DurationTimeParser.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs index c5a16f0..43aa0fa 100644 --- a/src/DurationMancer/DurationTimeParser.cs +++ b/src/DurationMancer/DurationTimeParser.cs @@ -4,7 +4,7 @@ namespace DurationMancer; /// -/// Provides methods for parsing various duration string formats into a . +/// Provides methods for parsing various duration string formats into a . /// public static partial class DurationTimeParser { @@ -17,12 +17,12 @@ public static partial class DurationTimeParser private const string StandardDurationTimeFormat = @"^(?:(?\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 TimePattern = $"{StandardDurationTimeFormat}|" + + @$"^(?:{DaysPattern})?\s*" + + @$"(?:{HoursPattern})?\s*" + + @$"(?:{MinutesPattern})?\s*" + + @$"(?:{SecondsPattern})?\s*" + + $"(?:{MillisecondsPattern})?$"; private static readonly Regex TimeRegex = CreateTimeFormatRegex(); From 31be0d481bb5372d3b0e06a28fc826da5fed333d Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:38:55 +0100 Subject: [PATCH 08/17] Fix SonarQube code coverage detection (#2) Fix SonarQube code coverage detection The code coverage reports were necessary to name with the `CoverletOutput` parameter, otherwise files were generated starting with `TestResults` whereby this is should be the directory and not part of the code coverage report file name. In addition, the SonarQube prepare step was updated to point SonarQube to the correct coverage report files. Because this library is targets multiple frameworks, each of its test runs creates a dedicated code coverage report file, hence a wildcard (asterisk) was necessary to used to tell SonarQube to fetch all of those files and merge them by itself. --- .github/workflows/build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 94c4ce3..e926fb3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -81,7 +81,7 @@ jobs: /d:sonar.host.url="https://sonarcloud.io" /v:"${{ env.GitVersion_FullSemVer }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage.opencover.xml + /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage*.opencover.xml # --- Build --- - name: Build ${{ env.solutionPath }} ${{ env.GitVersion_FullSemVer }} @@ -99,12 +99,12 @@ jobs: --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - /p:CoverletOutput="./../../TestResults/" + /p:CoverletOutput="./../../TestResults/coverage" - name: Upload dotnet test results uses: actions/upload-artifact@v4 with: - name: TestResults + name: TestResults.zip path: ./TestResults/ # Use always() to always run this step to publish test results when there are test failures if: ${{ always() }} @@ -115,7 +115,7 @@ jobs: run: | .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - # --- NuGet Pack, Upload & Publish --- + # --- NuGet Pack + Upload --- - name: Pack NuGet package run: > dotnet pack ${{ env.solutionPath }} @@ -127,6 +127,6 @@ jobs: - name: Upload NuGet package artifact uses: actions/upload-artifact@v6 with: - name: NuGetPackage + name: NuGetPackage.zip path: ./nupkg/*.nupkg retention-days: 3 From fa2cbd77f31c38529f93ab4b935f0c3c4df17632 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:21:47 +0100 Subject: [PATCH 09/17] Optimize and speedup build runs (#3) Optimize and speedup build runs Switched to the Ubuntu runner, because the Windows runner needs much more time in setting it up and there're no dependencies which would a Windows require for building. In addition the SonarQube setup was simplify to avoid relative paths for running the sonar scanner. This should result a little speed up too. Furthermore, the build configuration was optimized by using the artifact upload action v4 instead of v6 which requires Node.js and not letting any command run in PowerShell, because the bash is by default available on an Ubuntu runner. As well a SonarQube marked security issue [Avoid expanding secrets in run block (githubactions:S7636)](https://next.sonarqube.com/sonarqube/coding_rules?open=githubactions%3AS7636&rule_key=githubactions%3AS7636) was fixed by providing the SonarQube token as pipeline environment variable instead of expanding it during runtime. --- .github/workflows/build.yaml | 47 ++++++++++++------------------------ 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e926fb3..329b465 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,13 +13,16 @@ permissions: pull-requests: write env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 # Opt out of telemetry to speed up the build + DOTNET_NOLOGO: 1 # Prevents the dotnet CLI logo from displaying that speeds up build times solutionPath: './DurationMancer.slnx' buildConfiguration: 'Release' + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} jobs: build: name: Build and analyze - runs-on: windows-latest + runs-on: ubuntu-latest steps: - name: Code checkout uses: actions/checkout@v4 @@ -48,43 +51,25 @@ jobs: java-version: 17 distribution: 'zulu' - # --- SonarCloud caching & installation --- - - name: Cache SonarQube Cloud packages - uses: actions/cache@v4 - with: - path: ~\sonar\cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - name: Cache SonarQube Cloud scanner - id: cache-sonar-scanner - uses: actions/cache@v4 - with: - path: .\.sonar\scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner + # --- SonarCloud installation --- - name: Install SonarQube Cloud scanner - if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' - shell: powershell - run: | - New-Item -Path .\.sonar\scanner -ItemType Directory - dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner + run: dotnet tool install dotnet-sonarscanner --global # --- Restore --- - - name: Restore ${{ env.solutionPath }} dependencies + - name: Restore dependencies run: dotnet restore ${{ env.solutionPath }} # --- SonarCloud begin --- - name: Prepare SonarQube analyze - shell: powershell run: > - .\.sonar\scanner\dotnet-sonarscanner begin /k:"HaGGi13_DurationMancer" /o:"haggi13" + dotnet-sonarscanner begin /k:"HaGGi13_DurationMancer" /o:"haggi13" /d:sonar.host.url="https://sonarcloud.io" /v:"${{ env.GitVersion_FullSemVer }}" - /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage*.opencover.xml # --- Build --- - - name: Build ${{ env.solutionPath }} ${{ env.GitVersion_FullSemVer }} + - name: Build ${{ env.GitVersion_FullSemVer }} run: > dotnet build ${{ env.solutionPath }} --no-restore @@ -98,22 +83,20 @@ jobs: --results-directory "./TestResults/" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" /p:CollectCoverage=true - /p:CoverletOutputFormat=opencover + /p:CoverletOutputFormat="opencover" /p:CoverletOutput="./../../TestResults/coverage" - name: Upload dotnet test results uses: actions/upload-artifact@v4 with: - name: TestResults.zip + name: TestResults path: ./TestResults/ # Use always() to always run this step to publish test results when there are test failures if: ${{ always() }} # --- SonarCloud end --- - name: Run SonarQube analyze - shell: powershell - run: | - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" # --- NuGet Pack + Upload --- - name: Pack NuGet package @@ -125,8 +108,8 @@ jobs: /p:PackageVersion=${{ env.GitVersion_FullSemVer }} - name: Upload NuGet package artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: - name: NuGetPackage.zip + name: NuGetPackage path: ./nupkg/*.nupkg retention-days: 3 From b88c87bdc9257a619e831990117ed360ede0500e Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:32:06 +0100 Subject: [PATCH 10/17] Add benchmarking project for `DurationTimeParser` (#4) Add benchmarking project for `DurationTimeParser` Introduced a new `DurationMancer.Benchmarks` project utilizing BenchmarkDotNet for performance benchmarking. Added various benchmarks for standard, human-readable, and edge-case duration formats. Excluded the benchmarks project in general and for SonarQube from code coverage reports and analysis. Added a GitHub Actions workflow to automate benchmarking runs using BenchmarkDotNet. Updated the README to include benchmark results for various .NET versions and duration parsing scenarios. Added benchmark results for .NET versions and parsing scenarios to README file. --- .github/workflows/benchmarks.yaml | 63 +++++++++++++++++++ .github/workflows/build.yaml | 26 +++++--- Directory.Packages.props | 3 +- DurationMancer.slnx | 1 + README.md | 45 ++++++++++++- .../Configuration.cs | 22 +++++++ .../DurationMancer.Benchmarks.csproj | 18 ++++++ .../DurationTimeParserBenchmarks.cs | 47 ++++++++++++++ .../DurationMancer.Benchmarks/Program.cs | 4 ++ 9 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/benchmarks.yaml create mode 100644 benchmarks/DurationMancer.Benchmarks/Configuration.cs create mode 100644 benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj create mode 100644 benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs create mode 100644 benchmarks/DurationMancer.Benchmarks/Program.cs diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml new file mode 100644 index 0000000..15fbea2 --- /dev/null +++ b/.github/workflows/benchmarks.yaml @@ -0,0 +1,63 @@ +name: Benchmarks + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + benchmarksPath: './benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj' + buildConfiguration: 'Release' + +jobs: + benchmarks: + name: Run Benchmarks + runs-on: ubuntu-latest + steps: + - name: Code checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis and for GitVersion based versioning + + # --- GitVersion --- + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4.2.0 + with: + versionSpec: '6.5.x' + - name: Determine Version + id: version_step # step id used as reference for output values + uses: gittools/actions/gitversion/command@v4.2.0 + with: + arguments: '/output buildserver /l console /config GitVersion.yaml /updateprojectfiles' # have to self-define all arguments; /updateprojectfiles not impl. in execute task + + # --- Setup .NET --- + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # --- Restore --- + - name: Restore + run: dotnet restore ${{ env.benchmarksPath }} + + # --- Build --- + - name: Build ${{ env.GitVersion_FullSemVer }} + run: dotnet build ${{ env.benchmarksPath }} --no-restore -c Release + + # --- Run Benchmarks --- + - name: Run Benchmarks for ${{ env.GitVersion_FullSemVer }} + run: dotnet run --project ${{ env.benchmarksPath }} --no-build -c Release -f net10.0 + + # --- Publish Benchmark Results --- + - name: Upload Benchmark Results + uses: actions/upload-artifact@v4 + with: + name: BenchmarkResults-${{ env.GitVersion_FullSemVer }} + path: ./BenchmarkDotNet.Artifacts/results + retention-days: 30 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 329b465..53cf57c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,7 +15,8 @@ permissions: env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 # Opt out of telemetry to speed up the build DOTNET_NOLOGO: 1 # Prevents the dotnet CLI logo from displaying that speeds up build times - solutionPath: './DurationMancer.slnx' + libraryPath: './src/DurationMancer/DurationMancer.csproj' + testsPath: './tests/DurationMancer.Tests/DurationMancer.Tests.csproj' buildConfiguration: 'Release' SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -56,8 +57,10 @@ jobs: run: dotnet tool install dotnet-sonarscanner --global # --- Restore --- - - name: Restore dependencies - run: dotnet restore ${{ env.solutionPath }} + - name: Restore library + run: dotnet restore ${{ env.libraryPath }} + - name: Restore tests + run: dotnet restore ${{ env.testsPath }} # --- SonarCloud begin --- - name: Prepare SonarQube analyze @@ -67,18 +70,25 @@ jobs: /v:"${{ env.GitVersion_FullSemVer }}" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage*.opencover.xml + /d:sonar.exclusions="benchmarks/**" + /d:sonar.coverage.exclusions="benchmarks/**" # --- Build --- - - name: Build ${{ env.GitVersion_FullSemVer }} + - name: Build library ${{ env.GitVersion_FullSemVer }} run: > - dotnet build ${{ env.solutionPath }} + dotnet build ${{ env.libraryPath }} + --no-restore + -c ${{ env.buildConfiguration }} + - name: Build tests + run: > + dotnet build ${{ env.testsPath }} --no-restore -c ${{ env.buildConfiguration }} # --- Test --- - - name: Test ${{ env.solutionPath }} + - name: Test run: > - dotnet test ${{ env.solutionPath }} --no-build -c ${{ env.buildConfiguration }} + dotnet test ${{ env.testsPath }} --no-build -c ${{ env.buildConfiguration }} --logger trx --results-directory "./TestResults/" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" @@ -101,7 +111,7 @@ jobs: # --- NuGet Pack + Upload --- - name: Pack NuGet package run: > - dotnet pack ${{ env.solutionPath }} + dotnet pack ${{ env.libraryPath }} --no-build -c ${{ env.buildConfiguration }} -o ./nupkg diff --git a/Directory.Packages.props b/Directory.Packages.props index 871dfbc..574c688 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + @@ -14,4 +15,4 @@ - + \ No newline at end of file diff --git a/DurationMancer.slnx b/DurationMancer.slnx index 40c5c69..688880b 100644 --- a/DurationMancer.slnx +++ b/DurationMancer.slnx @@ -1,4 +1,5 @@ + diff --git a/README.md b/README.md index b95d745..963d3c6 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,6 @@ This format is best for precise, structured input. It follows the pattern: `[day - `1.05:00:00` → 1 day, 5 hours - `00:00:45.500` → 45.5 seconds ---- - ### Human-Readable Format This format is ideal for quick CLI input. You can mix and match units using full names or shorthand. @@ -83,3 +81,46 @@ This format is ideal for quick CLI input. You can mix and match units using full - `1.5 hours` - `100ms` - `2days 4h 15m 30s` + +## Benchmarks + +``` +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2) +AMD Ryzen 9 7950X3D 4.20GHz, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.103 + [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 + .NET 10 : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 + .NET 9 : .NET 9.0.13 (9.0.13, 9.0.1326.6317), X64 RyuJIT x86-64-v4 + .NET 8 : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4 +``` + +| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated | +| ------------------------------ | --------- | ----------: | ---------: | --------: | ----------: | ----: | ------: | ---: | -----: | --------: | +| 'Standard: HH:mm:ss' | .NET 10.0 | 298.1505 ns | 2.4080 ns | 2.2524 ns | 301.3607 ns | 8.02 | 0.06 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 337.5800 ns | 6.5587 ns | 6.1350 ns | 346.8909 ns | 9.08 | 0.16 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 10.0 | 392.8773 ns | 3.5938 ns | 3.3616 ns | 397.7572 ns | 10.56 | 0.09 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 10.0 | 452.5045 ns | 2.3685 ns | 1.9778 ns | 454.8526 ns | 12.17 | 0.06 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 10.0 | 759.7605 ns | 6.1496 ns | 5.7524 ns | 768.1694 ns | 20.43 | 0.16 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 560.4385 ns | 2.5589 ns | 2.2684 ns | 563.9663 ns | 15.07 | 0.07 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 10.0 | 37.1920 ns | 0.0890 ns | 0.0789 ns | 37.3029 ns | 1.00 | 0.00 | 2 | - | - | +| 'Null input' | .NET 10.0 | 0.8341 ns | 0.0046 ns | 0.0038 ns | 0.8401 ns | 0.02 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 9.0 | 354.0586 ns | 7.0855 ns | 8.1597 ns | 363.1163 ns | 6.11 | 0.14 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 397.6152 ns | 6.9474 ns | 6.4986 ns | 406.1554 ns | 6.86 | 0.12 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 9.0 | 471.8717 ns | 9.2507 ns | 9.4998 ns | 485.1973 ns | 8.15 | 0.17 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 9.0 | 536.7581 ns | 10.7092 ns | 8.9427 ns | 546.0439 ns | 9.27 | 0.16 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 9.0 | 873.6216 ns | 8.8310 ns | 7.8284 ns | 886.2697 ns | 15.08 | 0.17 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 690.4374 ns | 4.3499 ns | 3.8561 ns | 696.4002 ns | 11.92 | 0.10 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 9.0 | 57.9295 ns | 0.2371 ns | 0.4027 ns | 58.8710 ns | 1.00 | 0.01 | 2 | - | - | +| 'Null input' | .NET 9.0 | 0.8429 ns | 0.0099 ns | 0.0087 ns | 0.8568 ns | 0.01 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 8.0 | 315.1999 ns | 2.1455 ns | 1.9019 ns | 318.1252 ns | 5.29 | 0.04 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 368.5408 ns | 1.7708 ns | 1.4787 ns | 370.8185 ns | 6.18 | 0.03 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 8.0 | 440.2367 ns | 1.8504 ns | 1.6403 ns | 442.6820 ns | 7.38 | 0.04 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 8.0 | 492.3350 ns | 5.4852 ns | 5.1308 ns | 499.0772 ns | 8.26 | 0.09 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 8.0 | 853.8517 ns | 8.0323 ns | 7.5134 ns | 865.4025 ns | 14.32 | 0.13 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 707.8297 ns | 5.2995 ns | 4.9572 ns | 715.4481 ns | 11.87 | 0.09 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 8.0 | 59.6169 ns | 0.2654 ns | 0.2352 ns | 59.9797 ns | 1.00 | 0.01 | 2 | - | - | +| 'Null input' | .NET 8.0 | 0.9443 ns | 0.0370 ns | 0.0803 ns | 1.0528 ns | 0.02 | 0.00 | 1 | - | - | + +\* full combo = "2 days 4 hours 15 minutes 30 seconds 500 milliseconds" diff --git a/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs new file mode 100644 index 0000000..f19b0eb --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs @@ -0,0 +1,22 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; + +namespace DurationMancer.Benchmarks; + +public class Configuration : ManualConfig +{ + public Configuration() + { + AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithId(".NET 8")); + AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithId(".NET 9")); + AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithId(".NET 10")); + + AddExporter(MarkdownExporter.GitHub); + AddExporter(HtmlExporter.Default); + + AddColumn(StatisticColumn.P95); + } +} diff --git a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj new file mode 100644 index 0000000..46ba2f3 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + Exe + false + false + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage + + + + + + + + + + + diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs new file mode 100644 index 0000000..7199181 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; + +namespace DurationMancer.Benchmarks; + +[MemoryDiagnoser] +[RankColumn] +[Config(typeof(Configuration))] +public class DurationTimeParserBenchmarks +{ + // --- Standard duration format inputs --- + + [Benchmark(Description = "Standard: HH:mm:ss")] + public bool Standard_HHmmss() + => DurationTimeParser.TryParse("12:30:45", out _); + + [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] + public bool Standard_Full() + => DurationTimeParser.TryParse("3.08:15:30.250", out _); + + // --- Human-readable format inputs --- + + [Benchmark(Description = "Human: single unit (5m)")] + public bool Human_SingleUnit() + => DurationTimeParser.TryParse("5m", out _); + + [Benchmark(Description = "Human: two units (1h 30m)")] + public bool Human_TwoUnits() + => DurationTimeParser.TryParse("1h 30m", out _); + + [Benchmark(Description = "Human: full combo")] + public bool Human_FullCombo() + => DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _); + + [Benchmark(Description = "Human: decimals (1.5h 100ms)")] + public bool Human_Decimals() + => DurationTimeParser.TryParse("1.5h 100ms", out _); + + // --- Edge / failure cases --- + + [Benchmark(Description = "Invalid input", Baseline = true)] + public bool Invalid() + => DurationTimeParser.TryParse("not a duration", out _); + + [Benchmark(Description = "Null input")] + public bool Null() + => DurationTimeParser.TryParse(null, out _); +} diff --git a/benchmarks/DurationMancer.Benchmarks/Program.cs b/benchmarks/DurationMancer.Benchmarks/Program.cs new file mode 100644 index 0000000..69766a6 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using DurationMancer.Benchmarks; + +BenchmarkRunner.Run(); From fc117c48629e5521abaf13f52a2c9b89d4a83d11 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:36:01 +0100 Subject: [PATCH 11/17] Optimize regex usage + improve parsing performance (#5) Optimize regex usage + improve parsing performance Removed redundant `RegexOptions.Compiled` flag from the `GeneratedRegex` attribute because the source generator produces by default an already optimized RegEx at compile time. Replaced `.Value` with `.ValueSpan` to minimize string allocation and enhance performance during parsing. Cached frequently accessed `Group` instances to avoid redundant lookups. Updated exception handling in parsing methods to specify relevant exceptions (`FormatException` and `OverflowException`). Improved README formatting and grammar. Added usage examples for both human-readable and standard duration formats. Updated benchmark results to reflect changes in performance. --- README.md | 136 ++++++++++++----------- src/DurationMancer/DurationTimeParser.cs | 55 +++++---- 2 files changed, 106 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 963d3c6..bd355cb 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,9 @@ # DurationMancer -DurationMancer is a simle library that provides a flexible time parser that understands both technical and natural language inputs. You can use these formats wherever a time duration is required and you don't want to deal with the hassle of parsing strings yourself. +DurationMancer is a simple library that provides a flexible time parser that understands both technical and natural language inputs. You can use these formats wherever a time duration is required, and you don't want to deal with the hassle of parsing strings yourself. ℹ️ Negative values are not supported (yet)! -## Usage - -The usage very simple, like shown in the examples below. - -### Humand-readable Format -```csharp -// Example 1: Mix of full words and shorthand -string input1 = "2days 4h 15m 30s"; -TimeSpan result1 = DurationParser.Parse(input1); -// result1 == TimeSpan.FromDays(2) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(15) + TimeSpan.FromSeconds(30) - -// Example 2: Decimal values with flexible spacing -string input2 = "1.5 hours 100ms"; -TimeSpan result2 = DurationParser.Parse(input2); -// result2 == TimeSpan.FromHours(1.5) + TimeSpan.FromMilliseconds(100) -``` - -### Standard Duration Format -```csharp -// Example 1: Days, hours, minutes, and seconds -string input1 = "1.05:00:00"; -TimeSpan result1 = DurationParser.Parse(input1); -// result1 == new TimeSpan(days: 1, hours: 5, minutes: 0, seconds: 0) - -// Example 2: Seconds with milliseconds -string input2 = "00:00:45.500"; -TimeSpan result2 = DurationParser.Parse(input2); -// result2 == TimeSpan.FromSeconds(45.5) -``` - ## Supported Formats ### Standard Duration Format @@ -41,7 +11,7 @@ TimeSpan result2 = DurationParser.Parse(input2); This format is best for precise, structured input. It follows the pattern: `[days.]HH:mm:ss[.milliseconds]` | Component | Format | Range / Description | -| :--------------- | :----- | :------------------------------------------------ | +|:-----------------|:-------|:--------------------------------------------------| | **Days** | `d.` | Optional. Any number of digits followed by a dot. | | **Hours** | `HH` | **Required.** 00 to 23. | | **Minutes** | `mm` | **Required.** 00 to 59. | @@ -61,7 +31,7 @@ This format is ideal for quick CLI input. You can mix and match units using full #### Supported Units | Unit | Keywords (Case-Insensitive) | -| :--------------- | :---------------------------------- | +|:-----------------|:------------------------------------| | **Days** | `d`, `day`, `days` | | **Hours** | `h`, `hour`, `hours` | | **Minutes** | `m`, `min`, `minute`, `minutes` | @@ -76,11 +46,45 @@ This format is ideal for quick CLI input. You can mix and match units using full **Examples:** -- `1d 12h` -- `5m 30s` -- `1.5 hours` -- `100ms` -- `2days 4h 15m 30s` +- 1d 12h → `1.12:00:00` +- 5m 30s → `00:05:30` +- 1.5 hours → `01:30:00` +- 100 ms → `00:00:00.100` +- 2 days 4 h 15 m 30 s → `2.04:15:30` + +## Usage + +The usage is straightforward, like shown in the examples below. + +### Standard Duration Format +```csharp +// Example 1: Days, hours, minutes, and seconds +string input1 = "1.05:00:00"; +TimeSpan result1 = DurationTimeParser.TryParse(input1); +// result1 == new TimeSpan(days: 1, hours: 5, minutes: 0, seconds: 0) +// result1 -> 1.05:00:00 + +// Example 2: Seconds with milliseconds +string input2 = "00:00:45.500"; +TimeSpan result2 = DurationTimeParser.TryParse(input2); +// result2 == TimeSpan.FromSeconds(45) + TimeSpan.FromMilliseconds(500) +// result2 -> 00:00:45.500 +``` + +### Human-readable Format +```csharp +// Example 1: Mix of full words and shorthand +string input1 = "2days 4h 15m 30s"; +TimeSpan result1 = DurationTimeParser.TryParse(input1); +// result1 == TimeSpan.FromDays(2) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(15) + TimeSpan.FromSeconds(30) +// result1 -> 2.04:15:30 + +// Example 2: Decimal values with flexible spacing +string input2 = "1.5 hours 100ms"; +TimeSpan result2 = DurationTimeParser.TryParse(input2); +// result2 == TimeSpan.FromHours(1.5) + TimeSpan.FromMilliseconds(100) +// result2 -> 01:30:00.100 +``` ## Benchmarks @@ -94,33 +98,33 @@ AMD Ryzen 9 7950X3D 4.20GHz, 1 CPU, 32 logical and 16 physical cores .NET 8 : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4 ``` -| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated | -| ------------------------------ | --------- | ----------: | ---------: | --------: | ----------: | ----: | ------: | ---: | -----: | --------: | -| 'Standard: HH:mm:ss' | .NET 10.0 | 298.1505 ns | 2.4080 ns | 2.2524 ns | 301.3607 ns | 8.02 | 0.06 | 3 | 0.0348 | 1752 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 337.5800 ns | 6.5587 ns | 6.1350 ns | 346.8909 ns | 9.08 | 0.16 | 4 | 0.0362 | 1840 B | -| 'Human: single unit (5m)' | .NET 10.0 | 392.8773 ns | 3.5938 ns | 3.3616 ns | 397.7572 ns | 10.56 | 0.09 | 5 | 0.0339 | 1712 B | -| 'Human: two units (1h 30m)' | .NET 10.0 | 452.5045 ns | 2.3685 ns | 1.9778 ns | 454.8526 ns | 12.17 | 0.06 | 6 | 0.0353 | 1776 B | -| 'Human: full combo' \* | .NET 10.0 | 759.7605 ns | 6.1496 ns | 5.7524 ns | 768.1694 ns | 20.43 | 0.16 | 8 | 0.0401 | 2024 B | -| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 560.4385 ns | 2.5589 ns | 2.2684 ns | 563.9663 ns | 15.07 | 0.07 | 7 | 0.0372 | 1880 B | -| 'Invalid input' | .NET 10.0 | 37.1920 ns | 0.0890 ns | 0.0789 ns | 37.3029 ns | 1.00 | 0.00 | 2 | - | - | -| 'Null input' | .NET 10.0 | 0.8341 ns | 0.0046 ns | 0.0038 ns | 0.8401 ns | 0.02 | 0.00 | 1 | - | - | -| | | | | | | | | | | | -| 'Standard: HH:mm:ss' | .NET 9.0 | 354.0586 ns | 7.0855 ns | 8.1597 ns | 363.1163 ns | 6.11 | 0.14 | 3 | 0.0348 | 1752 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 397.6152 ns | 6.9474 ns | 6.4986 ns | 406.1554 ns | 6.86 | 0.12 | 4 | 0.0362 | 1840 B | -| 'Human: single unit (5m)' | .NET 9.0 | 471.8717 ns | 9.2507 ns | 9.4998 ns | 485.1973 ns | 8.15 | 0.17 | 5 | 0.0339 | 1712 B | -| 'Human: two units (1h 30m)' | .NET 9.0 | 536.7581 ns | 10.7092 ns | 8.9427 ns | 546.0439 ns | 9.27 | 0.16 | 6 | 0.0353 | 1776 B | -| 'Human: full combo' \* | .NET 9.0 | 873.6216 ns | 8.8310 ns | 7.8284 ns | 886.2697 ns | 15.08 | 0.17 | 8 | 0.0401 | 2024 B | -| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 690.4374 ns | 4.3499 ns | 3.8561 ns | 696.4002 ns | 11.92 | 0.10 | 7 | 0.0372 | 1880 B | -| 'Invalid input' | .NET 9.0 | 57.9295 ns | 0.2371 ns | 0.4027 ns | 58.8710 ns | 1.00 | 0.01 | 2 | - | - | -| 'Null input' | .NET 9.0 | 0.8429 ns | 0.0099 ns | 0.0087 ns | 0.8568 ns | 0.01 | 0.00 | 1 | - | - | -| | | | | | | | | | | | -| 'Standard: HH:mm:ss' | .NET 8.0 | 315.1999 ns | 2.1455 ns | 1.9019 ns | 318.1252 ns | 5.29 | 0.04 | 3 | 0.0348 | 1752 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 368.5408 ns | 1.7708 ns | 1.4787 ns | 370.8185 ns | 6.18 | 0.03 | 4 | 0.0362 | 1840 B | -| 'Human: single unit (5m)' | .NET 8.0 | 440.2367 ns | 1.8504 ns | 1.6403 ns | 442.6820 ns | 7.38 | 0.04 | 5 | 0.0339 | 1712 B | -| 'Human: two units (1h 30m)' | .NET 8.0 | 492.3350 ns | 5.4852 ns | 5.1308 ns | 499.0772 ns | 8.26 | 0.09 | 6 | 0.0353 | 1776 B | -| 'Human: full combo' \* | .NET 8.0 | 853.8517 ns | 8.0323 ns | 7.5134 ns | 865.4025 ns | 14.32 | 0.13 | 8 | 0.0401 | 2024 B | -| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 707.8297 ns | 5.2995 ns | 4.9572 ns | 715.4481 ns | 11.87 | 0.09 | 7 | 0.0372 | 1880 B | -| 'Invalid input' | .NET 8.0 | 59.6169 ns | 0.2654 ns | 0.2352 ns | 59.9797 ns | 1.00 | 0.01 | 2 | - | - | -| 'Null input' | .NET 8.0 | 0.9443 ns | 0.0370 ns | 0.0803 ns | 1.0528 ns | 0.02 | 0.00 | 1 | - | - | +| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated | +|--------------------------------|-----------|------------:|-----------:|-----------:|------------:|------:|--------:|-----:|-------:|----------:| +| 'Standard: HH:mm:ss' | .NET 10.0 | 300.9489 ns | 5.9727 ns | 11.2181 ns | 319.4432 ns | 6.79 | 0.26 | 3 | 0.0329 | 1656 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 307.9156 ns | 3.4664 ns | 2.7063 ns | 310.8405 ns | 6.94 | 0.10 | 3 | 0.0334 | 1688 B | +| 'Human: single unit (5m)' | .NET 10.0 | 397.4792 ns | 2.1752 ns | 2.0347 ns | 400.2274 ns | 8.96 | 0.11 | 4 | 0.0334 | 1688 B | +| 'Human: two units (1h 30m)' | .NET 10.0 | 418.4242 ns | 4.4143 ns | 3.9132 ns | 425.3554 ns | 9.44 | 0.14 | 5 | 0.0339 | 1720 B | +| 'Human: full combo' \* | .NET 10.0 | 650.1754 ns | 4.3376 ns | 3.8452 ns | 656.4196 ns | 14.66 | 0.18 | 7 | 0.0372 | 1880 B | +| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 533.1936 ns | 3.1562 ns | 2.9523 ns | 537.8815 ns | 12.02 | 0.15 | 6 | 0.0353 | 1816 B | +| 'Invalid input' | .NET 10.0 | 44.3513 ns | 0.5849 ns | 0.5185 ns | 45.2777 ns | 1.00 | 0.02 | 2 | - | - | +| 'Null input' | .NET 10.0 | 0.9634 ns | 0.0264 ns | 0.0221 ns | 0.9963 ns | 0.02 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 9.0 | 340.3667 ns | 4.1653 ns | 3.4782 ns | 344.6905 ns | 5.26 | 0.09 | 3 | 0.0329 | 1656 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 363.9161 ns | 4.1630 ns | 3.6904 ns | 368.8706 ns | 5.62 | 0.09 | 4 | 0.0334 | 1688 B | +| 'Human: single unit (5m)' | .NET 9.0 | 478.1021 ns | 4.4622 ns | 3.9556 ns | 484.8228 ns | 7.39 | 0.12 | 5 | 0.0334 | 1688 B | +| 'Human: two units (1h 30m)' | .NET 9.0 | 512.0947 ns | 6.9916 ns | 6.1979 ns | 521.3898 ns | 7.91 | 0.14 | 6 | 0.0339 | 1720 B | +| 'Human: full combo' \* | .NET 9.0 | 848.9257 ns | 13.7756 ns | 12.2117 ns | 869.7854 ns | 13.12 | 0.26 | 8 | 0.0372 | 1880 B | +| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 737.0302 ns | 8.3758 ns | 7.8347 ns | 748.2742 ns | 11.39 | 0.19 | 7 | 0.0353 | 1816 B | +| 'Invalid input' | .NET 9.0 | 64.7167 ns | 0.9763 ns | 0.9133 ns | 66.1356 ns | 1.00 | 0.02 | 2 | - | - | +| 'Null input' | .NET 9.0 | 0.9930 ns | 0.0224 ns | 0.0210 ns | 1.0187 ns | 0.02 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 8.0 | 353.3871 ns | 3.5373 ns | 2.7617 ns | 357.3055 ns | 5.52 | 0.09 | 3 | 0.0329 | 1656 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 384.0297 ns | 6.0336 ns | 5.3486 ns | 391.8443 ns | 6.00 | 0.12 | 4 | 0.0334 | 1688 B | +| 'Human: single unit (5m)' | .NET 8.0 | 503.3029 ns | 9.9447 ns | 10.6408 ns | 518.5876 ns | 7.86 | 0.20 | 5 | 0.0334 | 1688 B | +| 'Human: two units (1h 30m)' | .NET 8.0 | 525.4900 ns | 4.2636 ns | 3.5603 ns | 529.8040 ns | 8.20 | 0.14 | 5 | 0.0339 | 1720 B | +| 'Human: full combo' \* | .NET 8.0 | 872.3254 ns | 9.7794 ns | 9.1477 ns | 886.8218 ns | 13.62 | 0.25 | 7 | 0.0372 | 1880 B | +| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 772.2243 ns | 11.7644 ns | 11.0045 ns | 785.5411 ns | 12.06 | 0.25 | 6 | 0.0353 | 1816 B | +| 'Invalid input' | .NET 8.0 | 64.0676 ns | 1.0798 ns | 1.0101 ns | 65.4616 ns | 1.00 | 0.02 | 2 | - | - | +| 'Null input' | .NET 8.0 | 0.9657 ns | 0.0224 ns | 0.0198 ns | 0.9943 ns | 0.02 | 0.00 | 1 | - | - | \* full combo = "2 days 4 hours 15 minutes 30 seconds 500 milliseconds" diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs index 43aa0fa..767a34d 100644 --- a/src/DurationMancer/DurationTimeParser.cs +++ b/src/DurationMancer/DurationTimeParser.cs @@ -26,7 +26,7 @@ public static partial class DurationTimeParser private static readonly Regex TimeRegex = CreateTimeFormatRegex(); - [GeneratedRegex(TimePattern, RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(TimePattern, RegexOptions.IgnoreCase)] private static partial Regex CreateTimeFormatRegex(); /// @@ -87,18 +87,28 @@ private static bool TryParseDurationFormat(Match match, out TimeSpan result) { result = TimeSpan.Zero; - if (!match.Groups["hh"].Success) + // Caching the group in a variable to avoid multiple string lookups by calling .Success and .Value + var hoursGroup = match.Groups["hh"]; + + // Check if the standard format was used, if so, hours must be given + if (!hoursGroup.Success) { return false; } - _ = int.TryParse(match.Groups["d"].Value, out var d); - _ = int.TryParse(match.Groups["hh"].Value, out var hh); - _ = int.TryParse(match.Groups["mm"].Value, out var mm); - _ = int.TryParse(match.Groups["ss"].Value, out var ss); - _ = int.TryParse(match.Groups["ms"].Value, out var ms); + var daysGroup = match.Groups["d"]; + var minutesGroup = match.Groups["mm"]; + var secondsGroup = match.Groups["ss"]; + var millisecondsGroup = match.Groups["ms"]; + + // Use .ValueSpan to avoid string allocations, for better performance + _ = int.TryParse(daysGroup.ValueSpan, out var days); + _ = int.TryParse(hoursGroup.ValueSpan, out var hours); + _ = int.TryParse(minutesGroup.ValueSpan, out var minutes); + _ = int.TryParse(secondsGroup.ValueSpan, out var seconds); + _ = int.TryParse(millisecondsGroup.ValueSpan, out var milliseconds); - result = new TimeSpan(d, hh, mm, ss, ms); + result = new TimeSpan(days, hours, minutes, seconds, milliseconds); return true; } @@ -115,24 +125,31 @@ private static bool TryParseHumanReadableDurationFormat(Match match, out TimeSpa try { - var days = match.Groups["days"].Success - ? double.Parse(match.Groups["days"].Value, CultureInfo.InvariantCulture) + var daysGroup = match.Groups["days"]; + var hoursGroup = match.Groups["hours"]; + var minutesGroup = match.Groups["minutes"]; + var secondsGroup = match.Groups["seconds"]; + var millisecondsGroup = match.Groups["milliseconds"]; + + // Use .ValueSpan to avoid string allocations, for better performance + var days = daysGroup.Success + ? double.Parse(daysGroup.ValueSpan, CultureInfo.InvariantCulture) : 0; - var hours = match.Groups["hours"].Success - ? double.Parse(match.Groups["hours"].Value, CultureInfo.InvariantCulture) + var hours = hoursGroup.Success + ? double.Parse(hoursGroup.ValueSpan, CultureInfo.InvariantCulture) : 0; - var minutes = match.Groups["minutes"].Success - ? double.Parse(match.Groups["minutes"].Value, CultureInfo.InvariantCulture) + var minutes = minutesGroup.Success + ? double.Parse(minutesGroup.ValueSpan, CultureInfo.InvariantCulture) : 0; - var seconds = match.Groups["seconds"].Success - ? double.Parse(match.Groups["seconds"].Value, CultureInfo.InvariantCulture) + var seconds = secondsGroup.Success + ? double.Parse(secondsGroup.ValueSpan, CultureInfo.InvariantCulture) : 0; - var milliSeconds = match.Groups["milliseconds"].Success - ? int.Parse(match.Groups["milliseconds"].Value, CultureInfo.InvariantCulture) + var milliSeconds = millisecondsGroup.Success + ? int.Parse(millisecondsGroup.ValueSpan, CultureInfo.InvariantCulture) : 0; var timeSpan = TimeSpan.FromDays(days) + @@ -144,7 +161,7 @@ private static bool TryParseHumanReadableDurationFormat(Match match, out TimeSpa // As we do not support nanoseconds or ticks, we have to round to the nearest millisecond result = RoundOnMilliseconds(timeSpan); } - catch (Exception) + catch (Exception e) when (e is FormatException or OverflowException) { return false; } From 106e0cda33f2570ca0371e320cd3c0846d4631d1 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:32:27 +0100 Subject: [PATCH 12/17] Add `Parse` method to `DurationTimeParser` (#6) Add `Parse` method to `DurationTimeParser` Introduced a `Parse` method to validate and convert input strings to `TimeSpan`, throwing relevant exceptions (`ArgumentNullException`, `FormatException`) for invalid cases. This method was introduced for not passive coding approaches to satisfy this possible need of developers too. Added comprehensive unit tests to cover the new method `Parse` functionality. To avoid input value redundancy and enhance reusability, all values were extracted to use-case dedicated classes. Extend benchmarks Furthermore, benchmarks for the method `Parse` were added. In addition, the usage examples in the README file were fixed and a reference to the Benchmark page in the Wiki added. The later was done to keep the README small and focused on the easy get started. Added benchmarks for the new method `Parse` and hide not needed columns "Job" and "Alloc Ratio" in results. --- README.md | 61 +- .../Configuration.cs | 5 +- .../DurationTimeParserBenchmarks.Parse.cs | 70 ++ .../DurationTimeParserBenchmarks.TryParse.cs | 66 ++ .../DurationTimeParserBenchmarks.cs | 47 -- .../DurationMancer.Benchmarks/Program.cs | 3 +- src/DurationMancer/DurationTimeParser.cs | 17 + .../DurationTimeParserTests.Parse.cs | 283 +++++++ .../DurationTimeParserTests.TryParse.cs | 302 +++++++ .../DurationTimeParserTests.cs | 794 +----------------- .../DurationTimeParserInvalidTestData.cs | 200 +++++ .../DurationTimeParserRollingOverTestData.cs | 392 +++++++++ .../DurationTimeParserUnrollingTestData.cs | 105 +++ .../DurationTimeParserValidTestData.cs | 244 ++++++ 14 files changed, 1697 insertions(+), 892 deletions(-) create mode 100644 benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs create mode 100644 benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs delete mode 100644 benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs create mode 100644 tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs create mode 100644 tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs create mode 100644 tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs create mode 100644 tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs create mode 100644 tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs create mode 100644 tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs diff --git a/README.md b/README.md index bd355cb..613ced5 100644 --- a/README.md +++ b/README.md @@ -57,74 +57,37 @@ This format is ideal for quick CLI input. You can mix and match units using full The usage is straightforward, like shown in the examples below. ### Standard Duration Format + ```csharp -// Example 1: Days, hours, minutes, and seconds +// Example 1: TryParse Days, hours, minutes, and seconds string input1 = "1.05:00:00"; -TimeSpan result1 = DurationTimeParser.TryParse(input1); +bool isSuccessfulParsed = DurationTimeParser.TryParse(input1, out TimeSpan result1); // result1 == new TimeSpan(days: 1, hours: 5, minutes: 0, seconds: 0) // result1 -> 1.05:00:00 -// Example 2: Seconds with milliseconds +// Example 2: Parse Seconds with milliseconds string input2 = "00:00:45.500"; -TimeSpan result2 = DurationTimeParser.TryParse(input2); -// result2 == TimeSpan.FromSeconds(45) + TimeSpan.FromMilliseconds(500) +TimeSpan result2 = DurationTimeParser.Parse(input2); +// result2 == new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 45, milliseconds: 500) // result2 -> 00:00:45.500 ``` ### Human-readable Format + ```csharp // Example 1: Mix of full words and shorthand string input1 = "2days 4h 15m 30s"; -TimeSpan result1 = DurationTimeParser.TryParse(input1); -// result1 == TimeSpan.FromDays(2) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(15) + TimeSpan.FromSeconds(30) +bool isSuccessfulParsed = DurationTimeParser.TryParse(input1, out TimeSpan result1); +// result1 == new TimeSpan(days: 2, hours: 4, minutes: 15, seconds: 30) // result1 -> 2.04:15:30 // Example 2: Decimal values with flexible spacing string input2 = "1.5 hours 100ms"; -TimeSpan result2 = DurationTimeParser.TryParse(input2); -// result2 == TimeSpan.FromHours(1.5) + TimeSpan.FromMilliseconds(100) +TimeSpan result2 = DurationTimeParser.Parse(input2); +// result2 == new TimeSpan(days: 0, hours: 1, minutes: 30, seconds: 0, milliseconds: 100) // result2 -> 01:30:00.100 ``` ## Benchmarks -``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2) -AMD Ryzen 9 7950X3D 4.20GHz, 1 CPU, 32 logical and 16 physical cores -.NET SDK 10.0.103 - [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 - .NET 10 : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 - .NET 9 : .NET 9.0.13 (9.0.13, 9.0.1326.6317), X64 RyuJIT x86-64-v4 - .NET 8 : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4 -``` - -| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated | -|--------------------------------|-----------|------------:|-----------:|-----------:|------------:|------:|--------:|-----:|-------:|----------:| -| 'Standard: HH:mm:ss' | .NET 10.0 | 300.9489 ns | 5.9727 ns | 11.2181 ns | 319.4432 ns | 6.79 | 0.26 | 3 | 0.0329 | 1656 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 307.9156 ns | 3.4664 ns | 2.7063 ns | 310.8405 ns | 6.94 | 0.10 | 3 | 0.0334 | 1688 B | -| 'Human: single unit (5m)' | .NET 10.0 | 397.4792 ns | 2.1752 ns | 2.0347 ns | 400.2274 ns | 8.96 | 0.11 | 4 | 0.0334 | 1688 B | -| 'Human: two units (1h 30m)' | .NET 10.0 | 418.4242 ns | 4.4143 ns | 3.9132 ns | 425.3554 ns | 9.44 | 0.14 | 5 | 0.0339 | 1720 B | -| 'Human: full combo' \* | .NET 10.0 | 650.1754 ns | 4.3376 ns | 3.8452 ns | 656.4196 ns | 14.66 | 0.18 | 7 | 0.0372 | 1880 B | -| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 533.1936 ns | 3.1562 ns | 2.9523 ns | 537.8815 ns | 12.02 | 0.15 | 6 | 0.0353 | 1816 B | -| 'Invalid input' | .NET 10.0 | 44.3513 ns | 0.5849 ns | 0.5185 ns | 45.2777 ns | 1.00 | 0.02 | 2 | - | - | -| 'Null input' | .NET 10.0 | 0.9634 ns | 0.0264 ns | 0.0221 ns | 0.9963 ns | 0.02 | 0.00 | 1 | - | - | -| | | | | | | | | | | | -| 'Standard: HH:mm:ss' | .NET 9.0 | 340.3667 ns | 4.1653 ns | 3.4782 ns | 344.6905 ns | 5.26 | 0.09 | 3 | 0.0329 | 1656 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 363.9161 ns | 4.1630 ns | 3.6904 ns | 368.8706 ns | 5.62 | 0.09 | 4 | 0.0334 | 1688 B | -| 'Human: single unit (5m)' | .NET 9.0 | 478.1021 ns | 4.4622 ns | 3.9556 ns | 484.8228 ns | 7.39 | 0.12 | 5 | 0.0334 | 1688 B | -| 'Human: two units (1h 30m)' | .NET 9.0 | 512.0947 ns | 6.9916 ns | 6.1979 ns | 521.3898 ns | 7.91 | 0.14 | 6 | 0.0339 | 1720 B | -| 'Human: full combo' \* | .NET 9.0 | 848.9257 ns | 13.7756 ns | 12.2117 ns | 869.7854 ns | 13.12 | 0.26 | 8 | 0.0372 | 1880 B | -| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 737.0302 ns | 8.3758 ns | 7.8347 ns | 748.2742 ns | 11.39 | 0.19 | 7 | 0.0353 | 1816 B | -| 'Invalid input' | .NET 9.0 | 64.7167 ns | 0.9763 ns | 0.9133 ns | 66.1356 ns | 1.00 | 0.02 | 2 | - | - | -| 'Null input' | .NET 9.0 | 0.9930 ns | 0.0224 ns | 0.0210 ns | 1.0187 ns | 0.02 | 0.00 | 1 | - | - | -| | | | | | | | | | | | -| 'Standard: HH:mm:ss' | .NET 8.0 | 353.3871 ns | 3.5373 ns | 2.7617 ns | 357.3055 ns | 5.52 | 0.09 | 3 | 0.0329 | 1656 B | -| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 384.0297 ns | 6.0336 ns | 5.3486 ns | 391.8443 ns | 6.00 | 0.12 | 4 | 0.0334 | 1688 B | -| 'Human: single unit (5m)' | .NET 8.0 | 503.3029 ns | 9.9447 ns | 10.6408 ns | 518.5876 ns | 7.86 | 0.20 | 5 | 0.0334 | 1688 B | -| 'Human: two units (1h 30m)' | .NET 8.0 | 525.4900 ns | 4.2636 ns | 3.5603 ns | 529.8040 ns | 8.20 | 0.14 | 5 | 0.0339 | 1720 B | -| 'Human: full combo' \* | .NET 8.0 | 872.3254 ns | 9.7794 ns | 9.1477 ns | 886.8218 ns | 13.62 | 0.25 | 7 | 0.0372 | 1880 B | -| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 772.2243 ns | 11.7644 ns | 11.0045 ns | 785.5411 ns | 12.06 | 0.25 | 6 | 0.0353 | 1816 B | -| 'Invalid input' | .NET 8.0 | 64.0676 ns | 1.0798 ns | 1.0101 ns | 65.4616 ns | 1.00 | 0.02 | 2 | - | - | -| 'Null input' | .NET 8.0 | 0.9657 ns | 0.0224 ns | 0.0198 ns | 0.9943 ns | 0.02 | 0.00 | 1 | - | - | - -\* full combo = "2 days 4 hours 15 minutes 30 seconds 500 milliseconds" +For detailed benchmark results, please check the [Benchmarks page](https://github.com/HaGGi13/DurationMancer/wiki/Benchmarks) in the Wiki. diff --git a/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs index f19b0eb..84e62cf 100644 --- a/benchmarks/DurationMancer.Benchmarks/Configuration.cs +++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs @@ -14,9 +14,8 @@ public Configuration() AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithId(".NET 9")); AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithId(".NET 10")); - AddExporter(MarkdownExporter.GitHub); - AddExporter(HtmlExporter.Default); - AddColumn(StatisticColumn.P95); + + HideColumns("Job", "Alloc Ratio"); } } diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs new file mode 100644 index 0000000..e722049 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs @@ -0,0 +1,70 @@ +using BenchmarkDotNet.Attributes; + +namespace DurationMancer.Benchmarks; + +public static partial class DurationTimeParserBenchmarks +{ + [MemoryDiagnoser] + [RankColumn] + [Config(typeof(Configuration))] + public class Parse + { + // --- Standard duration format inputs --- + + [Benchmark(Description = "Standard: HH:mm:ss")] + public TimeSpan Standard_HHmmss() + => DurationTimeParser.Parse("12:30:45"); + + [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] + public TimeSpan Standard_Full() + => 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: two units (1h 30m)")] + public TimeSpan Human_TwoUnits() + => DurationTimeParser.Parse("1h 30m"); + + [Benchmark(Description = "Human: full combo")] + public TimeSpan Human_FullCombo() + => 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"); + + // --- Edge / failure cases --- + + [Benchmark(Description = "Invalid input", Baseline = true)] + public TimeSpan Invalid() + { + try + { + // This will throw a FormatException + return DurationTimeParser.Parse("not a duration"); + } + catch + { + return TimeSpan.Zero; + } + } + + [Benchmark(Description = "Null input")] + public TimeSpan Null() + { + try + { + // This will throw an ArgumentNullException + return DurationTimeParser.Parse(null); + } + catch + { + return TimeSpan.Zero; + } + } + } +} diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs new file mode 100644 index 0000000..e219c86 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs @@ -0,0 +1,66 @@ +using BenchmarkDotNet.Attributes; + +namespace DurationMancer.Benchmarks; + +public static partial class DurationTimeParserBenchmarks +{ + [MemoryDiagnoser] + [RankColumn] + [Config(typeof(Configuration))] + public class TryParse + { + // --- Standard duration format inputs --- + + [Benchmark(Description = "Standard: HH:mm:ss")] + public bool Standard_HHmmss() + { + 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 _); + } + + // --- Human-readable format inputs --- + + [Benchmark(Description = "Human: single unit (5m)")] + public bool Human_SingleUnit() + { + return DurationTimeParser.TryParse("5m", out _); + } + + [Benchmark(Description = "Human: two units (1h 30m)")] + public bool Human_TwoUnits() + { + return DurationTimeParser.TryParse("1h 30m", out _); + } + + [Benchmark(Description = "Human: full combo")] + public bool Human_FullCombo() + { + 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() + { + return DurationTimeParser.TryParse("1.5h 100ms", out _); + } + + // --- Edge / failure cases --- + + [Benchmark(Description = "Invalid input", Baseline = true)] + public bool Invalid() + { + return DurationTimeParser.TryParse("not a duration", out _); + } + + [Benchmark(Description = "Null input")] + public bool Null() + { + return DurationTimeParser.TryParse(null, out _); + } + } +} diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs deleted file mode 100644 index 7199181..0000000 --- a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs +++ /dev/null @@ -1,47 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace DurationMancer.Benchmarks; - -[MemoryDiagnoser] -[RankColumn] -[Config(typeof(Configuration))] -public class DurationTimeParserBenchmarks -{ - // --- Standard duration format inputs --- - - [Benchmark(Description = "Standard: HH:mm:ss")] - public bool Standard_HHmmss() - => DurationTimeParser.TryParse("12:30:45", out _); - - [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] - public bool Standard_Full() - => DurationTimeParser.TryParse("3.08:15:30.250", out _); - - // --- Human-readable format inputs --- - - [Benchmark(Description = "Human: single unit (5m)")] - public bool Human_SingleUnit() - => DurationTimeParser.TryParse("5m", out _); - - [Benchmark(Description = "Human: two units (1h 30m)")] - public bool Human_TwoUnits() - => DurationTimeParser.TryParse("1h 30m", out _); - - [Benchmark(Description = "Human: full combo")] - public bool Human_FullCombo() - => DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _); - - [Benchmark(Description = "Human: decimals (1.5h 100ms)")] - public bool Human_Decimals() - => DurationTimeParser.TryParse("1.5h 100ms", out _); - - // --- Edge / failure cases --- - - [Benchmark(Description = "Invalid input", Baseline = true)] - public bool Invalid() - => DurationTimeParser.TryParse("not a duration", out _); - - [Benchmark(Description = "Null input")] - public bool Null() - => DurationTimeParser.TryParse(null, out _); -} diff --git a/benchmarks/DurationMancer.Benchmarks/Program.cs b/benchmarks/DurationMancer.Benchmarks/Program.cs index 69766a6..0ebe600 100644 --- a/benchmarks/DurationMancer.Benchmarks/Program.cs +++ b/benchmarks/DurationMancer.Benchmarks/Program.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Running; using DurationMancer.Benchmarks; -BenchmarkRunner.Run(); +BenchmarkRunner.Run(); +BenchmarkRunner.Run(); diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs index 767a34d..c8e8301 100644 --- a/src/DurationMancer/DurationTimeParser.cs +++ b/src/DurationMancer/DurationTimeParser.cs @@ -77,6 +77,23 @@ public static bool TryParse(string? input, out TimeSpan timeSpan) TryParseHumanReadableDurationFormat(match, out timeSpan); } + /// + /// Parses a given input string into a representation. + /// Throws a if the input string is not in a valid duration format. + /// + /// The input string that represents a duration in a valid format. + /// A that represents the parsed duration. + /// Thrown when the input string is null. + /// Thrown when the input string is not in a valid format or cannot be parsed. + public static TimeSpan Parse(string? input) + { + ArgumentNullException.ThrowIfNull(input); + + return TryParse(input, out var result) + ? result + : throw new FormatException($"Input string '{input}' was not in a correct duration format."); + } + /// /// Tries to parse a possibly matched time in duration format [d.]HH:mm:ss[.fff]. /// diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs new file mode 100644 index 0000000..cf768ec --- /dev/null +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.Parse.cs @@ -0,0 +1,283 @@ +using AwesomeAssertions; +using DurationMancer.Tests.TestData; + +namespace DurationMancer.Tests; + +public static partial class DurationTimeParserTests +{ + public sealed class Parse + { + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidZeroInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_ZeroInput_ReturnsTrueAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(TimeSpan.Zero); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.Valid3SecondsInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_ThreeSecondsInput_ReturnsTrueAndTimeSpanWithThreeSeconds(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, 3)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidStandardDurationFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_StandardDurationFormat_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.ValidHumanReadableFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_HumanReadableFormat_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Arrange + parsedResult.Should().Be(expectedResult); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidMixedUnrollingAndRollingOverInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_MixedUnrollingAndRollingOverInput_ReturnsTrueAndTimeSpanWithCorrectValue(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + [Fact] + public void Parse_NullInput_ReturnsFalseAndTimeSpanZero() + { + // Arrange + // Act + Action parse = () => DurationTimeParser.Parse(null!); + + // Assert + parse.Should().Throw(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidEmptyInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void Parse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) + { + // Arrange + // Act + Action parse = () => DurationTimeParser.Parse(input!); + + // Assert + parse.Should().Throw(); + } + + #region Invalid input tests + + [Theory] + [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidMixedFormatInputs), + MemberType = typeof(DurationTimeParserInvalidTestData))] + public void Parse_MixedInputFormats_ReturnsFalseAndTimeSpanZero(string input) + { + // Arrange + // Act + Action parse = () => DurationTimeParser.Parse(input); + + // Assert + 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 + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver1000MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverThousandMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecond(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, 1)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver1001MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void + Parse_RollingOverThousandAndOneMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecondAndOneMillisecond( + string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, 0, 1, 1)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver60SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverSixtySecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinute(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 1, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver65SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void + Parse_RollingOverSixtyFiveSecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinuteAndFiveSeconds( + string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 1, 5)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver60MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverSixtyMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHour(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(1, 0, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver65MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverSixtyFiveMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHourAndFiveMinutes( + string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(1, 5, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver24HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverTwentyFourHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDay(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(1, 0, 0, 0)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver25HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void Parse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDayAndOneHour( + string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input!); + + // Assert + parsedResult.Should().Be(new TimeSpan(1, 1, 0, 0)); + } + + #endregion + + #region Value unrolling tests that result in current and next smaller unit + + [Theory] + [MemberData(nameof(DurationTimeParserUnrollingTestData.ValidUnrolling3Dot5SecondsInputs), + MemberType = typeof(DurationTimeParserUnrollingTestData))] + public void Parse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithThreeSecondsAndFiveHundredMilliseconds(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(new TimeSpan(0, 0, 0, 3, 500)); + } + + [Theory] + [MemberData(nameof(DurationTimeParserUnrollingTestData.ValidUnrollingFractionInputs), + MemberType = typeof(DurationTimeParserUnrollingTestData))] + public void Parse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValue(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.Parse(input); + + // Assert + parsedResult.Should().Be(expectedResult); + } + + #endregion + } +} diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs new file mode 100644 index 0000000..3116750 --- /dev/null +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.TryParse.cs @@ -0,0 +1,302 @@ +using AwesomeAssertions; +using DurationMancer.Tests.TestData; + +namespace DurationMancer.Tests; + +public static partial class DurationTimeParserTests +{ + public sealed class TryParse + { + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidZeroInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_ZeroInput_ReturnsTrueAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.Valid3SecondsInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_ThreeSecondsInput_ReturnsTrueAndTimeSpanWithThreeSeconds(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, 3)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidStandardDurationFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_StandardDurationFormat_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.ValidHumanReadableFormatInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_HumanReadableFormat_ShouldParseCorrectly(string input, string result) + { + // Arrange + _ = TimeSpan.TryParse(result, out var expectedResult); + + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Arrange + parsedValue.Should().Be(expectedResult); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidMixedUnrollingAndRollingOverInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_MixedUnrollingAndRollingOverInput_ReturnsTrueAndTimeSpanWithCorrectValue(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(); + } + + [Fact] + public void TryParse_NullInput_ReturnsFalseAndTimeSpanZero() + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(null, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserValidTestData.ValidEmptyInputs), + MemberType = typeof(DurationTimeParserValidTestData))] + public void TryParse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + #region Invalid input tests + + [Theory] + [MemberData(nameof(DurationTimeParserInvalidTestData.InvalidMixedFormatInputs), + 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) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(TimeSpan.Zero); + parsedResult.Should().BeFalse(); + } + + #endregion + + #region Value rolling over tests that result in next bigger unit + + [Theory] + [MemberData(nameof(DurationTimeParserRollingOverTestData.ValidRollingOver1000MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverThousandMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecond(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.ValidRollingOver1001MillisecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void + TryParse_RollingOverThousandAndOneMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecondAndOneMillisecond( + 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.ValidRollingOver60SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverSixtySecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinute(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.ValidRollingOver65SecondsInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void + TryParse_RollingOverSixtyFiveSecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinuteAndFiveSeconds( + 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.ValidRollingOver60MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverSixtyMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHour(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.ValidRollingOver65MinutesInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverSixtyFiveMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHourAndFiveMinutes( + 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.ValidRollingOver24HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverTwentyFourHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDay(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.ValidRollingOver25HoursInputs), + MemberType = typeof(DurationTimeParserRollingOverTestData))] + public void TryParse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDayAndOneHour( + 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 Value unrolling tests that result in current and next smaller unit + + [Theory] + [MemberData(nameof(DurationTimeParserUnrollingTestData.ValidUnrolling3Dot5SecondsInputs), + MemberType = typeof(DurationTimeParserUnrollingTestData))] + public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithThreeSecondsAndFiveHundredMilliseconds(string input) + { + // Arrange + // Act + var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); + + // Assert + parsedValue.Should().Be(new TimeSpan(0, 0, 0, 3, 500)); + parsedResult.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(DurationTimeParserUnrollingTestData.ValidUnrollingFractionInputs), + MemberType = typeof(DurationTimeParserUnrollingTestData))] + public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValue(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 + } +} diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.cs index 66815c0..13d2f6d 100644 --- a/tests/DurationMancer.Tests/DurationTimeParserTests.cs +++ b/tests/DurationMancer.Tests/DurationTimeParserTests.cs @@ -1,799 +1,9 @@ using AwesomeAssertions; +using DurationMancer.Tests.TestData; namespace DurationMancer.Tests; -public sealed class DurationTimeParserTests +public static partial class DurationTimeParserTests { - [Theory] - [InlineData("00:00:00.0")] - [InlineData("00:00:00.00")] - [InlineData("00:00:00")] - [InlineData("0.00:00:00")] - [InlineData("00.00:00:00")] - [InlineData("0ms")] - [InlineData("0millisecond")] - [InlineData("0milliseconds")] - [InlineData("0 ms")] - [InlineData("0 millisecond")] - [InlineData("0 milliseconds")] - [InlineData("0s")] - [InlineData("0sec")] - [InlineData("0second")] - [InlineData("0seconds")] - [InlineData("0 s")] - [InlineData("0 sec")] - [InlineData("0 second")] - [InlineData("0 seconds")] - [InlineData("0m")] - [InlineData("0min")] - [InlineData("0minute")] - [InlineData("0minutes")] - [InlineData("0 m")] - [InlineData("0 min")] - [InlineData("0 minute")] - [InlineData("0 minutes")] - [InlineData("0h")] - [InlineData("0hour")] - [InlineData("0hours")] - [InlineData("0 h")] - [InlineData("0 hour")] - [InlineData("0 hours")] - [InlineData("0d")] - [InlineData("0day")] - [InlineData("0days")] - [InlineData("0 d")] - [InlineData("0 day")] - [InlineData("0 days")] - [InlineData("0d 0h 0m 0s 0ms")] - [InlineData("0day 0hour 0min 0sec 0ms")] - [InlineData("0day 0hour 0minute 0second 0millisecond")] - [InlineData("0days 0hours 0minutes 0seconds 0milliseconds")] - [InlineData("0 d 0 h 0 m 0 s 0 ms")] - [InlineData("0 day 0 hour 0 min 0 sec 0 ms")] - [InlineData("0 day 0 hour 0 minute 0 second 0 millisecond")] - [InlineData("0 days 0 hours 0 minutes 0 seconds 0 milliseconds")] - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 0m 0 s 0ms")] - [InlineData("0day 0 hour 0min 0 sec 0ms")] - [InlineData("0day 0 hour 0minute 0 second 0millisecond")] - [InlineData("0days 0hours 0minutes 0 seconds 0milliseconds")] - public void TryParse_ZeroInput_ReturnsTrueAndTimeSpanZero(string? input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(TimeSpan.Zero); - parsedResult.Should().BeTrue(); - } - - [Theory] - [InlineData("00:00:03")] - [InlineData("00:00:03.0")] - [InlineData("00:00:03.00")] - [InlineData("0.00:00:03")] - [InlineData("00.00:00:03")] - [InlineData("3s")] - [InlineData("3sec")] - [InlineData("3second")] - [InlineData("3seconds")] - [InlineData("3 s")] - [InlineData("3 sec")] - [InlineData("3 second")] - [InlineData("3 seconds")] - [InlineData("3s 0ms")] - [InlineData("3sec 0ms")] - [InlineData("3second 0millisecond")] - [InlineData("3seconds 0milliseconds")] - [InlineData("3 s 0 ms")] - [InlineData("3 sec 0 ms")] - [InlineData("3 second 0 millisecond")] - [InlineData("3 seconds 0 milliseconds")] - [InlineData("0m 3s")] - [InlineData("0min 3sec")] - [InlineData("0minute 3second")] - [InlineData("0minutes 3seconds")] - [InlineData("0 m 3 s")] - [InlineData("0 min 3 sec")] - [InlineData("0 minute 3 second")] - [InlineData("0 minutes 3 seconds")] - [InlineData("0m 3s 0ms")] - [InlineData("0min 3sec 0ms")] - [InlineData("0minute 3second 0millisecond")] - [InlineData("0minutes 3seconds 0milliseconds")] - [InlineData("0 m 3 s 0 ms")] - [InlineData("0 min 3 sec 0 ms")] - [InlineData("0 minute 3 second 0 millisecond")] - [InlineData("0 minutes 3 seconds 0 milliseconds")] - [InlineData("0h 0m 3s")] - [InlineData("0hour 0min 3sec")] - [InlineData("0hour 0minute 3second")] - [InlineData("0hours 0minutes 3seconds")] - [InlineData("0 h 0 m 3 s")] - [InlineData("0 hour 0 min 3 sec")] - [InlineData("0 hour 0 minute 3 second")] - [InlineData("0 hours 0 minutes 3 seconds")] - [InlineData("0h 0m 3s 0ms")] - [InlineData("0hour 0min 3sec 0ms")] - [InlineData("0hour 0minute 3second 0millisecond")] - [InlineData("0hours 0minutes 3seconds 0milliseconds")] - [InlineData("0 h 0 m 3 s 0 ms")] - [InlineData("0 hour 0 min 3 sec 0 ms")] - [InlineData("0 hour 0 minute 3 second 0 millisecond")] - [InlineData("0 hours 0 minutes 3 seconds 0 milliseconds")] - [InlineData("0d 0h 0m 3s")] - [InlineData("0day 0hour 0min 3sec")] - [InlineData("0day 0hour 0minute 3second")] - [InlineData("0days 0hours 0minutes 3seconds")] - [InlineData("0 d 0 h 0 m 3 s")] - [InlineData("0 day 0 hour 0 min 3 sec")] - [InlineData("0 day 0 hour 0 minute 3 second")] - [InlineData("0 days 0 hours 0 minutes 3 seconds")] - [InlineData("0d 0h 0m 3s 0ms")] - [InlineData("0day 0hour 0min 3sec 0ms")] - [InlineData("0day 0hour 0minute 3second 0millisecond")] - [InlineData("0days 0hours 0minutes 3seconds 0milliseconds")] - [InlineData("0 d 0 h 0 m 3 s 0 ms")] - [InlineData("0 day 0 hour 0 min 3 sec 0 ms")] - [InlineData("0 day 0 hour 0 minute 3 second 0 millisecond")] - [InlineData("0 days 0 hours 0 minutes 3 seconds 0 milliseconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 0m 3 s 0ms")] - [InlineData("0day 0hour 0min 3 sec 0ms")] - [InlineData("0day 0 hour 0 minute 3second 0millisecond")] - [InlineData("0days 0hours 0minutes 3 seconds 0milliseconds")] - public void TryParse_ThreeSecondsInput_ReturnsTrueAndTimeSpanWithThreeSeconds(string? input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(new TimeSpan(0, 0, 3)); - parsedResult.Should().BeTrue(); - } - - [Theory] - [InlineData("00:00:00", 0, 0, 0, 0)] // Zero time - [InlineData("01:00:00", 0, 1, 0, 0)] // Hours only - [InlineData("00:30:00", 0, 0, 30, 0)] // Minutes only - [InlineData("00:00:45", 0, 0, 0, 45)] // Seconds only - [InlineData("1.00:00:00", 1, 0, 0, 0)] // Days only - [InlineData("00:00:00.500", 0, 0, 0, 0, 500)] // With milliseconds - [InlineData("1.23:45:56.789", 1, 23, 45, 56, 789)] // Complex case - public void TryParse_StandardDurationFormat_ShouldParseCorrectly(string input, int days, int hours, int minutes, int seconds, - int milliseconds = 0) - { - // 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] - [InlineData("1d", "1.00:00:00")] - [InlineData("2 days", "2.00:00:00")] - [InlineData("3h", "03:00:00")] - [InlineData("4 hours", "04:00:00")] - [InlineData("5m", "00:05:00")] - [InlineData("6 minutes", "00:06:00")] - [InlineData("7s", "00:00:07")] - [InlineData("8 seconds", "00:00:08")] - [InlineData("100ms", "00:00:00.100")] - [InlineData("1d 2h 3m 4s 500ms", "1.02:03:04.500")] - public void TryParse_HumanReadableFormat_ShouldParseCorrectly(string input, string result) - { - // Arrange - _ = TimeSpan.TryParse(result, out var expectedResult); - - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Arrange - parsedValue.Should().Be(expectedResult); - parsedResult.Should().BeTrue(); - } - - [Theory] - [InlineData("69.5m 0.5s", "01:09:30.500")] - [InlineData("0.3m 100.07s", "00:01:58.070")] - [InlineData("0.01h 100.007s", "00:02:16.007")] - [InlineData("0.0003d", "00:00:25.920")] - public void TryParse_MixedUnrollingAndRollingOverInput_ReturnsTrueAndTimeSpanWithCorrectValue(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(); - } - - #region Invalid input tests - - [Fact] - public void TryParse_NullInput_ReturnsFalseAndTimeSpanZero() - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(null, out var parsedValue); - - // Assert - parsedValue.Should().Be(TimeSpan.Zero); - parsedResult.Should().BeFalse(); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(" ")] - [InlineData("\t")] - [InlineData("\n")] - [InlineData(" \n ")] - public void TryParse_EmptyInput_ReturnsFalseAndTimeSpanZero(string? input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(TimeSpan.Zero); - parsedResult.Should().BeFalse(); - } - - [Theory] - [InlineData("Foo")] - [InlineData(" Bar")] - [InlineData("Foo 3s")] - [InlineData("3s Bar")] - [InlineData("Foo 00:00:03")] - [InlineData("00:00:03 Bar")] - [InlineData("Foo 00:00:03 Bar")] - [InlineData("3house")] - [InlineData("3 house")] - [InlineData("3houses")] - [InlineData("3 houses")] - [InlineData("invalid")] - [InlineData("0h0m3s")] - [InlineData("0m 3s 1m")] - [InlineData("3s 1h")] - [InlineData("+3s")] - [InlineData("-3s")] - [InlineData("3,5s")] - [InlineData("3,005s")] - [InlineData("3,005.0s")] - [InlineData("3,5 s")] - [InlineData("1y")] - [InlineData("1d 2x")] - [InlineData("00:00")] - [InlineData("00:00.0")] - [InlineData("0.00:00")] - [InlineData("00:00:60")] - [InlineData("00:00:61")] - [InlineData("00:60:00")] - [InlineData("00:61:00")] - [InlineData("24:00:00")] - [InlineData("25:00:00")] - [InlineData("00:00:00.0001")] - [InlineData("00:00:60.0")] - [InlineData("00:00:61.0")] - [InlineData("00:60:00.0")] - [InlineData("00:61:00.0")] - [InlineData("24:00:00.0")] - [InlineData("25:00:00.0")] - [InlineData("0.00:00:60")] - [InlineData("0.00:00:61")] - [InlineData("0.00:60:00")] - [InlineData("0.00:61:00")] - [InlineData("0.24:00:00")] - [InlineData("0.25:00:00")] - [InlineData("1E+999999999 days")] // The value is too large for double.Parse and will throw OverflowException - [InlineData("1.2.3 days")] // The double.Parse will throw FormatException due to multiple decimal points - [InlineData( - "999999999 days 23 hours 59 minutes 59 seconds")] // This will cause TimeSpan overflow when components are added together - [InlineData("1 day 2.5ms")] // int.Parse will throw FormatException due to an invalid integer format - public void TryParse_InvalidInput_ReturnsFalseAndTimeSpanZero(string? input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(TimeSpan.Zero); - parsedResult.Should().BeFalse(); - } - - #endregion - - #region Value rolling over tests that result in next bigger unit - - [Theory] - [InlineData("1000ms")] - [InlineData("1000millisecond")] - [InlineData("1000milliseconds")] - [InlineData("1000 ms")] - [InlineData("1000 millisecond")] - [InlineData("1000 milliseconds")] - [InlineData("0s 1000ms")] - [InlineData("0sec 1000ms")] - [InlineData("0second 1000millisecond")] - [InlineData("0seconds 1000milliseconds")] - [InlineData("0 s 1000 ms")] - [InlineData("0 sec 1000 ms")] - [InlineData("0 second 1000 millisecond")] - [InlineData("0 seconds 1000 milliseconds")] - [InlineData("0m 0s 1000ms")] - [InlineData("0min 0sec 1000ms")] - [InlineData("0minute 0second 1000millisecond")] - [InlineData("0minutes 0seconds 1000milliseconds")] - [InlineData("0 m 0 s 1000 ms")] - [InlineData("0 min 0 sec 1000 ms")] - [InlineData("0 minute 0 second 1000 millisecond")] - [InlineData("0 minutes 0 seconds 1000 milliseconds")] - [InlineData("0h 0m 0s 1000ms")] - [InlineData("0hour 0min 0sec 1000ms")] - [InlineData("0hour 0minute 0second 1000millisecond")] - [InlineData("0hours 0minutes 0seconds 1000milliseconds")] - [InlineData("0 h 0 m 0 s 1000 ms")] - [InlineData("0 hour 0 min 0 sec 1000 ms")] - [InlineData("0 hour 0 minute 0 second 1000 millisecond")] - [InlineData("0 hours 0 minutes 0 seconds 1000 milliseconds")] - [InlineData("0d 0h 0m 0s 1000ms")] - [InlineData("0day 0hour 0min 0sec 1000ms")] - [InlineData("0day 0hour 0minute 0second 1000millisecond")] - [InlineData("0days 0hours 0minutes 0seconds 1000milliseconds")] - [InlineData("0 d 0 h 0 m 0 s 1000 ms")] - [InlineData("0 day 0 hour 0 min 0 sec 1000 ms")] - [InlineData("0 day 0 hour 0 minute 0 second 1000 millisecond")] - [InlineData("0 days 0 hours 0 minutes 0 seconds 1000 milliseconds")] - public void TryParse_RollingOverThousandMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecond(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] - [InlineData("1001ms")] - [InlineData("1001millisecond")] - [InlineData("1001milliseconds")] - [InlineData("1001 ms")] - [InlineData("1001 millisecond")] - [InlineData("1001 milliseconds")] - [InlineData("0s 1001ms")] - [InlineData("0sec 1001ms")] - [InlineData("0second 1001millisecond")] - [InlineData("0seconds 1001milliseconds")] - [InlineData("0 s 1001 ms")] - [InlineData("0 sec 1001 ms")] - [InlineData("0 second 1001 millisecond")] - [InlineData("0 seconds 1001 milliseconds")] - [InlineData("0m 0s 1001ms")] - [InlineData("0min 0sec 1001ms")] - [InlineData("0minute 0second 1001millisecond")] - [InlineData("0minutes 0seconds 1001milliseconds")] - [InlineData("0 m 0 s 1001 ms")] - [InlineData("0 min 0 sec 1001 ms")] - [InlineData("0 minute 0 second 1001 millisecond")] - [InlineData("0 minutes 0 seconds 1001 milliseconds")] - [InlineData("0h 0m 0s 1001ms")] - [InlineData("0hour 0min 0sec 1001ms")] - [InlineData("0hour 0minute 0second 1001millisecond")] - [InlineData("0hours 0minutes 0seconds 1001milliseconds")] - [InlineData("0 h 0 m 0 s 1001 ms")] - [InlineData("0 hour 0 min 0 sec 1001 ms")] - [InlineData("0 hour 0 minute 0 second 1001 millisecond")] - [InlineData("0 hours 0 minutes 0 seconds 1001 milliseconds")] - [InlineData("0d 0h 0m 0s 1001ms")] - [InlineData("0day 0hour 0min 0sec 1001ms")] - [InlineData("0day 0hour 0minute 0second 1001millisecond")] - [InlineData("0days 0hours 0minutes 0seconds 1001milliseconds")] - [InlineData("0 d 0 h 0 m 0 s 1001 ms")] - [InlineData("0 day 0 hour 0 min 0 sec 1001 ms")] - [InlineData("0 day 0 hour 0 minute 0 second 1001 millisecond")] - [InlineData("0 days 0 hours 0 minutes 0 seconds 1001 milliseconds")] - public void - TryParse_RollingOverThousandAndOneMillisecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneSecondAndOneMillisecond( - 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] - [InlineData("60s")] - [InlineData("60sec")] - [InlineData("60second")] - [InlineData("60seconds")] - [InlineData("60 s")] - [InlineData("60 sec")] - [InlineData("60 second")] - [InlineData("60 seconds")] - [InlineData("0m 60s")] - [InlineData("0min 60sec")] - [InlineData("0minute 60second")] - [InlineData("0minutes 60seconds")] - [InlineData("0 m 60 s")] - [InlineData("0 min 60 sec")] - [InlineData("0 minute 60 second")] - [InlineData("0 minutes 60 seconds")] - [InlineData("0h 0m 60s")] - [InlineData("0hour 0min 60sec")] - [InlineData("0hour 0minute 60second")] - [InlineData("0hours 0minutes 60seconds")] - [InlineData("0 h 0 m 60 s")] - [InlineData("0 hour 0 min 60 sec")] - [InlineData("0 hour 0 minute 60 second")] - [InlineData("0 hours 0minutes 60seconds")] - [InlineData("0d 0h 0m 60s")] - [InlineData("0day 0hour 0min 60sec")] - [InlineData("0day 0hour 0minute 60second")] - [InlineData("0days 0hours 0minutes 60seconds")] - [InlineData("0 d 0 h 0 m 60 s")] - [InlineData("0 day 0 hour 0 min 60 sec")] - [InlineData("0 day 0 hour 0 minute 60 second")] - [InlineData("0 days 0 hours 0minutes 60 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 0m 60 s 0ms")] - [InlineData("0day 0hour 0min 60 sec 0ms")] - [InlineData("0day 0 hour 0 minute 60second 0millisecond")] - [InlineData("0days 0hours 0minutes 60 seconds 0milliseconds")] - public void TryParse_RollingOverSixtySecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinute(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] - [InlineData("65s")] - [InlineData("65sec")] - [InlineData("65second")] - [InlineData("65seconds")] - [InlineData("65 s")] - [InlineData("65 sec")] - [InlineData("65 second")] - [InlineData("65 seconds")] - [InlineData("0m 65s")] - [InlineData("0min 65sec")] - [InlineData("0minute 65second")] - [InlineData("0minutes 65seconds")] - [InlineData("0 m 65 s")] - [InlineData("0 min 65 sec")] - [InlineData("0 minute 65 second")] - [InlineData("0 minutes 65 seconds")] - [InlineData("0h 0m 65s")] - [InlineData("0hour 0min 65sec")] - [InlineData("0hour 0minute 65second")] - [InlineData("0hours 0minutes 65seconds")] - [InlineData("0 h 0 m 65 s")] - [InlineData("0 hour 0 min 65 sec")] - [InlineData("0 hour 0 minute 65 second")] - [InlineData("0 hours 0minutes 65seconds")] - [InlineData("0d 0h 0m 65s")] - [InlineData("0day 0hour 0min 65sec")] - [InlineData("0day 0hour 0minute 65second")] - [InlineData("0days 0hours 0minutes 65seconds")] - [InlineData("0 d 0 h 0 m 65 s")] - [InlineData("0 day 0 hour 0 min 65 sec")] - [InlineData("0 day 0 hour 0 minute 65 second")] - [InlineData("0 days 0 hours 0minutes 65 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 0m 65 s 0ms")] - [InlineData("0day 0hour 0min 65 sec 0ms")] - [InlineData("0day 0 hour 0 minute 65second 0millisecond")] - [InlineData("0days 0hours 0minutes 65 seconds 0milliseconds")] - public void - TryParse_RollingOverSixtyFiveSecondsInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneMinuteAndFiveSeconds( - 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] - [InlineData("60m")] - [InlineData("60min")] - [InlineData("60minute")] - [InlineData("60minutes")] - [InlineData("60 m")] - [InlineData("60 min")] - [InlineData("60 minute")] - [InlineData("60 minutes")] - [InlineData("60m 0s")] - [InlineData("60min 0sec")] - [InlineData("60minute 0second")] - [InlineData("60minutes 0seconds")] - [InlineData("60 m 0 s")] - [InlineData("60 min 0 sec")] - [InlineData("60 minute 0 second")] - [InlineData("60 minutes 0 seconds")] - [InlineData("0h 60m 0s")] - [InlineData("0hour 60min 0sec")] - [InlineData("0hour 60minute 0second")] - [InlineData("0hours 60minutes 0seconds")] - [InlineData("0 h 60 m 0 s")] - [InlineData("0 hour 60 min 0 sec")] - [InlineData("0 hour 60 minute 0 second")] - [InlineData("0 hours 60 minutes 0 seconds")] - [InlineData("0d 0h 60m 0s")] - [InlineData("0day 0hour 60min 0sec")] - [InlineData("0day 0hour 60minute 0second")] - [InlineData("0days 0hours 60minutes 0seconds")] - [InlineData("0 d 0 h 60 m 0 s")] - [InlineData("0 day 0 hour 60 min 0 sec")] - [InlineData("0 day 0 hour 60 minute 0 second")] - [InlineData("0 days 0 hours 60 minutes 0 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 60m 0 s 0ms")] - [InlineData("0day 0hour 60min 0 sec 0ms")] - [InlineData("0day 0 hour 60 minute 0second 0millisecond")] - [InlineData("0days 0hours 60minutes 0 seconds 0milliseconds")] - public void TryParse_RollingOverSixtyMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHour(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] - [InlineData("65m")] - [InlineData("65min")] - [InlineData("65minute")] - [InlineData("65minutes")] - [InlineData("65 m")] - [InlineData("65 min")] - [InlineData("65 minute")] - [InlineData("65 minutes")] - [InlineData("65m 0s")] - [InlineData("65min 0sec")] - [InlineData("65minute 0second")] - [InlineData("65minutes 0seconds")] - [InlineData("65 m 0 s")] - [InlineData("65 min 0 sec")] - [InlineData("65 minute 0 second")] - [InlineData("65 minutes 0 seconds")] - [InlineData("0h 65m 0s")] - [InlineData("0hour 65min 0sec")] - [InlineData("0hour 65minute 0second")] - [InlineData("0hours 65minutes 0seconds")] - [InlineData("0 h 65 m 0 s")] - [InlineData("0 hour 65 min 0 sec")] - [InlineData("0 hour 65 minute 0 second")] - [InlineData("0 hours 65 minutes 0 seconds")] - [InlineData("0d 0h 65m 0s")] - [InlineData("0day 0hour 65min 0sec")] - [InlineData("0day 0hour 65minute 0second")] - [InlineData("0days 0hours 65minutes 0seconds")] - [InlineData("0 d 0 h 65 m 0 s")] - [InlineData("0 day 0 hour 65 min 0 sec")] - [InlineData("0 day 0 hour 65 minute 0 second")] - [InlineData("0 days 0 hours 65 minutes 0 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 65m 0 s 0ms")] - [InlineData("0day 0hour 65min 0 sec 0ms")] - [InlineData("0day 0 hour 65 minute 0second 0millisecond")] - [InlineData("0days 0hours 65minutes 0 seconds 0milliseconds")] - public void TryParse_RollingOverSixtyFiveMinutesInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneHourAndFiveMinutes( - 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] - [InlineData("24h 0m 0s")] - [InlineData("24hour 0min 0sec")] - [InlineData("24hour 0minute 0second")] - [InlineData("24hours 0minutes 0seconds")] - [InlineData("24 h 0 m 0 s")] - [InlineData("24 hour 0 min 0 sec")] - [InlineData("24 hour 0 minute 0 second")] - [InlineData("24 hours 0 minutes 0 seconds")] - [InlineData("0d 24h 0m 0s")] - [InlineData("0day 24hour 0min 0sec")] - [InlineData("0day 24hour 0minute 0second")] - [InlineData("0days 24hours 0minutes 0seconds")] - [InlineData("0 d 24 h 0 m 0 s")] - [InlineData("0 day 24 hour 0 min 0 sec")] - [InlineData("0 day 24 hour 0 minute 0 second")] - [InlineData("0 days 24 hours 0 minutes 0 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 24 h 0m 0 s 0ms")] - [InlineData("0day 24hour 0min 0 sec 0ms")] - [InlineData("0day 24 hour 0 minute 0second 0millisecond")] - [InlineData("0days 24hours 0minutes 0 seconds 0milliseconds")] - public void TryParse_RollingOverTwentyFourHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDay(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] - [InlineData("25h 0m 0s")] - [InlineData("25hour 0min 0sec")] - [InlineData("25hour 0minute 0second")] - [InlineData("25hours 0minutes 0seconds")] - [InlineData("25 h 0 m 0 s")] - [InlineData("25 hour 0 min 0 sec")] - [InlineData("25 hour 0 minute 0 second")] - [InlineData("25 hours 0 minutes 0 seconds")] - [InlineData("0d 25h 0m 0s")] - [InlineData("0day 25hour 0min 0sec")] - [InlineData("0day 25hour 0minute 0second")] - [InlineData("0days 25hours 0minutes 0seconds")] - [InlineData("0 d 25 h 0 m 0 s")] - [InlineData("0 day 25 hour 0 min 0 sec")] - [InlineData("0 day 25 hour 0 minute 0 second")] - [InlineData("0 days 25 hours 0 minutes 0 seconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 25 h 0m 0 s 0ms")] - [InlineData("0day 25hour 0min 0 sec 0ms")] - [InlineData("0day 25 hour 0 minute 0second 0millisecond")] - [InlineData("0days 25hours 0minutes 0 seconds 0milliseconds")] - public void TryParse_RollingOverTwentyFiveHoursInNotDurationFormat_ReturnsTrueAndTimeSpanWithOneDayAndOneHour( - 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 Value unrolling tests that result in current and next smaller unit - - [Theory] - [InlineData("3.5s")] - [InlineData("3.5sec")] - [InlineData("3.5second")] - [InlineData("3.5seconds")] - [InlineData("3.5 s")] - [InlineData("3.5 sec")] - [InlineData("3.5 second")] - [InlineData("3.5 seconds")] - [InlineData("3.5s 0ms")] - [InlineData("3.5sec 0ms")] - [InlineData("3.5second 0millisecond")] - [InlineData("3.5seconds 0milliseconds")] - [InlineData("3.5 s 0 ms")] - [InlineData("3.5 sec 0 ms")] - [InlineData("3.5 second 0 millisecond")] - [InlineData("3.5 seconds 0 milliseconds")] - [InlineData("0m 3.5s")] - [InlineData("0min 3.5sec")] - [InlineData("0minute 3.5second")] - [InlineData("0minutes 3.5seconds")] - [InlineData("0 m 3.5 s")] - [InlineData("0 min 3.5 sec")] - [InlineData("0 minute 3.5 second")] - [InlineData("0 minutes 3.5 seconds")] - [InlineData("0m 3.5s 0ms")] - [InlineData("0min 3.5sec 0ms")] - [InlineData("0minute 3.5second 0millisecond")] - [InlineData("0minutes 3.5seconds 0milliseconds")] - [InlineData("0 m 3.5 s 0 ms")] - [InlineData("0 min 3.5 sec 0 ms")] - [InlineData("0 minute 3.5 second 0 millisecond")] - [InlineData("0 minutes 3.5 seconds 0 milliseconds")] - [InlineData("0h 0m 3.5s")] - [InlineData("0hour 0min 3.5sec")] - [InlineData("0hour 0minute 3.5second")] - [InlineData("0hours 0minutes 3.5seconds")] - [InlineData("0 h 0 m 3.5 s")] - [InlineData("0 hour 0 min 3.5 sec")] - [InlineData("0 hour 0 minute 3.5 second")] - [InlineData("0 hours 0 minutes 3.5 seconds")] - [InlineData("0h 0m 3.5s 0ms")] - [InlineData("0hour 0min 3.5sec 0ms")] - [InlineData("0hour 0minute 3.5second 0millisecond")] - [InlineData("0hours 0minutes 3.5seconds 0milliseconds")] - [InlineData("0 h 0 m 3.5 s 0 ms")] - [InlineData("0 hour 0 min 3.5 sec 0 ms")] - [InlineData("0 hour 0 minute 3.5 second 0 millisecond")] - [InlineData("0 hours 0 minutes 3.5 seconds 0 milliseconds")] - [InlineData("0d 0h 0m 3.5s")] - [InlineData("0day 0hour 0min 3.5sec")] - [InlineData("0day 0hour 0minute 3.5second")] - [InlineData("0days 0hours 0minutes 3.5seconds")] - [InlineData("0 d 0 h 0 m 3.5 s")] - [InlineData("0 day 0 hour 0 min 3.5 sec")] - [InlineData("0 day 0 hour 0 minute 3.5 second")] - [InlineData("0 days 0 hours 0 minutes 3.5 seconds")] - [InlineData("0d 0h 0m 3.5s 0ms")] - [InlineData("0day 0hour 0min 3.5sec 0ms")] - [InlineData("0day 0hour 0minute 3.5second 0millisecond")] - [InlineData("0days 0hours 0minutes 3.5seconds 0milliseconds")] - [InlineData("0 d 0 h 0 m 3.5 s 0 ms")] - [InlineData("0 day 0 hour 0 min 3.5 sec 0 ms")] - [InlineData("0 day 0 hour 0 minute 3.5 second 0 millisecond")] - [InlineData("0 days 0 hours 0 minutes 3.5 seconds 0 milliseconds")] - - // Mixed w/ spaces and w/o spaces between units - [InlineData("0d 0 h 0m 3.5 s 0ms")] - [InlineData("0day 0hour 0min 3.5 sec 0ms")] - [InlineData("0day 0 hour 0 minute 3.5second 0millisecond")] - [InlineData("0days 0hours 0minutes 3.5 seconds 0milliseconds")] - public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithThreeSecondsAndFiveHundredMilliseconds(string input) - { - // Arrange - // Act - var parsedResult = DurationTimeParser.TryParse(input, out var parsedValue); - - // Assert - parsedValue.Should().Be(new TimeSpan(0, 0, 0, 3, 500)); - parsedResult.Should().BeTrue(); - } - - [Theory] - [InlineData("1.25d 1.5h 1.5m 1.5s 7ms", "1.07:31:31.507")] - [InlineData("1.25d 1.5m 7ms", "1.06:01:30.007")] - [InlineData("1.5m 13ms", "00:01:30.013")] - public void TryParse_UnrollFractionalValue_ReturnsTrueAndTimeSpanWithCorrectValue(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 } diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs new file mode 100644 index 0000000..ad15c8d --- /dev/null +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserInvalidTestData.cs @@ -0,0 +1,200 @@ +namespace DurationMancer.Tests.TestData; + +public sealed class DurationTimeParserInvalidTestData +{ + /// + /// Represents a collection of mixed input formats considered as invalid inputs for + /// testing the duration time parsing functionality. + /// + /// + /// 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 => + [ + // Human-readable days + standard time + "1d 00:00:00", + "1 d 00:00:00", + "1day 00:00:00", + "1 day 00:00:00", + "1days 00:00:00", + "1 days 00:00:00", + "2d 12:30:45", + "2 days 12:30:45", + "1d 1.12:30:45", + "1 day 1.12:30:45", + "1d 00:00:00.500", + "1 day 00:00:00.500", + "2 days 1.23:45:56.789", + + // Human-readable hours + standard time + "1h 00:00:00", + "1 h 00:00:00", + "1hour 00:00:00", + "1 hour 00:00:00", + "1hours 00:00:00", + "1 hours 00:00:00", + "2h 01:30:00", + "2 hours 01:30:00", + "1h 0.01:30:00", + "3 hours 00:00:00.100", + + // Human-readable minutes + standard time + "1m 00:00:00", + "1 m 00:00:00", + "1min 00:00:00", + "1 min 00:00:00", + "1minute 00:00:00", + "1 minute 00:00:00", + "1minutes 00:00:00", + "1 minutes 00:00:00", + "5m 01:00:30", + "5 minutes 01:00:30", + "10 min 0.12:00:00", + "15 minutes 00:00:00.250", + + // Human-readable seconds + standard time + "1s 00:00:00", + "1 s 00:00:00", + "1sec 00:00:00", + "1 sec 00:00:00", + "1second 00:00:00", + "1 second 00:00:00", + "1seconds 00:00:00", + "1 seconds 00:00:00", + "30s 00:01:00", + "30 seconds 00:01:00", + "10 sec 1.00:00:00", + "5 seconds 00:00:00.500", + + // Human-readable milliseconds + standard time + "100ms 00:00:00", + "100 ms 00:00:00", + "100millisecond 00:00:00", + "100 millisecond 00:00:00", + "100milliseconds 00:00:00", + "100 milliseconds 00:00:00", + "500ms 00:00:01", + "500 milliseconds 00:00:01", + "250 ms 1.12:30:00", + "100 milliseconds 00:00:00.100", + + // Standard time + trailing human-readable units + "00:00:00 1d", + "00:00:00 1 day", + "00:00:00 1h", + "00:00:00 1 hour", + "00:00:00 1m", + "00:00:00 1 minute", + "00:00:00 1s", + "00:00:00 1 second", + "00:00:00 100ms", + "00:00:00 100 milliseconds", + "01:30:00 5s", + "1.12:30:00 500ms", + "00:00:00.500 1s", + + // Standard time sandwiched between human-readable units + "1d 00:00:00 1s", + "1 day 01:00:00 100ms", + "2h 00:30:00 5s", + "1d 1h 00:00:00", + "1 day 2 hours 00:30:00", + + // Multiple human-readable units + standard time + "1d 2h 00:00:00", + "1 day 2 hours 00:00:00", + "1d 2h 3m 00:00:00", + "1 day 2 hours 3 minutes 00:00:00", + "1d 2h 3m 4s 00:00:00", + "1 day 2 hours 3 minutes 4 seconds 00:00:00", + "1d 2h 3m 4s 500ms 00:00:00", + "1 day 2 hours 3 minutes 4 seconds 500 milliseconds 00:00:00", + + // Standard time + multiple trailing human-readable units + "00:00:00 1d 2h", + "00:00:00 1 day 2 hours", + "00:00:00 1d 2h 3m", + "00:00:00 1d 2h 3m 4s", + "00:00:00 1d 2h 3m 4s 500ms", + + // Standard format with days prefix + human-readable suffix + "1.12:30:45 1d", + "1.12:30:45 1 day", + "1.12:30:45 100ms", + "0.00:00:00 1s", + + // Standard format with milliseconds + human-readable suffix + "00:00:00.500 1d", + "00:00:00.500 1 hour", + "00:00:00.500 1m", + "00:00:00.100 100ms", + + // 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" + ]; + + /// + /// Represents a collection of strings that are considered invalid inputs for + /// testing the duration time parsing functionality. + /// + /// + /// 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 => + [ + "Foo", + " Bar", + "Foo 3s", + "3s Bar", + "Foo 00:00:03", + "00:00:03 Bar", + "Foo 00:00:03 Bar", + "3house", + "3 house", + "3houses", + "3 houses", + "invalid", + "0h0m3s", + "0m 3s 1m", + "3s 1h", + "+3s", + "-3s", + "3,5s", + "3,005s", + "3,005.0s", + "3,5 s", + "1y", + "1d 2x", + "00:00", + "00:00.0", + "0.00:00", + "00:00:60", + "00:00:61", + "00:60:00", + "00:61:00", + "24:00:00", + "25:00:00", + "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", + "0.00:00:60", + "0.00:00:61", + "0.00:60:00", + "0.00:61:00", + "0.24:00:00", + "0.25:00:00", + "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 + ]; +} diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs new file mode 100644 index 0000000..9eff267 --- /dev/null +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserRollingOverTestData.cs @@ -0,0 +1,392 @@ +namespace DurationMancer.Tests.TestData; + +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. + /// + /// + /// 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", + "1000millisecond", + "1000milliseconds", + "1000 ms", + "1000 millisecond", + "1000 milliseconds", + "0s 1000ms", + "0sec 1000ms", + "0second 1000millisecond", + "0seconds 1000milliseconds", + "0 s 1000 ms", + "0 sec 1000 ms", + "0 second 1000 millisecond", + "0 seconds 1000 milliseconds", + "0m 0s 1000ms", + "0min 0sec 1000ms", + "0minute 0second 1000millisecond", + "0minutes 0seconds 1000milliseconds", + "0 m 0 s 1000 ms", + "0 min 0 sec 1000 ms", + "0 minute 0 second 1000 millisecond", + "0 minutes 0 seconds 1000 milliseconds", + "0h 0m 0s 1000ms", + "0hour 0min 0sec 1000ms", + "0hour 0minute 0second 1000millisecond", + "0hours 0minutes 0seconds 1000milliseconds", + "0 h 0 m 0 s 1000 ms", + "0 hour 0 min 0 sec 1000 ms", + "0 hour 0 minute 0 second 1000 millisecond", + "0 hours 0 minutes 0 seconds 1000 milliseconds", + "0d 0h 0m 0s 1000ms", + "0day 0hour 0min 0sec 1000ms", + "0day 0hour 0minute 0second 1000millisecond", + "0days 0hours 0minutes 0seconds 1000milliseconds", + "0 d 0 h 0 m 0 s 1000 ms", + "0 day 0 hour 0 min 0 sec 1000 ms", + "0 day 0 hour 0 minute 0 second 1000 millisecond", + "0 days 0 hours 0 minutes 0 seconds 1000 milliseconds" + ]; + + /// + /// Represents a collection of human-readable valid inputs that span over one second and include an + /// additional one millisecond, formatted in various valid representations. + /// + /// + /// 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", + "1001millisecond", + "1001milliseconds", + "1001 ms", + "1001 millisecond", + "1001 milliseconds", + "0s 1001ms", + "0sec 1001ms", + "0second 1001millisecond", + "0seconds 1001milliseconds", + "0 s 1001 ms", + "0 sec 1001 ms", + "0 second 1001 millisecond", + "0 seconds 1001 milliseconds", + "0m 0s 1001ms", + "0min 0sec 1001ms", + "0minute 0second 1001millisecond", + "0minutes 0seconds 1001milliseconds", + "0 m 0 s 1001 ms", + "0 min 0 sec 1001 ms", + "0 minute 0 second 1001 millisecond", + "0 minutes 0 seconds 1001 milliseconds", + "0h 0m 0s 1001ms", + "0hour 0min 0sec 1001ms", + "0hour 0minute 0second 1001millisecond", + "0hours 0minutes 0seconds 1001milliseconds", + "0 h 0 m 0 s 1001 ms", + "0 hour 0 min 0 sec 1001 ms", + "0 hour 0 minute 0 second 1001 millisecond", + "0 hours 0 minutes 0 seconds 1001 milliseconds", + "0d 0h 0m 0s 1001ms", + "0day 0hour 0min 0sec 1001ms", + "0day 0hour 0minute 0second 1001millisecond", + "0days 0hours 0minutes 0seconds 1001milliseconds", + "0 d 0 h 0 m 0 s 1001 ms", + "0 day 0 hour 0 min 0 sec 1001 ms", + "0 day 0 hour 0 minute 0 second 1001 millisecond", + "0 days 0 hours 0 minutes 0 seconds 1001 milliseconds" + ]; + + /// + /// 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. + /// + /// + /// 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", + "60sec", + "60second", + "60seconds", + "60 s", + "60 sec", + "60 second", + "60 seconds", + "0m 60s", + "0min 60sec", + "0minute 60second", + "0minutes 60seconds", + "0 m 60 s", + "0 min 60 sec", + "0 minute 60 second", + "0 minutes 60 seconds", + "0h 0m 60s", + "0hour 0min 60sec", + "0hour 0minute 60second", + "0hours 0minutes 60seconds", + "0 h 0 m 60 s", + "0 hour 0 min 60 sec", + "0 hour 0 minute 60 second", + "0 hours 0minutes 60seconds", + "0d 0h 0m 60s", + "0day 0hour 0min 60sec", + "0day 0hour 0minute 60second", + "0days 0hours 0minutes 60seconds", + "0 d 0 h 0 m 60 s", + "0 day 0 hour 0 min 60 sec", + "0 day 0 hour 0 minute 60 second", + "0 days 0 hours 0minutes 60 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 0m 60 s 0ms", + "0day 0hour 0min 60 sec 0ms", + "0day 0 hour 0 minute 60second 0millisecond", + "0days 0hours 0minutes 60 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of valid human-readable input strings that specify durations + /// rolling over 65 seconds, used for testing duration parsing functionality. + /// + /// + /// 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", + "65sec", + "65second", + "65seconds", + "65 s", + "65 sec", + "65 second", + "65 seconds", + "0m 65s", + "0min 65sec", + "0minute 65second", + "0minutes 65seconds", + "0 m 65 s", + "0 min 65 sec", + "0 minute 65 second", + "0 minutes 65 seconds", + "0h 0m 65s", + "0hour 0min 65sec", + "0hour 0minute 65second", + "0hours 0minutes 65seconds", + "0 h 0 m 65 s", + "0 hour 0 min 65 sec", + "0 hour 0 minute 65 second", + "0 hours 0minutes 65seconds", + "0d 0h 0m 65s", + "0day 0hour 0min 65sec", + "0day 0hour 0minute 65second", + "0days 0hours 0minutes 65seconds", + "0 d 0 h 0 m 65 s", + "0 day 0 hour 0 min 65 sec", + "0 day 0 hour 0 minute 65 second", + "0 days 0 hours 0minutes 65 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 0m 65 s 0ms", + "0day 0hour 0min 65 sec 0ms", + "0day 0 hour 0 minute 65second 0millisecond", + "0days 0hours 0minutes 65 seconds 0milliseconds" + ]; + + /// + /// Represents a set of valid human-readable input strings that specify durations rolling over 60 minutes, + /// used for testing duration parsing functionality. + /// + /// + /// 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", + "60min", + "60minute", + "60minutes", + "60 m", + "60 min", + "60 minute", + "60 minutes", + "60m 0s", + "60min 0sec", + "60minute 0second", + "60minutes 0seconds", + "60 m 0 s", + "60 min 0 sec", + "60 minute 0 second", + "60 minutes 0 seconds", + "0h 60m 0s", + "0hour 60min 0sec", + "0hour 60minute 0second", + "0hours 60minutes 0seconds", + "0 h 60 m 0 s", + "0 hour 60 min 0 sec", + "0 hour 60 minute 0 second", + "0 hours 60 minutes 0 seconds", + "0d 0h 60m 0s", + "0day 0hour 60min 0sec", + "0day 0hour 60minute 0second", + "0days 0hours 60minutes 0seconds", + "0 d 0 h 60 m 0 s", + "0 day 0 hour 60 min 0 sec", + "0 day 0 hour 60 minute 0 second", + "0 days 0 hours 60 minutes 0 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 60m 0 s 0ms", + "0day 0hour 60min 0 sec 0ms", + "0day 0 hour 60 minute 0second 0millisecond", + "0days 0hours 60minutes 0 seconds 0milliseconds" + ]; + + /// + /// 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. + /// + /// + /// 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", + "65min", + "65minute", + "65minutes", + "65 m", + "65 min", + "65 minute", + "65 minutes", + "65m 0s", + "65min 0sec", + "65minute 0second", + "65minutes 0seconds", + "65 m 0 s", + "65 min 0 sec", + "65 minute 0 second", + "65 minutes 0 seconds", + "0h 65m 0s", + "0hour 65min 0sec", + "0hour 65minute 0second", + "0hours 65minutes 0seconds", + "0 h 65 m 0 s", + "0 hour 65 min 0 sec", + "0 hour 65 minute 0 second", + "0 hours 65 minutes 0 seconds", + "0d 0h 65m 0s", + "0day 0hour 65min 0sec", + "0day 0hour 65minute 0second", + "0days 0hours 65minutes 0seconds", + "0 d 0 h 65 m 0 s", + "0 day 0 hour 65 min 0 sec", + "0 day 0 hour 65 minute 0 second", + "0 days 0 hours 65 minutes 0 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 65m 0 s 0ms", + "0day 0hour 65min 0 sec 0ms", + "0day 0 hour 65 minute 0second 0millisecond", + "0days 0hours 65minutes 0 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of human-readable input strings that signify a time duration + /// rolling over 24 hours, formatted in various valid notations. + /// + /// + /// 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 0m 0s", + "24hour 0min 0sec", + "24hour 0minute 0second", + "24hours 0minutes 0seconds", + "24 h 0 m 0 s", + "24 hour 0 min 0 sec", + "24 hour 0 minute 0 second", + "24 hours 0 minutes 0 seconds", + "0d 24h 0m 0s", + "0day 24hour 0min 0sec", + "0day 24hour 0minute 0second", + "0days 24hours 0minutes 0seconds", + "0 d 24 h 0 m 0 s", + "0 day 24 hour 0 min 0 sec", + "0 day 24 hour 0 minute 0 second", + "0 days 24 hours 0 minutes 0 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 24 h 0m 0 s 0ms", + "0day 24hour 0min 0 sec 0ms", + "0day 24 hour 0 minute 0second 0millisecond", + "0days 24hours 0minutes 0 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of human-readable input strings that signify a time duration + /// rolling over 25 hours, formatted in various valid notations. + /// + /// + /// 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 0m 0s", + "25hour 0min 0sec", + "25hour 0minute 0second", + "25hours 0minutes 0seconds", + "25 h 0 m 0 s", + "25 hour 0 min 0 sec", + "25 hour 0 minute 0 second", + "25 hours 0 minutes 0 seconds", + "0d 25h 0m 0s", + "0day 25hour 0min 0sec", + "0day 25hour 0minute 0second", + "0days 25hours 0minutes 0seconds", + "0 d 25 h 0 m 0 s", + "0 day 25 hour 0 min 0 sec", + "0 day 25 hour 0 minute 0 second", + "0 days 25 hours 0 minutes 0 seconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 25 h 0m 0 s 0ms", + "0day 25hour 0min 0 sec 0ms", + "0day 25 hour 0 minute 0second 0millisecond", + "0days 25hours 0minutes 0 seconds 0milliseconds" + ]; +} diff --git a/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs b/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs new file mode 100644 index 0000000..0cce147 --- /dev/null +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserUnrollingTestData.cs @@ -0,0 +1,105 @@ +namespace DurationMancer.Tests.TestData; + +public sealed class DurationTimeParserUnrollingTestData +{ + /// + /// Represents a collection of valid human-readable input strings describing a duration of 3.5 seconds + /// for testing parsing functionality. + /// + /// + /// This property provides test data comprising multiple valid representations of a fractional duration of 3.5 seconds. + /// The test data includes variations with optional spaces, unit abbreviations, and extended unit names, as well as + /// combinations with zero values for other time units (e.g., milliseconds, minutes, hours, days). It ensures that the + /// parsing method accurately recognizes and processes valid inputs describing the specified duration. + /// + public static TheoryData ValidUnrolling3Dot5SecondsInputs => + [ + "3.5s", + "3.5sec", + "3.5second", + "3.5seconds", + "3.5 s", + "3.5 sec", + "3.5 second", + "3.5 seconds", + "3.5s 0ms", + "3.5sec 0ms", + "3.5second 0millisecond", + "3.5seconds 0milliseconds", + "3.5 s 0 ms", + "3.5 sec 0 ms", + "3.5 second 0 millisecond", + "3.5 seconds 0 milliseconds", + "0m 3.5s", + "0min 3.5sec", + "0minute 3.5second", + "0minutes 3.5seconds", + "0 m 3.5 s", + "0 min 3.5 sec", + "0 minute 3.5 second", + "0 minutes 3.5 seconds", + "0m 3.5s 0ms", + "0min 3.5sec 0ms", + "0minute 3.5second 0millisecond", + "0minutes 3.5seconds 0milliseconds", + "0 m 3.5 s 0 ms", + "0 min 3.5 sec 0 ms", + "0 minute 3.5 second 0 millisecond", + "0 minutes 3.5 seconds 0 milliseconds", + "0h 0m 3.5s", + "0hour 0min 3.5sec", + "0hour 0minute 3.5second", + "0hours 0minutes 3.5seconds", + "0 h 0 m 3.5 s", + "0 hour 0 min 3.5 sec", + "0 hour 0 minute 3.5 second", + "0 hours 0 minutes 3.5 seconds", + "0h 0m 3.5s 0ms", + "0hour 0min 3.5sec 0ms", + "0hour 0minute 3.5second 0millisecond", + "0hours 0minutes 3.5seconds 0milliseconds", + "0 h 0 m 3.5 s 0 ms", + "0 hour 0 min 3.5 sec 0 ms", + "0 hour 0 minute 3.5 second 0 millisecond", + "0 hours 0 minutes 3.5 seconds 0 milliseconds", + "0d 0h 0m 3.5s", + "0day 0hour 0min 3.5sec", + "0day 0hour 0minute 3.5second", + "0days 0hours 0minutes 3.5seconds", + "0 d 0 h 0 m 3.5 s", + "0 day 0 hour 0 min 3.5 sec", + "0 day 0 hour 0 minute 3.5 second", + "0 days 0 hours 0 minutes 3.5 seconds", + "0d 0h 0m 3.5s 0ms", + "0day 0hour 0min 3.5sec 0ms", + "0day 0hour 0minute 3.5second 0millisecond", + "0days 0hours 0minutes 3.5seconds 0milliseconds", + "0 d 0 h 0 m 3.5 s 0 ms", + "0 day 0 hour 0 min 3.5 sec 0 ms", + "0 day 0 hour 0 minute 3.5 second 0 millisecond", + "0 days 0 hours 0 minutes 3.5 seconds 0 milliseconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 0m 3.5 s 0ms", + "0day 0hour 0min 3.5 sec 0ms", + "0day 0 hour 0 minute 3.5second 0millisecond", + "0days 0hours 0minutes 3.5 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of human-readable valid duration strings that include unrolled fractional components + /// for testing the parsing functionality of duration strings. + /// + /// + /// This property provides test data that contains duration strings with fractional values + /// applied to days, hours, minutes, seconds, or milliseconds. It ensures the parser can accurately + /// interpret fractional components and combine them with other valid time representations. + /// + 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 new file mode 100644 index 0000000..93e422b --- /dev/null +++ b/tests/DurationMancer.Tests/TestData/DurationTimeParserValidTestData.cs @@ -0,0 +1,244 @@ +namespace DurationMancer.Tests.TestData; + +public sealed class DurationTimeParserValidTestData +{ + /// + /// Represents a collection of empty or whitespace-only input strings used for + /// testing the duration parsing functionality. + /// + /// + /// This property provides test data that includes various empty string representations, + /// such as blank strings, whitespace, tab, and newline characters. It ensures the parsing + /// method correctly identifies and handles invalid, non-substantive inputs. + /// + public static TheoryData ValidEmptyInputs => + [ + "", + " ", + " ", + "\t", + "\n", + " \n " + ]; + + /// + /// Represents a collection of valid human-readable and standard format zero-duration input strings used for + /// testing the duration parsing functionality. + /// + /// + /// This property provides test data that includes a wide variety of zero-duration representations + /// across different formats such as time spans, units, and mixed notations. It ensures that the + /// parsing method can correctly identify and process inputs that denote a zero duration, + /// regardless of the specific format or unit syntax used. + /// + public static TheoryData ValidZeroInputs => + [ + "00:00:00.0", + "00:00:00.00", + "00:00:00", + "0.00:00:00", + "00.00:00:00", + "0ms", + "0millisecond", + "0milliseconds", + "0 ms", + "0 millisecond", + "0 milliseconds", + "0s", + "0sec", + "0second", + "0seconds", + "0 s", + "0 sec", + "0 second", + "0 seconds", + "0m", + "0min", + "0minute", + "0minutes", + "0 m", + "0 min", + "0 minute", + "0 minutes", + "0h", + "0hour", + "0hours", + "0 h", + "0 hour", + "0 hours", + "0d", + "0day", + "0days", + "0 d", + "0 day", + "0 days", + "0d 0h 0m 0s 0ms", + "0day 0hour 0min 0sec 0ms", + "0day 0hour 0minute 0second 0millisecond", + "0days 0hours 0minutes 0seconds 0milliseconds", + "0 d 0 h 0 m 0 s 0 ms", + "0 day 0 hour 0 min 0 sec 0 ms", + "0 day 0 hour 0 minute 0 second 0 millisecond", + "0 days 0 hours 0 minutes 0 seconds 0 milliseconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 0m 0 s 0ms", + "0day 0 hour 0min 0 sec 0ms", + "0day 0 hour 0minute 0 second 0millisecond", + "0days 0hours 0minutes 0 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of valid human-readable and standard format input strings + /// that correctly parse to a duration of 3 seconds. + /// + /// + /// This property includes various formats and representations of a 3-second duration, such as + /// time-formatted strings (e.g., "00:00:03"), shorthand notations (e.g., "3s"), and + /// combinations of seconds with other time components like milliseconds, minutes, hours, or days. + /// The provided inputs are used to verify the correctness and flexibility of the duration parsing logic. + /// + public static TheoryData Valid3SecondsInputs => + [ + "00:00:03", + "00:00:03.0", + "00:00:03.00", + "0.00:00:03", + "00.00:00:03", + "3s", + "3sec", + "3second", + "3seconds", + "3 s", + "3 sec", + "3 second", + "3 seconds", + "3s 0ms", + "3sec 0ms", + "3second 0millisecond", + "3seconds 0milliseconds", + "3 s 0 ms", + "3 sec 0 ms", + "3 second 0 millisecond", + "3 seconds 0 milliseconds", + "0m 3s", + "0min 3sec", + "0minute 3second", + "0minutes 3seconds", + "0 m 3 s", + "0 min 3 sec", + "0 minute 3 second", + "0 minutes 3 seconds", + "0m 3s 0ms", + "0min 3sec 0ms", + "0minute 3second 0millisecond", + "0minutes 3seconds 0milliseconds", + "0 m 3 s 0 ms", + "0 min 3 sec 0 ms", + "0 minute 3 second 0 millisecond", + "0 minutes 3 seconds 0 milliseconds", + "0h 0m 3s", + "0hour 0min 3sec", + "0hour 0minute 3second", + "0hours 0minutes 3seconds", + "0 h 0 m 3 s", + "0 hour 0 min 3 sec", + "0 hour 0 minute 3 second", + "0 hours 0 minutes 3 seconds", + "0h 0m 3s 0ms", + "0hour 0min 3sec 0ms", + "0hour 0minute 3second 0millisecond", + "0hours 0minutes 3seconds 0milliseconds", + "0 h 0 m 3 s 0 ms", + "0 hour 0 min 3 sec 0 ms", + "0 hour 0 minute 3 second 0 millisecond", + "0 hours 0 minutes 3 seconds 0 milliseconds", + "0d 0h 0m 3s", + "0day 0hour 0min 3sec", + "0day 0hour 0minute 3second", + "0days 0hours 0minutes 3seconds", + "0 d 0 h 0 m 3 s", + "0 day 0 hour 0 min 3 sec", + "0 day 0 hour 0 minute 3 second", + "0 days 0 hours 0 minutes 3 seconds", + "0d 0h 0m 3s 0ms", + "0day 0hour 0min 3sec 0ms", + "0day 0hour 0minute 3second 0millisecond", + "0days 0hours 0minutes 3seconds 0milliseconds", + "0 d 0 h 0 m 3 s 0 ms", + "0 day 0 hour 0 min 3 sec 0 ms", + "0 day 0 hour 0 minute 3 second 0 millisecond", + "0 days 0 hours 0 minutes 3 seconds 0 milliseconds", + + // Mixed w/ spaces and w/o spaces between units + "0d 0 h 0m 3 s 0ms", + "0day 0hour 0min 3 sec 0ms", + "0day 0 hour 0 minute 3second 0millisecond", + "0days 0hours 0minutes 3 seconds 0milliseconds" + ]; + + /// + /// Represents a collection of test data consisting of valid standard duration format strings + /// and their corresponding components for duration parsing functionality. + /// + /// + /// This property provides test cases that include well-formed duration strings + /// following standard formatting conventions, such as "hh:mm:ss" or + /// "d.hh:mm:ss.fff". The property also includes expected breakdowns of these + /// strings into days, hours, minutes, seconds, and milliseconds to validate + /// the parsing behavior of the duration parser. + /// + public static TheoryData ValidStandardDurationFormatInputs => new() + { + { "00:00:00", 0, 0, 0, 0, 0 }, // Zero time + { "01:00:00", 0, 1, 0, 0, 0 }, // Hours only + { "00:30:00", 0, 0, 30, 0, 0 }, // Minutes only + { "00:00:45", 0, 0, 0, 45, 0 }, // Seconds only + { "1.00:00:00", 1, 0, 0, 0, 0 }, // Days only + { "00:00:00.500", 0, 0, 0, 0, 500 }, // With millisecond + { "1.23:45:56.789", 1, 23, 45, 56, 789 } // Complex case + }; + + /// + /// Represents a collection of valid human-readable duration format input strings + /// mapped to their equivalent standardized time span representations. + /// + /// + /// This property provides test data for verifying the ability of the duration parsing + /// functionality to correctly interpret and convert various human-readable duration + /// formats, such as days, hours, minutes, seconds, and milliseconds, into their corresponding + /// TimeSpan representations. The data includes diverse combinations of units with different + /// syntaxes, ensuring robustness and accuracy in parsing. + /// + public static TheoryData ValidHumanReadableFormatInputs => 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" } + }; + + /// + /// 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. + /// + /// + /// This property provides test data where inputs combine mixed unrolling and rolling-over + /// representations of time spans, such as mixtures of minutes, seconds, and fractional components. + /// It ensures the parsing method correctly interprets and handles such inputs by normalizing + /// and converting them into coherent TimeSpan values. + /// + public static TheoryData ValidMixedUnrollingAndRollingOverInputs => 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" } + }; +} From 3508b5f6bfdab57cfd4e871c06ce9e8d28ca903d Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:48:38 +0100 Subject: [PATCH 13/17] Remove empty test class and add badges to README (#7) Remove empty test class and add badges to README Removed an unused, empty test class from the codebase to improve clarity. Updated the `README` file by adding relevant badges for project status and metrics. --- README.md | 13 +++++++++++++ .../DurationMancer.Tests/DurationTimeParserTests.cs | 9 --------- 2 files changed, 13 insertions(+), 9 deletions(-) delete mode 100644 tests/DurationMancer.Tests/DurationTimeParserTests.cs diff --git a/README.md b/README.md index 613ced5..3578c73 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![Build and analyze][1]][10] +[![Quality Gate Status][2]][11] + # DurationMancer DurationMancer is a simple library that provides a flexible time parser that understands both technical and natural language inputs. You can use these formats wherever a time duration is required, and you don't want to deal with the hassle of parsing strings yourself. @@ -91,3 +94,13 @@ TimeSpan result2 = DurationTimeParser.Parse(input2); ## Benchmarks For detailed benchmark results, please check the [Benchmarks page](https://github.com/HaGGi13/DurationMancer/wiki/Benchmarks) in the Wiki. + + + +[1]: https://github.com/HaGGi13/DurationMancer/actions/workflows/build.yaml/badge.svg +[2]: https://sonarcloud.io/api/project_badges/measure?project=HaGGi13_DurationMancer&metric=alert_status + + + +[10]: https://github.com/HaGGi13/DurationMancer/actions/workflows/build.yaml "Build pipeline" +[11]: https://sonarcloud.io/summary/new_code?id=HaGGi13_DurationMancer "Latest new code analysis" diff --git a/tests/DurationMancer.Tests/DurationTimeParserTests.cs b/tests/DurationMancer.Tests/DurationTimeParserTests.cs deleted file mode 100644 index 13d2f6d..0000000 --- a/tests/DurationMancer.Tests/DurationTimeParserTests.cs +++ /dev/null @@ -1,9 +0,0 @@ -using AwesomeAssertions; -using DurationMancer.Tests.TestData; - -namespace DurationMancer.Tests; - -public static partial class DurationTimeParserTests -{ - -} From 85434d0326615903be752bd543a2b306b5862901 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:00:50 +0100 Subject: [PATCH 14/17] Exclude benchmarks project from rider code coverage (#8) Exclude benchmarks project from rider code coverage --- .../DurationMancer.Benchmarks.csproj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj index 46ba2f3..15cb4e4 100644 --- a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj +++ b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj @@ -4,9 +4,12 @@ Exe false false - System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage + + + + From 1b7a53ab47f6ccfe61b26d0d5cd7e9521faa00e0 Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:59:52 +0100 Subject: [PATCH 15/17] Add NuGet release pipeline (#9) Added a release pipeline to automate NuGet package publishing. This workflow handles versioning, building, testing, packaging, and publishing to NuGet.org, as well as creating a GitHub Releases with relevant artifacts. --- .github/workflows/release.yaml | 112 +++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..fb99ab0 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,112 @@ +name: Release to NuGet + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' # e.g., v1.0.0, v2.3.1 + +permissions: + contents: write # Needed for GitHub Release creation + id-token: write # Needed for GitHub OIDC token issuance + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + libraryPath: './src/DurationMancer/DurationMancer.csproj' + testsPath: './tests/DurationMancer.Tests/DurationMancer.Tests.csproj' + buildConfiguration: 'Release' + +jobs: + release: + name: Pack & Publish + runs-on: ubuntu-latest + environment: nuget # GitHub Environment with protection rules (recommended) + steps: + + # --- Checkout --- + - name: Code checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # --- GitVersion --- + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4.2.0 + with: + versionSpec: '6.5.x' + - name: Determine Version + id: version_step # step id used as reference for output values + uses: gittools/actions/gitversion/command@v4.2.0 + with: + arguments: '/output buildserver /l console /config GitVersion.yaml /updateprojectfiles' # have to self-define all arguments; /updateprojectfiles not impl. in execute task + + # --- .NET Setup --- + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # --- Restore --- + - name: Restore library + run: dotnet restore ${{ env.libraryPath }} + - name: Restore tests + run: dotnet restore ${{ env.testsPath }} + + # --- Build --- + - name: Build library ${{ env.GitVersion_FullSemVer }} + run: > + dotnet build ${{ env.libraryPath }} + --no-restore + -c ${{ env.buildConfiguration }} + - name: Build tests + run: > + dotnet build ${{ env.testsPath }} + --no-restore + -c ${{ env.buildConfiguration }} + + # --- Test (safety net before publish) --- + - name: Test + run: > + dotnet test ${{ env.testsPath }} + --no-build + -c ${{ env.buildConfiguration }} + --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + + # --- Pack --- + - name: Pack NuGet package + run: > + dotnet pack ${{ env.libraryPath }} + --no-build + -c ${{ env.buildConfiguration }} + -o ./nupkg + /p:PackageVersion=${{ env.GitVersion_FullSemVer }} + + # --- Validate package --- + - name: Validate NuGet package + run: dotnet nuget verify ./nupkg/*.nupkg --all || true # 'verify' checks signatures; use 'true' fallback if unsigned + + # --- Trusted Publishing: OIDC login + - name: NuGet login (OIDC → temp API key) + env: + NUGET_USER: ${{ secrets.NUGET_USER }} + uses: NuGet/login@v1 + id: nuget_login + with: + user: $NUGET_USER + + # --- Push to nuget.org --- + - name: Push to NuGet.org + env: + NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: > + dotnet nuget push ./nupkg/*.nupkg + --api-key $NUGET_API_KEY + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + # --- GitHub Release --- + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: ./nupkg/*.nupkg From 16818b019f256654a39723b5d16f277cd7e397ae Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:33:19 +0100 Subject: [PATCH 16/17] Update branch triggers in workflow configuration (#11) Adjusted branch triggers in the build workflow to include `main`, `release/*`, and `develop` branches for both pushes and pull requests. --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 53cf57c..c5c7406 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,10 +2,10 @@ name: Build and analyze on: push: - branches: [develop, master] + branches: [main, release/*, develop] pull_request: types: [opened, synchronize, reopened] - branches: [develop, master] + branches: [main, release/*, develop] workflow_dispatch: permissions: From d9c98e3b7bce48328aa23526eaee27ea264df67c Mon Sep 17 00:00:00 2001 From: Christopher Wolf <4867233+HaGGi13@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:44:37 +0100 Subject: [PATCH 17/17] Enable symbol package generation (#12) Enable symbol package generation (#12) Added `IncludeSymbols` and `SymbolPackageFormat` properties to `Directory.Build.props` to enable generation of `.snupkg` symbol packages for better debugging and NuGet symbol support. --- Directory.Build.props | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 51d10a2..357b947 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,8 @@ net8.0;net9.0;net10.0 enable enable + true + snupkg