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" } + }; +}