diff --git a/.github/workflows/domain-abstractions.yml b/.github/workflows/domain-abstractions.yml index 601844c..3214fa5 100644 --- a/.github/workflows/domain-abstractions.yml +++ b/.github/workflows/domain-abstractions.yml @@ -35,7 +35,7 @@ jobs: - name: Build Domain.Abstractions run: dotnet build ${{ env.PROJECT_PATH }} --configuration Release --no-restore - + publish-nuget: runs-on: ubuntu-latest needs: build-and-test @@ -66,4 +66,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: domain-abstractions-package - path: ./artifacts/*.nupkg + path: ./artifacts/*.nupkg \ No newline at end of file diff --git a/.github/workflows/domain-default.yml b/.github/workflows/domain-default.yml index ca29aa3..d642a6d 100644 --- a/.github/workflows/domain-default.yml +++ b/.github/workflows/domain-default.yml @@ -36,13 +36,22 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore dependencies - run: dotnet restore ${{ env.PROJECT_PATH }} + run: dotnet restore ${{ env.TEST_PATH }} - name: Build Domain.Default - run: dotnet build ${{ env.PROJECT_PATH }} --configuration Release --no-restore + run: dotnet build ${{ env.TEST_PATH }} --configuration Release --no-restore - name: Run Domain.Default tests - run: dotnet test ${{ env.TEST_PATH }} --configuration Release --verbosity normal + run: dotnet test ${{ env.TEST_PATH }} --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./TestResults/**/coverage.cobertura.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} publish-nuget: runs-on: ubuntu-latest @@ -74,4 +83,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: domain-default-package - path: ./artifacts/*.nupkg + path: ./artifacts/*.nupkg \ No newline at end of file diff --git a/.github/workflows/domain-extensions.yml b/.github/workflows/domain-extensions.yml index 89cd989..c761586 100644 --- a/.github/workflows/domain-extensions.yml +++ b/.github/workflows/domain-extensions.yml @@ -38,13 +38,22 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore dependencies - run: dotnet restore ${{ env.PROJECT_PATH }} + run: dotnet restore ${{ env.TEST_PATH }} - name: Build Domain.Extensions - run: dotnet build ${{ env.PROJECT_PATH }} --configuration Release --no-restore + run: dotnet build ${{ env.TEST_PATH }} --configuration Release --no-restore - name: Run Domain.Extensions tests - run: dotnet test ${{ env.TEST_PATH }} --configuration Release --verbosity normal + run: dotnet test ${{ env.TEST_PATH }} --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./TestResults/**/coverage.cobertura.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} publish-nuget: runs-on: ubuntu-latest @@ -76,4 +85,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: domain-extensions-package - path: ./artifacts/*.nupkg + path: ./artifacts/*.nupkg \ No newline at end of file diff --git a/.github/workflows/intervals-net-data.yml b/.github/workflows/intervals-net-data.yml new file mode 100644 index 0000000..9bd6528 --- /dev/null +++ b/.github/workflows/intervals-net-data.yml @@ -0,0 +1,126 @@ +name: CI/CD - Intervals.NET.Data + +on: + push: + branches: [ master, main ] + paths: + - 'src/Intervals.NET.Data/**' + - 'tests/Intervals.NET.Data.Tests/**' + - 'global.json' + - '.github/workflows/intervals-net-data.yml' + pull_request: + branches: [ master, main ] + paths: + - 'src/Intervals.NET.Data/**' + - 'tests/Intervals.NET.Data.Tests/**' + - 'global.json' + - '.github/workflows/intervals-net-data.yml' + workflow_dispatch: + +env: + DOTNET_VERSION: '8.x.x' + PROJECT_PATH: 'src/Intervals.NET.Data/Intervals.NET.Data.csproj' + TEST_PATH: 'tests/Intervals.NET.Data.Tests/Intervals.NET.Data.Tests.csproj' + +permissions: + contents: read + packages: write + +concurrency: + group: "intervals-net-data-${{ github.ref }}" + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-packages-${{ runner.os }}-${{ hashFiles('src/Intervals.NET.Data/Intervals.NET.Data.csproj', 'tests/Intervals.NET.Data.Tests/Intervals.NET.Data.Tests.csproj', 'global.json') }} + restore-keys: | + nuget-packages-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore ${{ env.TEST_PATH }} + + - name: Build + run: dotnet build ${{ env.TEST_PATH }} --configuration Release --no-restore + + - name: Run tests (if present) + if: always() + run: | + if [ -f "${{ env.TEST_PATH }}" ]; then + dotnet test ${{ env.TEST_PATH }} --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults --logger "trx;LogFileName=test_results.trx" + mkdir -p test-artifacts || true + cp ./TestResults/*/test_results.trx test-artifacts/ || true + echo "Tests executed" + else + echo "No test project found at ${{ env.TEST_PATH }}" + fi + + - name: Upload coverage reports to Codecov + if: always() + uses: codecov/codecov-action@v4 + with: + files: ./TestResults/**/coverage.cobertura.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: intervals-net-data-test-results + path: test-artifacts/ + + publish-nuget: + runs-on: ubuntu-latest + needs: build-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore ${{ env.PROJECT_PATH }} + + - name: Build + run: dotnet build ${{ env.PROJECT_PATH }} --configuration Release --no-restore + + - name: Pack Intervals.NET.Data + run: dotnet pack ${{ env.PROJECT_PATH }} --configuration Release --no-build --output ./artifacts + + - name: Publish Intervals.NET.Data to NuGet + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "${NUGET_API_KEY}" ]; then + echo "NUGET_API_KEY not set, skipping push" + exit 0 + fi + dotnet nuget push ./artifacts/Intervals.NET.Data.*.nupkg --api-key ${NUGET_API_KEY} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: intervals-net-data-package + path: ./artifacts/*.nupkg diff --git a/Intervals.NET.sln b/Intervals.NET.sln index 96fc83d..f48690d 100644 --- a/Intervals.NET.sln +++ b/Intervals.NET.sln @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionIt .github\workflows\domain-default.yml = .github\workflows\domain-default.yml .github\workflows\domain-extensions.yml = .github\workflows\domain-extensions.yml .github\workflows\intervals-net.yml = .github\workflows\intervals-net.yml + .github\workflows\intervals-net-data.yml = .github\workflows\intervals-net-data.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EAF02F30-A5E4-4237-B402-6F946F2B2C09}" @@ -39,6 +40,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Results", "Results", "{F375 benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RealWorldScenariosBenchmarks-report-github.md = benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RealWorldScenariosBenchmarks-report-github.md benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.SetOperationsBenchmarks-report-github.md = benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.SetOperationsBenchmarks-report-github.md benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.VariableStepDomainBenchmarks-report-github.md = benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.VariableStepDomainBenchmarks-report-github.md + benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RangeDataBenchmarks-report-github.md = benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RangeDataBenchmarks-report-github.md + benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RangeDataExtensionsBenchmarks-report-github.md = benchmarks\Results\Intervals.NET.Benchmarks.Benchmarks.RangeDataExtensionsBenchmarks-report-github.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Intervals.NET.Domain.Abstractions", "src\Domain\Intervals.NET.Domain.Abstractions\Intervals.NET.Domain.Abstractions.csproj", "{EE258066-15D2-413B-B2F5-9122A0FA2387}" @@ -55,6 +58,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Intervals.NET.Domain.Defaul EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Intervals.NET.Domain.Extensions.Tests", "tests\Intervals.NET.Domain.Extensions.Tests\Intervals.NET.Domain.Extensions.Tests.csproj", "{9F5470DF-88E2-44DC-B6D0-176EBFAF5A25}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{07DC76CB-F380-40B5-A4C5-7241D10D180C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Intervals.NET.Data", "src\Intervals.NET.Data\Intervals.NET.Data.csproj", "{B5095989-5E11-405B-A1C8-D38210B64C91}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{74428AFD-6630-46D5-8FE5-BD0B272DD619}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Intervals.NET.Data.Tests", "tests\Intervals.NET.Data.Tests\Intervals.NET.Data.Tests.csproj", "{AD1E4AC1-99BF-4C55-B63E-BEE289C9D144}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +104,14 @@ Global {9F5470DF-88E2-44DC-B6D0-176EBFAF5A25}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F5470DF-88E2-44DC-B6D0-176EBFAF5A25}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F5470DF-88E2-44DC-B6D0-176EBFAF5A25}.Release|Any CPU.Build.0 = Release|Any CPU + {B5095989-5E11-405B-A1C8-D38210B64C91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5095989-5E11-405B-A1C8-D38210B64C91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5095989-5E11-405B-A1C8-D38210B64C91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5095989-5E11-405B-A1C8-D38210B64C91}.Release|Any CPU.Build.0 = Release|Any CPU + {AD1E4AC1-99BF-4C55-B63E-BEE289C9D144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD1E4AC1-99BF-4C55-B63E-BEE289C9D144}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD1E4AC1-99BF-4C55-B63E-BEE289C9D144}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD1E4AC1-99BF-4C55-B63E-BEE289C9D144}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {A2F7DF66-08BE-438A-A354-C09499B8B8B7} = {EAF02F30-A5E4-4237-B402-6F946F2B2C09} @@ -106,5 +125,9 @@ Global {592DCBFE-8570-44E3-B9DD-351AA775BFC8} = {28A5727D-3EDB-4F19-8B68-1DBD790EB8E2} {EAC4D033-A7D7-4242-8661-3F231257B4FE} = {592DCBFE-8570-44E3-B9DD-351AA775BFC8} {9F5470DF-88E2-44DC-B6D0-176EBFAF5A25} = {592DCBFE-8570-44E3-B9DD-351AA775BFC8} + {07DC76CB-F380-40B5-A4C5-7241D10D180C} = {EAF02F30-A5E4-4237-B402-6F946F2B2C09} + {B5095989-5E11-405B-A1C8-D38210B64C91} = {07DC76CB-F380-40B5-A4C5-7241D10D180C} + {74428AFD-6630-46D5-8FE5-BD0B272DD619} = {28A5727D-3EDB-4F19-8B68-1DBD790EB8E2} + {AD1E4AC1-99BF-4C55-B63E-BEE289C9D144} = {74428AFD-6630-46D5-8FE5-BD0B272DD619} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 7971fa9..1526dd6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ A production-ready .NET library for working with mathematical intervals and rang - [Working with Custom Types](#working-with-custom-types) - [Domain Extensions](#domain-extensions) ๐Ÿ‘ˆ *NEW: Step-based operations* - [Advanced Usage Examples](#advanced-usage-examples) ๐Ÿ‘ˆ *Click to expand* +- [RangeData Library](#rangedata-library) ๐Ÿ‘ˆ *Click to expand* - [Performance](#-performance) - [Detailed Benchmark Results](#detailed-benchmark-results) ๐Ÿ‘ˆ *Click to expand* - [Testing & Quality](#-testing--quality) @@ -1602,6 +1603,122 @@ public void ValidateCoordinates(double lat, double lon) +## RangeData Library + +### RangeData Overview + +`RangeData` is a lightweight, in-process, **lazy, domain-aware** data structure that combines ranges with associated data sequences. It allows **composable operations** like intersection, union, trimming, and projections while maintaining strict invariants. + +| Feature / Library | RangeData | Intervals.NET | System.Range | Rx | Pandas | C++20 Ranges | Kafka Streams / EventStore | +|-----------------------------------------------------------------|-----------|---------------|--------------|-----------|-----------|--------------|------------------------------| +| **Lazy evaluation** | โœ… Yes | โœ… Partial | โœ… Yes | โœ… Yes | โŒ No | โœ… Yes | โœ… Yes | +| **Domain-aware discrete ranges** | โœ… Yes | โœ… Yes | โŒ No | โŒ No | โŒ No | โœ… Partial | โœ… Partial | +| **Associated data (`IEnumerable`)** | โœ… Yes | โŒ No | โŒ No | โœ… Yes | โœ… Yes | โŒ No | โœ… Yes | +| **Strict invariant (range length = data length)** | โœ… Yes | โŒ No | โŒ No | โŒ No | โŒ No | โŒ No | โŒ No | +| **Right-biased union / intersection** | โœ… Yes | โŒ No | โŒ No | โŒ No | โŒ No | โŒ No | โœ… Yes | +| **Lazy composition (skip/take/concat without materialization)** | โœ… Yes | โŒ No | โŒ No | โœ… Yes | โŒ No | โœ… Yes | โœ… Partial | +| **In-process, single-machine** | โœ… Yes | โœ… Yes | โœ… Yes | โœ… Yes | โœ… Yes | โœ… Yes | โŒ No (distributed) | +| **Distributed / persisted event streams** | โŒ No | โŒ No | โŒ No | โŒ No | โŒ No | โŒ No | โœ… Yes | +| **Composable slices / trimming / projections** | โœ… Yes | โŒ No | โŒ No | โœ… Partial | โœ… Partial | โœ… Partial | โœ… Partial | +| **Generic over any data / domain** | โœ… Yes | โœ… Partial | โŒ No | โœ… Partial | โŒ No | โœ… Partial | โœ… Partial | +| **Use case: in-memory sliding window / cache / projections** | โœ… Yes | โŒ No | โŒ No | โœ… Partial | โœ… Partial | โœ… Partial | โœ… Yes | + +
+๐Ÿ› ๏ธ Implementation Details & Notes + +- **Lazy evaluation:** `RangeData` builds **iterator graphs** using `IEnumerable`. Data is only materialized when iterated. Operations like `Skip`, `Take`, `Concat` do **not allocate new arrays or lists**. +- **Domain-awareness:** Supports any discrete domain via `IRangeDomain`. This allows flexible steps, custom metrics, and ensures consistent range arithmetic. +- **Expected invariant/contract:** The **range length should equal the data sequence length**. `RangeData` and `RangeDataExtensions` do **not** enforce this at runtime for performance reasons; callers are responsible for providing consistent inputs or can validate them (for example with `IsValid`) when safety is more important than allocation/CPU overhead. +- **Right-biased operations:** `Union` and `Intersect` always take **data from the right operand** in overlapping regions, ideal for cache updates or incremental data ingestion. +- **Composable slices:** Supports trimming (`TrimStart`, `TrimEnd`) and projections while keeping laziness intact. You can work with a `RangeData` without ever iterating the data. +- **Trade-offs:** Zero allocation is **not fully achievable** because `IEnumerable` is a reference type. Some intermediate enumerables may exist, but memory usage remains minimal. +- **Comparison to event streaming:** Conceptually similar to event sourcing projections or Kafka streams (right-biased, discrete offsets), but fully **in-process**, lightweight, and generic. +- **Ideal use cases:** Sliding window caches, time-series processing, projections of incremental datasets, or any scenario requiring **efficient, composable range-data operations**. + +
+ + +### Overview + +`RangeData` is an abstraction that couples: + +- a **logical range** (`Range`), +- a **data sequence** (`IEnumerable`), +- a **discrete domain** (`IRangeDomain`) that defines steps and distances. + +> **Key Invariant:** The length of the range (measured in domain steps) **must exactly match** the number of data elements. This ensures strict consistency between the range and its data. + +This abstraction allows working with **large or dynamic sequences** without immediately materializing them, making all operations lazy and memory-efficient. + +--- + +### Core Design Principles + +- **Immutability:** All operations return new `RangeData` instances; originals remain unchanged. +- **Lazy evaluation:** LINQ operators and iterators are used; data is processed only on enumeration. +- **Domain-agnostic:** Supports any `IRangeDomain` implementation. +- **Right-biased operations:** On intersection or union, data from the *right* (fresh/new) range takes priority. +- **Minimal allocations:** No unnecessary arrays or lists; only `IEnumerable` iterators are created. + +
+Extension Methods Details + +#### Intersection (`Intersect`) +- Returns the intersection of two `RangeData` objects. +- Data is **sourced from the right range** (fresh data). +- Returns `null` if there is no overlap. +- Lazy, O(n) for skip/take on the data sequence. + +#### Union (`Union`) +- Combines two ranges if they are **overlapping or adjacent**. +- In overlapping regions, **right range data takes priority**. +- Returns `null` if ranges are completely disjoint. +- Handles three cases: + 1. Left fully contained in right โ†’ only right data used. + 2. Partial overlap โ†’ left non-overlapping portion + right data. + 3. Left wraps around right โ†’ left non-overlapping left + right + left non-overlapping right. + +#### TrimStart / TrimEnd +- Trim the range from the start or end. +- Returns new `RangeData` with sliced data. +- Returns `null` if the trim removes the entire range. + +#### Containment & Adjacency Checks +- `Contains(value)` / `Contains(range)` check range membership. +- `IsTouching`, `IsBeforeAndAdjacentTo`, `IsAfterAndAdjacentTo` verify **overlap or adjacency**. +- Useful for merging sequences or building ordered chains. + +
+ +
+Trade-offs & Limitations + +- `IEnumerable` does not automatically validate the invariant โ€” users are responsible for ensuring data length matches range length. +- Lazy operations only incur complexity O(n) **when iterating**. +- Not fully zero-allocation: iterators themselves are allocated, but overhead is minimal. +- Lazy iterators enable **Sliding Window Cache** scenarios: data can expire without being enumerated. + +
+ +
+Use Cases & Examples + +- **Time-series processing:** merging and slicing measurements over time ranges. +- **Event-sourcing projections:** managing streams of events with metadata. +- **Sliding Window Cache:** lazy access to partially loaded sequences. +- **Incremental datasets:** combining fresh updates with historical data. + +```csharp +var domain = new IntegerFixedStepDomain(); +var oldData = new RangeData(Range.Closed(10, 20), oldValues, domain); +var newData = new RangeData(Range.Closed(18, 30), newValues, domain); + +// Right-biased union +var union = oldData.Union(newData); // Range [10, 30], overlapping [18,20] comes from newData +``` + +
+ ## โšก Performance **Intervals.NET is designed for zero allocations and high throughput:** diff --git a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/ConstructionBenchmarks.cs b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/ConstructionBenchmarks.cs index 3184c5a..f28d867 100644 --- a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/ConstructionBenchmarks.cs +++ b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/ConstructionBenchmarks.cs @@ -83,7 +83,7 @@ public void Naive_Int_FiniteOpen() [Benchmark] public void IntervalsNet_Int_FiniteOpen() { - _ = Range.Open(10, 20); + _ = Range.Open(10, 20); } /// @@ -99,7 +99,7 @@ public void Naive_Int_HalfOpen() [Benchmark] public void IntervalsNet_Int_HalfOpen() { - _ = Range.ClosedOpen(10, 20); + _ = Range.ClosedOpen(10, 20); } #endregion diff --git a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/DomainOperationsBenchmarks.cs b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/DomainOperationsBenchmarks.cs index 6862802..b098c2f 100644 --- a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/DomainOperationsBenchmarks.cs +++ b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/DomainOperationsBenchmarks.cs @@ -1,5 +1,4 @@ using BenchmarkDotNet.Attributes; -using Intervals.NET.Domain.Abstractions; using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Default.DateTime; using Intervals.NET.Domain.Default.TimeSpan; diff --git a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataBenchmarks.cs b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataBenchmarks.cs new file mode 100644 index 0000000..bb4d16d --- /dev/null +++ b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataBenchmarks.cs @@ -0,0 +1,85 @@ +using BenchmarkDotNet.Attributes; +using Intervals.NET.Data; +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using System.Linq; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Benchmarks.Benchmarks; + +[MemoryDiagnoser] +[ThreadingDiagnoser] +public class RangeDataBenchmarks +{ + [Params(10, 1000, 100000)] + public int N; + + private readonly IntegerFixedStepDomain _domain = new(); + private Range _fullRange; + private RangeData _rangeData; + private int[] _backingArray = null!; + private int _midPoint; + + [GlobalSetup] + public void Setup() + { + _fullRange = Range.Closed(0, N - 1); + _backingArray = Enumerable.Range(0, N).ToArray(); + _rangeData = new RangeData(_fullRange, _backingArray, _domain); + _midPoint = N / 2; + } + + [Benchmark(Baseline = true)] + public RangeData Construction() + { + // Materialize a fresh array to measure construction cost + var data = Enumerable.Range(0, N).ToArray(); + return new RangeData(Range.Closed(0, N - 1), data, _domain); + } + + [Benchmark] + public bool TryGet_Hit() + { + return _rangeData.TryGet(_midPoint, out _); + } + + [Benchmark] + public bool TryGet_Miss() + { + // Point outside the range + return _rangeData.TryGet(N + 10, out _); + } + + [Benchmark] + public int Indexer_Hit() + { + return _rangeData[_midPoint]; + } + + [Benchmark] + public RangeData Slice_Small() + { + var sub = Range.Closed(_midPoint, Math.Min(_midPoint + 4, N - 1)); + return _rangeData.Slice(sub); + } + + [Benchmark] + public RangeData Slice_Medium() + { + var len = Math.Max(1, N / 10); + var sub = Range.Closed(_midPoint, Math.Min(_midPoint + len - 1, N - 1)); + return _rangeData.Slice(sub); + } + + [Benchmark] + public int Iterate_First100() + { + var sum = 0; + foreach (var v in _rangeData.Data.Take(100)) + { + sum += v; + } + + return sum; + } +} \ No newline at end of file diff --git a/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataExtensionsBenchmarks.cs b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataExtensionsBenchmarks.cs new file mode 100644 index 0000000..78d18e0 --- /dev/null +++ b/benchmarks/Intervals.NET.Benchmarks/Benchmarks/RangeDataExtensionsBenchmarks.cs @@ -0,0 +1,60 @@ +using BenchmarkDotNet.Attributes; +using Intervals.NET.Data; +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using System.Linq; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Benchmarks.Benchmarks; + +[MemoryDiagnoser] +public class RangeDataExtensionsBenchmarks +{ + private readonly IntegerFixedStepDomain _domain = new(); + private RangeData _left = null!; + private RangeData _right = null!; + + [GlobalSetup] + public void Setup() + { + // Left: [0, 999] + var leftRange = Range.Closed(0, 999); + var leftData = Enumerable.Range(0, 1000).ToArray(); + _left = new RangeData(leftRange, leftData, _domain); + + // Right: [500, 1499] + var rightRange = Range.Closed(500, 1499); + var rightData = Enumerable.Range(500, 1000).ToArray(); + _right = new RangeData(rightRange, rightData, _domain); + } + + [Benchmark(Baseline = true)] + public RangeData? Intersect() + { + return _left.Intersect(_right); + } + + [Benchmark] + public RangeData? Union() + { + return _left.Union(_right); + } + + [Benchmark] + public RangeData? TrimStart() + { + // Trim start to 250 => resulting range [250, 999] + return _left.TrimStart(250); + } + + [Benchmark] + public int Union_Enumerate() + { + var u = _left.Union(_right); + if (u is null) return -1; + var sum = 0; + foreach (var v in u.Data) + sum += v; + return sum; + } +} \ No newline at end of file diff --git a/benchmarks/Intervals.NET.Benchmarks/Intervals.NET.Benchmarks.csproj b/benchmarks/Intervals.NET.Benchmarks/Intervals.NET.Benchmarks.csproj index f498dbf..75052c6 100644 --- a/benchmarks/Intervals.NET.Benchmarks/Intervals.NET.Benchmarks.csproj +++ b/benchmarks/Intervals.NET.Benchmarks/Intervals.NET.Benchmarks.csproj @@ -22,6 +22,7 @@ + - + \ No newline at end of file diff --git a/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataBenchmarks-report-github.md b/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataBenchmarks-report-github.md new file mode 100644 index 0000000..eddb5c5 --- /dev/null +++ b/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataBenchmarks-report-github.md @@ -0,0 +1,102 @@ +``` + +BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) +Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores +.NET SDK 8.0.403 + [Host] : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + DefaultJob : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + + +``` +| Method | N | Mean | Error | StdDev | Ratio | RatioSD | Completed Work Items | Lock Contentions | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|----------------- |------- |--------------:|-------------:|-------------:|------:|--------:|---------------------:|-----------------:|---------:|---------:|---------:|----------:|------------:| +| **Construction** | **10** | **38.46 ns** | **0.835 ns** | **0.962 ns** | **1.00** | **0.00** | **-** | **-** | **0.0363** | **-** | **-** | **152 B** | **1.00** | +| TryGet_Hit | 10 | 33.36 ns | 0.603 ns | 0.694 ns | 0.87 | 0.02 | - | - | 0.0114 | - | - | 48 B | 0.32 | +| TryGet_Miss | 10 | 27.84 ns | 0.590 ns | 1.079 ns | 0.73 | 0.03 | - | - | 0.0114 | - | - | 48 B | 0.32 | +| Indexer_Hit | 10 | 32.06 ns | 0.678 ns | 1.014 ns | 0.83 | 0.03 | - | - | 0.0114 | - | - | 48 B | 0.32 | +| Slice_Small | 10 | 52.21 ns | 0.857 ns | 0.986 ns | 1.36 | 0.04 | - | - | 0.0344 | - | - | 144 B | 0.95 | +| Slice_Medium | 10 | 60.00 ns | 1.244 ns | 2.044 ns | 1.54 | 0.08 | - | - | 0.0343 | - | - | 144 B | 0.95 | +| Iterate_First100 | 10 | 93.23 ns | 0.912 ns | 0.761 ns | 2.41 | 0.08 | - | - | 0.0114 | - | - | 48 B | 0.32 | +| | | | | | | | | | | | | | | +| **Construction** | **1000** | **321.89 ns** | **5.495 ns** | **5.643 ns** | **1.00** | **0.00** | **-** | **-** | **0.9828** | **-** | **-** | **4112 B** | **1.00** | +| TryGet_Hit | 1000 | 36.75 ns | 0.585 ns | 0.457 ns | 0.11 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.01 | +| TryGet_Miss | 1000 | 33.48 ns | 0.722 ns | 1.013 ns | 0.11 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.01 | +| Indexer_Hit | 1000 | 45.48 ns | 0.923 ns | 0.987 ns | 0.14 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.01 | +| Slice_Small | 1000 | 59.89 ns | 1.147 ns | 1.073 ns | 0.19 | 0.01 | - | - | 0.0343 | - | - | 144 B | 0.04 | +| Slice_Medium | 1000 | 62.33 ns | 0.958 ns | 0.800 ns | 0.19 | 0.00 | - | - | 0.0343 | - | - | 144 B | 0.04 | +| Iterate_First100 | 1000 | 733.13 ns | 12.717 ns | 24.804 ns | 2.32 | 0.10 | - | - | 0.0114 | - | - | 48 B | 0.01 | +| | | | | | | | | | | | | | | +| **Construction** | **100000** | **238,733.68 ns** | **3,409.826 ns** | **3,189.553 ns** | **1.000** | **0.00** | **-** | **-** | **124.5117** | **124.5117** | **124.5117** | **400154 B** | **1.000** | +| TryGet_Hit | 100000 | 40.35 ns | 0.360 ns | 0.281 ns | 0.000 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.000 | +| TryGet_Miss | 100000 | 39.82 ns | 0.802 ns | 0.750 ns | 0.000 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.000 | +| Indexer_Hit | 100000 | 50.10 ns | 0.717 ns | 0.670 ns | 0.000 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.000 | +| Slice_Small | 100000 | 58.86 ns | 0.787 ns | 0.657 ns | 0.000 | 0.00 | - | - | 0.0343 | - | - | 144 B | 0.000 | +| Slice_Medium | 100000 | 59.12 ns | 1.252 ns | 2.126 ns | 0.000 | 0.00 | - | - | 0.0343 | - | - | 144 B | 0.000 | +| Iterate_First100 | 100000 | 720.83 ns | 10.438 ns | 10.251 ns | 0.003 | 0.00 | - | - | 0.0114 | - | - | 48 B | 0.000 | + +## Summary + +This run captures RangeData microbenchmarks executed with BenchmarkDotNet v0.13.12 on .NET SDK 8.0.403 (Host .NET 8.0.11) on an Intel Core i7-1065G7. Measurements were taken for three dataset sizes (N = 10, 1,000, 100,000) and report timing, allocations, and GC activity for construction, lookup, slicing, and iteration. + +Key findings (exact numbers): + +- Construction cost and allocations grow strongly with N: N=10 โ€” 38.46 ns / 152 B (Gen0 0.0363); N=1,000 โ€” 321.89 ns / 4,112 B (Gen0 0.9828); N=100,000 โ€” 238,733.68 ns / 400,154 B (Gen0 124.5117). +- Lookups are low-latency and allocation-stable: TryGet_Hit = 33.36 ns (N=10), 36.75 ns (N=1,000), 40.35 ns (N=100,000); TryGet_Miss = 27.84 ns โ†’ 33.48 ns โ†’ 39.82 ns; Indexer_Hit = 32.06 ns โ†’ 45.48 ns โ†’ 50.10 ns. These lookup cases report ~48 B allocated. +- Slice_Small is steady (~52โ€“60 ns) across sizes: 52.21 ns (N=10), 59.89 ns (N=1,000), 58.86 ns (N=100,000) and allocates ~144 B. +- Iterating the first 100 elements shows a mid/large-N peak: 93.23 ns (N=10), 733.13 ns (N=1,000), 720.83 ns (N=100,000); allocated โ‰ˆ48 B. + +Guidance + +- Prefer reusing constructed `RangeData` instances in latency-sensitive paths: lookups (TryGet/Indexer) are very cheap (~30โ€“50 ns) and stable. +- Avoid constructing many large `RangeData` instances repeatedly; construction time and memory pressure increase dramatically with N. Prebuild, reuse, or batch construction to reduce GC churn (Gen0 climbs from 0.0363 to 124.5117 across sizes). +- When iterating small prefixes frequently (e.g., first 100 items), benchmark your workload โ€” iteration shows a notable increase at mid/large N and may benefit from optimized access patterns. + +Short artifacts + +- Release-note blurb: RangeData microbenchmarks show low-cost lookups (TryGet/Indexer โ‰ˆ 30โ€“50 ns; ~48 B) while Construction time and allocations grow with dataset size (38.46 ns โ†’ 238,733.68 ns; 152 B โ†’ 400,154 B). See the full table above for per-case numbers. + +- Tweet-style highlight: Fast lookups: TryGet ~33โ€“40 ns (48 B); Construction scales from 38.46 ns (N=10) to 238,733.68 ns (N=100,000) โ€” full benchmarks in the report. + +- README quick bullets: + - Low-latency lookups: TryGet/Indexer ~27.8โ€“50.1 ns, ~48 B allocations. + - Construction cost increases with N; large N causes significant allocations and Gen0 activity. + - Slice_Small ~52โ€“60 ns (144 B); iterating first 100 elements can be costly at mid/large N (โ‰ˆ720โ€“733 ns). + +## Assessment (grades) + +This section assigns simple grades for Performance and Memory usage for each benchmarked operation. Grades use a pragmatic scale: A = excellent, B = good, C = acceptable / worth attention, D = poor. Grades are relative to the microbenchmark context and realistic expectations for hot-path code. + +Notes on methodology and root causes: +- The benchmark code materializes arrays in `Construction()` using `Enumerable.Range(...).ToArray()` โ€” these allocations dominate the Construction measurements. +- Many library operations use LINQ operators (`Skip`, `Take`, `Concat`, `Concat` chains) and return lazy `IEnumerable` instances; the measured small allocations (48 B, 144 B, 192 B, etc.) are consistent with a tiny iterator or wrapper object created by LINQ plus the `RangeData` record allocation when applicable. +- Where possible, comments reference implementation points in `src/Intervals.NET.Data/RangeData.cs` and `src/Intervals.NET.Data/Extensions/RangeDataExtensions.cs` to explain unavoidable allocations. + +Grades (using the exact numbers reported) + +- Construction (N=10: 38.46 ns / 152 B; N=1,000: 321.89 ns / 4,112 B; N=100,000: 238,733.68 ns / 400,154 B): + - Performance: D (large N shows high cost โ€” dominated by materializing the backing array in the benchmark); Memory: D (hundreds of KB at N=100k). Reason: the benchmark intentionally materializes with `ToArray()`; that allocation and copying are the main contributors (see `Construction()` in `RangeDataBenchmarks.cs`). If the workload reuses a prebuilt array, construction cost is much lower. + +- TryGet_Hit (N=10: 33.36 ns / 48 B; N=1,000: 36.75 ns / 48 B; N=100,000: 40.35 ns / 48 B): + - Performance: A (very low-latency lookups ~33โ€“40 ns). Memory: B (48 B allocation). Reason: `TryGet` uses `Data.Skip(intIndex)` and then enumerates โ€” `Skip` returns a small iterator object. When `Data` is an array (the benchmark's common case), an alternative zero-allocation indexed path would be possible (see note below). + +- TryGet_Miss (N=10: 27.84 ns / 48 B; N=1,000: 33.48 ns / 48 B; N=100,000: 39.82 ns / 48 B): + - Performance: A; Memory: B. Reason: similar to TryGet_Hit โ€” fast but a small iterator allocation from LINQ. + +- Indexer_Hit (N=10: 32.06 ns / 48 B; N=1,000: 45.48 ns / 48 B; N=100,000: 50.10 ns / 48 B): + - Performance: A; Memory: B. Reason: indexer delegates to `TryGet` and inherits the same allocation profile. + +- Slice_Small (N=10: 52.21 ns / 144 B; N=1,000: 59.89 ns / 144 B; N=100,000: 58.86 ns / 144 B): + - Performance: A-; Memory: B. Reason: `Slice`/indexer for sub-ranges builds a lazy `IEnumerable` via `Skip().Take()` and returns a new `RangeData` instance; that produces slightly larger per-call allocations (the 144 B reflects an iterator plus the `RangeData` allocation). + +- Slice_Medium (N=10: 60.00 ns / 144 B; N=1,000: 62.33 ns / 144 B; N=100,000: 59.12 ns / 144 B): + - Performance: A-; Memory: B. Same reasoning as Slice_Small. + +- Iterate_First100 (N=10: 93.23 ns / 48 B; N=1,000: 733.13 ns / 48 B; N=100,000: 720.83 ns / 48 B): + - Performance: B (fast for small N, but iterating 100 items in the mid/large cases costs ~720โ€“733 ns). Memory: B (48 B). Reason: the benchmark's `Take(100)` iterates up to 100 elements โ€” for N=10 fewer elements are iterated (hence cheaper). The allocation is the small enumerator/iterator wrapper. + +Assessment summary and optimization guidance + +- What is unavoidable vs. fixable: + - The large allocations shown for `Construction` are not inherent to `RangeData` itself but are dominated by the benchmark's `ToArray()` materialization; in practice, if callers provide already-materialized arrays, the per-construction overhead is only the `RangeData` object reference (small). Therefore Construction's large numbers are avoidable by changing caller behavior (reuse/prebuild backing arrays) rather than library internals. + - The small per-call allocations (48 B, 144 B, 192 B, 424 B, 456 B) come from creating LINQ iterator/adapter objects (`Skip`, `Take`, `Concat`, `Union` helpers) and the `RangeData` record allocation when a new instance is returned. These are typically small and expected with the current lazy-LINQ design. They are near-minimal for the current implementation that exposes `IEnumerable` semantics. + - If zero-allocation lookups are required when the backing sequence is an array or list, the library could add overloads or specialized fast-paths that accept `IReadOnlyList`/`T[]`/`Memory` or detect array-backed sequences and use direct indexing. Doing so would remove the iterator allocation for `TryGet`/`Indexer` and further reduce latency. \ No newline at end of file diff --git a/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataExtensionsBenchmarks-report-github.md b/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataExtensionsBenchmarks-report-github.md new file mode 100644 index 0000000..a7b039c --- /dev/null +++ b/benchmarks/Results/Intervals.NET.Benchmarks.Benchmarks.RangeDataExtensionsBenchmarks-report-github.md @@ -0,0 +1,71 @@ +``` + +BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) +Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores +.NET SDK 8.0.403 + [Host] : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + DefaultJob : .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|---------------- |-------------:|-----------:|-----------:|------:|--------:|-------:|----------:|------------:| +| Intersect | 121.45 ns | 2.091 ns | 1.956 ns | 1.00 | 0.00 | 0.0458 | 192 B | 1.00 | +| Union | 286.50 ns | 5.781 ns | 11.138 ns | 2.35 | 0.14 | 0.1011 | 424 B | 2.21 | +| TrimStart | 57.91 ns | 1.465 ns | 3.884 ns | 0.48 | 0.02 | 0.0343 | 144 B | 0.75 | +| Union_Enumerate | 11,588.82 ns | 129.171 ns | 114.507 ns | 95.61 | 1.83 | 0.1068 | 456 B | 2.38 | + +## Summary + +This run captures RangeData extensions microbenchmarks executed with BenchmarkDotNet v0.13.12 on .NET SDK 8.0.403 (Host .NET 8.0.11) on an Intel Core i7-1065G7. The table measures common extensions (Intersect, Union, TrimStart) and an enumerate-heavy Union_Enumerate scenario, reporting timings and allocations. + +Key findings (exact numbers): + +- Intersect runs in 121.45 ns and allocates 192 B (Gen0 0.0458). +- Union is heavier: 286.50 ns and 424 B (Gen0 0.1011), while `Union_Enumerate` is an order of magnitude slower (11,588.82 ns) and allocates 456 B (Gen0 0.1068). +- TrimStart is cheap: 57.91 ns and 144 B (Gen0 0.0343). + +Guidance + +- Use `Intersect` or `TrimStart` for low-latency operations; they both complete in well under 200 ns and have modest allocations. +- `Union` performs more work and allocates more (286.50 ns / 424 B); use it when set-combining semantics are required and measure if used in tight loops. +- `Union_Enumerate` indicates that enumerating a union can be costly (โ‰ˆ11.6 ยตs); avoid repeated enumeration in hot paths or consider materializing results once if they are reused. + +Short artifacts + +- Release-note blurb: RangeData extension benchmarks show Intersect ~121 ns (192 B) and TrimStart ~58 ns (144 B); Union is heavier (~286 ns / 424 B) and enumerating unions can be expensive (~11,589 ns / 456 B). + +- Tweet-style highlight: Intersect ~121 ns; TrimStart ~58 ns; Union ~286 ns (424 B); enumerating unions โ‰ˆ11.6 ยตs โ€” see benchmarks for details. + +- README quick bullets: + - Fast extensions: Intersect ~121 ns (192 B) and TrimStart ~58 ns (144 B). + - Union costs ~286 ns and 424 B; materialize if reusing results. + - Enumerating a union can be expensive (~11.6 ยตs); avoid repeated enumeration in hot code paths. + +## Assessment (grades) + +This section assigns pragmatic grades for Performance and Memory usage for the RangeData extension operations in the table above. Grades: A = excellent, B = good, C = acceptable/worth attention, D = poor. + +Observations and root causes: +- The extension methods intentionally use lazy `IEnumerable` composition (calls to `Concat`, `Skip`, `Take`, and returning existing `IEnumerable` sequences) to preserve immutability and defer materialization. These LINQ combinators create small iterator/adapter objects which account for the measured allocations (192 B, 424 B, 144 B, 456 B). +- `Union` and `Union_Enumerate` perform more work and enumerate/concatenate multiple sequences; `Union_Enumerate` specifically triggers enumeration of the constructed union sequence which exposes the full cost of combining and iterating the lazy pipeline. + +Grades (exact numbers from table): + +- Intersect (121.45 ns / 192 B): + - Performance: A-; Memory: B. Reason: `Intersect` validates domains and then slices the right operand (`right[intersectedRange]`) which returns a `RangeData` over `Skip/Take` โ€” the 192 B aligns with the iterator wrappers plus the resulting `RangeData` allocation. Good throughput; small allocations are expected for lazy slicing. + +- Union (286.50 ns / 424 B): + - Performance: B; Memory: C. Reason: `Union` computes union/intersection and constructs a composed `IEnumerable` via `CombineDataWithFreshPrimary`, which may concatenate several sequences; the larger allocation (424 B) reflects multiple iterator wrappers and the returned `RangeData` instance. + +- TrimStart (57.91 ns / 144 B): + - Performance: A; Memory: B. Reason: `TrimStart` delegates to `TryGet` slice logic producing a small iterator and a `RangeData` instance; a low-latency op with modest allocation. + +- Union_Enumerate (11,588.82 ns / 456 B): + - Performance: C; Memory: C. Reason: enumerating the union forces the lazy pipeline to produce and iterate potentially large sequences. The time reflects processing ~1,500 distinct elements (union of two 1,000-length ranges with overlap), and the allocation is modest (iterator wrappers + enumeration overhead). If consumers must enumerate unions frequently, materializing the union once (ToArray/ToList) may be faster overall despite the allocation. + +Assessment summary and optimization guidance + +- Unavoidable vs. fixable allocations: + - The allocations for lazy combinators (192 B, 144 B) are small and expected given the design goals (lazy evaluation, immutability). They are near-minimal for returning `IEnumerable` without an API change. + - Larger allocations and the cost when enumerating unions are a consequence of combining multiple sequences; enumerating will necessarily touch each element. If repeated enumeration of the union is common, materializing the union once is a practical optimization. \ No newline at end of file diff --git a/src/Domain/Intervals.NET.Domain.Abstractions/IFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Abstractions/IFixedStepDomain.cs index d3eeed1..4f2966f 100644 --- a/src/Domain/Intervals.NET.Domain.Abstractions/IFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Abstractions/IFixedStepDomain.cs @@ -7,14 +7,7 @@ namespace Intervals.NET.Domain.Abstractions; /// /// The type of the values in the domain. Must implement IComparable<T>. /// -public interface IFixedStepDomain : IRangeDomain where T : IComparable -{ - /// - /// Calculates the distance in discrete steps between two values. - /// This operation is O(1) and returns an exact integer count. - /// - /// The starting value. - /// The ending value. - /// The number of complete steps from start to end. - long Distance(T start, T end); -} \ No newline at end of file +/// +/// Implement as a readonly record struct for automatic allocation-free value equality. +/// +public interface IFixedStepDomain : IRangeDomain where T : IComparable; \ No newline at end of file diff --git a/src/Domain/Intervals.NET.Domain.Abstractions/IRangeDomain.cs b/src/Domain/Intervals.NET.Domain.Abstractions/IRangeDomain.cs index f2520b3..ad714c8 100644 --- a/src/Domain/Intervals.NET.Domain.Abstractions/IRangeDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Abstractions/IRangeDomain.cs @@ -5,6 +5,32 @@ namespace Intervals.NET.Domain.Abstractions; /// Provides step-based navigation and boundary alignment operations. /// /// The type of the values in the domain. Must implement IComparable<T>. +/// +/// Implementation Guidance: +/// +/// +/// Record structs (recommended): Implement as a readonly record struct with no state, only logic. +/// Record structs automatically implement value-based equality (IEquatable<TSelf>) without boxing, +/// ensuring zero allocation and optimal performance. All built-in domains follow this pattern. +/// +/// +/// Regular structs: Can be used but must explicitly implement IEquatable<TSelf> +/// to ensure allocation-free equality checks. Without IEquatable, struct equality will box. +/// +/// +/// Class domains: Can be used when domain logic requires state or shared resources +/// (e.g., holiday calendars, configuration). Should implement IEquatable<TClassName> +/// for better performance in equality comparisons. +/// +/// +/// Why not IEquatable<IRangeDomain<T>>? +/// +/// This interface intentionally does NOT inherit IEquatable<IRangeDomain<T>> because that would +/// require implementing Equals(IRangeDomain<T>? other), which would box struct parameters when called. +/// Instead, record structs implement IEquatable<ConcreteType>, and EqualityComparer<T>.Default +/// will use that implementation without any boxing. +/// +/// public interface IRangeDomain where T : IComparable { /// @@ -36,4 +62,14 @@ public interface IRangeDomain where T : IComparable /// The value to be ceiled. /// The smallest domain boundary that is greater than or equal to the specified value. T Ceiling(T value); + + /// + /// Calculates the distance in discrete steps between two values. + /// Implementations may have different complexity characteristics: fixed-step domains typically + /// compute this in O(1), while variable-step domains may iterate and therefore be O(N). + /// + /// The starting value. + /// The ending value. + /// The number of complete steps from start to end. + long Distance(T start, T end); } \ No newline at end of file diff --git a/src/Domain/Intervals.NET.Domain.Abstractions/IVariableStepDomain.cs b/src/Domain/Intervals.NET.Domain.Abstractions/IVariableStepDomain.cs index cdaf387..714e301 100644 --- a/src/Domain/Intervals.NET.Domain.Abstractions/IVariableStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Abstractions/IVariableStepDomain.cs @@ -8,15 +8,9 @@ namespace Intervals.NET.Domain.Abstractions; /// /// The type of the values in the domain. Must implement IComparable<T>. /// -public interface IVariableStepDomain : IRangeDomain where T : IComparable -{ - /// - /// Calculates the distance between two values in domain-specific units. - /// May return fractional values to account for partial steps. - /// โš ๏ธ Warning: This operation may be O(N) depending on the domain implementation. - /// - /// The starting value. - /// The ending value. - /// The distance from start to end, potentially including fractional steps. - double Distance(T start, T end); -} \ No newline at end of file +/// +/// For stateless logic, implement as a readonly record struct for automatic allocation-free equality. +/// For stateful domains (e.g., business day calendars with holiday data), implement as a class +/// with explicit IEquatable<TClassName> to compare domain state/configuration. +/// +public interface IVariableStepDomain : IRangeDomain where T : IComparable; \ No newline at end of file diff --git a/src/Domain/Intervals.NET.Domain.Abstractions/Intervals.NET.Domain.Abstractions.csproj b/src/Domain/Intervals.NET.Domain.Abstractions/Intervals.NET.Domain.Abstractions.csproj index 2dd1d41..62bb26f 100644 --- a/src/Domain/Intervals.NET.Domain.Abstractions/Intervals.NET.Domain.Abstractions.csproj +++ b/src/Domain/Intervals.NET.Domain.Abstractions/Intervals.NET.Domain.Abstractions.csproj @@ -6,7 +6,7 @@ enable true Intervals.NET.Domain.Abstractions - 0.0.1 + 0.0.2 blaze6950 Core abstractions for domain-specific range operations in Intervals.NET. Defines interfaces for fixed-step and variable-step domains, enabling type-safe, performant range manipulations with explicit O(1) vs O(N) semantics. Use this package to implement custom domains or extend the library. range;interval;domain;abstractions;interfaces;performance;fixed-step;variable-step;generic;intervals diff --git a/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateOnlyBusinessDaysVariableStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateOnlyBusinessDaysVariableStepDomain.cs index 3221b77..e3af9ed 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateOnlyBusinessDaysVariableStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateOnlyBusinessDaysVariableStepDomain.cs @@ -64,7 +64,7 @@ namespace Intervals.NET.Domain.Default.Calendar; /// - Base interface for variable-step domains /// /// -public readonly struct StandardDateOnlyBusinessDaysVariableStepDomain : IVariableStepDomain +public readonly record struct StandardDateOnlyBusinessDaysVariableStepDomain : IVariableStepDomain { /// /// Adds the specified number of business days to the given date. @@ -163,17 +163,17 @@ public DateOnly Ceiling(DateOnly value) /// โš ๏ธ Performance: O(N) - Iterates through each day in the range. /// [Pure] - public double Distance(DateOnly start, DateOnly end) + public long Distance(DateOnly start, DateOnly end) { var current = Floor(start); var target = Floor(end); if (current == target) { - return 0.0; // Same date = 0 steps needed + return 0; // Same date = 0 steps needed } - double count = 0; + long count = 0; if (current < target) { diff --git a/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateTimeBusinessDaysVariableStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateTimeBusinessDaysVariableStepDomain.cs index d746788..0a3f65f 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateTimeBusinessDaysVariableStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Calendar/StandardDateTimeBusinessDaysVariableStepDomain.cs @@ -64,7 +64,7 @@ namespace Intervals.NET.Domain.Default.Calendar; /// - Base interface for variable-step domains /// /// -public readonly struct StandardDateTimeBusinessDaysVariableStepDomain : IVariableStepDomain +public readonly record struct StandardDateTimeBusinessDaysVariableStepDomain : IVariableStepDomain { /// /// Adds the specified number of business days to the given date. @@ -186,17 +186,17 @@ public System.DateTime Ceiling(System.DateTime value) /// โš ๏ธ Performance: O(N) - Iterates through each day in the range. /// [Pure] - public double Distance(System.DateTime start, System.DateTime end) + public long Distance(System.DateTime start, System.DateTime end) { var current = Floor(start); var target = Floor(end); if (current == target) { - return 0.0; // Same date = 0 steps needed + return 0; // Same date = 0 steps needed } - double count = 0; + long count = 0; if (current < target) { diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateOnlyDayFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateOnlyDayFixedStepDomain.cs index d66dcce..23ebf46 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateOnlyDayFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateOnlyDayFixedStepDomain.cs @@ -11,7 +11,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// DateOnly represents dates without time components and is naturally aligned to day boundaries. /// Requires: .NET 6.0 or greater. /// -public readonly struct DateOnlyDayFixedStepDomain : IFixedStepDomain +public readonly record struct DateOnlyDayFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeDayFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeDayFixedStepDomain.cs index eb9725c..1b3bb3b 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeDayFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeDayFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with day steps. Steps are added or subtracted in whole days. /// -public readonly struct DateTimeDayFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeDayFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeHourFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeHourFixedStepDomain.cs index 1f2d807..06c388d 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeHourFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeHourFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with hour steps. Steps are added or subtracted in whole hours. /// -public readonly struct DateTimeHourFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeHourFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMicrosecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMicrosecondFixedStepDomain.cs index a37e4b7..3d1b9eb 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMicrosecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMicrosecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with microsecond steps. Steps are added or subtracted in whole microseconds. /// -public readonly struct DateTimeMicrosecondFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeMicrosecondFixedStepDomain : IFixedStepDomain { private const long TicksPerMicrosecond = 10; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMillisecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMillisecondFixedStepDomain.cs index 5e17f3e..4cffe3e 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMillisecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMillisecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with millisecond steps. Steps are added or subtracted in whole milliseconds. /// -public readonly struct DateTimeMillisecondFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeMillisecondFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMinuteFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMinuteFixedStepDomain.cs index 208bd2b..a05f826 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMinuteFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMinuteFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with minute steps. Steps are added or subtracted in whole minutes. /// -public readonly struct DateTimeMinuteFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeMinuteFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMonthFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMonthFixedStepDomain.cs index d954488..60dc6da 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMonthFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeMonthFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with month steps. Steps are added or subtracted in whole months. /// -public readonly struct DateTimeMonthFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeMonthFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeSecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeSecondFixedStepDomain.cs index 0cfc23f..872700c 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeSecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeSecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with second steps. Steps are added or subtracted in whole seconds. /// -public readonly struct DateTimeSecondFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeSecondFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeTicksFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeTicksFixedStepDomain.cs index ed704b0..fa82813 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeTicksFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeTicksFixedStepDomain.cs @@ -8,7 +8,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// Fixed step domain for DateTime with tick steps. Steps are added or subtracted in ticks. /// A tick is 100 nanoseconds, the smallest unit of time in .NET DateTime. /// -public readonly struct DateTimeTicksFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeTicksFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeYearFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeYearFixedStepDomain.cs index 4086d35..0e3a6fc 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeYearFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/DateTimeYearFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Fixed step domain for DateTime with year steps. Steps are added or subtracted in whole years. /// -public readonly struct DateTimeYearFixedStepDomain : IFixedStepDomain +public readonly record struct DateTimeYearFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyHourFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyHourFixedStepDomain.cs index c15f29e..9b2c9a5 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyHourFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyHourFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Requires: .NET 6.0 or greater. /// -public readonly struct TimeOnlyHourFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlyHourFixedStepDomain : IFixedStepDomain { private const long TicksPerHour = global::System.TimeSpan.TicksPerHour; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMicrosecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMicrosecondFixedStepDomain.cs index cb1148d..39e6bd2 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMicrosecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMicrosecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Provides a fixed-step domain implementation for with a step size of 1 microsecond (10 ticks). /// -public readonly struct TimeOnlyMicrosecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlyMicrosecondFixedStepDomain : IFixedStepDomain { private const long TicksPerMicrosecond = 10; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMillisecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMillisecondFixedStepDomain.cs index 98834ee..f73c44c 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMillisecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMillisecondFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Requires: .NET 6.0 or greater. /// -public readonly struct TimeOnlyMillisecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlyMillisecondFixedStepDomain : IFixedStepDomain { private const long TicksPerMillisecond = global::System.TimeSpan.TicksPerMillisecond; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMinuteFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMinuteFixedStepDomain.cs index 985a76c..62de5c3 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMinuteFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyMinuteFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Requires: .NET 6.0 or greater. /// -public readonly struct TimeOnlyMinuteFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlyMinuteFixedStepDomain : IFixedStepDomain { private const long TicksPerMinute = global::System.TimeSpan.TicksPerMinute; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlySecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlySecondFixedStepDomain.cs index 8786d0b..aba9e35 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlySecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlySecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// Provides a fixed-step domain implementation for with a step size of 1 second. /// -public readonly struct TimeOnlySecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlySecondFixedStepDomain : IFixedStepDomain { private const long TicksPerSecond = global::System.TimeSpan.TicksPerSecond; diff --git a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyTickFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyTickFixedStepDomain.cs index fdab9a0..05db8f6 100644 --- a/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyTickFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/DateTime/TimeOnlyTickFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.DateTime; /// /// This is the finest granularity TimeOnly domain. /// -public readonly struct TimeOnlyTickFixedStepDomain : IFixedStepDomain +public readonly record struct TimeOnlyTickFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Default/Intervals.NET.Domain.Default.csproj b/src/Domain/Intervals.NET.Domain.Default/Intervals.NET.Domain.Default.csproj index 9e59769..3562adb 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Intervals.NET.Domain.Default.csproj +++ b/src/Domain/Intervals.NET.Domain.Default/Intervals.NET.Domain.Default.csproj @@ -6,7 +6,7 @@ enable true Intervals.NET.Domain.Default - 0.0.1 + 0.0.2 blaze6950 Ready-to-use domain implementations for Intervals.NET. Includes 36 optimized domains: numeric types (int, long, double, decimal, etc.), DateTime/DateOnly/TimeOnly with multiple granularities (day, hour, minute, second, tick), TimeSpan domains, and business calendar support. All struct-based with aggressive inlining for maximum performance. range;interval;domain;datetime;numeric;timespan;calendar;business-days;performance;zero-allocation;generic;intervals diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/ByteFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/ByteFixedStepDomain.cs index 565ffb1..6e24583 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/ByteFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/ByteFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for bytes (Byte). Steps are of size 1. /// -public readonly struct ByteFixedStepDomain : IFixedStepDomain +public readonly record struct ByteFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/DecimalFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/DecimalFixedStepDomain.cs index 0a156be..c1c4033 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/DecimalFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/DecimalFixedStepDomain.cs @@ -8,7 +8,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// Provides a fixed-step domain for decimal numbers. Steps are of size 1. /// This domain provides precise decimal arithmetic without floating-point errors. /// -public readonly struct DecimalFixedStepDomain : IFixedStepDomain +public readonly record struct DecimalFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/DoubleFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/DoubleFixedStepDomain.cs index 81ea6d0..9810956 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/DoubleFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/DoubleFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// Note: Due to floating-point precision limitations, this domain is best suited /// for integer-like double values. For precise decimal arithmetic, use DecimalFixedStepDomain. /// -public readonly struct DoubleFixedStepDomain : IFixedStepDomain +public readonly record struct DoubleFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/FloatFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/FloatFixedStepDomain.cs index 88af977..c309d3d 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/FloatFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/FloatFixedStepDomain.cs @@ -11,7 +11,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// This domain treats float values as having discrete steps of 1.0f. /// Due to floating-point precision limitations, results may not be exact for very large values. /// -public readonly struct FloatFixedStepDomain : IFixedStepDomain +public readonly record struct FloatFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/IntegerFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/IntegerFixedStepDomain.cs index e28b6f7..8ff86f1 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/IntegerFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/IntegerFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for integers. Steps are of size 1. /// -public readonly struct IntegerFixedStepDomain : IFixedStepDomain +public readonly record struct IntegerFixedStepDomain : IFixedStepDomain { /// [Pure] @@ -33,4 +33,4 @@ namespace Intervals.NET.Domain.Default.Numeric; [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Ceiling(int value) => value; -} +} \ No newline at end of file diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/LongFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/LongFixedStepDomain.cs index 79322ff..090b453 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/LongFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/LongFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for 64-bit integers. Steps are of size 1. /// -public readonly struct LongFixedStepDomain : IFixedStepDomain +public readonly record struct LongFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/SByteFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/SByteFixedStepDomain.cs index 06d84ee..9d94fac 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/SByteFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/SByteFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for signed bytes (SByte). Steps are of size 1. /// -public readonly struct SByteFixedStepDomain : IFixedStepDomain +public readonly record struct SByteFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/ShortFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/ShortFixedStepDomain.cs index a6e2aca..334a3d3 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/ShortFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/ShortFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for short integers (Int16). Steps are of size 1. /// -public readonly struct ShortFixedStepDomain : IFixedStepDomain +public readonly record struct ShortFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/UIntFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/UIntFixedStepDomain.cs index 9d0f4cc..e3ed5bc 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/UIntFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/UIntFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for unsigned integers (UInt32). Steps are of size 1. /// -public readonly struct UIntFixedStepDomain : IFixedStepDomain +public readonly record struct UIntFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/ULongFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/ULongFixedStepDomain.cs index c7843c6..8ed4564 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/ULongFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/ULongFixedStepDomain.cs @@ -11,7 +11,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// Distance calculation may not be accurate for ranges larger than long.MaxValue. /// In such cases, the distance is clamped to long.MaxValue. /// -public readonly struct ULongFixedStepDomain : IFixedStepDomain +public readonly record struct ULongFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/Numeric/UShortFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/Numeric/UShortFixedStepDomain.cs index c5e6557..3fb36e5 100644 --- a/src/Domain/Intervals.NET.Domain.Default/Numeric/UShortFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/Numeric/UShortFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.Numeric; /// /// Provides a fixed-step domain for unsigned short integers (UInt16). Steps are of size 1. /// -public readonly struct UShortFixedStepDomain : IFixedStepDomain +public readonly record struct UShortFixedStepDomain : IFixedStepDomain { /// [Pure] diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanDayFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanDayFixedStepDomain.cs index b086645..2ac9aac 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanDayFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanDayFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 day (24 hours). /// -public readonly struct TimeSpanDayFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanDayFixedStepDomain : IFixedStepDomain { private const long TicksPerDay = global::System.TimeSpan.TicksPerDay; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanHourFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanHourFixedStepDomain.cs index 8c920a0..05ce172 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanHourFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanHourFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 hour. /// -public readonly struct TimeSpanHourFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanHourFixedStepDomain : IFixedStepDomain { private const long TicksPerHour = global::System.TimeSpan.TicksPerHour; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMicrosecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMicrosecondFixedStepDomain.cs index d5f2348..af85407 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMicrosecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMicrosecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 microsecond (10 ticks). /// -public readonly struct TimeSpanMicrosecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanMicrosecondFixedStepDomain : IFixedStepDomain { private const long TicksPerMicrosecond = 10; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMillisecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMillisecondFixedStepDomain.cs index 64c73af..b14647c 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMillisecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMillisecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 millisecond. /// -public readonly struct TimeSpanMillisecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanMillisecondFixedStepDomain : IFixedStepDomain { private const long TicksPerMillisecond = global::System.TimeSpan.TicksPerMillisecond; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMinuteFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMinuteFixedStepDomain.cs index 600d5db..0cde5a7 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMinuteFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanMinuteFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 minute. /// -public readonly struct TimeSpanMinuteFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanMinuteFixedStepDomain : IFixedStepDomain { private const long TicksPerMinute = global::System.TimeSpan.TicksPerMinute; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanSecondFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanSecondFixedStepDomain.cs index 48848e4..a483f2e 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanSecondFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanSecondFixedStepDomain.cs @@ -7,7 +7,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// Provides a fixed-step domain implementation for with a step size of 1 second. /// -public readonly struct TimeSpanSecondFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanSecondFixedStepDomain : IFixedStepDomain { private const long TicksPerSecond = global::System.TimeSpan.TicksPerSecond; diff --git a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanTickFixedStepDomain.cs b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanTickFixedStepDomain.cs index 3b56650..436057b 100644 --- a/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanTickFixedStepDomain.cs +++ b/src/Domain/Intervals.NET.Domain.Default/TimeSpan/TimeSpanTickFixedStepDomain.cs @@ -10,7 +10,7 @@ namespace Intervals.NET.Domain.Default.TimeSpan; /// /// This is the finest granularity TimeSpan domain, operating at the tick level (100ns precision). /// -public readonly struct TimeSpanTickFixedStepDomain : IFixedStepDomain +public readonly record struct TimeSpanTickFixedStepDomain : IFixedStepDomain { [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Domain/Intervals.NET.Domain.Extensions/Intervals.NET.Domain.Extensions.csproj b/src/Domain/Intervals.NET.Domain.Extensions/Intervals.NET.Domain.Extensions.csproj index cc4a4d1..8f81717 100644 --- a/src/Domain/Intervals.NET.Domain.Extensions/Intervals.NET.Domain.Extensions.csproj +++ b/src/Domain/Intervals.NET.Domain.Extensions/Intervals.NET.Domain.Extensions.csproj @@ -6,7 +6,7 @@ enable true Intervals.NET.Domain.Extensions - 0.0.1 + 0.0.2 blaze6950 Extension methods for domain-aware range operations in Intervals.NET. Provides Span (count steps), Expand, ExpandByRatio, and Shift operations. Clearly separated into Fixed (O(1)) and Variable (O(N)) namespaces for explicit performance semantics. Works seamlessly with all domain implementations. range;interval;domain;extensions;span;expand;shift;performance;fixed-step;variable-step;intervals diff --git a/src/Domain/Intervals.NET.Domain.Extensions/Variable/RangeDomainExtensions.cs b/src/Domain/Intervals.NET.Domain.Extensions/Variable/RangeDomainExtensions.cs index a2f580a..723a2cd 100644 --- a/src/Domain/Intervals.NET.Domain.Extensions/Variable/RangeDomainExtensions.cs +++ b/src/Domain/Intervals.NET.Domain.Extensions/Variable/RangeDomainExtensions.cs @@ -79,13 +79,15 @@ public static class RangeDomainExtensions /// The type of the values in the range. Must implement IComparable<T>. /// The type of the domain that implements IVariableStepDomain<TRangeValue>. /// - /// The span (distance) of the range as a double, potentially including fractional steps, - /// or infinity if the range is unbounded. + /// The span (distance) of the range as a double representing the number of complete discrete domain steps, + /// or positive infinity if the range is unbounded. + /// The return type is double to accommodate infinity values; the actual step count is always an integer (converted from long). /// /// /// - /// Counts the number of domain steps that fall within the range boundaries, respecting inclusivity. - /// Unlike fixed-step domains, this may return fractional values to account for partial steps. + /// Counts the number of discrete domain steps that fall within the range boundaries, respecting inclusivity. + /// Variable-step domains use which returns a long (discrete step count), + /// not fractional distances. The double return type exists solely to represent infinity for unbounded ranges. /// /// /// @@ -122,7 +124,9 @@ public static RangeValue Span(this Range +/// Extension methods for IEnumerable to create RangeData objects. +/// +public static class EnumerableExtensions +{ + /// + /// Converts an IEnumerable of data into a RangeData object associated with the specified finite range. + /// + /// + /// The collection of data to associate with the range. + /// + /// + /// The finite range to associate with the data. + /// + /// + /// The range domain that defines the behavior of the range. + /// + /// + /// The type of the range boundaries. Must implement IComparable<TRangeType>. + /// + /// + /// The type of the data associated with the range. + /// + /// + /// The type of the range domain. Must implement IRangeDomain<TRangeType>. + /// + /// + /// A new RangeData object containing the specified range and data. + /// + public static RangeData ToRangeData( + this IEnumerable data, Range range, TRangeDomain domain) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain => new(range, data, domain); +} \ No newline at end of file diff --git a/src/Intervals.NET.Data/Extensions/RangeDataExtensions.cs b/src/Intervals.NET.Data/Extensions/RangeDataExtensions.cs new file mode 100644 index 0000000..7e05a52 --- /dev/null +++ b/src/Intervals.NET.Data/Extensions/RangeDataExtensions.cs @@ -0,0 +1,848 @@ +using System.Runtime.CompilerServices; +using Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using Intervals.NET.Extensions; + +namespace Intervals.NET.Data.Extensions; + +/// +/// Extension methods for . +/// +/// These extensions mirror logical range operations from Intervals.NET while correctly +/// propagating associated data sequences and maintaining strict invariants. +/// +/// +/// +/// Strict Invariant: +/// +/// The logical length of the range (as defined by domain distance) MUST exactly match +/// the number of elements in the associated data sequence. All operations preserve this invariant. +/// Any operation that would break this invariant is not implemented. +/// +/// +/// Design Principles: +/// +/// Immutability: All operations return new instances; no mutation occurs. +/// Domain-Agnostic: Operations work with any implementation. +/// Consistency Guarantee: Extensions are designed not to create RangeData with mismatched range/data lengths when the input RangeData instances satisfy the invariant. +/// Lazy Evaluation: Data sequences use LINQ operators; materialization is deferred. +/// +/// +/// Performance: +/// +/// Operations over may be O(n). For repeated access, +/// consider materializing data sequences (e.g., ToList(), ToArray()) before creating RangeData. +/// +/// +public static class RangeDataExtensions +{ + #region Domain Validation + + /// + /// Validates that two RangeData objects have equal domains. + /// + /// + /// + /// While the generic type constraint ensures both operands have the same TRangeDomain type, + /// custom domain implementations may have instance-specific state. This validation ensures + /// that operations are performed on compatible domain instances. + /// + /// + /// Performance: This comparison is allocation-free when TRangeDomain is a record struct + /// (automatic IEquatable<TSelf> implementation) or a class implementing IEquatable<TClassName>. + /// EqualityComparer<T>.Default will use the type's IEquatable implementation directly without boxing. + /// All built-in domains are record structs, ensuring zero-allocation equality checks. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ValidateDomainEquality( + RangeData left, + RangeData right, + string operationName) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + if (!EqualityComparer.Default.Equals(left.Domain, right.Domain)) + { + throw new ArgumentException( + $"Cannot {operationName} RangeData objects with different domain instances. " + + "Both operands must use the same domain instance or equivalent domains.", + nameof(right)); + } + } + + #endregion + + #region Set Operations + + /// + /// Computes the intersection of two objects. + /// + /// Returns a new RangeData containing the overlapping range with data sliced from the + /// right operand. If ranges do not overlap, returns null. + /// + /// + /// โšก Performance: O(n) where n is the number of elements to skip/take from the data sequence. + /// + /// + /// The left RangeData object (older/stale data). + /// The right RangeData object (newer/fresh data - used as data source for intersection). + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// A new RangeData object with the intersected range and sliced data from the right operand, + /// or null if the ranges do not overlap. + /// + /// Thrown when domains are not equal. + /// + /// Right-Biased Behavior: + /// + /// The intersection always uses data from the right operand (fresh data). + /// This ensures consistency with + /// and follows the principle that the right operand represents newer/fresher data. + /// + /// + /// Invariant Preservation: + /// + /// The resulting RangeData has data length exactly matching the intersection range length. + /// + /// + /// Example (Right-Biased): + /// + /// var domain = new IntegerFixedStepDomain(); + /// var oldData = new RangeData(Range.Closed(10, 30), staleValues, domain); + /// var newData = new RangeData(Range.Closed(20, 40), freshValues, domain); + /// + /// var intersection = oldData.Intersect(newData); + /// // Range [20, 30], data from newData (fresh), not oldData (stale) + /// + /// + /// Use Cases: + /// + /// Cache queries: get the fresh overlapping portion + /// Data validation: compare with latest values + /// Time-series: extract recent measurements in overlapping period + /// + /// + public static RangeData? Intersect( + this RangeData left, + RangeData right) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + ValidateDomainEquality(left, right, "intersect"); + + // Compute range intersection + var intersectedRange = left.Range.Intersect(right.Range); + + if (!intersectedRange.HasValue) + { + return null; + } + + // Slice data from RIGHT operand (fresh data) to match the intersection + return right[intersectedRange.Value]; + } + + /// + /// Computes the union of two objects + /// if they are contiguous (overlapping or adjacent). + /// + /// Returns a new RangeData with the combined range and distinct data. + /// Overlapping data appears only once, with the right operand taking priority. + /// + /// + /// โšก Performance: O(n + m) where n and m are the data sequence lengths. + /// + /// + /// The left RangeData object (older/stale data). + /// The right RangeData object (newer/fresh data - takes priority in overlapping regions). + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// A new RangeData object with the union range and distinct data, + /// or null if the ranges are disjoint (neither overlapping nor adjacent). + /// + /// Thrown when domains are not equal. + /// + /// Union Distinct Semantics: + /// + /// This is NOT a "union all" operation. Overlapping data appears only once. + /// The resulting RangeData maintains the strict invariant: range length equals data length. + /// + /// + /// Conflict Resolution (Right-Biased): + /// + /// When ranges overlap, data from the right operand is used for the intersection region. + /// This follows the principle that the right operand typically represents newer/fresher data + /// (e.g., cache updates, incremental data loads, time-series updates). + /// Data from the left operand is included only for the non-overlapping portion. + /// + /// + /// Data Construction Algorithm: + /// + /// Compute union range and intersection range (if any). + /// If no overlap: concatenate left + right data in proper order. + /// If overlap exists: + /// + /// Take all data from right operand (covers its entire range with fresh data). + /// Take only non-overlapping data from left operand. + /// Result: distinct data matching union range exactly, with right's data preferred. + /// + /// + /// + /// + /// Invariant Preservation: + /// + /// The resulting data sequence length exactly matches the union range's domain distance. + /// + /// + /// Example (Right-Biased): + /// + /// var domain = new IntegerFixedStepDomain(); + /// var oldData = new RangeData(Range.Closed(10, 20), staleValues, domain); // 11 elements (stale) + /// var newData = new RangeData(Range.Closed(18, 30), freshValues, domain); // 13 elements (fresh) + /// + /// var union = oldData.Union(newData); + /// // Range [10, 30], 21 elements total + /// // staleValues[0..7] (indices 10-17, non-overlapping left part) + /// // + freshValues[0..12] (indices 18-30, all fresh data) + /// // The overlap [18, 20] uses freshValues (fresh), not staleValues (stale) + /// + /// + /// Use Cases: + /// + /// Cache updates: merging old cached data with fresh updates + /// Time-series: combining historical data with recent measurements + /// Incremental loads: adding new data to existing dataset + /// + /// + public static RangeData? Union( + this RangeData left, + RangeData right) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + ValidateDomainEquality(left, right, "union"); + + // Check if ranges can be merged (overlapping or adjacent) + var unionRange = left.Range.Union(right.Range); + + if (!unionRange.HasValue) + { + // Ranges are disjoint - cannot form union + return null; + } + + // Compute intersection to determine overlap + var intersectionRange = left.Range.Intersect(right.Range); + + var unionData = intersectionRange.HasValue + // Ranges overlap - need to deduplicate (RIGHT-BIASED: fresh data wins) + ? MergeOverlappingRanges(left, right) + // No overlap - ranges are adjacent + : ConcatenateAdjacentRanges(left, right); + + return new RangeData( + unionRange.Value, + unionData, + left.Domain); + + // Local functions with aggressive inlining for hot-path performance + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static IEnumerable ConcatenateAdjacentRanges( + RangeData leftRange, + RangeData rightRange) + { + // Determine ordering and concatenate in correct sequence + return leftRange.Range.IsBefore(rightRange.Range) + ? leftRange.Data.Concat(rightRange.Data) + : rightRange.Data.Concat(leftRange.Data); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static IEnumerable MergeOverlappingRanges( + RangeData leftRange, + RangeData rightRange) + { + // RIGHT-BIASED: Always prioritize right's data (fresh) over left's data (stale) + // Get the portions of LEFT that don't overlap with RIGHT + // Note: We avoid ToList() to prevent allocation and GC pressure + var leftOnlyRanges = leftRange.Range.Except(rightRange.Range); + + return CombineDataWithFreshPrimary( + freshData: rightRange.Data, + freshRange: rightRange.Range, + staleRangeData: leftRange, + staleOnlyRanges: leftOnlyRanges); + } + + static IEnumerable CombineDataWithFreshPrimary( + IEnumerable freshData, + Range freshRange, + RangeData staleRangeData, + IEnumerable> staleOnlyRanges) + { + // Handle three topological cases by manually iterating the enumerable: + // - Count == 0: stale completely contained in fresh [F...S...F] โ†’ use only fresh + // - Count == 1: stale extends beyond fresh on one side [S..S]F...F] or [F...F]S..S] + // - Count == 2: stale wraps around fresh [S..S]F...F[S..S] (fresh is contained) + + // Manually iterate to avoid materializing the entire collection + using var enumerator = staleOnlyRanges.GetEnumerator(); + + // Try to get the first range + if (!enumerator.MoveNext()) + { + // Count == 0: No exclusive ranges, stale is completely contained in fresh + return HandleStaleContainedInFresh(freshData); + } + + var firstRange = enumerator.Current; + + // Try to get the second range + if (!enumerator.MoveNext()) + { + // Count == 1: Single exclusive range, stale extends on one side + return HandleStaleExtendsOneSide(freshData, freshRange, staleRangeData, firstRange); + } + + var secondRange = enumerator.Current; + + // Check if there's a third range (error condition) + if (enumerator.MoveNext()) + { + // Count > 2: This should never happen with Range.Except + throw new InvalidOperationException( + "Range.Except returned more than 2 ranges, which indicates an invalid state."); + } + + // Count == 2: Two exclusive ranges, stale wraps around fresh + return HandleStaleWrapsFresh(freshData, staleRangeData, firstRange, secondRange); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static IEnumerable HandleStaleContainedInFresh( + IEnumerable freshData) + { + // Stale is completely covered by fresh: [F....S....F] + // Use only fresh data (no stale data needed) + return freshData; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static IEnumerable HandleStaleExtendsOneSide( + IEnumerable freshData, + Range freshRange, + RangeData staleRangeData, + Range staleOnlyRange) + { + // Stale extends beyond fresh on one side + // Determine if it extends to the left or right + var staleExclusivePart = staleRangeData[staleOnlyRange]; + + // Check if stale extends to the left of fresh + var staleExtendsLeft = RangeValue.Compare( + staleOnlyRange.Start, + freshRange.Start) < 0; + + return staleExtendsLeft + ? staleExclusivePart.Data.Concat(freshData) // [S..S]F...F] + : freshData.Concat(staleExclusivePart.Data); // [F...F]S..S] + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static IEnumerable HandleStaleWrapsFresh( + IEnumerable freshData, + RangeData staleRangeData, + Range leftStaleRange, + Range rightStaleRange) + { + // Stale wraps around fresh: [S..S]F....F[S..S] + // Fresh is completely contained within stale + // Combine: left stale part + fresh (priority) + right stale part + var leftStalePart = staleRangeData[leftStaleRange]; + var rightStalePart = staleRangeData[rightStaleRange]; + return leftStalePart.Data.Concat(freshData).Concat(rightStalePart.Data); + } + } + + #endregion + + #region Trimming Operations + + /// + /// Trims the start of the range to a new start value, adjusting data accordingly. + /// + /// Returns a new RangeData with the trimmed range and sliced data. + /// + /// + /// โšก Performance: O(n) where n is the number of elements to skip. + /// + /// + /// The source RangeData object. + /// The new start value for the range. + /// Whether the new start boundary is inclusive. Defaults to true. + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// A new RangeData with the trimmed range and sliced data if the new start is within the current range; + /// null if the new start lies completely outside the original range; + /// a non-null RangeData with an empty range and empty data sequence if trimming results in a logically empty + /// but still in-bounds range (for example, the start equals the end with at least one boundary exclusive). + /// + /// + /// Example: + /// + /// var domain = new IntegerFixedStepDomain(); + /// var rd = new RangeData(Range.Closed(10, 30), data, domain); + /// + /// var trimmed = rd.TrimStart(15); // Range [15, 30], inclusive by default + /// var trimmed2 = rd.TrimStart(15, false); // Range (15, 30], exclusive start + /// var invalid = rd.TrimStart(40); // null (new start beyond end) + /// var invalid2 = rd.TrimStart(5); // null (new start before current start) + /// + /// + public static RangeData? TrimStart( + this RangeData source, + TRangeType newStart, + bool isStartInclusive = true) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + if (!Factories.Range.TryCreate( + new RangeValue(newStart), + source.Range.End, + isStartInclusive, + source.Range.IsEndInclusive, + out var trimmedRange, + out _)) + { + return null; + } + + // Check if the trimmed range is fully contained within the source range + if (!source.Range.Contains(trimmedRange)) + { + return null; + } + + // Slice data to match the trimmed range + if (!source.TryGet(trimmedRange, out var result)) + { + return null; + } + + return result; + } + + /// + /// Trims the end of the range to a new end value, adjusting data accordingly. + /// + /// Returns a new RangeData with the trimmed range and sliced data. + /// + /// + /// โšก Performance: O(n) where n is the number of elements to take. + /// + /// + /// The source RangeData object. + /// The new end value for the range. + /// Whether the new end boundary is inclusive. Defaults to true. + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// A new RangeData with the trimmed range and sliced data if the new end is within the current range; + /// null if the new end lies completely outside the original range; + /// a non-null RangeData with an empty range and empty data sequence if trimming results in a logically empty + /// but still in-bounds range (for example, the start equals the end with at least one boundary exclusive). + /// + /// + /// Example: + /// + /// var domain = new IntegerFixedStepDomain(); + /// var rd = new RangeData(Range.Closed(10, 30), data, domain); + /// + /// var trimmed = rd.TrimEnd(25); // Range [10, 25], inclusive by default + /// var trimmed2 = rd.TrimEnd(25, false); // Range [10, 25), exclusive end + /// var invalid = rd.TrimEnd(5); // null (new end before start) + /// var invalid2 = rd.TrimEnd(35); // null (new end after current end) + /// + /// + public static RangeData? TrimEnd( + this RangeData source, + TRangeType newEnd, + bool isEndInclusive = true) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + if (!Factories.Range.TryCreate( + source.Range.Start, + new RangeValue(newEnd), + source.Range.IsStartInclusive, + isEndInclusive, + out var trimmedRange, + out _)) + { + return null; + } + + // Check if the trimmed range is fully contained within the source range + if (!source.Range.Contains(trimmedRange)) + { + return null; + } + + // Slice data to match the trimmed range + if (!source.TryGet(trimmedRange, out var result)) + { + return null; + } + + return result; + } + + #endregion + + #region Containment Checks + + /// + /// Determines whether the range contains the specified value. + /// + /// Delegates to . + /// Does NOT inspect or validate data. + /// + /// + /// The source RangeData object. + /// The value to check for containment. + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// True if the range contains the value; otherwise, false. + /// + /// + /// This is a pure range operation; data is not inspected. + /// + /// + public static bool Contains( + this RangeData source, + TRangeType value) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain => source.Range.Contains(value); + + /// + /// Determines whether the range completely contains another range. + /// + /// Delegates to . + /// Does NOT inspect or validate data. + /// + /// + /// The source RangeData object. + /// The range to check for containment. + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// True if the source range fully contains the specified range; otherwise, false. + /// + /// + /// This is a pure range operation; data is not inspected. + /// + /// + public static bool Contains( + this RangeData source, + Range range) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain => source.Range.Contains(range); + + #endregion + + /// + /// Validates that the sequence + /// exactly matches the number of logical elements implied by the + /// and the associated domain instance. + /// + /// IMPORTANT: This method enumerates the underlying returned by + /// RangeData.Data. Enumeration may force deferred execution, be expensive, or produce + /// side-effects (for example if the sequence is generated on-the-fly). Callers should be + /// aware that calling this method will iterate the entire data sequence (up to the expected + /// count + 1 to detect oversize sequences). + /// + /// + /// The RangeData instance to validate. + /// When validation fails contains a descriptive message; null on success. + /// True if the data sequence length matches the logical range length; otherwise false. + public static bool IsValid( + this RangeData source, + out string? message) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + // Calculate expected element count using the same index arithmetic as RangeData.TryGet + var startIndex = 0L; + var endIndex = source.Domain.Distance(source.Range.Start.Value, source.Range.End.Value); + + if (!source.Range.IsStartInclusive) + { + startIndex++; + } + + if (!source.Range.IsEndInclusive) + { + endIndex--; + } + + long expectedCount; + if (endIndex < startIndex) + { + expectedCount = 0; + } + else + { + try + { + checked + { + expectedCount = (endIndex - startIndex) + 1; + } + } + catch (OverflowException) + { + message = $"Range {source.Range} is too large to validate."; + return false; + } + } + + try + { + using var e = source.Data.GetEnumerator(); + long actualCount = 0; + + // Enumerate but bail out early if we detect more elements than expected + while (e.MoveNext()) + { + actualCount++; + if (actualCount > expectedCount) + { + message = + $"Data sequence contains more elements ({actualCount}) than expected ({expectedCount}) for range {source.Range}."; + return false; + } + } + + if (actualCount < expectedCount) + { + message = + $"Data sequence contains fewer elements ({actualCount}) than expected ({expectedCount}) for range {source.Range}."; + return false; + } + + message = null; + return true; + } + catch (Exception ex) + { + // If enumeration throws, surface a helpful message + message = $"Exception while enumerating data sequence: {ex.GetType().Name} - {ex.Message}"; + return false; + } + } + + #region Relationship Checks + + /// + /// Determines whether this RangeData touches another RangeData through overlap or adjacency. + /// + /// Two RangeData objects are touching if their ranges either overlap or are adjacent + /// (touch at exactly one boundary). This is a symmetric relationship. + /// + /// + /// The source RangeData object. + /// The other RangeData object. + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// True if the ranges are touching (overlap or adjacent); otherwise, false. + /// Thrown when domains are not equal. + /// + /// Symmetric Relationship: + /// + /// a.IsTouching(b) is always equivalent to b.IsTouching(a). + /// + /// + /// Use Case: + /// + /// Use this method to determine if two RangeData objects can be merged using + /// . + /// + /// + /// Example: + /// + /// var domain = new IntegerFixedStepDomain(); + /// var rd1 = new RangeData(Range.Closed(10, 20), data1, domain); + /// var rd2 = new RangeData(Range.Open(20, 30), data2, domain); + /// + /// bool touching = rd1.IsTouching(rd2); // true (adjacent at 20) + /// + /// + public static bool IsTouching( + this RangeData source, + RangeData other) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + ValidateDomainEquality(source, other, "check relationship of"); + + // Touching = overlapping or adjacent (symmetric) + return source.Range.Overlaps(other.Range) || source.Range.IsAdjacent(other.Range); + } + + /// + /// Determines whether this RangeData is positioned before and adjacent to another RangeData. + /// + /// Returns true if this range ends exactly where the other range starts, with no gap or overlap. + /// This is a directional (non-symmetric) relationship. + /// + /// + /// The source RangeData object (expected to come first). + /// The other RangeData object (expected to come second). + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// True if source.Range ends exactly where other.Range starts (adjacent, source before other); + /// otherwise, false. + /// + /// Thrown when domains are not equal. + /// + /// Directional Relationship: + /// + /// a.IsBeforeAndAdjacentTo(b) implies b.IsAfterAndAdjacentTo(a). + /// + /// + /// Use Case: + /// + /// Use this method when you need to verify ordered, non-overlapping sequences. + /// + /// + /// Example: + /// + /// var domain = new IntegerFixedStepDomain(); + /// var rd1 = new RangeData(Range.Closed(10, 20), data1, domain); + /// var rd2 = new RangeData(Range.Open(20, 30), data2, domain); + /// + /// bool adjacent = rd1.IsBeforeAndAdjacentTo(rd2); // true + /// bool reverse = rd2.IsBeforeAndAdjacentTo(rd1); // false (wrong direction) + /// + /// + public static bool IsBeforeAndAdjacentTo( + this RangeData source, + RangeData other) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain + { + ValidateDomainEquality(source, other, "check relationship of"); + + // Check if source.end touches other.start + if (!source.Range.End.IsFinite || !other.Range.Start.IsFinite) + { + return false; + } + + var comparison = RangeValue.Compare(source.Range.End, other.Range.Start); + if (comparison != 0) + { + return false; + } + + // Boundaries are equal - adjacent if exactly one is inclusive + return source.Range.IsEndInclusive != other.Range.IsStartInclusive; + } + + /// + /// Determines whether this RangeData is positioned after and adjacent to another RangeData. + /// + /// Returns true if this range starts exactly where the other range ends, with no gap or overlap. + /// This is a directional (non-symmetric) relationship. + /// + /// + /// The source RangeData object (expected to come second). + /// The other RangeData object (expected to come first). + /// + /// The type of the range values. Must implement IComparable<TRangeType>. + /// + /// The type of the associated data. + /// + /// The type of the range domain that implements IRangeDomain<TRangeType>. + /// + /// + /// True if source.Range starts exactly where other.Range ends (adjacent, source after other); + /// otherwise, false. + /// + /// Thrown when domains are not equal. + /// + /// Directional Relationship: + /// + /// a.IsAfterAndAdjacentTo(b) implies b.IsBeforeAndAdjacentTo(a). + /// + /// + /// Use Case: + /// + /// Use this method when you need to verify ordered, non-overlapping sequences. + /// + /// + /// Example: + /// + /// var domain = new IntegerFixedStepDomain(); + /// var rd1 = new RangeData(Range.Closed(10, 20), data1, domain); + /// var rd2 = new RangeData(Range.Open(20, 30), data2, domain); + /// + /// bool adjacent = rd2.IsAfterAndAdjacentTo(rd1); // true + /// bool reverse = rd1.IsAfterAndAdjacentTo(rd2); // false (wrong direction) + /// + /// + public static bool IsAfterAndAdjacentTo( + this RangeData source, + RangeData other) + where TRangeType : IComparable + where TRangeDomain : IRangeDomain => + // This is simply the inverse of IsBeforeAndAdjacentTo + other.IsBeforeAndAdjacentTo(source); + + #endregion +} diff --git a/src/Intervals.NET.Data/Intervals.NET.Data.csproj b/src/Intervals.NET.Data/Intervals.NET.Data.csproj new file mode 100644 index 0000000..08e91b5 --- /dev/null +++ b/src/Intervals.NET.Data/Intervals.NET.Data.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0 + enable + enable + true + Intervals.NET.Data + 0.0.1 + blaze6950 + RangeData<TRange, TData, TDomain> โ€” a lazy, immutable abstraction that couples a logical Range<TRange>; a data sequence (IEnumerable<TData>); and a discrete IRangeDomain<TRange>. Designed so the domain-measured range length matches the data sequence length (optionally validated via the IsValid extension), enables right-biased union/intersection, and provides efficient, low-allocation operations for time-series, event streams, and incremental datasets. + rangedata;range;interval;timeseries;data;lazy;immutable;domain;serialization;union;intersection;sliding-window + https://github.com/blaze6950/Intervals.NET + https://github.com/blaze6950/Intervals.NET + MIT + README.md + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Intervals.NET.Data/RangeData.cs b/src/Intervals.NET.Data/RangeData.cs new file mode 100644 index 0000000..2f5afe3 --- /dev/null +++ b/src/Intervals.NET.Data/RangeData.cs @@ -0,0 +1,309 @@ +๏ปฟusing Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using Intervals.NET.Extensions; + +namespace Intervals.NET.Data; + +/// +/// Represents a finite range associated with a collection of data. +/// +/// +/// The type of the range boundaries. Must implement IComparable<TRangeType>. +/// +/// +/// The type of the data associated with the range. +/// +/// +/// The type of the domain used for range calculations. Must implement IRangeDomain<TRangeType>. +/// +/// +/// +/// Caller Responsibility: The caller is responsible for ensuring that the provided data sequence +/// corresponds exactly to the specified range and domain semantics. +/// No validation of data length or consistency is performed. +/// +/// +/// Performance: Operations over may be O(n). +/// For better performance with repeated access, consider materializing the data sequence (e.g., using ToList() or ToArray()) +/// before passing it to RangeData. +/// +/// +/// Equality Semantics: This record type overrides equality to compare only the +/// and properties. The property is intentionally excluded from equality comparison. +/// This means two RangeData instances with the same Range and Domain but different Data will be considered equal. +/// This design treats RangeData as an identity based on its logical span (range + domain), not its content (data). +/// Be aware of this behavior when using RangeData in sets, dictionaries, or other equality-based collections. +/// +/// +public record RangeData where TRangeType : IComparable + where TRangeDomain : IRangeDomain +{ + public RangeData(Range range, IEnumerable data, TRangeDomain domain) + { + if (!range.Start.IsFinite || !range.End.IsFinite) + { + throw new ArgumentException("Range must be finite.", nameof(range)); + } + + ArgumentNullException.ThrowIfNull(data); + // Use pattern matching null-check to avoid boxing when TRangeDomain is a value type (struct). + // ArgumentNullException.ThrowIfNull(domain) would box the value-type generic, causing an allocation. + if (domain is null) + { + throw new ArgumentNullException(nameof(domain)); + } + + Range = range; + Data = data; + Domain = domain; + } + + /// + /// The finite range associated with the data. + /// + public Range Range { get; } + + /// + /// The data associated with the range. + /// + public IEnumerable Data { get; } + + /// + /// The domain used for range calculations. + /// + public TRangeDomain Domain { get; } + + /// + /// Gets the data element corresponding to the specified point within the range, + /// using the provided domain to calculate the index. + /// + /// + /// The point within the range for which to retrieve the data element. + /// + /// + /// Thrown if no data element is available for the specified point, for example because the point is + /// outside the range or the underlying data sequence does not provide a value for that point. + /// + public TDataType this[TRangeType point] => TryGet(point, out var data) + ? data! + : throw new IndexOutOfRangeException($"No data element is available for point {point} in this RangeData (point may be outside the range or the underlying data sequence may be too short)."); + + /// + /// Gets the data elements corresponding to the specified sub-range within the range, + /// using the provided domain to calculate the indices. + /// + /// + /// The sub-range within the range for which to retrieve the data elements. + /// + /// + /// Thrown if the sub-range is not finite. + /// + public RangeData this[Range subRange] + { + get + { + if (!subRange.Start.IsFinite || !subRange.End.IsFinite) + { + throw new ArgumentException("Sub-range must be finite.", nameof(subRange)); + } + + if (!Range.Contains(subRange)) + { + throw new ArgumentOutOfRangeException(nameof(subRange), subRange, "Sub-range is outside the bounds of the source range."); + } + + if (TryGet(subRange, out var data)) + { + return data!; + } + + // Fallback: if TryGet failed for other reasons (overflow), provide a generic exception + throw new InvalidOperationException($"Unable to retrieve sub-range {subRange} from RangeData."); + } + } + + /// + /// Tries to get the data element corresponding to the specified point within the range. + /// + /// The point within the range for which to retrieve the data element. + /// + /// When this method returns, contains the data element associated with the specified point, + /// if the point is within the range; otherwise, the default value for TDataType. + /// + /// + /// true if the data element was found; otherwise, false. + /// + public bool TryGet(TRangeType point, out TDataType? data) + { + // Ensure the requested point is logically contained in the parent range (respects inclusive/exclusive bounds) + if (!Range.Contains(point)) + { + data = default; + return false; + } + + // Align baseStart to the first included element of the parent range. This ensures indices map + // to the actual first element of Data even when the parent range is exclusive at the start. + var baseStart = Range.IsStartInclusive ? Range.Start.Value : Domain.Add(Range.Start.Value, 1); + + var index = Domain.Distance(baseStart, point); + + // Guard against index overflow (long โ†’ int cast) + if (index < 0 || index > int.MaxValue) + { + data = default; + return false; + } + + var intIndex = (int)index; + + // Skip to the target index and check if an element exists + // This supports null values as valid data (unlike ElementAtOrDefault) + // and avoids exceptions (unlike ElementAt) + var remaining = Data.Skip(intIndex); + using var enumerator = remaining.GetEnumerator(); + + if (enumerator.MoveNext()) + { + data = enumerator.Current; + return true; + } + + data = default; + return false; + } + + /// + /// Tries to get the data elements corresponding to the specified sub-range within the range. + /// + /// The sub-range within the range for which to retrieve the data elements. + /// + /// When this method returns, contains the RangeData associated with the specified sub-range, + /// if the sub-range is finite; otherwise, null. + /// + /// + /// true if the sub-range is finite and data was retrieved; otherwise, false. + /// + public bool TryGet(Range subRange, out RangeData? data) + { + if (!subRange.Start.IsFinite || !subRange.End.IsFinite) + { + data = null; + return false; + } + + // Ensure the requested subRange is fully contained within this.Range (respects inclusive/exclusive bounds) + if (!Range.Contains(subRange)) + { + data = null; + return false; + } + + // Align baseStart to the first included element of the parent range so indices are computed + // relative to the first element held by Data. + var baseStart = Range.IsStartInclusive ? Range.Start.Value : Domain.Add(Range.Start.Value, 1); + + var startIndex = Domain.Distance(baseStart, subRange.Start.Value); + var endIndex = Domain.Distance(baseStart, subRange.End.Value); + + // Adjust indices based on inclusiveness + // If start is exclusive, skip the boundary element + if (!subRange.IsStartInclusive) + { + startIndex++; + } + + // If end is exclusive, don't include the boundary element + if (!subRange.IsEndInclusive) + { + endIndex--; + } + + // Calculate the count of elements to take + // If adjusted indices result in startIndex > endIndex, the range is empty + var count = endIndex - startIndex + 1; + if (count <= 0) + { + // Return empty RangeData for empty ranges + data = Empty(subRange, Domain); + return true; + } + + // Guard against index overflow (long โ†’ int cast) + // Note: We check this after the empty-range handling because a negative endIndex + // combined with count <= 0 indicates a logically empty range, not an error + if (startIndex < 0 || startIndex > int.MaxValue || endIndex < 0 || endIndex > int.MaxValue) + { + data = null; + return false; + } + + // Guard against count overflow + if (count > int.MaxValue) + { + data = null; + return false; + } + + var subset = Data.Skip((int)startIndex).Take((int)count); + + data = new RangeData(subRange, subset, Domain); + return true; + } + + /// + /// Returns a new instance + /// containing the data elements corresponding to the specified sub-range. + /// + /// The sub-range within the range for which to retrieve the data elements. + /// A new RangeData instance containing the sliced data. + /// Thrown if the sub-range is not finite. + /// + /// This is a named alternative to the indexer syntax for improved readability in fluent APIs. + /// + public RangeData Slice(Range subRange) + => this[subRange]; + + /// + /// Creates an empty instance + /// with the specified range and domain. + /// + /// The finite range associated with the empty data. + /// The domain used for range calculations. + /// A new RangeData instance with an empty data sequence. + /// Thrown if the range is not finite. + /// Thrown if domain is null. + public static RangeData Empty( + Range range, + TRangeDomain domain) + => new(range, Enumerable.Empty(), domain); + + /// + /// Determines whether the specified RangeData is equal to the current RangeData. + /// + /// The RangeData to compare with the current RangeData. + /// + /// true if the specified RangeData is equal to the current RangeData; otherwise, false. + /// + public virtual bool Equals(RangeData? other) + { + if (other is null) + { + return false; + } + + // Range.Equals is allocation-free (value type) + // Domain.Equals is allocation-free when TRangeDomain is a record struct (automatic IEquatable) + // or when it's a class implementing IEquatable + return Range.Equals(other.Range) + && Domain.Equals(other.Domain); + } + + /// + /// Serves as the default hash function. + /// + /// + /// A hash code for the current RangeData. + /// + public override int GetHashCode() => HashCode.Combine(Range, Domain); +} \ No newline at end of file diff --git a/src/Intervals.NET/Factories/RangeFactory.cs b/src/Intervals.NET/Factories/RangeFactory.cs index 41dcbab..469ea56 100644 --- a/src/Intervals.NET/Factories/RangeFactory.cs +++ b/src/Intervals.NET/Factories/RangeFactory.cs @@ -144,6 +144,35 @@ public static Range Create(RangeValue start, RangeValue end, bool is where T : IComparable => new(start, end, isStartInclusive, isEndInclusive); + + /// + /// Attempts to create a range with explicit inclusivity settings. + /// Returns a boolean indicating whether the created range is valid. + /// + /// The starting value of the range. + /// The ending value of the range. + /// True if the start value is inclusive; false if exclusive. + /// True if the end value is inclusive; false if exclusive. + /// The resulting range when creation succeeds; default when it fails. + /// An optional message describing why creation failed. + /// The type of values in the range. Must implement IComparable<T>. + /// True when creation succeeded and range is valid; false otherwise. + public static bool TryCreate(RangeValue start, RangeValue end, bool isStartInclusive, + bool isEndInclusive, out Range range, out string? message) + where T : IComparable + { + // Validate bounds first without throwing + if (Range.TryValidateBounds(start, end, isStartInclusive, isEndInclusive, out message)) + { + // Construct range without re-validating (skipValidation = true) + range = new Range(start, end, isStartInclusive, isEndInclusive, skipValidation: true); + return true; + } + + range = default; + return false; + } + /// /// Parses a range from the given input string. /// The expected format is: diff --git a/src/Intervals.NET/Intervals.NET.csproj b/src/Intervals.NET/Intervals.NET.csproj index 34b0419..9dbfaca 100644 --- a/src/Intervals.NET/Intervals.NET.csproj +++ b/src/Intervals.NET/Intervals.NET.csproj @@ -6,7 +6,7 @@ enable true Intervals.NET - 0.0.3 + 0.0.4 blaze6950 Production-ready .NET library for type-safe mathematical intervals and ranges. Zero-allocation struct-based design with comprehensive set operations (intersection, union, contains, overlaps), explicit infinity support, span-based parsing, and custom interpolated string handler. Generic over IComparable<T> with 100% test coverage. Built for correctness and performance. Range record was updated by restricting the creation of new Range struct using the record with keyword diff --git a/src/Intervals.NET/Range.cs b/src/Intervals.NET/Range.cs index ce2769a..3638cf7 100644 --- a/src/Intervals.NET/Range.cs +++ b/src/Intervals.NET/Range.cs @@ -30,20 +30,9 @@ namespace Intervals.NET; internal Range(RangeValue start, RangeValue end, bool isStartInclusive = true, bool isEndInclusive = false) { // Validate that start <= end when both are finite - if (start.IsFinite && end.IsFinite) + if (!TryValidateBounds(start, end, isStartInclusive, isEndInclusive, out var message)) { - var comparison = RangeValue.Compare(start, end); - if (comparison > 0) - { - throw new ArgumentException("Start value cannot be greater than end value.", nameof(start)); - } - - // If start == end, at least one bound must be inclusive for the range to contain any values - if (comparison == 0 && !isStartInclusive && !isEndInclusive) - { - throw new ArgumentException("When start equals end, at least one bound must be inclusive.", - nameof(start)); - } + throw new ArgumentException(message, nameof(start)); } Start = start; @@ -52,6 +41,36 @@ internal Range(RangeValue start, RangeValue end, bool isStartInclusive = t IsEndInclusive = isEndInclusive; } + /// + /// Checks whether the provided bounds form a valid range. Returns false and an explanatory message when invalid. + /// + /// Start bound. + /// End bound. + /// Whether start is inclusive. + /// Whether end is inclusive. + /// Optional error message when validation fails. + internal static bool TryValidateBounds(RangeValue start, RangeValue end, bool isStartInclusive, bool isEndInclusive, out string? message) + { + message = null; + + // Validate ordering regardless of finiteness (RangeValue.Compare handles infinities correctly) + var comparison = RangeValue.Compare(start, end); + if (comparison > 0) + { + message = "Start value cannot be greater than end value."; + return false; + } + + // If start == end, at least one bound must be inclusive for the range to contain any values + if (comparison == 0 && !isStartInclusive && !isEndInclusive) + { + message = "When start equals end, at least one bound must be inclusive."; + return false; + } + + return true; + } + /// /// Internal constructor that skips validation for performance. /// Use only when values are already validated (e.g., from parser). @@ -59,6 +78,11 @@ internal Range(RangeValue start, RangeValue end, bool isStartInclusive = t [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Range(RangeValue start, RangeValue end, bool isStartInclusive, bool isEndInclusive, bool skipValidation) { + if (!skipValidation && !TryValidateBounds(start, end, isStartInclusive, isEndInclusive, out var message)) + { + throw new ArgumentException(message, nameof(start)); + } + Start = start; End = end; IsStartInclusive = isStartInclusive; @@ -89,6 +113,12 @@ internal Range(RangeValue start, RangeValue end, bool isStartInclusive, bo /// public bool IsEndInclusive { get; } + /// + /// Returns true when this Range's bounds and inclusivity form a valid range. + /// This is computed on-demand. + /// + public bool IsValid => TryValidateBounds(Start, End, IsStartInclusive, IsEndInclusive, out _); + /// /// Returns a string representation of the range. /// Example: [start, end), (start, end], etc. diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensionsTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensionsTests.cs new file mode 100644 index 0000000..eaa1716 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensionsTests.cs @@ -0,0 +1,427 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +/// +/// Tests for RangeData extension methods. +/// +public class RangeDataExtensionsTests +{ + private readonly IntegerFixedStepDomain _domain = new(); + private readonly IntegerFixedStepDomain _differentDomain = new(); + + #region Intersect Tests + + [Fact] + public void Intersect_WithOverlappingRanges_ReturnsIntersection() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; // [20, 30] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(20, 30), data2, _domain); + + // Act + var result = rd1.Intersect(rd2); + + // Assert + Assert.NotNull(result); + Assert.Equal(20, result.Range.Start.Value); + Assert.Equal(20, result.Range.End.Value); + Assert.Single(result.Data); // Only element at index 10 (value 20) + Assert.Equal(11, result.Data.First()); + } + + [Fact] + public void Intersect_WithNonOverlappingRanges_ReturnsNull() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 }; // [30, 40] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(30, 40), data2, _domain); + + // Act + var result = rd1.Intersect(rd2); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Intersect_WithDifferentStatelessStructDomains_NoException() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(20, 30), data2, _differentDomain); + + // Act + var ex = Record.Exception(() => rd1.Intersect(rd2)); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Intersect_WithOneRangeContainedInAnother_ReturnsSmaller() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; // [10, 30] + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; // [15, 25] + + var rd1 = new RangeData( + Range.Closed(10, 30), data1, _domain); + var rd2 = new RangeData( + Range.Closed(15, 25), data2, _domain); + + // Act + var result = rd1.Intersect(rd2); + + // Assert + Assert.NotNull(result); + Assert.Equal(15, result.Range.Start.Value); + Assert.Equal(25, result.Range.End.Value); + Assert.Equal(11, result.Data.Count()); + } + + #endregion + + #region Union Tests + + [Fact] + public void Union_WithAdjacentRanges_ReturnsUnion() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] - 11 elements + var data2 = new[] { 21, 22, 23, 24, 25, 26, 27, 28, 29, 30 }; // (20, 30] - 10 elements + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.OpenClosed(20, 30), data2, _domain); + + // Act + var result = rd1.Union(rd2); + + // Assert + Assert.NotNull(result); + Assert.Equal(10, result.Range.Start.Value); + Assert.Equal(30, result.Range.End.Value); + Assert.Equal(21, result.Data.Count()); // 11 + 10 elements + } + + [Fact] + public void Union_WithOverlappingRanges_ReturnsUnionButWithRightValuesForTheIntersection() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; // [20, 30] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(20, 30), data2, _domain); + + // Act + var result = rd1.Union(rd2); + + // Assert + Assert.NotNull(result); + Assert.Equal(10, result.Range.Start.Value); + Assert.Equal(30, result.Range.End.Value); + Assert.Equal(21, result.Data.Count()); // One intersected point is right-bias merged + } + + [Fact] + public void Union_WithDisjointRanges_ReturnsNull() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 }; // [30, 40] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(30, 40), data2, _domain); + + // Act + var result = rd1.Union(rd2); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Union_WithDifferentStructDomainsWithoutState_TreatedAsSingletonNoException() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(20, 30), data2, _differentDomain); + + // Act + var ex = Record.Exception(() => rd1.Union(rd2)); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Union_IntersectionAtDiscretePoint_RightValueTakesPreferenceWithinIntersection() + { + // Arrange + var data1 = new[] { 1, 2, 3 }; // [10, 12] + var data2 = new[] { 4, 5, 6 }; // [12, 14] + + var rd1 = new RangeData( + Range.Closed(10, 12), data1, _domain); + var rd2 = new RangeData( + Range.Closed(12, 14), data2, _domain); + + // Act + var result = rd1.Union(rd2); + + // Assert + Assert.NotNull(result); + var resultData = result.Data.ToList(); + Assert.Equal(new[] { 1, 2, 4, 5, 6 }, resultData); + } + + #endregion + + #region TrimStart Tests + + [Fact] + public void TrimStart_WithValidNewStart_ReturnsTrimmedRange() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimStart(15); + + // Assert + Assert.NotNull(result); + Assert.Equal(15, result.Range.Start.Value); + Assert.Equal(20, result.Range.End.Value); + Assert.Equal(6, result.Data.Count()); // [15, 20] + } + + [Fact] + public void TrimStart_WithStartBeyondEnd_ReturnsNull() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimStart(30); + + // Assert + Assert.Null(result); + } + + [Fact] + public void TrimStart_WithStartAtEnd_ReturnsOneElement() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimStart(20); + + // Assert + Assert.NotNull(result); + Assert.Equal(20, result.Range.Start.Value); + Assert.Equal(20, result.Range.End.Value); + Assert.Single(result.Data); + } + + #endregion + + #region TrimEnd Tests + + [Fact] + public void TrimEnd_WithValidNewEnd_ReturnsTrimmedRange() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimEnd(15); + + // Assert + Assert.NotNull(result); + Assert.Equal(10, result.Range.Start.Value); + Assert.Equal(15, result.Range.End.Value); + Assert.Equal(6, result.Data.Count()); // [10, 15] + } + + [Fact] + public void TrimEnd_WithEndBeforeStart_ReturnsNull() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimEnd(5); + + // Assert + Assert.Null(result); + } + + [Fact] + public void TrimEnd_WithEndAtStart_ReturnsOneElement() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var result = rd.TrimEnd(10); + + // Assert + Assert.NotNull(result); + Assert.Equal(10, result.Range.Start.Value); + Assert.Equal(10, result.Range.End.Value); + Assert.Single(result.Data); + } + + #endregion + + #region Contains Tests + + [Fact] + public void Contains_Value_WithValueInRange_ReturnsTrue() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act & Assert + Assert.True(rd.Contains(10)); + Assert.True(rd.Contains(15)); + Assert.True(rd.Contains(20)); + } + + [Fact] + public void Contains_Value_WithValueOutsideRange_ReturnsFalse() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act & Assert + Assert.False(rd.Contains(5)); + Assert.False(rd.Contains(25)); + } + + [Fact] + public void Contains_Range_WithContainedRange_ReturnsTrue() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act & Assert + Assert.True(rd.Contains(Range.Closed(12, 18))); + Assert.True(rd.Contains(Range.Closed(10, 20))); + } + + [Fact] + public void Contains_Range_WithNonContainedRange_ReturnsFalse() + { + // Arrange + var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act & Assert + Assert.False(rd.Contains(Range.Closed(15, 25))); + Assert.False(rd.Contains(Range.Closed(5, 15))); + } + + #endregion + + #region IsTouching Tests + + [Fact] + public void IsTouching_WithAdjacentRanges_ReturnsTrue() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] - 11 elements + var data2 = new[] { 21, 22, 23, 24, 25, 26, 27, 28, 29, 30 }; // (20, 30] - 10 elements + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.OpenClosed(20, 30), data2, _domain); + + // Act & Assert + Assert.True(rd1.IsTouching(rd2)); + } + + [Fact] + public void IsTouching_WithOverlappingRanges_ReturnsTrue() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 }; // [20, 30] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(20, 30), data2, _domain); + + // Act & Assert + Assert.True(rd1.IsTouching(rd2)); + } + + [Fact] + public void IsTouching_WithDisjointRanges_ReturnsFalse() + { + // Arrange + var data1 = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // [10, 20] + var data2 = new[] { 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 }; // [30, 40] + + var rd1 = new RangeData( + Range.Closed(10, 20), data1, _domain); + var rd2 = new RangeData( + Range.Closed(30, 40), data2, _domain); + + // Act & Assert + Assert.False(rd1.IsTouching(rd2)); + } + + #endregion +} \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_AdjacencyAndValidityTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_AdjacencyAndValidityTests.cs new file mode 100644 index 0000000..b6b6863 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_AdjacencyAndValidityTests.cs @@ -0,0 +1,164 @@ +using System.Collections; +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +public class RangeDataExtensions_AdjacencyAndValidityTests +{ + private readonly IntegerFixedStepDomain _domain = new(); + + [Fact] + public void IsValid_WithCorrectLength_ReturnsTrue() + { + // Arrange + var data = Enumerable.Range(1, 11).ToArray(); // [10,20] -> 11 elements + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var ok = rd.IsValid(out var message); + + // Assert + Assert.True(ok); + Assert.Null(message); + } + + [Fact] + public void IsValid_WithFewerElements_ReturnsFalseAndMessageContainsFewer() + { + // Arrange + var data = Enumerable.Range(1, 10).ToArray(); // one element short + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var ok = rd.IsValid(out var message); + + // Assert + Assert.False(ok); + Assert.NotNull(message); + Assert.Contains("fewer elements", message); + } + + [Fact] + public void IsValid_WithMoreElements_ReturnsFalseAndMessageContainsMore() + { + // Arrange + var data = Enumerable.Range(1, 12).ToArray(); // one element too many + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var ok = rd.IsValid(out var message); + + // Assert + Assert.False(ok); + Assert.NotNull(message); + Assert.Contains("more elements", message); + } + + [Fact] + public void IsValid_WithThrowingEnumerator_ReturnsFalseAndReportsException() + { + // Arrange + var data = new ThrowingEnumerable(); + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Act + var ok = rd.IsValid(out var message); + + // Assert + Assert.False(ok); + Assert.NotNull(message); + Assert.Contains("Exception while enumerating data sequence", message); + Assert.Contains("InvalidOperationException", message); + } + + [Fact] + public void IsBeforeAndAdjacentTo_WithOneInclusiveOtherExclusive_ReturnsTrue() + { + // Arrange + var leftData = Enumerable.Range(1, 11).ToArray(); // [10,20] - 11 elements + var rightData = Enumerable.Range(21, 10).ToArray(); // (20,30] - 10 elements (21-30) + + var left = new RangeData( + Range.Closed(10, 20), leftData, _domain); + var right = new RangeData( + Range.OpenClosed(20, 30), rightData, _domain); + + // Act + var result = left.IsBeforeAndAdjacentTo(right); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsBeforeAndAdjacentTo_WithBothInclusive_ReturnsFalse() + { + // Arrange + var left = new RangeData( + Range.Closed(10, 20), Enumerable.Range(1, 11), _domain); + var right = new RangeData( + Range.Closed(20, 30), Enumerable.Range(1, 11), _domain); + + // Act + var result = left.IsBeforeAndAdjacentTo(right); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsBeforeAndAdjacentTo_WithDifferentBoundaries_ReturnsFalse() + { + // Arrange + var left = new RangeData( + Range.Closed(10, 20), Enumerable.Range(1, 11), _domain); + var right = new RangeData( + Range.Closed(21, 30), Enumerable.Range(1, 10), _domain); + + // Act + var result = left.IsBeforeAndAdjacentTo(right); + + // Assert + Assert.False(result); + } + + // NOTE: RangeData constructor requires finite ranges; constructing RangeData with infinite + // boundaries throws ArgumentException. Therefore testing the infinite-boundary branch in + // IsBeforeAndAdjacentTo is not possible via RangeData instances and is intentionally omitted. + + [Fact] + public void IsAfterAndAdjacentTo_DelegatesToIsBeforeAndAdjacentTo() + { + // Arrange + var left = new RangeData( + Range.Closed(10, 20), Enumerable.Range(1, 11), _domain); + var right = new RangeData( + Range.OpenClosed(20, 30), Enumerable.Range(21, 10), _domain); + + // Act & Assert + Assert.True(right.IsAfterAndAdjacentTo(left)); + Assert.False(left.IsAfterAndAdjacentTo(right)); + } + + // Helper throwing enumerable used to simulate enumeration errors + private sealed class ThrowingEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() => new ThrowingEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class ThrowingEnumerator : IEnumerator + { + public int Current => throw new NotSupportedException(); + object IEnumerator.Current => Current; + public void Dispose() { } + public bool MoveNext() => throw new InvalidOperationException("boom"); + public void Reset() => throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_DomainValidationTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_DomainValidationTests.cs new file mode 100644 index 0000000..96f9341 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_DomainValidationTests.cs @@ -0,0 +1,44 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Intervals.NET.Data.Tests.Helpers; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +public class RangeDataExtensions_DomainValidationTests +{ + [Fact] + public void Intersect_WithDifferentDomainInstanceThatIsNotEqual_ThrowsArgumentException() + { + // Arrange + var domainA = new NonEqualDomainStub(); + var domainB = new NonEqualDomainStub(); // separate instance but Equals returns false + + var dataA = Enumerable.Range(1, 11); + var dataB = Enumerable.Range(1, 11); + + var rdA = new RangeData(Range.Closed(10, 20), dataA, domainA); + var rdB = new RangeData(Range.Closed(10, 20), dataB, domainB); + + // Act & Assert - domains are same compile-time type but instances are not equal + Assert.Throws(() => RangeDataExtensions.Intersect(rdA, rdB)); + } + + [Fact] + public void Intersect_WithDifferentInstancesButEqualValueDomain_DoesNotThrow() + { + // Arrange + var domain1 = new IntegerFixedStepDomain(); + var domain2 = new IntegerFixedStepDomain(); // separate instance but value-equal (struct) + + var data1 = Enumerable.Range(1, 11); + var data2 = Enumerable.Range(1, 11); + + var rd1 = new RangeData(Range.Closed(10, 20), data1, domain1); + var rd2 = new RangeData(Range.Closed(10, 20), data2, domain2); + + // Act & Assert - should not throw + var result = RangeDataExtensions.Intersect(rd1, rd2); + Assert.NotNull(result); + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_IsValidEdgeTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_IsValidEdgeTests.cs new file mode 100644 index 0000000..90a29f2 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_IsValidEdgeTests.cs @@ -0,0 +1,26 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +public class RangeDataExtensions_IsValidEdgeTests +{ + private readonly IntegerFixedStepDomain _domain = new(); + + [Fact] + public void IsValid_WithEmptyLogicalRange_ReturnsTrueAndNoMessage() + { + // Arrange + // Create a range that results in zero logical steps: open-closed range with same start and end + var range = Range.OpenClosed(10, 10); + var rd = new RangeData(range, Enumerable.Empty(), _domain); + + // Act + var isValid = RangeDataExtensions.IsValid(rd, out var message); + + // Assert + Assert.True(isValid); + Assert.Null(message); + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_TrimOverflowTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_TrimOverflowTests.cs new file mode 100644 index 0000000..f7c5670 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeDataExtensions_TrimOverflowTests.cs @@ -0,0 +1,40 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Data.Tests.Helpers; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +public class RangeDataExtensions_TrimOverflowTests +{ + [Fact] + public void TrimStart_WithHugeDistanceDomain_ReturnsNull() + { + // Arrange + var hugeDomain = new HugeDistanceDomainStub(((long)int.MaxValue) + 1000); + var data = Enumerable.Empty(); + var originalRange = Range.Closed(0, 100); + var rd = new RangeData(originalRange, data, hugeDomain); + + // Act + var result = RangeDataExtensions.TrimStart(rd, 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public void TrimEnd_WithHugeDistanceDomain_ReturnsNull() + { + // Arrange + var hugeDomain = new HugeDistanceDomainStub(((long)int.MaxValue) + 1000); + var data = Enumerable.Empty(); + var originalRange = Range.Closed(0, 100); + var rd = new RangeData(originalRange, data, hugeDomain); + + // Act + var result = RangeDataExtensions.TrimEnd(rd, 99); + + // Assert + Assert.Null(result); + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/Extensions/RangeData_EqualityAndSliceTests.cs b/tests/Intervals.NET.Data.Tests/Extensions/RangeData_EqualityAndSliceTests.cs new file mode 100644 index 0000000..e10bf6a --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Extensions/RangeData_EqualityAndSliceTests.cs @@ -0,0 +1,95 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests.Extensions; + +public class RangeData_EqualityAndSliceTests +{ + private readonly IntegerFixedStepDomain _domain = new(); + + [Fact] + public void ToRangeData_CreatesRangeDataWithSameRangeAndData() + { + // Arrange + var data = Enumerable.Range(1, 11).ToArray(); + var range = Range.Closed(10, 20); + + // Act + var rd = data.ToRangeData(range, _domain); + + // Assert + Assert.Equal(range, rd.Range); + Assert.Equal(_domain, rd.Domain); + Assert.True(rd.Data.SequenceEqual(data)); + } + + [Fact] + public void Equals_And_GetHashCode_ConsiderOnlyRangeAndDomain() + { + // Arrange + var data1 = Enumerable.Range(1, 11); + var data2 = Enumerable.Range(101, 11); // different content + var range = Range.Closed(10, 20); + + var rd1 = new RangeData(range, data1, _domain); + var rd2 = new RangeData(range, data2, _domain); + + // Act & Assert + Assert.True(rd1.Equals(rd2)); + Assert.Equal(rd1.GetHashCode(), rd2.GetHashCode()); + } + + [Fact] + public void Equals_WithDifferentRange_ReturnsFalse() + { + // Arrange + var data = Enumerable.Range(1, 11).ToArray(); + var leftRange = Range.Closed(10, 20); + var rightRange = Range.Closed(11, 21); + + var rd1 = new RangeData(leftRange, data, _domain); + var rd2 = new RangeData(rightRange, data, _domain); + + // Act & Assert + Assert.False(rd1.Equals(rd2)); + } + + [Fact] + public void Slice_ReturnsExpectedSubset() + { + // Arrange + var data = Enumerable.Range(1, 11).ToArray(); // indices 0..10 correspond to 10..20 + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + var subRange = Range.Closed(12, 14); // should yield elements at indices 2..4 => values 3,4,5 + + // Act + var sliced = rd.Slice(subRange); + + // Assert + Assert.Equal(subRange, sliced.Range); + Assert.True(sliced.Data.SequenceEqual([3, 4, 5])); + } + + [Fact] + public void Slice_WithExclusiveStartSameAsEnd_ReturnsEmpty() + { + // Arrange + var data = Enumerable.Range(1, 11).ToArray(); + var rd = new RangeData( + Range.Closed(10, 20), data, _domain); + + // Create sub-range where start == end but start is exclusive and end is inclusive + // This should result in an empty slice per RangeData.TryGet logic + var subRange = Range.OpenClosed(10, 10); + + // Act + var sliced = rd.Slice(subRange); + + // Assert + Assert.Equal(subRange, sliced.Range); + Assert.False(sliced.Data.Any()); + } +} diff --git a/tests/Intervals.NET.Data.Tests/Helpers/TestDomains.cs b/tests/Intervals.NET.Data.Tests/Helpers/TestDomains.cs new file mode 100644 index 0000000..915f88c --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Helpers/TestDomains.cs @@ -0,0 +1,41 @@ +using Intervals.NET.Domain.Abstractions; + +namespace Intervals.NET.Data.Tests.Helpers; + +public sealed class NonEqualDomainStub : IRangeDomain +{ + public int Add(int value, long steps) => throw new NotSupportedException(); + public int Subtract(int value, long steps) => throw new NotSupportedException(); + public int Floor(int value) => throw new NotSupportedException(); + public int Ceiling(int value) => throw new NotSupportedException(); + public long Distance(int start, int end) => throw new NotSupportedException(); + + public override bool Equals(object? obj) => false; // never equal to any other domain + public override int GetHashCode() => 42; +} + +public sealed class HugeDistanceDomainStub : IRangeDomain +{ + private readonly long _distance; + public HugeDistanceDomainStub(long distance) => _distance = distance; + + public int Add(int value, long steps) => (int)(value + steps); + public int Subtract(int value, long steps) => (int)(value - steps); + public int Floor(int value) => value; + public int Ceiling(int value) => value; + + public long Distance(int start, int end) + { + // Return configured huge distance regardless of inputs to trigger overflow guards + return _distance; + } + + public override bool Equals(object? obj) + { + if (obj is HugeDistanceDomainStub other) + return other._distance == _distance; + return false; + } + + public override int GetHashCode() => _distance.GetHashCode(); +} diff --git a/tests/Intervals.NET.Data.Tests/Intervals.NET.Data.Tests.csproj b/tests/Intervals.NET.Data.Tests/Intervals.NET.Data.Tests.csproj new file mode 100644 index 0000000..a09920c --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/Intervals.NET.Data.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + Intervals.NET.Data.Tests + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Intervals.NET.Data.Tests/RangeDataTests.cs b/tests/Intervals.NET.Data.Tests/RangeDataTests.cs new file mode 100644 index 0000000..18ce056 --- /dev/null +++ b/tests/Intervals.NET.Data.Tests/RangeDataTests.cs @@ -0,0 +1,736 @@ +using Intervals.NET.Data.Tests.Helpers; +using Intervals.NET.Domain.Default.Numeric; +using Range = Intervals.NET.Factories.Range; + +namespace Intervals.NET.Data.Tests; + +/// +/// Tests for the RangeData class, focusing on correct handling of range inclusiveness. +/// +public class RangeDataTests +{ + private readonly IntegerFixedStepDomain _domain = new(); + + #region Constructor Tests + + [Fact] + public void Constructor_WithValidFiniteRange_CreatesInstance() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + + // Act + var rangeData = new RangeData(range, data, _domain); + + // Assert + Assert.NotNull(rangeData); + Assert.Equal(range, rangeData.Range); + Assert.Equal(data, rangeData.Data); + } + + [Fact] + public void Constructor_WithInfiniteRange_ThrowsArgumentException() + { + // Arrange + var range = Range.Create(RangeValue.NegativeInfinity, new RangeValue(10), true, true); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + + // Act & Assert + Assert.Throws(() => + new RangeData(range, data, _domain)); + } + + [Fact] + public void Constructor_WithNullData_ThrowsArgumentNullException() + { + // Arrange + var range = Range.Closed(0, 10); + + // Act & Assert + Assert.Throws(() => + new RangeData(range, null!, _domain)); + } + + #endregion + + #region Point Indexer Tests + + [Fact] + public void PointIndexer_WithValidPoint_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10" }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var result = rangeData[5]; + + // Assert + Assert.Equal("d5", result); + } + + [Fact] + public void PointIndexer_WithOutOfBoundsPoint_ThrowsIndexOutOfRangeException() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10" }; + var rangeData = new RangeData(range, data, _domain); + + // Act & Assert + Assert.Throws(() => rangeData[15]); + } + + #endregion + + #region Sub-Range Indexer Tests - Closed Ranges [start, end] + + [Fact] + public void SubRangeIndexer_ClosedRange_ReturnsCorrectData() + { + // Arrange - [0, 10] with data for each point + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [2, 5] โ†’ should return 4 elements: 2, 3, 4, 5 + var subRange = Range.Closed(2, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([2, 3, 4, 5], result.Data); + } + + [Fact] + public void SubRangeIndexer_ClosedRange_SinglePoint_ReturnsOneElement() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [5, 5] โ†’ should return 1 element: 5 + var subRange = Range.Closed(5, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([5], result.Data); + } + + #endregion + + #region Sub-Range Indexer Tests - Open Ranges (start, end) + + [Fact] + public void SubRangeIndexer_OpenRange_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get (2, 5) โ†’ should return 2 elements: 3, 4 (excludes both 2 and 5) + var subRange = Range.Open(2, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([3, 4], result.Data); + } + + [Fact] + public void SubRangeIndexer_OpenRange_EmptyRange_ReturnsEmpty() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get (2, 3) โ†’ should return 0 elements (no integers between 2 and 3 exclusively) + var subRange = Range.Open(2, 3); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Empty(result.Data); + } + + [Fact] + public void SubRangeIndexer_OpenRange_SinglePointRange_ThrowsArgumentException() + { + // Act - Get (5, 5) โ†’ should throw because (5, 5) is invalid (start == end with both exclusive) + var exception = Record.Exception(() => Range.Open(5, 5)); + + // Assert - This should throw during range creation, not during indexer access + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region Sub-Range Indexer Tests - Half-Open Ranges [start, end) and (start, end] + + [Fact] + public void SubRangeIndexer_ClosedOpenRange_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [2, 5) โ†’ should return 3 elements: 2, 3, 4 (includes 2, excludes 5) + var subRange = Range.ClosedOpen(2, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([2, 3, 4], result.Data); + } + + [Fact] + public void SubRangeIndexer_OpenClosedRange_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get (2, 5] โ†’ should return 3 elements: 3, 4, 5 (excludes 2, includes 5) + var subRange = Range.OpenClosed(2, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([3, 4, 5], result.Data); + } + + [Fact] + public void SubRangeIndexer_ClosedOpenRange_SinglePoint_ReturnsEmpty() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [5, 6) โ†’ should return 1 element: 5 + var subRange = Range.ClosedOpen(5, 6); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([5], result.Data); + } + + [Fact] + public void SubRangeIndexer_OpenClosedRange_SinglePoint_ReturnsOneElement() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get (4, 5] โ†’ should return 1 element: 5 + var subRange = Range.OpenClosed(4, 5); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal(subRange, result.Range); + Assert.Equal([5], result.Data); + } + + #endregion + + #region TryGet Tests - Sub-Range + + [Fact] + public void TryGet_SubRange_WithValidClosedRange_ReturnsTrue() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var subRange = Range.Closed(2, 5); + var success = rangeData.TryGet(subRange, out var result); + + // Assert + Assert.True(success); + Assert.NotNull(result); + Assert.Equal([2, 3, 4, 5], result.Data); + } + + [Fact] + public void TryGet_SubRange_WithValidOpenRange_ReturnsTrue() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var subRange = Range.Open(2, 5); + var success = rangeData.TryGet(subRange, out var result); + + // Assert + Assert.True(success); + Assert.NotNull(result); + Assert.Equal([3, 4], result.Data); + } + + [Fact] + public void TryGet_SubRange_WithInfiniteRange_ReturnsFalse() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var subRange = Range.Create(RangeValue.NegativeInfinity, new RangeValue(5), true, true); + var success = rangeData.TryGet(subRange, out var result); + + // Assert + Assert.False(success); + Assert.Null(result); + } + + [Fact] + public void TryGet_SubRange_WithEmptyRange_ReturnsTrueWithEmptyData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - (2, 3) contains no integers + var subRange = Range.Open(2, 3); + var success = rangeData.TryGet(subRange, out var result); + + // Assert + Assert.True(success); + Assert.NotNull(result); + Assert.Empty(result.Data); + } + + [Fact] + public void TryGet_SubRange_EmptyButContained_WithExclusiveStartParent_ReturnsTrueWithEmptyData() + { + // Arrange - Parent range: (10,20] means it contains 11, 12, ..., 20 + var parentRange = Range.OpenClosed(10, 20); + var data = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; + var rangeData = new RangeData(parentRange, data, _domain); + + // Act - Sub-range (10,10] is logically empty but contained in parent + // This tests the fix for the bug where endIndex becomes -1, and the negative-index guard + // would return false before the empty-range handling was reached + var subRange = Range.OpenClosed(10, 10); + var success = rangeData.TryGet(subRange, out var result); + + // Assert - Should return true with empty data, not false + Assert.True(success); + Assert.NotNull(result); + Assert.Empty(result.Data); + } + + [Fact] + public void TryGet_SubRange_EmptyAtStartBoundary_WithClosedOpenParent_ReturnsTrueWithEmptyData() + { + // Arrange - Parent range: [10,20) means it contains 10, 11, ..., 19 + var parentRange = Range.ClosedOpen(10, 20); + var data = new[] { 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }; + var rangeData = new RangeData(parentRange, data, _domain); + + // Act - Sub-range [10,10) is logically empty + var subRange = Range.ClosedOpen(10, 10); + var success = rangeData.TryGet(subRange, out var result); + + // Assert - Should return true with empty data + Assert.True(success); + Assert.NotNull(result); + Assert.Empty(result.Data); + } + + [Fact] + public void TryGet_SubRange_EmptyAtEndBoundary_WithOpenClosedParent_ReturnsTrueWithEmptyData() + { + // Arrange - Parent range: (10,20] means it contains 11, 12, ..., 20 + var parentRange = Range.OpenClosed(10, 20); + var data = new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; + var rangeData = new RangeData(parentRange, data, _domain); + + // Act - Sub-range [20,20) is logically empty but at the end boundary + var subRange = Range.ClosedOpen(20, 20); + var success = rangeData.TryGet(subRange, out var result); + + // Assert - Should return true with empty data + Assert.True(success); + Assert.NotNull(result); + Assert.Empty(result.Data); + } + + #endregion + + #region TryGet Tests - Point + + [Fact] + public void TryGet_Point_WithValidPoint_ReturnsTrue() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10" }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var success = rangeData.TryGet(5, out var result); + + // Assert + Assert.True(success); + Assert.Equal("d5", result); + } + + [Fact] + public void TryGet_Point_WithOutOfBoundsPoint_ReturnsFalse() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "d10" }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var success = rangeData.TryGet(15, out var result); + + // Assert + Assert.False(success); + Assert.Null(result); + } + + #endregion + + #region Edge Cases + + [Fact] + public void SubRangeIndexer_FullRange_ReturnsAllData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get the full range [0, 10] + var result = rangeData[range]; + + // Assert + Assert.NotNull(result); + Assert.Equal(data, result.Data); + } + + [Fact] + public void SubRangeIndexer_AtBoundaries_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [0, 2] + var subRange = Range.Closed(0, 2); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal([0, 1, 2], result.Data); + } + + [Fact] + public void SubRangeIndexer_AtEndBoundaries_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [8, 10] + var subRange = Range.Closed(8, 10); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal([8, 9, 10], result.Data); + } + + #endregion + + #region Complex Scenarios + + [Fact] + public void SubRangeIndexer_MultipleInclusivenessVariations_AllReturnCorrectData() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act & Assert - Test all four combinations for range [3, 6] + + // [3, 6] โ†’ 3, 4, 5, 6 (4 elements) + var closed = rangeData[Range.Closed(3, 6)]; + Assert.Equal([3, 4, 5, 6], closed.Data); + + // (3, 6) โ†’ 4, 5 (2 elements) + var open = rangeData[Range.Open(3, 6)]; + Assert.Equal([4, 5], open.Data); + + // [3, 6) โ†’ 3, 4, 5 (3 elements) + var closedOpen = rangeData[Range.ClosedOpen(3, 6)]; + Assert.Equal([3, 4, 5], closedOpen.Data); + + // (3, 6] โ†’ 4, 5, 6 (3 elements) + var openClosed = rangeData[Range.OpenClosed(3, 6)]; + Assert.Equal([4, 5, 6], openClosed.Data); + } + + [Fact] + public void SubRangeIndexer_WithNonZeroStartRange_ReturnsCorrectData() + { + // Arrange - Range starts at 5, not 0 + var range = Range.Closed(5, 15); + var data = new[] { 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [7, 10] + var subRange = Range.Closed(7, 10); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal([7, 8, 9, 10], result.Data); + } + + [Fact] + public void SubRangeIndexer_WithNegativeRange_ReturnsCorrectData() + { + // Arrange + var range = Range.Closed(-5, 5); + var data = new[] { -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5 }; + var rangeData = new RangeData(range, data, _domain); + + // Act - Get [-2, 2] + var subRange = Range.Closed(-2, 2); + var result = rangeData[subRange]; + + // Assert + Assert.NotNull(result); + Assert.Equal([-2, -1, 0, 1, 2], result.Data); + } + + #endregion + + #region Overflow and Edge Case Tests + + [Fact] + public void PointIndexer_WithInsufficientData_ThrowsIndexOutOfRangeException() + { + // Arrange - Range expects 11 elements but only provide 5 + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4 }; + var rangeData = new RangeData(range, data, _domain); + + // Act & Assert - Accessing beyond available data + Assert.Throws(() => rangeData[7]); + } + + [Fact] + public void TryGet_Point_WithInsufficientData_ReturnsFalse() + { + // Arrange - Range expects 11 elements but only provide 5 + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var result = rangeData.TryGet(7, out var value); + + // Assert + Assert.False(result); + Assert.Equal(default, value); + } + + [Fact] + public void SubRangeIndexer_WithSubRangeOutsideParentRange_ThrowsArgumentOutOfRangeException() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act & Assert - Sub-range extends beyond parent range + var subRange = Range.Closed(5, 15); + Assert.Throws(() => rangeData[subRange]); + } + + [Fact] + public void TryGet_SubRange_WithSubRangeOutsideParentRange_ReturnsFalse() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var subRange = Range.Closed(5, 15); + var result = rangeData.TryGet(subRange, out var resultData); + + // Assert + Assert.False(result); + Assert.Null(resultData); + } + + [Fact] + public void SubRangeIndexer_WithInfiniteSubRange_ThrowsArgumentException() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act & Assert - Sub-range with infinite end + var subRange = Range.Create(new RangeValue(5), RangeValue.PositiveInfinity, true, false); + Assert.Throws(() => rangeData[subRange]); + } + + [Fact] + public void TryGet_SubRange_WithInfiniteSubRange_ReturnsFalse() + { + // Arrange + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, _domain); + + // Act + var subRange = Range.Create(new RangeValue(5), RangeValue.PositiveInfinity, true, false); + var result = rangeData.TryGet(subRange, out var resultData); + + // Assert + Assert.False(result); + Assert.Null(resultData); + } + + [Fact] + public void SubRangeIndexer_WithOverflowInTryGet_ThrowsInvalidOperationException() + { + // Arrange - Use domain that causes overflow in TryGet but not in Contains check + var hugeDomain = new HugeDistanceDomainStub(((long)int.MaxValue) + 100); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act & Assert - The subrange is contained, but TryGet will fail due to overflow + // This triggers the InvalidOperationException fallback + var subRange = Range.Closed(2, 5); + var exception = Assert.Throws(() => rangeData[subRange]); + Assert.Contains("Unable to retrieve sub-range", exception.Message); + } + + [Fact] + public void TryGet_Point_WithNegativeIndexFromDomain_ReturnsFalse() + { + // Arrange - Use domain that returns negative distance + var hugeDomain = new HugeDistanceDomainStub(-100); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act + var result = rangeData.TryGet(5, out var value); + + // Assert + Assert.False(result); + Assert.Equal(default, value); + } + + [Fact] + public void TryGet_Point_WithIndexExceedingIntMax_ReturnsFalse() + { + // Arrange - Use domain that returns distance > int.MaxValue + var hugeDomain = new HugeDistanceDomainStub(long.MaxValue); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act + var result = rangeData.TryGet(5, out var value); + + // Assert + Assert.False(result); + Assert.Equal(default, value); + } + + [Fact] + public void TryGet_SubRange_WithStartIndexExceedingIntMax_ReturnsFalse() + { + // Arrange - Use domain that returns huge distances + var hugeDomain = new HugeDistanceDomainStub(((long)int.MaxValue) + 100); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act + var subRange = Range.Closed(2, 5); + var result = rangeData.TryGet(subRange, out var resultData); + + // Assert + Assert.False(result); + Assert.Null(resultData); + } + + [Fact] + public void TryGet_SubRange_WithNegativeStartIndex_ReturnsFalse() + { + // Arrange - Use domain that returns negative distance + var hugeDomain = new HugeDistanceDomainStub(-50); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act + var subRange = Range.Closed(2, 5); + var result = rangeData.TryGet(subRange, out var resultData); + + // Assert + Assert.False(result); + Assert.Null(resultData); + } + + [Fact] + public void TryGet_SubRange_WithEndIndexExceedingIntMax_ReturnsFalse() + { + // Arrange - Use domain that returns huge distance for endIndex + var hugeDomain = new HugeDistanceDomainStub(((long)int.MaxValue) + 100); + var range = Range.Closed(0, 10); + var data = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var rangeData = new RangeData(range, data, hugeDomain); + + // Act - Both start and end indices will exceed int.MaxValue + var subRange = Range.Closed(2, 5); + var result = rangeData.TryGet(subRange, out var resultData); + + // Assert + Assert.False(result); + Assert.Null(resultData); + } + + #endregion +} diff --git a/tests/Intervals.NET.Data/Extensions/RangeDataExtensionsTests.cs b/tests/Intervals.NET.Data/Extensions/RangeDataExtensionsTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/Intervals.NET.Domain.Default.Tests/Calendar/StandardDateTimeBusinessDaysVariableStepDomainTests.cs b/tests/Intervals.NET.Domain.Default.Tests/Calendar/StandardDateTimeBusinessDaysVariableStepDomainTests.cs index e25b9f4..3669016 100644 --- a/tests/Intervals.NET.Domain.Default.Tests/Calendar/StandardDateTimeBusinessDaysVariableStepDomainTests.cs +++ b/tests/Intervals.NET.Domain.Default.Tests/Calendar/StandardDateTimeBusinessDaysVariableStepDomainTests.cs @@ -1,6 +1,6 @@ using Intervals.NET.Domain.Default.Calendar; -namespace Intervals.NET.Tests.Domains.Calendar; +namespace Intervals.NET.Domain.Default.Tests.Calendar; /// /// Tests for StandardDateTimeBusinessDaysVariableStepDomain. @@ -16,7 +16,7 @@ public class StandardDateTimeBusinessDaysVariableStepDomainTests public void Floor_BusinessDayAtMidnight_ReturnsUnchanged() { // Arrange - Monday, January 6, 2025 at midnight - var monday = new DateTime(2025, 1, 6, 0, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Floor(monday); @@ -29,8 +29,8 @@ public void Floor_BusinessDayAtMidnight_ReturnsUnchanged() public void Floor_BusinessDayWithTime_ReturnsDateAtMidnight() { // Arrange - Monday, January 6, 2025 at 10:30 AM - var monday = new DateTime(2025, 1, 6, 10, 30, 0); - var expectedMidnight = new DateTime(2025, 1, 6, 0, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 10, 30, 0); + var expectedMidnight = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Floor(monday); @@ -43,8 +43,8 @@ public void Floor_BusinessDayWithTime_ReturnsDateAtMidnight() public void Floor_Saturday_ReturnsPreviousFridayAtMidnight() { // Arrange - Saturday, January 4, 2025 at 3:45 PM - var saturday = new DateTime(2025, 1, 4, 15, 45, 0); - var expectedFriday = new DateTime(2025, 1, 3, 0, 0, 0); + var saturday = new System.DateTime(2025, 1, 4, 15, 45, 0); + var expectedFriday = new System.DateTime(2025, 1, 3, 0, 0, 0); // Act var result = _domain.Floor(saturday); @@ -57,8 +57,8 @@ public void Floor_Saturday_ReturnsPreviousFridayAtMidnight() public void Floor_Sunday_ReturnsPreviousFridayAtMidnight() { // Arrange - Sunday, January 5, 2025 at 11:59 PM - var sunday = new DateTime(2025, 1, 5, 23, 59, 59); - var expectedFriday = new DateTime(2025, 1, 3, 0, 0, 0); + var sunday = new System.DateTime(2025, 1, 5, 23, 59, 59); + var expectedFriday = new System.DateTime(2025, 1, 3, 0, 0, 0); // Act var result = _domain.Floor(sunday); @@ -75,7 +75,7 @@ public void Floor_Sunday_ReturnsPreviousFridayAtMidnight() public void Ceiling_BusinessDayAtMidnight_ReturnsUnchanged() { // Arrange - Monday, January 6, 2025 at midnight - var monday = new DateTime(2025, 1, 6, 0, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Ceiling(monday); @@ -88,8 +88,8 @@ public void Ceiling_BusinessDayAtMidnight_ReturnsUnchanged() public void Ceiling_BusinessDayWithTime_ReturnsNextDayAtMidnight() { // Arrange - Monday, January 6, 2025 at 10:30 AM - var monday = new DateTime(2025, 1, 6, 10, 30, 0); - var expectedNextDay = new DateTime(2025, 1, 7, 0, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 10, 30, 0); + var expectedNextDay = new System.DateTime(2025, 1, 7, 0, 0, 0); // Act var result = _domain.Ceiling(monday); @@ -102,8 +102,8 @@ public void Ceiling_BusinessDayWithTime_ReturnsNextDayAtMidnight() public void Ceiling_Saturday_ReturnsNextMondayAtMidnight() { // Arrange - Saturday, January 4, 2025 - var saturday = new DateTime(2025, 1, 4, 15, 45, 0); - var expectedMonday = new DateTime(2025, 1, 6, 0, 0, 0); + var saturday = new System.DateTime(2025, 1, 4, 15, 45, 0); + var expectedMonday = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Ceiling(saturday); @@ -116,8 +116,8 @@ public void Ceiling_Saturday_ReturnsNextMondayAtMidnight() public void Ceiling_Sunday_ReturnsNextMondayAtMidnight() { // Arrange - Sunday, January 5, 2025 - var sunday = new DateTime(2025, 1, 5, 8, 0, 0); - var expectedMonday = new DateTime(2025, 1, 6, 0, 0, 0); + var sunday = new System.DateTime(2025, 1, 5, 8, 0, 0); + var expectedMonday = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Ceiling(sunday); @@ -134,8 +134,8 @@ public void Ceiling_Sunday_ReturnsNextMondayAtMidnight() public void Add_OneBusinessDay_FromMonday_ReturnsTuesday() { // Arrange - Monday, January 6, 2025 at 9:00 AM - var monday = new DateTime(2025, 1, 6, 9, 0, 0); - var expectedTuesday = new DateTime(2025, 1, 7, 9, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 9, 0, 0); + var expectedTuesday = new System.DateTime(2025, 1, 7, 9, 0, 0); // Act var result = _domain.Add(monday, 1); @@ -148,8 +148,8 @@ public void Add_OneBusinessDay_FromMonday_ReturnsTuesday() public void Add_OneBusinessDay_FromFriday_SkipsWeekendReturnsMonday() { // Arrange - Friday, January 3, 2025 at 5:00 PM - var friday = new DateTime(2025, 1, 3, 17, 0, 0); - var expectedMonday = new DateTime(2025, 1, 6, 17, 0, 0); + var friday = new System.DateTime(2025, 1, 3, 17, 0, 0); + var expectedMonday = new System.DateTime(2025, 1, 6, 17, 0, 0); // Act var result = _domain.Add(friday, 1); @@ -162,8 +162,8 @@ public void Add_OneBusinessDay_FromFriday_SkipsWeekendReturnsMonday() public void Add_FiveBusinessDays_SkipsWeekend() { // Arrange - Monday, January 6, 2025 - var monday = new DateTime(2025, 1, 6, 12, 0, 0); - var expectedNextMonday = new DateTime(2025, 1, 13, 12, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 12, 0, 0); + var expectedNextMonday = new System.DateTime(2025, 1, 13, 12, 0, 0); // Act var result = _domain.Add(monday, 5); @@ -176,7 +176,7 @@ public void Add_FiveBusinessDays_SkipsWeekend() public void Add_ZeroSteps_ReturnsUnchanged() { // Arrange - var wednesday = new DateTime(2025, 1, 8, 10, 30, 15); + var wednesday = new System.DateTime(2025, 1, 8, 10, 30, 15); // Act var result = _domain.Add(wednesday, 0); @@ -189,8 +189,8 @@ public void Add_ZeroSteps_ReturnsUnchanged() public void Add_NegativeSteps_MovesBackward() { // Arrange - Friday, January 10, 2025 - var friday = new DateTime(2025, 1, 10, 8, 0, 0); - var expectedMonday = new DateTime(2025, 1, 6, 8, 0, 0); + var friday = new System.DateTime(2025, 1, 10, 8, 0, 0); + var expectedMonday = new System.DateTime(2025, 1, 6, 8, 0, 0); // Act var result = _domain.Add(friday, -4); @@ -203,7 +203,7 @@ public void Add_NegativeSteps_MovesBackward() public void Add_PreservesTimeComponent() { // Arrange - Monday at 2:34:56 PM - var monday = new DateTime(2025, 1, 6, 14, 34, 56); + var monday = new System.DateTime(2025, 1, 6, 14, 34, 56); // Act var result = _domain.Add(monday, 3); @@ -222,8 +222,8 @@ public void Add_PreservesTimeComponent() public void Subtract_OneBusinessDay_FromTuesday_ReturnsMonday() { // Arrange - Tuesday, January 7, 2025 - var tuesday = new DateTime(2025, 1, 7, 11, 0, 0); - var expectedMonday = new DateTime(2025, 1, 6, 11, 0, 0); + var tuesday = new System.DateTime(2025, 1, 7, 11, 0, 0); + var expectedMonday = new System.DateTime(2025, 1, 6, 11, 0, 0); // Act var result = _domain.Subtract(tuesday, 1); @@ -236,8 +236,8 @@ public void Subtract_OneBusinessDay_FromTuesday_ReturnsMonday() public void Subtract_OneBusinessDay_FromMonday_SkipsWeekendReturnsFriday() { // Arrange - Monday, January 6, 2025 - var monday = new DateTime(2025, 1, 6, 16, 30, 0); - var expectedFriday = new DateTime(2025, 1, 3, 16, 30, 0); + var monday = new System.DateTime(2025, 1, 6, 16, 30, 0); + var expectedFriday = new System.DateTime(2025, 1, 3, 16, 30, 0); // Act var result = _domain.Subtract(monday, 1); @@ -250,8 +250,8 @@ public void Subtract_OneBusinessDay_FromMonday_SkipsWeekendReturnsFriday() public void Subtract_NegativeSteps_MovesForward() { // Arrange - Monday, January 6, 2025 - var monday = new DateTime(2025, 1, 6, 9, 0, 0); - var expectedFriday = new DateTime(2025, 1, 10, 9, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 9, 0, 0); + var expectedFriday = new System.DateTime(2025, 1, 10, 9, 0, 0); // Act var result = _domain.Subtract(monday, -4); @@ -268,8 +268,8 @@ public void Subtract_NegativeSteps_MovesForward() public void Distance_MondayToFriday_SameWeek_ReturnsFour() { // Arrange - var monday = new DateTime(2025, 1, 6, 9, 0, 0); - var friday = new DateTime(2025, 1, 10, 17, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 9, 0, 0); + var friday = new System.DateTime(2025, 1, 10, 17, 0, 0); // Act var result = _domain.Distance(monday, friday); @@ -282,8 +282,8 @@ public void Distance_MondayToFriday_SameWeek_ReturnsFour() public void Distance_FridayToMonday_SkipsWeekend_ReturnsOne() { // Arrange - var friday = new DateTime(2025, 1, 3, 17, 0, 0); - var monday = new DateTime(2025, 1, 6, 9, 0, 0); + var friday = new System.DateTime(2025, 1, 3, 17, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 9, 0, 0); // Act var result = _domain.Distance(friday, monday); @@ -296,7 +296,7 @@ public void Distance_FridayToMonday_SkipsWeekend_ReturnsOne() public void Distance_SameDateTime_ReturnsZero() { // Arrange - var date = new DateTime(2025, 1, 6, 14, 30, 45); + var date = new System.DateTime(2025, 1, 6, 14, 30, 45); // Act var result = _domain.Distance(date, date); @@ -309,8 +309,8 @@ public void Distance_SameDateTime_ReturnsZero() public void Distance_EndBeforeStart_ReturnsNegative() { // Arrange - var laterDate = new DateTime(2025, 1, 13); - var earlierDate = new DateTime(2025, 1, 6); + var laterDate = new System.DateTime(2025, 1, 13); + var earlierDate = new System.DateTime(2025, 1, 6); // Act var result = _domain.Distance(laterDate, earlierDate); @@ -323,8 +323,8 @@ public void Distance_EndBeforeStart_ReturnsNegative() public void Distance_IgnoresTimeComponent() { // Arrange - Same date, different times - var morning = new DateTime(2025, 1, 6, 8, 0, 0); - var evening = new DateTime(2025, 1, 6, 20, 0, 0); + var morning = new System.DateTime(2025, 1, 6, 8, 0, 0); + var evening = new System.DateTime(2025, 1, 6, 20, 0, 0); // Act var result = _domain.Distance(morning, evening); @@ -341,7 +341,7 @@ public void Distance_IgnoresTimeComponent() public void AddAndSubtract_AreInverse() { // Arrange - var original = new DateTime(2025, 1, 8, 14, 30, 45); + var original = new System.DateTime(2025, 1, 8, 14, 30, 45); // Act var added = _domain.Add(original, 7); @@ -355,15 +355,15 @@ public void AddAndSubtract_AreInverse() public void FloorAndCeiling_Weekend_ProduceDifferentResults() { // Arrange - Saturday - var saturday = new DateTime(2025, 1, 4, 12, 0, 0); + var saturday = new System.DateTime(2025, 1, 4, 12, 0, 0); // Act var floored = _domain.Floor(saturday); var ceiled = _domain.Ceiling(saturday); // Assert - Assert.Equal(new DateTime(2025, 1, 3, 0, 0, 0), floored); // Friday midnight - Assert.Equal(new DateTime(2025, 1, 6, 0, 0, 0), ceiled); // Monday midnight + Assert.Equal(new System.DateTime(2025, 1, 3, 0, 0, 0), floored); // Friday midnight + Assert.Equal(new System.DateTime(2025, 1, 6, 0, 0, 0), ceiled); // Monday midnight Assert.NotEqual(floored, ceiled); } @@ -371,8 +371,8 @@ public void FloorAndCeiling_Weekend_ProduceDifferentResults() public void Distance_MatchesManualAddition() { // Arrange - var start = new DateTime(2025, 1, 6, 9, 0, 0); - var end = new DateTime(2025, 1, 15, 9, 0, 0); + var start = new System.DateTime(2025, 1, 6, 9, 0, 0); + var end = new System.DateTime(2025, 1, 15, 9, 0, 0); // Act var distance = _domain.Distance(start, end); @@ -391,7 +391,7 @@ public void Distance_MatchesManualAddition() public void Add_LargeNumberOfDays_WorksCorrectly() { // Arrange - var start = new DateTime(2025, 1, 6, 9, 0, 0); + var start = new System.DateTime(2025, 1, 6, 9, 0, 0); // Act - Add 100 business days var result = _domain.Add(start, 100); @@ -405,7 +405,7 @@ public void Add_LargeNumberOfDays_WorksCorrectly() public void Floor_MidnightBusinessDay_ReturnsUnchanged() { // Arrange - Tuesday at exactly midnight - var tuesday = new DateTime(2025, 1, 7, 0, 0, 0); + var tuesday = new System.DateTime(2025, 1, 7, 0, 0, 0); // Act var result = _domain.Floor(tuesday); @@ -418,8 +418,8 @@ public void Floor_MidnightBusinessDay_ReturnsUnchanged() public void Floor_OneTickBeforeMidnight_ReturnsDateAtMidnight() { // Arrange - Monday at 23:59:59.9999999 - var almostMidnight = new DateTime(2025, 1, 6, 23, 59, 59, 999).AddTicks(9999); - var expectedMidnight = new DateTime(2025, 1, 6, 0, 0, 0); + var almostMidnight = new System.DateTime(2025, 1, 6, 23, 59, 59, 999).AddTicks(9999); + var expectedMidnight = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Floor(almostMidnight); @@ -432,8 +432,8 @@ public void Floor_OneTickBeforeMidnight_ReturnsDateAtMidnight() public void Ceiling_FridayWithTime_ReturnsNextMondayNotSaturday() { // Arrange - Friday at 11:30 AM - var friday = new DateTime(2025, 1, 10, 11, 30, 0); - var expectedMonday = new DateTime(2025, 1, 13, 0, 0, 0); // Next business day + var friday = new System.DateTime(2025, 1, 10, 11, 30, 0); + var expectedMonday = new System.DateTime(2025, 1, 13, 0, 0, 0); // Next business day // Act var result = _domain.Ceiling(friday); @@ -446,7 +446,7 @@ public void Ceiling_FridayWithTime_ReturnsNextMondayNotSaturday() public void Ceiling_BusinessDayExactlyAtMidnight_ReturnsUnchanged() { // Arrange - Monday, January 6, 2025 at midnight (a business day) - var monday = new DateTime(2025, 1, 6, 0, 0, 0); + var monday = new System.DateTime(2025, 1, 6, 0, 0, 0); // Act var result = _domain.Ceiling(monday); diff --git a/tests/Intervals.NET.Domain.Default.Tests/DateTime/DateTimeSubSecondFixedStepDomainTests.cs b/tests/Intervals.NET.Domain.Default.Tests/DateTime/DateTimeSubSecondFixedStepDomainTests.cs index 0ad7083..44dc343 100644 --- a/tests/Intervals.NET.Domain.Default.Tests/DateTime/DateTimeSubSecondFixedStepDomainTests.cs +++ b/tests/Intervals.NET.Domain.Default.Tests/DateTime/DateTimeSubSecondFixedStepDomainTests.cs @@ -75,6 +75,21 @@ public void DateTimeMinute_Ceiling_RoundsUpToNextMinute() Assert.Equal(new System.DateTime(2024, 1, 1, 10, 31, 0), result); } + [Fact] + public void DateTimeMinute_Distance_CalculatesMinutesCorrectly() + { + // Arrange + var domain = new DateTimeMinuteFixedStepDomain(); + var start = new System.DateTime(2024, 1, 1, 10, 30, 0); + var end = new System.DateTime(2024, 1, 1, 10, 45, 0); + + // Act + var result = domain.Distance(start, end); + + // Assert + Assert.Equal(15, result); + } + [Fact] public void DateTimeSecond_Add_AddsSecondsCorrectly() { @@ -103,6 +118,21 @@ public void DateTimeSecond_Ceiling_RoundsUpToNextSecond() Assert.Equal(new System.DateTime(2024, 1, 1, 10, 30, 46), result); } + [Fact] + public void DateTimeSecond_Distance_CalculatesSecondsCorrectly() + { + // Arrange + var domain = new DateTimeSecondFixedStepDomain(); + var start = new System.DateTime(2024, 1, 1, 10, 30, 15); + var end = new System.DateTime(2024, 1, 1, 10, 30, 45); + + // Act + var result = domain.Distance(start, end); + + // Assert + Assert.Equal(30, result); + } + [Fact] public void DateTimeMillisecond_Add_AddsMillisecondsCorrectly() { @@ -117,6 +147,20 @@ public void DateTimeMillisecond_Add_AddsMillisecondsCorrectly() Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0, 350), result); } + [Fact] + public void DateTimeMillisecond_Ceiling_RoundsUpCorrectly() + { + // Arrange + var domain = new DateTimeMillisecondFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 0, 0, 100).AddTicks(5000); + + // Act + var result = domain.Ceiling(value); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0, 101), result); + } + [Fact] public void DateTimeMillisecond_Distance_CalculatesCorrectly() { @@ -132,6 +176,34 @@ public void DateTimeMillisecond_Distance_CalculatesCorrectly() Assert.Equal(250, result); } + [Fact] + public void DateTimeMillisecond_Floor_RoundsDownCorrectly() + { + // Arrange + var domain = new DateTimeMillisecondFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 0, 0, 100).AddTicks(5000); + + // Act + var result = domain.Floor(value); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0, 100), result); + } + + [Fact] + public void DateTimeMillisecond_Subtract_SubtractsMillisecondsCorrectly() + { + // Arrange + var domain = new DateTimeMillisecondFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 0, 0, 350); + + // Act + var result = domain.Subtract(value, 250); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0, 100), result); + } + [Fact] public void DateTimeMicrosecond_Add_AddsMicrosecondsCorrectly() { @@ -147,17 +219,46 @@ public void DateTimeMicrosecond_Add_AddsMicrosecondsCorrectly() } [Fact] - public void DateTimeMicrosecond_Floor_RoundsDownCorrectly() + public void DateTimeMicrosecond_Ceiling_RoundsUpCorrectly() { // Arrange var domain = new DateTimeMicrosecondFixedStepDomain(); var value = new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(15); // Act - var result = domain.Floor(value); + var result = domain.Ceiling(value); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(20), result); + } + + [Fact] + public void DateTimeMicrosecond_Distance_CalculatesCorrectly() + { + // Arrange + var domain = new DateTimeMicrosecondFixedStepDomain(); + var start = new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(100); + var end = new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(600); + + // Act + var result = domain.Distance(start, end); // Assert - Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(10), result); + Assert.Equal(50, result); // (600-100)/10 = 50 microseconds + } + + [Fact] + public void DateTimeMicrosecond_Subtract_SubtractsMicrosecondsCorrectly() + { + // Arrange + var domain = new DateTimeMicrosecondFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(600); + + // Act + var result = domain.Subtract(value, 50); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0).AddTicks(100), result); } [Fact] @@ -188,6 +289,35 @@ public void DateTimeTicks_Ceiling_ReturnsUnchanged() Assert.Equal(value, result); // Ticks is finest granularity } + [Fact] + public void DateTimeTicks_Distance_CalculatesTicksCorrectly() + { + // Arrange + var domain = new DateTimeTicksFixedStepDomain(); + var start = new System.DateTime(2024, 1, 1, 10, 0, 0, 0).AddTicks(100); + var end = new System.DateTime(2024, 1, 1, 10, 0, 0, 0).AddTicks(600); + + // Act + var result = domain.Distance(start, end); + + // Assert + Assert.Equal(500, result); + } + + [Fact] + public void DateTimeTicks_Floor_ReturnsUnchanged() + { + // Arrange + var domain = new DateTimeTicksFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 0, 0, 0).AddTicks(123); + + // Act + var result = domain.Floor(value); + + // Assert + Assert.Equal(value, result); // Ticks is finest granularity + } + [Fact] public void DateTimeTicks_Subtract_SubtractsTicksCorrectly() { @@ -201,4 +331,46 @@ public void DateTimeTicks_Subtract_SubtractsTicksCorrectly() // Assert Assert.Equal(new System.DateTime(2024, 1, 1, 10, 0, 0, 0).AddTicks(750), result); } -} + + [Fact] + public void DateTimeHour_Subtract_SubtractsHoursCorrectly() + { + // Arrange + var domain = new DateTimeHourFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 15, 30, 0); + + // Act + var result = domain.Subtract(value, 5); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 30, 0), result); + } + + [Fact] + public void DateTimeMinute_Subtract_SubtractsMinutesCorrectly() + { + // Arrange + var domain = new DateTimeMinuteFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 45, 0); + + // Act + var result = domain.Subtract(value, 15); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 30, 0), result); + } + + [Fact] + public void DateTimeSecond_Subtract_SubtractsSecondsCorrectly() + { + // Arrange + var domain = new DateTimeSecondFixedStepDomain(); + var value = new System.DateTime(2024, 1, 1, 10, 30, 45); + + // Act + var result = domain.Subtract(value, 30); + + // Assert + Assert.Equal(new System.DateTime(2024, 1, 1, 10, 30, 15), result); + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Domain.Default.Tests/Intervals.NET.Domain.Default.Tests.csproj b/tests/Intervals.NET.Domain.Default.Tests/Intervals.NET.Domain.Default.Tests.csproj index 287de1d..cb6efcb 100644 --- a/tests/Intervals.NET.Domain.Default.Tests/Intervals.NET.Domain.Default.Tests.csproj +++ b/tests/Intervals.NET.Domain.Default.Tests/Intervals.NET.Domain.Default.Tests.csproj @@ -11,7 +11,7 @@ - + @@ -24,4 +24,4 @@ - + \ No newline at end of file diff --git a/tests/Intervals.NET.Domain.Default.Tests/Numeric/NumericUntestedDomainsTests.cs b/tests/Intervals.NET.Domain.Default.Tests/Numeric/NumericUntestedDomainsTests.cs index e3fed5c..b4dca11 100644 --- a/tests/Intervals.NET.Domain.Default.Tests/Numeric/NumericUntestedDomainsTests.cs +++ b/tests/Intervals.NET.Domain.Default.Tests/Numeric/NumericUntestedDomainsTests.cs @@ -17,6 +17,19 @@ public void Decimal_Add_WorksCorrectly() Assert.Equal(15.5m, result); } + [Fact] + public void Decimal_Add_WithNegativeOffset_WorksCorrectly() + { + // Arrange + var domain = new DecimalFixedStepDomain(); + + // Act + var result = domain.Add(10.5m, -3); + + // Assert + Assert.Equal(7.5m, result); + } + [Fact] public void Decimal_Ceiling_RoundsUpCorrectly() { @@ -30,6 +43,19 @@ public void Decimal_Ceiling_RoundsUpCorrectly() Assert.Equal(11.0m, result); } + [Fact] + public void Decimal_Ceiling_OnExactValue_ReturnsUnchanged() + { + // Arrange + var domain = new DecimalFixedStepDomain(); + + // Act + var result = domain.Ceiling(10.0m); + + // Assert + Assert.Equal(10.0m, result); + } + [Fact] public void Decimal_Distance_CalculatesCorrectly() { @@ -43,6 +69,19 @@ public void Decimal_Distance_CalculatesCorrectly() Assert.Equal(10, result); } + [Fact] + public void Decimal_Distance_WithNegativeRange_ReturnsNegative() + { + // Arrange + var domain = new DecimalFixedStepDomain(); + + // Act + var result = domain.Distance(20.5m, 10.5m); + + // Assert + Assert.Equal(-10, result); + } + [Fact] public void Double_Add_WorksCorrectly() { @@ -56,6 +95,19 @@ public void Double_Add_WorksCorrectly() Assert.Equal(15.5, result); } + [Fact] + public void Double_Add_WithNegativeOffset_WorksCorrectly() + { + // Arrange + var domain = new DoubleFixedStepDomain(); + + // Act + var result = domain.Add(10.5, -3); + + // Assert + Assert.Equal(7.5, result); + } + [Fact] public void Double_Ceiling_RoundsUpCorrectly() { @@ -69,6 +121,19 @@ public void Double_Ceiling_RoundsUpCorrectly() Assert.Equal(11.0, result); } + [Fact] + public void Double_Ceiling_OnExactValue_ReturnsUnchanged() + { + // Arrange + var domain = new DoubleFixedStepDomain(); + + // Act + var result = domain.Ceiling(10.0); + + // Assert + Assert.Equal(10.0, result); + } + [Fact] public void Double_Distance_CalculatesCorrectly() { @@ -82,6 +147,19 @@ public void Double_Distance_CalculatesCorrectly() Assert.Equal(10, result); } + [Fact] + public void Double_Distance_WithNegativeRange_ReturnsNegative() + { + // Arrange + var domain = new DoubleFixedStepDomain(); + + // Act + var result = domain.Distance(20.5, 10.5); + + // Assert + Assert.Equal(-10, result); + } + [Fact] public void Long_Add_WorksCorrectly() { @@ -95,6 +173,19 @@ public void Long_Add_WorksCorrectly() Assert.Equal(150L, result); } + [Fact] + public void Long_Add_WithNegativeOffset_WorksCorrectly() + { + // Arrange + var domain = new LongFixedStepDomain(); + + // Act + var result = domain.Add(100L, -30); + + // Assert + Assert.Equal(70L, result); + } + [Fact] public void Long_Ceiling_ReturnsUnchanged() { @@ -120,4 +211,95 @@ public void Long_Distance_CalculatesCorrectly() // Assert Assert.Equal(150L, result); } -} + + [Fact] + public void Long_Distance_WithNegativeRange_ReturnsNegative() + { + // Arrange + var domain = new LongFixedStepDomain(); + + // Act + var result = domain.Distance(250L, 100L); + + // Assert + Assert.Equal(-150L, result); + } + + [Fact] + public void Decimal_Floor_RoundsDownCorrectly() + { + // Arrange + var domain = new DecimalFixedStepDomain(); + + // Act + var result = domain.Floor(10.8m); + + // Assert + Assert.Equal(10.0m, result); + } + + [Fact] + public void Decimal_Subtract_SubtractsCorrectly() + { + // Arrange + var domain = new DecimalFixedStepDomain(); + + // Act + var result = domain.Subtract(20.5m, 5); + + // Assert + Assert.Equal(15.5m, result); + } + + [Fact] + public void Double_Floor_RoundsDownCorrectly() + { + // Arrange + var domain = new DoubleFixedStepDomain(); + + // Act + var result = domain.Floor(10.8); + + // Assert + Assert.Equal(10.0, result); + } + + [Fact] + public void Double_Subtract_SubtractsCorrectly() + { + // Arrange + var domain = new DoubleFixedStepDomain(); + + // Act + var result = domain.Subtract(20.5, 5); + + // Assert + Assert.Equal(15.5, result); + } + + [Fact] + public void Long_Floor_ReturnsUnchanged() + { + // Arrange + var domain = new LongFixedStepDomain(); + + // Act + var result = domain.Floor(42L); + + // Assert + Assert.Equal(42L, result); + } + + [Fact] + public void Long_Subtract_SubtractsCorrectly() + { + // Arrange + var domain = new LongFixedStepDomain(); + + // Act + var result = domain.Subtract(250L, 100); + + // Assert + Assert.Equal(150L, result); + } +} \ No newline at end of file diff --git a/tests/Intervals.NET.Domain.Extensions.Tests/Fixed/RangeDomainExtensionsTests.cs b/tests/Intervals.NET.Domain.Extensions.Tests/Fixed/RangeDomainExtensionsTests.cs index 7d77480..55d8d30 100644 --- a/tests/Intervals.NET.Domain.Extensions.Tests/Fixed/RangeDomainExtensionsTests.cs +++ b/tests/Intervals.NET.Domain.Extensions.Tests/Fixed/RangeDomainExtensionsTests.cs @@ -33,7 +33,7 @@ public void Span_IntegerClosedRange_ReturnsCorrectDistance() public void Span_IntegerOpenRange_ReturnsCorrectDistance() { // Arrange - var range = RangeFactory.Open(10, 20); + var range = RangeFactory.Open(10, 20); var domain = new IntegerFixedStepDomain(); // Act @@ -49,7 +49,7 @@ public void Span_IntegerOpenRange_ReturnsCorrectDistance() public void Span_IntegerClosedOpenRange_ReturnsCorrectDistance() { // Arrange - var range = RangeFactory.ClosedOpen(10, 20); + var range = RangeFactory.ClosedOpen(10, 20); var domain = new IntegerFixedStepDomain(); // Act @@ -219,7 +219,7 @@ public void Span_SingleStepRange_BothBoundariesBetweenSteps_ReturnsZero() // Arrange - DateTime values in the middle of a day (not aligned to day boundaries) var start = new DateTime(2024, 1, 1, 10, 0, 0); var end = new DateTime(2024, 1, 1, 15, 0, 0); - var range = RangeFactory.Open(start, end); + var range = RangeFactory.Open(start, end); var domain = new DateTimeDayFixedStepDomain(); // Act @@ -252,7 +252,7 @@ public void Span_SingleStepRange_StartOnBoundary_EndExclusive_ReturnsOne() public void Span_EmptyRange_ExclusiveBoundariesSameValue_ReturnsZero() { // Arrange - exclusive boundaries on the same integer - var range = RangeFactory.Open(10, 11); + var range = RangeFactory.Open(10, 11); var domain = new IntegerFixedStepDomain(); // Act @@ -270,7 +270,7 @@ public void Span_InvertedRange_StartGreaterThanEnd_ReturnsZero() // Arrange - test a range that becomes empty after floor adjustments var start2 = new DateTime(2024, 1, 1, 23, 0, 0); // Jan 1, 11 PM var end2 = new DateTime(2024, 1, 2, 1, 0, 0); // Jan 2, 1 AM - var range = RangeFactory.Open(start2, end2); + var range = RangeFactory.Open(start2, end2); var domain = new DateTimeDayFixedStepDomain(); // Act @@ -308,7 +308,7 @@ public void Span_EndExclusiveOnBoundary_ExcludesThatStep() // Arrange var start = new DateTime(2024, 1, 1, 0, 0, 0); var end = new DateTime(2024, 1, 3, 0, 0, 0); - var range = RangeFactory.ClosedOpen(start, end); + var range = RangeFactory.ClosedOpen(start, end); var domain = new DateTimeDayFixedStepDomain(); // Act diff --git a/tests/Intervals.NET.Domain.Extensions.Tests/Intervals.NET.Domain.Extensions.Tests.csproj b/tests/Intervals.NET.Domain.Extensions.Tests/Intervals.NET.Domain.Extensions.Tests.csproj index 2038e33..ef49148 100644 --- a/tests/Intervals.NET.Domain.Extensions.Tests/Intervals.NET.Domain.Extensions.Tests.csproj +++ b/tests/Intervals.NET.Domain.Extensions.Tests/Intervals.NET.Domain.Extensions.Tests.csproj @@ -11,7 +11,7 @@ - + @@ -25,4 +25,4 @@ - + \ No newline at end of file diff --git a/tests/Intervals.NET.Domain.Extensions.Tests/Variable/RangeDomainExtensionsTests.cs b/tests/Intervals.NET.Domain.Extensions.Tests/Variable/RangeDomainExtensionsTests.cs index a66a9b3..a2d6701 100644 --- a/tests/Intervals.NET.Domain.Extensions.Tests/Variable/RangeDomainExtensionsTests.cs +++ b/tests/Intervals.NET.Domain.Extensions.Tests/Variable/RangeDomainExtensionsTests.cs @@ -345,7 +345,7 @@ public void Span_SingleDayRange_BothBoundariesBetweenBusinessDays_ReturnsZero() // Arrange - both times within the same business day, exclusive boundaries var start = new DateTime(2024, 1, 1, 10, 0, 0); // Monday 10 AM var end = new DateTime(2024, 1, 1, 15, 0, 0); // Monday 3 PM - var range = RangeFactory.Open(start, end); + var range = RangeFactory.Open(start, end); var domain = new StandardDateTimeBusinessDaysVariableStepDomain(); // Act @@ -380,7 +380,7 @@ public void Span_EmptyRange_ExclusiveBoundariesConsecutiveBusinessDays_ReturnsZe // Arrange - exclusive boundaries on consecutive business days var start = new DateTime(2024, 1, 1); // Monday var end = new DateTime(2024, 1, 2); // Tuesday - var range = RangeFactory.Open(start, end); + var range = RangeFactory.Open(start, end); var domain = new StandardDateTimeBusinessDaysVariableStepDomain(); // Act @@ -398,7 +398,7 @@ public void Span_InvertedRange_StartGreaterThanEnd_ReturnsZero() // Arrange - valid range that becomes empty after floor adjustments with exclusive boundaries var start = new DateTime(2024, 1, 1, 23, 0, 0); // Monday, 11 PM var end = new DateTime(2024, 1, 2, 1, 0, 0); // Tuesday, 1 AM - var range = RangeFactory.Open(start, end); + var range = RangeFactory.Open(start, end); var domain = new StandardDateTimeBusinessDaysVariableStepDomain(); // Act @@ -436,7 +436,7 @@ public void Span_EndExclusiveOnBusinessDayBoundary_ExcludesThatDay() // Arrange - Monday to Friday, end exclusive var start = new DateTime(2024, 1, 1, 0, 0, 0); // Monday midnight var end = new DateTime(2024, 1, 5, 0, 0, 0); // Friday midnight - var range = RangeFactory.ClosedOpen(start, end); + var range = RangeFactory.ClosedOpen(start, end); var domain = new StandardDateTimeBusinessDaysVariableStepDomain(); // Act diff --git a/tests/Intervals.NET.Tests/Intervals.NET.Tests.csproj b/tests/Intervals.NET.Tests/Intervals.NET.Tests.csproj index d96e092..100c7bb 100644 --- a/tests/Intervals.NET.Tests/Intervals.NET.Tests.csproj +++ b/tests/Intervals.NET.Tests/Intervals.NET.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Intervals.NET.Tests/RangeExtensionsTests.cs b/tests/Intervals.NET.Tests/RangeExtensionsTests.cs index f730d96..4385093 100644 --- a/tests/Intervals.NET.Tests/RangeExtensionsTests.cs +++ b/tests/Intervals.NET.Tests/RangeExtensionsTests.cs @@ -53,7 +53,7 @@ public void Overlaps_WithAdjacentRanges_BothInclusive_ReturnsTrue() public void Overlaps_WithAdjacentRanges_OneExclusive_ReturnsFalse() { // Arrange - var range1 = RangeFactory.ClosedOpen(10, 20); + var range1 = RangeFactory.ClosedOpen(10, 20); var range2 = RangeFactory.Closed(20, 30); // Act @@ -127,7 +127,7 @@ public void Contains_Value_InsideClosedRange_ReturnsTrue() public void Contains_Value_InsideOpenRange_ReturnsTrue() { // Arrange - var range = RangeFactory.Open(10, 20); + var range = RangeFactory.Open(10, 20); // Act & Assert Assert.True(range.Contains(15)); @@ -139,7 +139,7 @@ public void Contains_Value_InsideOpenRange_ReturnsTrue() public void Contains_Value_InsideHalfOpenRange_ReturnsTrue() { // Arrange - var range = RangeFactory.ClosedOpen(10, 20); + var range = RangeFactory.ClosedOpen(10, 20); // Act & Assert Assert.True(range.Contains(15)); @@ -229,7 +229,7 @@ public void Contains_Value_WithDateTimeType_WorksCorrectly() // Arrange var start = new DateTime(2024, 1, 1); var end = new DateTime(2024, 12, 31); - var range = RangeFactory.ClosedOpen(start, end); + var range = RangeFactory.ClosedOpen(start, end); // Act & Assert Assert.True(range.Contains(new DateTime(2024, 1, 1))); // Included start @@ -298,7 +298,7 @@ public void Contains_WithIdenticalRanges_ReturnsTrue() public void Contains_WithSameBoundaries_InnerInclusive_OuterExclusive_ReturnsFalse() { // Arrange - var outer = RangeFactory.Open(10, 20); + var outer = RangeFactory.Open(10, 20); var inner = RangeFactory.Closed(10, 20); // Act @@ -313,7 +313,7 @@ public void Contains_WithSameBoundaries_OuterInclusive_InnerExclusive_ReturnsTru { // Arrange var outer = RangeFactory.Closed(10, 20); - var inner = RangeFactory.Open(10, 20); + var inner = RangeFactory.Open(10, 20); // Act var result = outer.Contains(inner); @@ -420,7 +420,7 @@ public void Intersect_WithDifferentInclusivity_UsesMoreRestrictive() { // Arrange var range1 = RangeFactory.Closed(10, 20); - var range2 = RangeFactory.Open(10, 20); + var range2 = RangeFactory.Open(10, 20); // Act var result = range1.Intersect(range2); @@ -501,7 +501,7 @@ public void Union_WithNonOverlappingNonAdjacentRanges_ReturnsNull() public void Union_WithAdjacentRanges_ReturnsUnion() { // Arrange - var range1 = RangeFactory.ClosedOpen(10, 20); + var range1 = RangeFactory.ClosedOpen(10, 20); var range2 = RangeFactory.Closed(20, 30); // Act @@ -533,7 +533,7 @@ public void Union_WithDifferentInclusivity_UsesMorePermissive() { // Arrange var range1 = RangeFactory.Closed(10, 20); - var range2 = RangeFactory.Open(10, 20); + var range2 = RangeFactory.Open(10, 20); // Act var result = range1.Union(range2); @@ -568,7 +568,7 @@ public void Union_WithInfiniteRanges_ReturnsInfiniteUnion() public void IsAdjacent_WithAdjacentRanges_OneInclusive_ReturnsTrue() { // Arrange - var range1 = RangeFactory.ClosedOpen(10, 20); + var range1 = RangeFactory.ClosedOpen(10, 20); var range2 = RangeFactory.Closed(20, 30); // Act @@ -596,8 +596,8 @@ public void IsAdjacent_WithAdjacentRanges_BothInclusive_ReturnsFalse() public void IsAdjacent_WithAdjacentRanges_BothExclusive_ReturnsFalse() { // Arrange - var range1 = RangeFactory.Open(10, 20); - var range2 = RangeFactory.Open(20, 30); + var range1 = RangeFactory.Open(10, 20); + var range2 = RangeFactory.Open(20, 30); // Act var result = range1.IsAdjacent(range2); @@ -625,7 +625,7 @@ public void IsAdjacent_WithReverseOrder_ReturnsTrue() { // Arrange var range1 = RangeFactory.Closed(20, 30); - var range2 = RangeFactory.ClosedOpen(10, 20); + var range2 = RangeFactory.ClosedOpen(10, 20); // Act var result = range1.IsAdjacent(range2); @@ -684,7 +684,7 @@ public void IsBefore_WithOverlappingRanges_ReturnsFalse() public void IsBefore_WithAdjacentRanges_OneExclusive_ReturnsTrue() { // Arrange - var range1 = RangeFactory.ClosedOpen(10, 20); + var range1 = RangeFactory.ClosedOpen(10, 20); var range2 = RangeFactory.Closed(20, 30); // Act @@ -1164,7 +1164,7 @@ public void Except_WithSinglePointAtStart_PreservesSinglePoint() { // Arrange var range = RangeFactory.Closed(10, 20); - var other = RangeFactory.Open(10, 20); + var other = RangeFactory.Open(10, 20); // Act var result = range.Except(other).ToList(); @@ -1283,7 +1283,7 @@ public void Except_EqualEndBoundaries_RangeInclusiveOtherExclusive_PreservesSing { // Arrange - End boundaries equal, range inclusive, other exclusive var range = RangeFactory.Closed(10, 50); // [10, 50] - var other = RangeFactory.ClosedOpen(30, 50); // [30, 50) + var other = RangeFactory.ClosedOpen(30, 50); // [30, 50) // Act var result = range.Except(other).ToList(); @@ -1418,8 +1418,8 @@ public void IsEmpty_WithSinglePoint_ExclusiveBoundaries_ReturnsTrue() public void Intersect_WithTouchingRanges_ExclusiveBoundaries_ReturnsNull() { // Arrange - [10,20) and [20,30) - touching at 20 but exclusive - var range1 = RangeFactory.ClosedOpen(new RangeValue(10), new RangeValue(20)); - var range2 = RangeFactory.ClosedOpen(new RangeValue(20), new RangeValue(30)); + var range1 = RangeFactory.ClosedOpen(new RangeValue(10), new RangeValue(20)); + var range2 = RangeFactory.ClosedOpen(new RangeValue(20), new RangeValue(30)); // Act - Tests edge case in Intersect var result = range1.Intersect(range2); @@ -1432,8 +1432,8 @@ public void Intersect_WithTouchingRanges_ExclusiveBoundaries_ReturnsNull() public void Overlaps_WithSameBoundaries_DifferentInclusivity_DetectsCorrectly() { // Arrange - Same values, one inclusive one exclusive - var range1 = RangeFactory.Closed(new RangeValue(10), new RangeValue(20)); - var range2 = RangeFactory.Open(new RangeValue(10), new RangeValue(20)); + var range1 = RangeFactory.Closed(new RangeValue(10), new RangeValue(20)); + var range2 = RangeFactory.Open(new RangeValue(10), new RangeValue(20)); // Act - Tests inclusivity check in Overlaps var result = range1.Overlaps(range2); @@ -1446,8 +1446,8 @@ public void Overlaps_WithSameBoundaries_DifferentInclusivity_DetectsCorrectly() public void Union_WithGap_BothExclusive_ReturnsNull() { // Arrange - Gap between ranges [10,20) and (25,30] - var range1 = RangeFactory.ClosedOpen(new RangeValue(10), new RangeValue(20)); - var range2 = RangeFactory.OpenClosed(new RangeValue(25), new RangeValue(30)); + var range1 = RangeFactory.ClosedOpen(new RangeValue(10), new RangeValue(20)); + var range2 = RangeFactory.OpenClosed(new RangeValue(25), new RangeValue(30)); // Act - Tests gap detection in Union var result = range1.Union(range2); @@ -1460,8 +1460,8 @@ public void Union_WithGap_BothExclusive_ReturnsNull() public void BitwiseOrOperator_PerformsUnion_SameAsUnionMethod() { // Arrange - var range1 = RangeFactory.Closed(new RangeValue(10), new RangeValue(20)); - var range2 = RangeFactory.Closed(new RangeValue(15), new RangeValue(25)); + var range1 = RangeFactory.Closed(new RangeValue(10), new RangeValue(20)); + var range2 = RangeFactory.Closed(new RangeValue(15), new RangeValue(25)); // Act - Tests op_BitwiseOr (uncovered operator) var resultOperator = range1 | range2; diff --git a/tests/Intervals.NET.Tests/RangeFactoryInterpolationTests.cs b/tests/Intervals.NET.Tests/RangeFactoryInterpolationTests.cs index bbf1bb9..d238cfe 100644 --- a/tests/Intervals.NET.Tests/RangeFactoryInterpolationTests.cs +++ b/tests/Intervals.NET.Tests/RangeFactoryInterpolationTests.cs @@ -321,7 +321,7 @@ public void FromString_Interpolated_Result_EqualsFactoryMethod() // Act var fromInterpolated = RangeFactory.FromString($"[{start}, {end})"); - var fromFactory = RangeFactory.ClosedOpen(start, end); + var fromFactory = RangeFactory.ClosedOpen(start, end); // Assert Assert.Equal(fromFactory, fromInterpolated); diff --git a/tests/Intervals.NET.Tests/RangeFactoryTests.cs b/tests/Intervals.NET.Tests/RangeFactoryTests.cs index 7afc0e3..e63b5ff 100644 --- a/tests/Intervals.NET.Tests/RangeFactoryTests.cs +++ b/tests/Intervals.NET.Tests/RangeFactoryTests.cs @@ -150,7 +150,7 @@ public void Open_WithFiniteValues_CreatesOpenRange() public void Open_WithImplicitConversion_WorksCorrectly() { // Arrange & Act - var range = Range.Open(5, 15); + var range = Range.Open(5, 15); // Assert Assert.Equal(5, range.Start.Value); @@ -163,7 +163,7 @@ public void Open_WithImplicitConversion_WorksCorrectly() public void Open_WithNegativeInfinityStart_CreatesUnboundedStartRange() { // Arrange & Act - var range = Range.Open(RangeValue.NegativeInfinity, 100); + var range = Range.Open(RangeValue.NegativeInfinity, 100); // Assert Assert.True(range.Start.IsNegativeInfinity); @@ -176,7 +176,7 @@ public void Open_WithNegativeInfinityStart_CreatesUnboundedStartRange() public void Open_WithPositiveInfinityEnd_CreatesUnboundedEndRange() { // Arrange & Act - var range = Range.Open(0, RangeValue.PositiveInfinity); + var range = Range.Open(0, RangeValue.PositiveInfinity); // Assert Assert.Equal(0, range.Start.Value); @@ -202,7 +202,7 @@ public void Open_WithBothInfinities_CreatesFullyUnboundedRange() public void Open_ToString_ReturnsCorrectFormat() { // Arrange & Act - var range = Range.Open(10, 20); + var range = Range.Open(10, 20); var result = range.ToString(); // Assert @@ -260,7 +260,7 @@ public void OpenClosed_WithImplicitConversion_WorksCorrectly() public void OpenClosed_WithNegativeInfinityStart_CreatesUnboundedStartRange() { // Arrange & Act - var range = Range.OpenClosed(RangeValue.NegativeInfinity, 100); + var range = Range.OpenClosed(RangeValue.NegativeInfinity, 100); // Assert Assert.True(range.Start.IsNegativeInfinity); @@ -273,7 +273,7 @@ public void OpenClosed_WithNegativeInfinityStart_CreatesUnboundedStartRange() public void OpenClosed_WithPositiveInfinityEnd_CreatesUnboundedEndRange() { // Arrange & Act - var range = Range.OpenClosed(0, RangeValue.PositiveInfinity); + var range = Range.OpenClosed(0, RangeValue.PositiveInfinity); // Assert Assert.Equal(0, range.Start.Value); @@ -331,7 +331,7 @@ public void ClosedOpen_WithFiniteValues_CreatesClosedOpenRange() public void ClosedOpen_WithImplicitConversion_WorksCorrectly() { // Arrange & Act - var range = Range.ClosedOpen(5, 15); + var range = Range.ClosedOpen(5, 15); // Assert Assert.Equal(5, range.Start.Value); @@ -344,7 +344,7 @@ public void ClosedOpen_WithImplicitConversion_WorksCorrectly() public void ClosedOpen_WithNegativeInfinityStart_CreatesUnboundedStartRange() { // Arrange & Act - var range = Range.ClosedOpen(RangeValue.NegativeInfinity, 100); + var range = Range.ClosedOpen(RangeValue.NegativeInfinity, 100); // Assert Assert.True(range.Start.IsNegativeInfinity); @@ -357,7 +357,7 @@ public void ClosedOpen_WithNegativeInfinityStart_CreatesUnboundedStartRange() public void ClosedOpen_WithPositiveInfinityEnd_CreatesUnboundedEndRange() { // Arrange & Act - var range = Range.ClosedOpen(0, RangeValue.PositiveInfinity); + var range = Range.ClosedOpen(0, RangeValue.PositiveInfinity); // Assert Assert.Equal(0, range.Start.Value); @@ -370,7 +370,7 @@ public void ClosedOpen_WithPositiveInfinityEnd_CreatesUnboundedEndRange() public void ClosedOpen_ToString_ReturnsCorrectFormat() { // Arrange & Act - var range = Range.ClosedOpen(10, 20); + var range = Range.ClosedOpen(10, 20); var result = range.ToString(); // Assert @@ -398,7 +398,7 @@ public void ClosedOpen_WithDateTimeType_WorksCorrectly() var endDate = new DateTime(2020, 12, 31); // Act - var range = Range.ClosedOpen(startDate, endDate); + var range = Range.ClosedOpen(startDate, endDate); // Assert Assert.Equal(startDate, range.Start.Value); @@ -461,7 +461,7 @@ public void FromString_WithInvalidInput_ThrowsFormatException() public void FromString_RoundTrip_PreservesRange() { // Arrange - var original = Range.ClosedOpen(10, 20); + var original = Range.ClosedOpen(10, 20); var stringRepresentation = original.ToString(); // Act @@ -492,7 +492,7 @@ public void FromString_RoundTrip_ClosedRange_PreservesRange() public void FromString_RoundTrip_OpenRange_PreservesRange() { // Arrange - var original = Range.Open(5, 15); + var original = Range.Open(5, 15); var stringRepresentation = original.ToString(); // Act @@ -520,7 +520,7 @@ public void FromString_RoundTrip_OpenClosedRange_PreservesRange() public void FromString_RoundTrip_WithDoubles_PreservesRange() { // Arrange - var original = Range.ClosedOpen(1.5, 9.5); + var original = Range.ClosedOpen(1.5, 9.5); var stringRepresentation = original.ToString(); // Act @@ -537,7 +537,7 @@ public void FromString_RoundTrip_WithDoubles_PreservesRange() public void FromString_RoundTrip_WithNegativeInfinity_PreservesRange() { // Arrange - var original = Range.Closed(RangeValue.NegativeInfinity, 100); + var original = Range.Closed(RangeValue.NegativeInfinity, 100); var stringRepresentation = original.ToString(); // Act @@ -554,7 +554,7 @@ public void FromString_RoundTrip_WithNegativeInfinity_PreservesRange() public void FromString_RoundTrip_WithPositiveInfinity_PreservesRange() { // Arrange - var original = Range.Closed(0, RangeValue.PositiveInfinity); + var original = Range.Closed(0, RangeValue.PositiveInfinity); var stringRepresentation = original.ToString(); // Act @@ -602,7 +602,7 @@ public void FromString_RoundTrip_WithNegativeNumbers_PreservesRange() public void FromString_InfinitySymbol_RoundTrip_WithNegativeInfinity_PreservesRange() { // Arrange - ToString outputs "-โˆž" - var original = Range.Closed(RangeValue.NegativeInfinity, 100); + var original = Range.Closed(RangeValue.NegativeInfinity, 100); var stringRepresentation = original.ToString(); // Verify ToString produces infinity symbol @@ -622,7 +622,7 @@ public void FromString_InfinitySymbol_RoundTrip_WithNegativeInfinity_PreservesRa public void FromString_InfinitySymbol_RoundTrip_WithPositiveInfinity_PreservesRange() { // Arrange - ToString outputs "โˆž" - var original = Range.Open(0, RangeValue.PositiveInfinity); + var original = Range.Open(0, RangeValue.PositiveInfinity); var stringRepresentation = original.ToString(); // Verify ToString produces infinity symbol @@ -833,9 +833,9 @@ public void FactoryMethods_CreateDifferentRangeTypes_WithSameValues() // Act var closed = Range.Closed(start, end); - var open = Range.Open(start, end); + var open = Range.Open(start, end); var openClosed = Range.OpenClosed(start, end); - var closedOpen = Range.ClosedOpen(start, end); + var closedOpen = Range.ClosedOpen(start, end); // Assert - All have same values but different inclusivity Assert.Equal(10, closed.Start.Value); @@ -910,7 +910,7 @@ public void Create_EquivalentToSpecificFactoryMethods() var factoryClosed = Range.Closed(start, end); var createOpen = Range.Create(start, end, false, false); - var factoryOpen = Range.Open(start, end); + var factoryOpen = Range.Open(start, end); // Assert Assert.Equal(factoryClosed, createClosed); @@ -978,6 +978,122 @@ public void Create_PreservesInclusivitySettings() Assert.True(range2.IsEndInclusive); } + [Fact] + public void Create_WithPositiveInfinityStartAndFiniteEnd_ThrowsArgumentException() + { + // Arrange + var start = RangeValue.PositiveInfinity; + var end = new RangeValue(100); + + // Act + var exception = Record.Exception(() => Range.Create(start, end, true, true)); + + // Assert + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Start value cannot be greater than end value", exception.Message); + } + + [Fact] + public void Create_WithPositiveInfinityStartAndNegativeInfinityEnd_ThrowsArgumentException() + { + // Arrange + var start = RangeValue.PositiveInfinity; + var end = RangeValue.NegativeInfinity; + + // Act + var exception = Record.Exception(() => Range.Create(start, end, false, false)); + + // Assert + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Start value cannot be greater than end value", exception.Message); + } + + [Fact] + public void Create_WithFiniteStartAndNegativeInfinityEnd_ThrowsArgumentException() + { + // Arrange + var start = new RangeValue(0); + var end = RangeValue.NegativeInfinity; + + // Act + var exception = Record.Exception(() => Range.Create(start, end, true, true)); + + // Assert + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Start value cannot be greater than end value", exception.Message); + } + + [Fact] + public void TryCreate_WithPositiveInfinityStartAndFiniteEnd_ReturnsFalse() + { + // Arrange + var start = RangeValue.PositiveInfinity; + var end = new RangeValue(100); + + // Act + var result = Range.TryCreate(start, end, true, true, out var range, out var message); + + // Assert + Assert.False(result); + Assert.Equal(default(Range), range); + Assert.NotNull(message); + Assert.Contains("Start value cannot be greater than end value", message); + } + + [Fact] + public void TryCreate_WithPositiveInfinityStartAndNegativeInfinityEnd_ReturnsFalse() + { + // Arrange + var start = RangeValue.PositiveInfinity; + var end = RangeValue.NegativeInfinity; + + // Act + var result = Range.TryCreate(start, end, false, false, out var range, out var message); + + // Assert + Assert.False(result); + Assert.Equal(default(Range), range); + Assert.NotNull(message); + Assert.Contains("Start value cannot be greater than end value", message); + } + + [Fact] + public void TryCreate_WithFiniteStartAndNegativeInfinityEnd_ReturnsFalse() + { + // Arrange + var start = new RangeValue(0); + var end = RangeValue.NegativeInfinity; + + // Act + var result = Range.TryCreate(start, end, true, true, out var range, out var message); + + // Assert + Assert.False(result); + Assert.Equal(default(Range), range); + Assert.NotNull(message); + Assert.Contains("Start value cannot be greater than end value", message); + } + + [Fact] + public void TryCreate_WithValidInfinityBounds_ReturnsTrue() + { + // Arrange + var start = RangeValue.NegativeInfinity; + var end = RangeValue.PositiveInfinity; + + // Act + var result = Range.TryCreate(start, end, false, false, out var range, out var message); + + // Assert + Assert.True(result); + Assert.Null(message); + Assert.True(range.Start.IsNegativeInfinity); + Assert.True(range.End.IsPositiveInfinity); + } + #endregion #region Edge Cases Tests diff --git a/tests/Intervals.NET.Tests/RangeStringParserTests.cs b/tests/Intervals.NET.Tests/RangeStringParserTests.cs index 82f58a5..7d8ac60 100644 --- a/tests/Intervals.NET.Tests/RangeStringParserTests.cs +++ b/tests/Intervals.NET.Tests/RangeStringParserTests.cs @@ -349,7 +349,7 @@ public void Parse_WithCustomFormatProvider_ParsesCorrectly() { // Arrange var input = "[1,5, 9,5]"; // European decimal separator (comma) - var culture = new System.Globalization.CultureInfo("de-DE"); + var culture = new CultureInfo("de-DE"); // Act var range = RangeStringParser.Parse(input, culture); @@ -364,7 +364,7 @@ public void Parse_WithInvariantCulture_ParsesCorrectly() { // Arrange var input = "[1.5, 9.5]"; - var culture = System.Globalization.CultureInfo.InvariantCulture; + var culture = CultureInfo.InvariantCulture; // Act var range = RangeStringParser.Parse(input, culture); @@ -543,7 +543,7 @@ public void TryParse_WithCustomFormatProvider_ReturnsTrue() { // Arrange var input = "[1,5, 9,5]"; - var culture = new System.Globalization.CultureInfo("de-DE"); + var culture = new CultureInfo("de-DE"); // Act var result = RangeStringParser.TryParse(input, out var range, culture); @@ -689,7 +689,7 @@ public void Parse_WithMultipleCommasInDecimalSeparatorCulture_ParsesCorrectly() { // Arrange - German culture uses comma as decimal separator var input = "[1,5, 2,5]"; // Two decimal numbers with commas - var culture = new System.Globalization.CultureInfo("de-DE"); + var culture = new CultureInfo("de-DE"); // Act var range = RangeStringParser.Parse(input, culture); @@ -704,7 +704,7 @@ public void Parse_WithThreeCommasInDecimalCulture_ParsesCorrectly() { // Arrange - Complex case with 3 commas total var input = "[1,23, 4,56]"; // Two decimals in German format - var culture = new System.Globalization.CultureInfo("de-DE"); + var culture = new CultureInfo("de-DE"); // Act var range = RangeStringParser.Parse(input, culture); diff --git a/tests/Intervals.NET.Tests/RangeStructTests.cs b/tests/Intervals.NET.Tests/RangeStructTests.cs index 8432ffe..238fc29 100644 --- a/tests/Intervals.NET.Tests/RangeStructTests.cs +++ b/tests/Intervals.NET.Tests/RangeStructTests.cs @@ -194,18 +194,19 @@ public void Constructor_WithFiniteStartAndInfinityEnd_DoesNotValidateOrder() } [Fact] - public void Constructor_WithPositiveInfinityStartAndNegativeInfinityEnd_DoesNotValidate() + public void Constructor_WithPositiveInfinityStartAndNegativeInfinityEnd_ThrowsArgumentException() { - // Arrange - This is logically wrong but not validated when infinities are involved + // Arrange - This is logically invalid and should now be validated var start = RangeValue.PositiveInfinity; var end = RangeValue.NegativeInfinity; // Act - var range = new Range(start, end); + var exception = Record.Exception(() => new Range(start, end)); - // Assert - Constructor doesn't validate infinity order - Assert.True(range.Start.IsPositiveInfinity); - Assert.True(range.End.IsNegativeInfinity); + // Assert - Constructor now validates infinity order + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Start value cannot be greater than end value", exception.Message); } #endregion