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
new file mode 100644
index 0000000..c5c7406
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,125 @@
+name: Build and analyze
+
+on:
+ push:
+ branches: [main, release/*, develop]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ branches: [main, release/*, develop]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ 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
+ libraryPath: './src/DurationMancer/DurationMancer.csproj'
+ testsPath: './tests/DurationMancer.Tests/DurationMancer.Tests.csproj'
+ buildConfiguration: 'Release'
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+jobs:
+ build:
+ name: Build and analyze
+ 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
+
+ # --- 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 installation ---
+ - name: Install SonarQube Cloud scanner
+ run: dotnet tool install dotnet-sonarscanner --global
+
+ # --- Restore ---
+ - name: Restore library
+ run: dotnet restore ${{ env.libraryPath }}
+ - name: Restore tests
+ run: dotnet restore ${{ env.testsPath }}
+
+ # --- SonarCloud begin ---
+ - name: Prepare SonarQube analyze
+ run: >
+ dotnet-sonarscanner begin /k:"HaGGi13_DurationMancer" /o:"haggi13"
+ /d:sonar.host.url="https://sonarcloud.io"
+ /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 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 ---
+ - name: Test
+ run: >
+ dotnet test ${{ env.testsPath }} --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/coverage"
+
+ - 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
+ run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
+
+ # --- NuGet Pack + Upload ---
+ - name: Pack NuGet package
+ run: >
+ dotnet pack ${{ env.libraryPath }}
+ --no-build
+ -c ${{ env.buildConfiguration }}
+ -o ./nupkg
+ /p:PackageVersion=${{ env.GitVersion_FullSemVer }}
+
+ - name: Upload NuGet package artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: NuGetPackage
+ path: ./nupkg/*.nupkg
+ retention-days: 3
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
diff --git a/Directory.Build.props b/Directory.Build.props
index 62abc08..357b947 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,9 +1,11 @@
- net6.0;net8.0;net9.0;net10.0
+ net8.0;net9.0;net10.0
enable
enable
+ true
+ snupkg
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5c1d675..574c688 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,5 +4,15 @@
true
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/DurationMancer.slnx b/DurationMancer.slnx
index f2525a4..688880b 100644
--- a/DurationMancer.slnx
+++ b/DurationMancer.slnx
@@ -1,3 +1,5 @@
-
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3578c73
--- /dev/null
+++ b/README.md
@@ -0,0 +1,106 @@
+[![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.
+
+ℹ️ Negative values are not supported (yet)!
+
+## 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 → `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: TryParse Days, hours, minutes, and seconds
+string input1 = "1.05:00:00";
+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: Parse Seconds with milliseconds
+string input2 = "00:00:45.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";
+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.Parse(input2);
+// result2 == new TimeSpan(days: 0, hours: 1, minutes: 30, seconds: 0, milliseconds: 100)
+// result2 -> 01:30:00.100
+```
+
+## 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/assets/images/package-icon.png b/assets/images/package-icon.png
new file mode 100644
index 0000000..682d35a
Binary files /dev/null and b/assets/images/package-icon.png differ
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/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs
new file mode 100644
index 0000000..84e62cf
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs
@@ -0,0 +1,21 @@
+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"));
+
+ AddColumn(StatisticColumn.P95);
+
+ HideColumns("Job", "Alloc Ratio");
+ }
+}
diff --git a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj
new file mode 100644
index 0000000..15cb4e4
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/Program.cs b/benchmarks/DurationMancer.Benchmarks/Program.cs
new file mode 100644
index 0000000..0ebe600
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/Program.cs
@@ -0,0 +1,5 @@
+using BenchmarkDotNet.Running;
+using DurationMancer.Benchmarks;
+
+BenchmarkRunner.Run();
+BenchmarkRunner.Run();
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.csproj b/src/DurationMancer.csproj
deleted file mode 100644
index c632161..0000000
--- a/src/DurationMancer.csproj
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/src/DurationMancer/DurationMancer.csproj b/src/DurationMancer/DurationMancer.csproj
new file mode 100644
index 0000000..391e065
--- /dev/null
+++ b/src/DurationMancer/DurationMancer.csproj
@@ -0,0 +1,26 @@
+
+
+
+ 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
+ parser;duration;time;timespan;humad-readable
+
+
+
+
+
+
+
+
diff --git a/src/DurationMancer/DurationTimeParser.cs b/src/DurationMancer/DurationTimeParser.cs
new file mode 100644
index 0000000..c8e8301
--- /dev/null
+++ b/src/DurationMancer/DurationTimeParser.cs
@@ -0,0 +1,201 @@
+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)]
+ 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);
+ }
+
+ ///
+ /// 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].
+ ///
+ /// 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;
+
+ // 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;
+ }
+
+ 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(days, hours, minutes, seconds, milliseconds);
+
+ 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 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 = hoursGroup.Success
+ ? double.Parse(hoursGroup.ValueSpan, CultureInfo.InvariantCulture)
+ : 0;
+
+ var minutes = minutesGroup.Success
+ ? double.Parse(minutesGroup.ValueSpan, CultureInfo.InvariantCulture)
+ : 0;
+
+ var seconds = secondsGroup.Success
+ ? double.Parse(secondsGroup.ValueSpan, CultureInfo.InvariantCulture)
+ : 0;
+
+ var milliSeconds = millisecondsGroup.Success
+ ? int.Parse(millisecondsGroup.ValueSpan, 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 e) when (e is FormatException or OverflowException)
+ {
+ 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.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/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" }
+ };
+}