diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index ebafe1cf8..e5f9f3baf 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -21,7 +21,7 @@ jobs:
lfs: true
- name: Setup .NET (With cache)
- uses: actions/setup-dotnet@v5.0.1
+ uses: actions/setup-dotnet@v5.3.0
with:
dotnet-version: |
6.0.x
@@ -37,12 +37,20 @@ jobs:
**/global.json
**/nuget.config
- - name: NBGV
+ - name: Install (or update) nbgv tool
+ run: dotnet tool update --global nbgv
+
+ - name: Set NBGV cloud variables
+ run: nbgv cloud -a
+
+ - name: Expose NBGV version as step outputs
id: nbgv
- uses: dotnet/nbgv@v0.5.1
- with:
- setAllVars: true
-
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ "SemVer2=$env:NBGV_SemVer2" >> $env:GITHUB_OUTPUT
+ "PrereleaseVersion=$env:NBGV_PrereleaseVersion" >> $env:GITHUB_OUTPUT
+
- name: NuGet Restore
run: dotnet restore DynamicData.sln
working-directory: src
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 643ae46fd..50f8371d1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -40,7 +40,7 @@ jobs:
Write-Host "OK: publishing from '$env:REF_NAME'."
- name: Setup .NET (With cache)
- uses: actions/setup-dotnet@v5.0.1
+ uses: actions/setup-dotnet@v5.3.0
with:
dotnet-version: |
6.0.x
@@ -56,11 +56,19 @@ jobs:
**/global.json
**/nuget.config
- - name: NBGV
+ - name: Install (or update) nbgv tool
+ run: dotnet tool update --global nbgv
+
+ - name: Set NBGV cloud variables
+ run: nbgv cloud -a
+
+ - name: Expose NBGV version as step outputs
id: nbgv
- uses: dotnet/nbgv@v0.5.1
- with:
- setAllVars: true
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ "SemVer2=$env:NBGV_SemVer2" >> $env:GITHUB_OUTPUT
+ "PrereleaseVersion=$env:NBGV_PrereleaseVersion" >> $env:GITHUB_OUTPUT
- name: Verify version matches branch policy
shell: pwsh
@@ -126,21 +134,24 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($pkg.Name) (exit $LASTEXITCODE)." }
}
- - name: Changelog
- uses: glennawatson/ChangeLog@0464dd89b26f61fecf24b41d675f8ffdb11c4c3f # v1
- id: changelog
+ - name: Install GitReleaseNoteGenerator
+ run: dotnet tool install -g GitReleaseNoteGenerator
+
+ - name: Generate release notes
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ RELEASE_VERSION: ${{ steps.nbgv.outputs.SemVer2 }}
+ shell: pwsh
+ run: git-release-notes --release-version "$env:RELEASE_VERSION" --output-file release-notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.nbgv.outputs.SemVer2 }}
IS_PRERELEASE: ${{ steps.nbgv.outputs.PrereleaseVersion != '' }}
- BODY: ${{ steps.changelog.outputs.commitLog }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
- $notesPath = Join-Path $env:RUNNER_TEMP 'release-notes.md'
- Set-Content -Path $notesPath -Value $env:BODY -Encoding utf8 -NoNewline
- $cmd = @('release', 'create', $env:TAG, '--title', $env:TAG, '--notes-file', $notesPath, '--target', $env:GITHUB_SHA)
+ $cmd = @('release', 'create', $env:TAG, '--title', $env:TAG, '--notes-file', 'release-notes.md', '--target', $env:GITHUB_SHA)
if ($env:IS_PRERELEASE -eq 'true') { $cmd += '--prerelease' }
gh @cmd
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index 347ab888d..d341fbbbd 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -3,6 +3,13 @@
$(AssemblyName) ($(TargetFramework))
+
+
+
+ $([System.Text.RegularExpressions.Regex]::Replace(%(Filename), '\.[^\.]+$', '.cs'))
+
+
+
$(DefineConstants);P_LINQ;SUPPORTS_BINDINGLIST
diff --git a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt
index ff1f94916..a225bfe5f 100644
--- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt
+++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt
@@ -1964,6 +1964,14 @@ namespace DynamicData
where TDestination : notnull
where TSource : notnull
where TKey : notnull { }
+ public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, DynamicData.TransformAsyncOptions options)
+ where TDestination : notnull
+ where TSource : notnull
+ where TKey : notnull { }
+ public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.IObservable>? forceTransform = null)
+ where TDestination : notnull
+ where TSource : notnull
+ where TKey : notnull { }
public static System.IObservable> TransformImmutable(this System.IObservable> source, System.Func transformFactory)
where TDestination : notnull
where TSource : notnull
@@ -2108,6 +2116,14 @@ namespace DynamicData
where TDestination : notnull
where TSource : notnull
where TKey : notnull { }
+ public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, DynamicData.TransformAsyncOptions options)
+ where TDestination : notnull
+ where TSource : notnull
+ where TKey : notnull { }
+ public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, System.IObservable>? forceTransform = null)
+ where TDestination : notnull
+ where TSource : notnull
+ where TKey : notnull { }
public static System.IObservable, TKey>> TransformToTree(this System.IObservable> source, System.Func pivotOn, System.IObservable, bool>>? predicateChanged = null)
where TObject : class
where TKey : notnull { }
@@ -2481,6 +2497,9 @@ namespace DynamicData
public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false)
where TSource : notnull
where TDestination : notnull { }
+ public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false)
+ where TSource : notnull
+ where TDestination : notnull { }
public static System.IObservable> TransformMany(this System.IObservable> source, System.Func> manySelector, System.Collections.Generic.IEqualityComparer? equalityComparer = null)
where TDestination : notnull
where TSource : notnull { }
@@ -3100,4 +3119,4 @@ namespace DynamicData.Tests
public void Dispose() { }
protected virtual void Dispose(bool isDisposing) { }
}
-}
+}
\ No newline at end of file
diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs
new file mode 100644
index 000000000..078a8f1b0
--- /dev/null
+++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs
@@ -0,0 +1,517 @@
+using System;
+
+using DynamicData.Aggregation;
+using DynamicData.Tests.Domain;
+using DynamicData.Tests.Utilities;
+
+using FluentAssertions;
+
+using Xunit;
+
+namespace DynamicData.Tests.AggregationTests;
+
+public partial class SumFixture
+{
+ public class ForCache
+ {
+ [Theory]
+ [InlineData(1, 10)]
+ [InlineData(3, 60)]
+ public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum)
+ {
+ var ages = new[] { 10, 20, 30 };
+ using var source = new TestSourceCache(p => p.Name);
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().BeEmpty("no items have been added to the source");
+
+ // UUT Action
+ for (var i = 0; i < itemCount; i++)
+ {
+ source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i]));
+ }
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(itemCount, "each AddOrUpdate should produce a new sum emission");
+ results.RecordedValues[^1].Should().Be(expectedSum, $"the sum of the first {itemCount} ages should be {expectedSum}");
+ }
+
+ [Theory]
+ [InlineData("A", 50)]
+ [InlineData("B", 40)]
+ [InlineData("C", 30)]
+ public void ItemIsRemoved_SumReflectsRemoval(string keyToRemove, int expectedSum)
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60");
+
+ // UUT Action
+ source.Remove(keyToRemove);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal");
+ results.RecordedValues[^1].Should().Be(expectedSum, $"removing '{keyToRemove}' should leave a sum of {expectedSum}");
+ }
+
+ [Fact]
+ public void ItemIsUpdated_SumReflectsNewValue()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(30, "the sum of ages 10 + 20 is 30");
+
+ // UUT Action: update "B" from age 20 to age 50 (same key, new value)
+ source.AddOrUpdate(new Person("B", 50));
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the update");
+ results.RecordedValues[^1].Should().Be(60, "updating 'B' from 20 to 50 should change the sum from 30 to 60");
+ }
+
+ [Fact]
+ public void MultipleChangesInBatch_SingleSumEmitted()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().BeEmpty("no items have been added to the source");
+
+ // UUT Action: add 3 items in a single batch
+ source.Edit(updater =>
+ {
+ updater.AddOrUpdate(new Person("A", 10));
+ updater.AddOrUpdate(new Person("B", 20));
+ updater.AddOrUpdate(new Person("C", 30));
+ });
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("a batched edit should produce exactly one sum emission")
+ .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void SourceIsEmpty_NoSumEmitted()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void AllItemsRemoved_SumReturnsToZero()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60");
+
+ // UUT Action: remove all items in a single batch
+ source.Edit(updater => updater.Clear());
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing");
+ results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero");
+ }
+
+ [Fact]
+ public void SourceCompletesAfterEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item")
+ .Which.Should().Be(10, "the sum of a single age of 10 is 10");
+
+ // UUT Action
+ source.Complete();
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source has completed");
+ }
+
+ [Fact]
+ public void SourceCompletesWithoutEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().BeEmpty("no items were added to the source");
+
+ // UUT Action
+ source.Complete();
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source has completed");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceCompletesImmediately_InitialSumAndCompletionPropagate()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+ source.Complete();
+
+ // UUT Construction: source is already completed, with pre-existing items.
+ // Subscription should produce both an initial sum and a completion, synchronously.
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription");
+ results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription")
+ .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.Complete();
+
+ // UUT Construction: source is already completed, with no items.
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceErrorsAfterEmitting_ErrorPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item");
+
+ // UUT Action
+ var error = new Exception("Test error");
+ source.SetError(error);
+
+ results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber");
+ results.HasCompleted.Should().BeFalse("an error is not a completion");
+ }
+
+ [Fact]
+ public void SourceErrorsWithoutEmitting_ErrorPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().BeEmpty("no items were added to the source");
+
+ // UUT Action
+ var error = new Exception("Test error");
+ source.SetError(error);
+
+ results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber");
+ results.HasCompleted.Should().BeFalse("an error is not a completion");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceFailsImmediately_ErrorPropagates()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ var error = new Exception("Test error");
+ source.SetError(error);
+
+ // UUT Construction: source is already in error state.
+ // The error should propagate synchronously upon subscription.
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription");
+ results.HasCompleted.Should().BeFalse("an error is not a completion");
+ }
+
+ [Fact]
+ public void NullableValuesAreTreatedAsZero()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", new int?(10), "F", null));
+ source.AddOrUpdate(new Person("B", null, "F", null));
+ source.AddOrUpdate(new Person("C", new int?(30), "F", null));
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(p => p.AgeNullable)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(40, "null values should be treated as zero, so the sum should be 10 + 0 + 30 = 40");
+ }
+
+ [Theory]
+ [InlineData(new[] { 10, 20, 30 }, 60)]
+ [InlineData(new[] { int.MaxValue }, int.MaxValue)]
+ [InlineData(new[] { int.MinValue }, int.MinValue)]
+ [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)]
+ [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)]
+ public void ItemsAreAdded_SumIsCorrect_ForInt(int[] ages, int expectedSum)
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ for (var i = 0; i < ages.Length; i++)
+ {
+ source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i]));
+ }
+
+ using var subscription = source.Connect()
+ .Sum(p => p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", ages)}] is {expectedSum}");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableInt()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", new int?(10), "F", null));
+ source.AddOrUpdate(new Person("B", new int?(20), "F", null));
+ source.AddOrUpdate(new Person("C", new int?(30), "F", null));
+
+ using var subscription = source.Connect()
+ .Sum(p => p.AgeNullable)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60, "the nullable int sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForLong()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (long)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60L, "the long sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableLong()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (long?)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForDouble()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (double)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60.0, "the double sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableDouble()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (double?)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForDecimal()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (decimal)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60M, "the decimal sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (decimal?)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForFloat()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (float)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60F, "the float sum of ages 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableFloat()
+ {
+ using var source = new TestSourceCache(p => p.Name);
+
+ source.AddOrUpdate(new Person("A", 10));
+ source.AddOrUpdate(new Person("B", 20));
+ source.AddOrUpdate(new Person("C", 30));
+
+ using var subscription = source.Connect()
+ .Sum(p => (float?)p.Age)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of ages 10 + 20 + 30 is 60");
+ }
+ }
+}
diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs
new file mode 100644
index 000000000..9e9108602
--- /dev/null
+++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs
@@ -0,0 +1,413 @@
+using System;
+using System.Linq;
+
+using DynamicData.Aggregation;
+using DynamicData.Tests.Utilities;
+
+using FluentAssertions;
+
+using Xunit;
+
+namespace DynamicData.Tests.AggregationTests;
+
+public partial class SumFixture
+{
+ public class ForList
+ {
+ [Theory]
+ [InlineData(1, 10)]
+ [InlineData(3, 60)]
+ public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum)
+ {
+ var items = new[] { 10, 20, 30 };
+ using var source = new TestSourceList();
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().BeEmpty("no items have been added to the source");
+
+ // UUT Action
+ source.AddRange(items.Take(itemCount));
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("an AddRange produces a single changeset")
+ .Which.Should().Be(expectedSum, $"the sum of the first {itemCount} items should be {expectedSum}");
+ }
+
+ [Theory]
+ [InlineData(0, 50)]
+ [InlineData(1, 40)]
+ [InlineData(2, 30)]
+ public void ItemIsRemoved_SumReflectsRemoval(int removalIndex, int expectedSum)
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60");
+
+ // UUT Action
+ source.RemoveAt(removalIndex);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal");
+ results.RecordedValues[^1].Should().Be(expectedSum, $"removing item at index {removalIndex} should leave a sum of {expectedSum}");
+ }
+
+ [Fact]
+ public void ItemIsReplaced_SumReflectsReplacement()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60");
+
+ // UUT Action: replace item at index 1 (value 20) with 50
+ source.ReplaceAt(1, 50);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the replacement");
+ results.RecordedValues[^1].Should().Be(90, "replacing 20 with 50 should change the sum from 60 to 90");
+ }
+
+ [Fact]
+ public void ItemsAreCleared_SumReturnsToZero()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items")
+ .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60");
+
+ // UUT Action
+ source.Clear();
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing");
+ results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero");
+ }
+
+ [Fact]
+ public void SourceIsEmpty_NoSumEmitted()
+ {
+ using var source = new TestSourceList();
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceCompletesAfterEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeFalse("the source can still publish notifications");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items");
+
+ // UUT Action
+ source.Complete();
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source has completed");
+ }
+
+ [Fact]
+ public void SourceCompletesWithoutEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceList();
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.RecordedValues.Should().BeEmpty("no items were added to the source");
+
+ // UUT Action
+ source.Complete();
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source has completed");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceCompletesImmediately_InitialSumAndCompletionPropagate()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+ source.Complete();
+
+ // UUT Construction: source is already completed, with pre-existing items.
+ // Subscription should produce both an initial sum and a completion, synchronously.
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription");
+ results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription")
+ .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates()
+ {
+ using var source = new TestSourceList();
+
+ source.Complete();
+
+ // UUT Construction: source is already completed, with no items.
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription");
+ results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted");
+ }
+
+ [Fact]
+ public void SourceErrorsAfterEmitting_ErrorPropagates()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ // UUT Construction
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeNull("no errors should have occurred");
+ results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items");
+
+ // UUT Action
+ var error = new Exception("Test error");
+ source.SetError(error);
+
+ results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber");
+ results.HasCompleted.Should().BeFalse("an error is not a completion");
+ }
+
+ [Fact]
+ public void SourceFailsImmediately_ErrorPropagates()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+ var error = new Exception("Test error");
+ source.SetError(error);
+
+ // UUT Construction: source is already in error state.
+ // The error should propagate synchronously upon subscription.
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .ValidateSynchronization()
+ .RecordValues(out var results);
+
+ results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription");
+ results.HasCompleted.Should().BeFalse("an error is not a completion");
+ }
+
+ [Theory]
+ [InlineData(new[] { 10, 20, 30 }, 60)]
+ [InlineData(new[] { int.MaxValue }, int.MaxValue)]
+ [InlineData(new[] { int.MinValue }, int.MinValue)]
+ [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)]
+ [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)]
+ public void ItemsAreAdded_SumIsCorrect_ForInt(int[] values, int expectedSum)
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(values);
+
+ using var subscription = source.Connect()
+ .Sum(x => x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", values)}] is {expectedSum}");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableInt()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (int?)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60, "the nullable int sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForLong()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (long)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60L, "the long sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableLong()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (long?)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForDouble()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (double)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60.0, "the double sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableDouble()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (double?)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForDecimal()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (decimal)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60M, "the decimal sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (decimal?)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForFloat()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (float)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60F, "the float sum of items 10 + 20 + 30 is 60");
+ }
+
+ [Fact]
+ public void ItemsAreAdded_SumIsCorrect_ForNullableFloat()
+ {
+ using var source = new TestSourceList();
+
+ source.AddRange(new[] { 10, 20, 30 });
+
+ using var subscription = source.Connect()
+ .Sum(x => (float?)x)
+ .RecordValues(out var results);
+
+ results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of items 10 + 20 + 30 is 60");
+ }
+ }
+}
diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.cs
deleted file mode 100644
index 6fecbcabc..000000000
--- a/src/DynamicData.Tests/AggregationTests/SumFixture.cs
+++ /dev/null
@@ -1,227 +0,0 @@
-using System;
-
-using DynamicData.Aggregation;
-using DynamicData.Tests.Domain;
-
-using FluentAssertions;
-
-using Xunit;
-
-namespace DynamicData.Tests.AggregationTests;
-
-public class SumFixture : IDisposable
-{
- private readonly SourceCache _source;
-
- public SumFixture() => _source = new SourceCache(p => p.Name);
-
- [Fact]
- public void AddedItemsContributeToSum()
- {
- var sum = 0;
- double dev = 0;
-
- var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x);
- var deviation = _source.Connect().StdDev(p => p.Age, (int)0).Subscribe(x => dev = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(60, "Accumulated value should be 60");
- dev.Should().Be(7.0710678118654755, "");
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumLong()
- {
- long sum = 0;
- double dev = 0;
-
- var accumulator = _source.Connect().Sum(p => Convert.ToInt64(p.Age)).Subscribe(x => sum = x);
- var deviation = _source.Connect().StdDev(p => p.Age, (long)0).Subscribe(x => dev = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(60, "Accumulated value should be 60");
- dev.Should().Be(7.0710678118654755, "");
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumFloat()
- {
- float sum = 0;
- double dev = 0;
-
- var accumulator = _source.Connect().Sum(p => Convert.ToSingle(p.Age)).Subscribe(x => sum = x);
- var deviation = _source.Connect().StdDev(p => p.Age, (float)0).Subscribe(x => dev = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(60, "Accumulated value should be 60");
- dev.Should().Be(7.0710678118654755, "");
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumDouble()
- {
- double sum = 0;
- double dev = 0;
-
- var accumulator = _source.Connect().Sum(p => Convert.ToDouble(p.Age)).Subscribe(x => sum = x);
- var deviation = _source.Connect().StdDev(p => p.Age, (double)0).Subscribe(x => dev = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(60, "Accumulated value should be 60");
- dev.Should().Be(7.0710678118654755, "");
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumDecimal()
- {
- decimal sum = 0;
- decimal dev = 0;
-
- var accumulator = _source.Connect().Sum(p => Convert.ToDecimal(p.Age)).Subscribe(x => sum = x);
- var deviation = _source.Connect().StdDev(p => p.Age, (decimal)0).Subscribe(x => dev = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(60, "Accumulated value should be 60");
- dev.Should().Be(7.0710678118654752440084436210M, "");
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumNullable()
- {
- var sum = 0;
-
- var accumulator = _source.Connect().Sum(p => p.AgeNullable).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", new int?(10), "F", null));
- _source.AddOrUpdate(new Person("B", new int?(20), "F", null));
- _source.AddOrUpdate(new Person("C", new int?(30), "F", null));
-
- sum.Should().Be(60, "Accumulated value should be 60");
-
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumLongNullable()
- {
- long sum = 0;
-
- var accumulator = _source.Connect().Sum(p => (long?)(p.AgeNullable.HasValue ? Convert.ToInt64(p.AgeNullable) : default)).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", new int?(10), "F", null));
- _source.AddOrUpdate(new Person("B", new int?(20), "F", null));
- _source.AddOrUpdate(new Person("C", new int?(30), "F", null));
-
- sum.Should().Be(60, "Accumulated value should be 60");
-
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumFloatNullable()
- {
- float sum = 0;
-
- var accumulator = _source.Connect().Sum(p => (float?)(p.AgeNullable.HasValue ? Convert.ToSingle(p.AgeNullable) : default)).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", new int?(10), "F", null));
- _source.AddOrUpdate(new Person("B", new int?(20), "F", null));
- _source.AddOrUpdate(new Person("C", new int?(30), "F", null));
-
- sum.Should().Be(60, "Accumulated value should be 60");
-
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumDoubleNullable()
- {
- double sum = 0;
-
- var accumulator = _source.Connect().Sum(p => (double?)(p.AgeNullable.HasValue ? Convert.ToDouble(p.AgeNullable) : default)).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", new int?(10), "F", null));
- _source.AddOrUpdate(new Person("B", new int?(20), "F", null));
- _source.AddOrUpdate(new Person("C", new int?(30), "F", null));
-
- sum.Should().Be(60, "Accumulated value should be 60");
-
- accumulator.Dispose();
- }
-
- [Fact]
- public void AddedItemsContributeToSumDecimalNullable()
- {
- decimal sum = 0;
-
- var accumulator = _source.Connect().Sum(p => (decimal?)(p.AgeNullable.HasValue ? Convert.ToDecimal(p.AgeNullable) : default)).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", new int?(10), "F", null));
- _source.AddOrUpdate(new Person("B", new int?(20), "F", null));
- _source.AddOrUpdate(new Person("C", new int?(30), "F", null));
-
- sum.Should().Be(60, "Accumulated value should be 60");
-
- accumulator.Dispose();
- }
-
- public void Dispose() => _source.Dispose();
-
- [Fact]
- public void InlineChangeReEvaluatesTotals()
- {
- var sum = 0;
-
- var somepropChanged = _source.Connect().WhenValueChanged(p => p.Age);
-
- var accumulator = _source.Connect().Sum(p => p.Age).InvalidateWhen(somepropChanged).Subscribe(x => sum = x);
-
- var personb = new Person("B", 5);
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(personb);
- _source.AddOrUpdate(new Person("C", 30));
-
- sum.Should().Be(45, "Sum should be 45 after inline change");
-
- personb.Age = 20;
-
- sum.Should().Be(60, "Sum should be 60 after inline change");
- accumulator.Dispose();
- }
-
- [Fact]
- public void RemoveProduceCorrectResult()
- {
- var sum = 0;
-
- var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x);
-
- _source.AddOrUpdate(new Person("A", 10));
- _source.AddOrUpdate(new Person("B", 20));
- _source.AddOrUpdate(new Person("C", 30));
-
- _source.Remove("A");
- sum.Should().Be(50, "Accumulated value should be 50 after remove");
- accumulator.Dispose();
- }
-}
diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs
new file mode 100644
index 000000000..48a60e35d
--- /dev/null
+++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs
@@ -0,0 +1,275 @@
+// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
+// Roland Pheasant licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+
+using DynamicData.Binding;
+
+using FluentAssertions;
+
+using Xunit;
+
+namespace DynamicData.Tests.Binding;
+
+///
+/// Single-threaded contract tests for :
+/// handler attachment ordering, no-dedup semantics, deep-chain re-walks on swaps.
+///
+public sealed class WhenPropertyChangedBehaviorFixture
+{
+ [Fact]
+ public void Shallow_NotifyInitialFalse_SubscribesHandlerBeforeReturning()
+ {
+ // notifyOnInitialValue=false: Subscribe must return only after the PropertyChanged handler
+ // is attached. A setter that fires immediately after Subscribe returns must reach the
+ // observer.
+ var model = new TestModel { Value = 10 };
+ var emissions = new List();
+
+ using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ model.Value = 20;
+
+ emissions.Should().Equal(new[] { 20 });
+ }
+
+ [Fact]
+ public void Shallow_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
+ {
+ var model = new TestModel { Value = 10 };
+ var emissions = new List();
+
+ using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: true)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ model.Value = 10;
+ model.Value = 10;
+ model.Value = 10;
+
+ emissions.Should().Equal(new[] { 10, 10, 10, 10 });
+ }
+
+ [Fact]
+ public void Shallow_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
+ {
+ var model = new TestModel { Value = 10 };
+ var emissions = new List();
+
+ using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ model.Value = 42;
+ model.Value = 42;
+
+ emissions.Should().Equal(new[] { 42, 42 });
+ }
+
+ [Fact]
+ public void DeepChain_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
+ {
+ var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
+ var emissions = new List();
+
+ using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ parent.Child!.Age = 1;
+ parent.Child!.Age = 1;
+ parent.Child!.Age = 1;
+
+ emissions.Should().Equal(new[] { 1, 1, 1, 1 });
+ }
+
+ [Fact]
+ public void DeepChain_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
+ {
+ var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
+ var emissions = new List();
+
+ using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ parent.Child!.Age = 7;
+ parent.Child!.Age = 7;
+
+ emissions.Should().Equal(new[] { 7, 7 });
+ }
+
+ [Fact]
+ public void DeepChain_PostSwap_LeafEventOnNewChild_Captured()
+ {
+ // After parent.Child is reassigned, the leaf-level subscription must be re-attached
+ // against the new child. A subsequent leaf mutation on the new child must be captured.
+ var parent = new ParentModel { Child = new ChildModel { Age = 10 } };
+ var emissions = new List();
+
+ using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ var newChild = new ChildModel { Age = 20 };
+ parent.Child = newChild;
+ newChild.Age = 30;
+
+ emissions.Should().Equal(new[] { 10, 20, 30 });
+ }
+
+ [Fact]
+ public void DeepChain_MidChainSwap_DeeperLevelsRetargetCorrectly()
+ {
+ // Mid-chain swap on a 4-level chain. When level 3 is reassigned, the leaf subscription
+ // must re-attach against the new subtree; events on the old subtree must be ignored
+ // (its notifier subscription was disposed).
+ var l1 = new Level1
+ {
+ Child = new Level2
+ {
+ Child = new Level3
+ {
+ Child = new Level4 { Leaf = 10 },
+ },
+ },
+ };
+
+ var emissions = new List();
+ using var sub = l1.WhenPropertyChanged(x => x.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true)
+ .Subscribe(pv => emissions.Add(pv.Value));
+
+ emissions.Should().Equal(new[] { 10 }, "initial emission");
+
+ var originalLeaf = l1.Child!.Child!.Child!;
+
+ var newL4 = new Level4 { Leaf = 20 };
+ l1.Child!.Child!.Child = newL4;
+
+ emissions.Should().Equal(new[] { 10, 20 }, "mid-chain swap emits the new leaf value");
+
+ newL4.Leaf = 30;
+ emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on new subtree is captured");
+
+ originalLeaf.Leaf = 999;
+ emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on detached subtree is ignored");
+ }
+
+ private sealed class TestModel : INotifyPropertyChanged
+ {
+ private int _value;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Value
+ {
+ get => _value;
+ set
+ {
+ _value = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
+ }
+ }
+ }
+
+ private sealed class ParentModel : INotifyPropertyChanged
+ {
+ private ChildModel? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ChildModel? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class ChildModel : INotifyPropertyChanged
+ {
+ private int _age;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Age
+ {
+ get => _age;
+ set
+ {
+ _age = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
+ }
+ }
+ }
+
+ private sealed class Level1 : INotifyPropertyChanged
+ {
+ private Level2? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Level2? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Level2 : INotifyPropertyChanged
+ {
+ private Level3? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Level3? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Level3 : INotifyPropertyChanged
+ {
+ private Level4? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Level4? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Level4 : INotifyPropertyChanged
+ {
+ private int _leaf;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Leaf
+ {
+ get => _leaf;
+ set
+ {
+ _leaf = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf)));
+ }
+ }
+ }
+}
diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs
new file mode 100644
index 000000000..9da900efe
--- /dev/null
+++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs
@@ -0,0 +1,474 @@
+// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
+// Roland Pheasant licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using DynamicData.Binding;
+using DynamicData.Tests.Utilities;
+
+using FluentAssertions;
+
+using Xunit;
+
+namespace DynamicData.Tests.Binding;
+
+///
+/// Multi-threaded race tests for .
+/// Each test forces concurrency between the operator's subscribe call (or chain re-walk) and one or more
+/// notifiers firing on other threads.
+///
+public sealed class WhenPropertyChangedRaceFixture
+{
+ private static readonly TimeSpan ConditionTimeout = TimeSpan.FromSeconds(30);
+
+ [Fact]
+ public async Task DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped()
+ {
+ // Two threads concurrently swap parent.Child. After both swaps complete, a leaf mutation
+ // on the current child must be captured. SharedDeliveryQueue serialises the level-0
+ // signals on the drainer, so the final level-1 subscription always targets parent.Child's
+ // current value.
+ const int iterations = 50;
+ var losses = 0;
+
+ for (var iter = 0; iter < iterations; iter++)
+ {
+ var parent = new ParentModel { Child = new ChildModel { Age = 0 } };
+ var emissions = new List();
+
+ using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false)
+ .Subscribe(pv => { lock (emissions) emissions.Add(pv.Value); });
+
+ var newChild1 = new ChildModel { Age = 1 };
+ var newChild2 = new ChildModel { Age = 2 };
+
+ using var barrier = new Barrier(2);
+ var taskA = Task.Run(() => { barrier.SignalAndWait(); parent.Child = newChild1; });
+ var taskB = Task.Run(() => { barrier.SignalAndWait(); parent.Child = newChild2; });
+ await Task.WhenAll(taskA, taskB).WaitAsync(ConditionTimeout);
+
+ var winner = parent.Child;
+ if (winner is null)
+ {
+ continue;
+ }
+
+ winner.Age = 99;
+
+ WaitForCondition(() => { lock (emissions) return emissions.Contains(99); });
+
+ lock (emissions)
+ {
+ if (!emissions.Contains(99))
+ {
+ losses++;
+ }
+ }
+ }
+
+ losses.Should().Be(0, $"out of {iterations} iterations, {losses} dropped the leaf event on the post-swap winner");
+ }
+
+ [Fact]
+ public async Task DeepChain_FiveLevels_AllLevelsMutatedConcurrently_FinalEmissionMatchesActual()
+ {
+ // Torture: five worker threads each mutating at a different level of a 5-level chain.
+ // Mutations that land on detached subtrees are ignored (their notifier subscriptions were
+ // disposed by ResubscribeFrom). Mutations on the live chain reach the drainer.
+ //
+ // Three invariants per iteration:
+ // (a) Rx contract: ValidateSynchronization catches any concurrent OnNext on the user
+ // observer (a SharedDeliveryQueue serialisation failure).
+ // (b) Value legality: every emission must be a value that some thread legitimately
+ // wrote.
+ // (c) Final consistency: after Task.WhenAll the drainer continues until the queue is
+ // empty. The last processed signal triggers a ReadCurrent against the now-frozen
+ // chain state, so emissions.Last() == ReadCurrent().
+ const int iterations = 50;
+ const int mutationsPerThread = 200;
+ var mismatches = 0;
+
+ for (var iter = 0; iter < iterations; iter++)
+ {
+ var root = NewDeepChain(0);
+ var emissions = new List();
+
+ using var sub = root.WhenPropertyChanged(r => r.Child!.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true)
+ .ValidateSynchronization()
+ .Subscribe(pv => { lock (emissions) emissions.Add(pv.Value); });
+
+ using var barrier = new Barrier(5);
+ var iterSeed = iter * 10_000;
+ var tasks = new[]
+ {
+ Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ root.Child = NewDeep2(iterSeed + 40_000 + i);
+ }
+ }),
+ Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ var l2 = root.Child;
+ if (l2 is not null) l2.Child = NewDeep3(iterSeed + 30_000 + i);
+ }
+ }),
+ Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ var l3 = root.Child?.Child;
+ if (l3 is not null) l3.Child = NewDeep4(iterSeed + 20_000 + i);
+ }
+ }),
+ Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ var l4 = root.Child?.Child?.Child;
+ if (l4 is not null) l4.Child = new Deep5 { Leaf = iterSeed + 10_000 + i };
+ }
+ }),
+ Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ var l5 = root.Child?.Child?.Child?.Child;
+ if (l5 is not null) l5.Leaf = i;
+ }
+ }),
+ };
+
+ await Task.WhenAll(tasks).WaitAsync(ConditionTimeout);
+
+ var actualFinal = root.Child!.Child!.Child!.Child!.Leaf;
+
+ WaitForCondition(() => { lock (emissions) return emissions.Count > 0 && emissions[^1] == actualFinal; });
+
+ var legal = new HashSet { 0 };
+ for (var i = 0; i < mutationsPerThread; i++)
+ {
+ legal.Add(i);
+ legal.Add(iterSeed + 10_000 + i);
+ legal.Add(iterSeed + 20_000 + i);
+ legal.Add(iterSeed + 30_000 + i);
+ legal.Add(iterSeed + 40_000 + i);
+ }
+
+ lock (emissions)
+ {
+ emissions.Should().NotBeEmpty($"iter {iter}: notifyOnInitialValue=true requires at least the initial emission");
+ emissions[0].Should().Be(0, $"iter {iter}: first emission must be the initial value");
+
+ var illegal = emissions.Where(v => !legal.Contains(v)).ToList();
+ illegal.Should().BeEmpty($"iter {iter}: every emission must be a value some thread wrote; saw {string.Join(",", illegal.Take(5))}");
+
+ if (emissions.Count == 0 || emissions[^1] != actualFinal)
+ {
+ mismatches++;
+ }
+ }
+ }
+
+ mismatches.Should().Be(0, $"out of {iterations} iterations, {mismatches} ended with the last emission not matching the actual final chain leaf");
+ }
+
+ [Fact(Skip = "AutoRefresh has a separate concurrency bug; tracked separately")]
+ public async Task AutoRefreshThenFilter_ConcurrentAddsAndPropertyActivation_AllItemsObserved()
+ {
+ // One adder thread sequentially adds items to the cache while a single flipper thread
+ // concurrently sets each item's Activated to true. Final filter contents must include
+ // every item (every item ends Activated=true).
+ //
+ // KeyedActivable's setter only raises PropertyChanged on actual value change, so a
+ // dropped false->true transition is unrecoverable.
+ //
+ // The race lives in AutoRefresh's internal Publish multicast: Sub 1 (Filter path)
+ // receives the Add and reads the property before Sub 2 (MergeMany) subscribes the
+ // per-item refresh handler. A concurrent flip landing in that gap is dropped. This
+ // is not a WhenPropertyChanged issue: AutoRefresh calls WhenPropertyChanged with
+ // notifyInitial=false, so the per-item subscribe attaches the handler immediately
+ // and has no internal race window.
+ const int iterations = 100;
+ const int itemCount = 200;
+
+ for (var iter = 0; iter < iterations; iter++)
+ {
+ using var cache = new SourceCache(x => x.Id);
+ var items = Enumerable.Range(0, itemCount).Select(i => new KeyedActivable(i)).ToList();
+
+ using var results = cache.Connect()
+ .AutoRefresh(x => x.Activated)
+ .Filter(x => x.Activated)
+ .AsAggregator();
+
+ using var barrier = new Barrier(2);
+
+ var adder = Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ foreach (var item in items) cache.AddOrUpdate(item);
+ });
+
+ var flipper = Task.Run(() =>
+ {
+ barrier.SignalAndWait();
+ foreach (var item in items) item.Activated = true;
+ });
+
+ await Task.WhenAll(adder, flipper).WaitAsync(ConditionTimeout);
+
+ var expected = items.Select(x => x.Id).ToHashSet();
+ WaitForCondition(() => results.Data.Keys.ToHashSet().SetEquals(expected));
+
+ var actual = results.Data.Keys.ToHashSet();
+ actual.Should().BeEquivalentTo(expected, $"iter {iter}: every item ends Activated=true and must appear in the filter (missing: {string.Join(",", expected.Except(actual))})");
+ results.Error.Should().BeNull($"iter {iter}: pipeline must not error");
+ }
+ }
+
+ [Fact(Skip = "AutoRefresh has a separate concurrency bug; tracked separately")]
+ public async Task AutoRefreshThenFilter_DualSubscribers_AllItemsObserved()
+ {
+ // Two independent cache subscribers running on the ThreadPool:
+ // Sub 1 (mutator): on every Add change, flips item.Activated to true
+ // Sub 2 (filter chain): AutoRefresh + Filter (filter = Activated)
+ // Items start with Activated=false (filtered out). The mutator flips every item, so
+ // the final filter contents must include every item.
+ //
+ // Same root cause as the single-flipper variant above: AutoRefresh's internal Publish
+ // multicasts the Add to the Filter path before MergeMany subscribes the per-item
+ // refresh handler. The mutator's flip can land in that gap and be dropped.
+ const int iterations = 100;
+ const int itemCount = 200;
+
+ for (var iter = 0; iter < iterations; iter++)
+ {
+ using var cache = new SourceCache(x => x.Id);
+ var items = Enumerable.Range(0, itemCount).Select(i => new KeyedActivable(i)).ToList();
+
+ using var mutator = cache.Connect()
+ .ObserveOn(TaskPoolScheduler.Default)
+ .Subscribe(changes =>
+ {
+ foreach (var change in changes)
+ {
+ if (change.Reason == ChangeReason.Add)
+ {
+ change.Current.Activated = true;
+ }
+ }
+ });
+
+ using var results = cache.Connect()
+ .ObserveOn(TaskPoolScheduler.Default)
+ .AutoRefresh(x => x.Activated)
+ .Filter(x => x.Activated)
+ .AsAggregator();
+
+ foreach (var item in items) cache.AddOrUpdate(item);
+
+ var expected = items.Select(x => x.Id).ToHashSet();
+ WaitForCondition(() => results.Data.Keys.ToHashSet().SetEquals(expected));
+
+ var actual = results.Data.Keys.ToHashSet();
+ actual.Should().BeEquivalentTo(expected, $"iter {iter}: every item was flipped to Activated=true by the mutator and must appear in the filter (missing: {string.Join(",", expected.Except(actual))})");
+ results.Error.Should().BeNull($"iter {iter}: pipeline must not error");
+ }
+ }
+
+ private static Deep1 NewDeepChain(int leaf) =>
+ new Deep1 { Child = NewDeep2(leaf) };
+
+ private static Deep2 NewDeep2(int leaf) =>
+ new Deep2 { Child = NewDeep3(leaf) };
+
+ private static Deep3 NewDeep3(int leaf) =>
+ new Deep3 { Child = NewDeep4(leaf) };
+
+ private static Deep4 NewDeep4(int leaf) =>
+ new Deep4 { Child = new Deep5 { Leaf = leaf } };
+
+ private static void WaitForCondition(Func condition, TimeSpan? timeout = null) =>
+ SpinWait.SpinUntil(condition, timeout ?? ConditionTimeout);
+
+ private sealed class Item : INotifyPropertyChanged
+ {
+ private int _value;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Id { get; init; }
+
+ public int Value
+ {
+ get => _value;
+ set
+ {
+ _value = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
+ }
+ }
+ }
+
+ private sealed class ParentModel : INotifyPropertyChanged
+ {
+ private ChildModel? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ChildModel? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class ChildModel : INotifyPropertyChanged
+ {
+ private int _age;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Age
+ {
+ get => _age;
+ set
+ {
+ _age = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
+ }
+ }
+ }
+
+ private sealed class Deep1 : INotifyPropertyChanged
+ {
+ private Deep2? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Deep2? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Deep2 : INotifyPropertyChanged
+ {
+ private Deep3? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Deep3? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Deep3 : INotifyPropertyChanged
+ {
+ private Deep4? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Deep4? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Deep4 : INotifyPropertyChanged
+ {
+ private Deep5? _child;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public Deep5? Child
+ {
+ get => _child;
+ set
+ {
+ _child = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
+ }
+ }
+ }
+
+ private sealed class Deep5 : INotifyPropertyChanged
+ {
+ private int _leaf;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Leaf
+ {
+ get => _leaf;
+ set
+ {
+ _leaf = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf)));
+ }
+ }
+ }
+
+ private sealed class KeyedActivable : INotifyPropertyChanged
+ {
+ private bool _activated;
+
+ public KeyedActivable(int id)
+ {
+ Id = id;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Id { get; }
+
+ public bool Activated
+ {
+ get => _activated;
+ set
+ {
+ if (_activated == value) return;
+ _activated = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Activated)));
+ }
+ }
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs
new file mode 100644
index 000000000..ebdc26950
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs
@@ -0,0 +1,617 @@
+using System;
+using System.Linq;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+
+using Microsoft.Reactive.Testing;
+
+using FluentAssertions;
+using Xunit;
+
+using DynamicData.Tests.Utilities;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshFixture
+{
+ public abstract class Base
+ {
+ [Fact]
+ public void ChangeSetBufferIsGiven_PropertyChangedNotificationsAreBufferedOnScheduler()
+ {
+ // Setup
+ using var source = new TestSourceCache- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var scheduler = new TestScheduler();
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ changeSetBuffer: TimeSpan.FromSeconds(10),
+ scheduler: scheduler)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish property change notification)
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the property change notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, within buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the buffer window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, to buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "a buffer window expired");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish property change notification)
+ ++item1.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the property change notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, within buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the buffer window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish additional property change notification)
+ ++item3.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the property change notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, to buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "a buffer window expired");
+ results.RecordedChangeSets.Skip(2).First().Count.Should().Be(2, "2 items published a property change notification");
+ results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(2, "2 items published a property change notification");
+ results.RecordedChangeSets.Skip(2).First().Select(change => change.Current).Should().BeEquivalentTo(new[] { item1, item3 }, "items #2 and #3 published property change notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (normal refresh)
+ source.Refresh(item2);
+
+ // Normal refreshes should not be buffered
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedChangeSets.Skip(3).First().Count.Should().Be(1, "1 item was refreshed, within the source");
+ results.RecordedChangeSets.Skip(3).First().Refreshes.Should().Be(1, "1 item was refreshed, within the source");
+ results.RecordedChangeSets.Skip(3).First().First().Current.Should().Be(item2, "item #2 was refreshed, within the source");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void ItemIsAdded_SubscribesToPropertyChanged()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item1.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item");
+ item2.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item");
+ item3.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item");
+ }
+
+ [Fact]
+ public void ItemIsMoved_NotificationPropagates()
+ {
+ // Setup
+ using var source = new Subject>();
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ var items = new [] { item1, item2, item3 };
+
+ var initialChangeset = new ChangeSet
- ()
+ {
+ new Change
- (reason: ChangeReason.Add, key: item1.Id, current: item1, index: 0),
+ new Change
- (reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1),
+ new Change
- (reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2)
+ };
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Prepend(initialChangeset))
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added to the source");
+ results.RecordedItemsSorted.Should().BeEquivalentTo(
+ items,
+ options => options.WithStrictOrdering(),
+ "item indexes should propagate");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ source.OnNext(new ChangeSet
- ()
+ {
+ new Change
- (
+ key: item3.Id,
+ current: item3,
+ currentIndex: 0,
+ previousIndex: 2)
+ });
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "an item was moved within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ results.RecordedItemsSorted.Should().BeEquivalentTo(
+ new[] { item3, item1, item2 },
+ options => options.WithStrictOrdering(),
+ "an item was moved within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void ItemIsRefreshed_NotificationPropagates()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ // UUT Action
+ source.Refresh(item2);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item was refreshed within the source");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item was refreshed within the source");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 was refreshed within the source");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void ItemIsRemoved_UnsubscribesFromPropertyChanged()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ source.Remove(item2);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was removed from the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item2.HasSubscriptions.Should().BeFalse("removing an item should trigger unsubscription from its reevaluator");
+ item1.HasSubscriptions.Should().BeTrue("the item was not removed from the source");
+ item3.HasSubscriptions.Should().BeTrue("the item was not removed from the source");
+ }
+
+ [Fact]
+ public void ItemIsUpdated_ReSubscribesToPropertyChanged()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ var item4 = new Item() { Id = 2 };
+ source.AddOrUpdate(item4);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was replaced within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item2.HasSubscriptions.Should().BeFalse("replacing an item should trigger unsubscription from its reevaluator");
+ item4.HasSubscriptions.Should().BeTrue("adding an item should invoke its reevaluator and subscribe to it");
+ item1.HasSubscriptions.Should().BeTrue("the item was not removed from the source");
+ item3.HasSubscriptions.Should().BeTrue("the item was not removed from the source");
+ }
+
+ [Fact]
+ public void PropertyChangedOccurs_ItemRefreshes()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void PropertyChangeThrottleIsGiven_PropertyChangedNotificationsAreThrottledByScheduler()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var scheduler = new TestScheduler();
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ propertyChangeThrottle: TimeSpan.FromSeconds(10),
+ scheduler: scheduler)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish property change notification)
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the throttle window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish additional property change notification, immediately)
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the throttle window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time to end of throttle window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "the throttle window ended");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published property change notifications");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published property change notifications");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published property change notifications");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish property change notification)
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish additional property change notification, within throttle window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks);
+ ++item2.Value;
+ scheduler.AdvanceBy(1);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time to end of original throttle window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window should have been extended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time to end of throttle window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(25).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "the throttle window ended");
+ results.RecordedChangeSets.Skip(2).First().Count.Should().Be(1, "1 item published property change notifications");
+ results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(1, "1 item published property change notifications");
+ results.RecordedChangeSets.Skip(2).First().First().Current.Should().Be(item2, "item #2 published property change notifications");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceCompletesWhenEmpty_CompletionPropagates(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+
+ // UUT Initialization & Action
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.Complete();
+
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeTrue("all notification sources have completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceCompletesWhenNotEmpty_CompletionDoesNotPropagate(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization & Action (source completion)
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.Complete();
+
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("PropertyChanged events can still publish notifications");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceFails_ErrorPropagates(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var error = new Exception("Test");
+
+
+ // UUT Initialization & Action
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.SetError(error);
+
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.SetError(error);
+
+ results.Error.Should().Be(error, "upstream errors should propagate downstream");
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ results.RecordedChangeSets.Should().BeEmpty("an error occurred before the initial changeset");
+ else
+ {
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ }
+ }
+
+ [Fact]
+ public void SourceIsNull_ThrowsException()
+ => FluentActions.Invoking(() => BuildUut(source: null!))
+ .Should()
+ .Throw();
+
+ [Fact]
+ public void SubscriptionIsDisposed_SubscriptionDisposalPropagates()
+ {
+ // Setup
+ using var source = new Subject>();
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ var initialChangeset = new ChangeSet
- ()
+ {
+ new Change
- (reason: ChangeReason.Add, key: item1.Id, current: item1),
+ new Change
- (reason: ChangeReason.Add, key: item2.Id, current: item2),
+ new Change
- (reason: ChangeReason.Add, key: item3.Id, current: item3)
+ };
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Prepend(initialChangeset))
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ subscription.Dispose();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ source.HasObservers.Should().BeFalse("subscription disposal should propagate");
+ item1.HasSubscriptions.Should().BeFalse("subscription disposal should propagate");
+ item2.HasSubscriptions.Should().BeFalse("subscription disposal should propagate");
+ item3.HasSubscriptions.Should().BeFalse("subscription disposal should propagate");
+ }
+
+ protected abstract IObservable> BuildUut(
+ IObservable> source,
+ TimeSpan? changeSetBuffer = null,
+ TimeSpan? propertyChangeThrottle = null,
+ IScheduler? scheduler = null);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs
new file mode 100644
index 000000000..c948aedb8
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+
+using FluentAssertions;
+using Xunit;
+
+using DynamicData.Tests.Utilities;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshFixture
+{
+ public class WithPropertyAccessor
+ : Base
+ {
+ [Fact(Skip = "Existing defect: propertyAccessor is not null checked, throws NRE on first notification, instead")]
+ public void PropertyAccessorIsNull_ThrowsException()
+ => FluentActions.Invoking(() => ObservableCacheEx.AutoRefresh(
+ source: Observable.Never>(),
+ propertyAccessor: (null as Expression>)!))
+ .Should()
+ .Throw();
+
+ [Fact]
+ public void PropertyChangedNotificationDoesNotMatchPropertyAccessor_IgnoresNotification()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ ++item2.OtherValue;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the property change notification should have been ignored");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ protected override IObservable> BuildUut(
+ IObservable> source,
+ TimeSpan? changeSetBuffer = null,
+ TimeSpan? propertyChangeThrottle = null,
+ IScheduler? scheduler = null)
+ => source.AutoRefresh(
+ propertyAccessor: static item => item.Value,
+ changeSetBuffer: changeSetBuffer,
+ propertyChangeThrottle: propertyChangeThrottle,
+ scheduler: scheduler);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs
new file mode 100644
index 000000000..402d175ff
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Linq;
+using System.Reactive.Concurrency;
+
+using FluentAssertions;
+using Xunit;
+
+using DynamicData.Tests.Utilities;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshFixture
+{
+ public class WithoutPropertyAccessor
+ : Base
+ {
+ [Fact]
+ public void PropertyChangedNotificationDoesNotSpecifyPropertyName_ItemRefreshes()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var item1 = new Item() { Id = 1 };
+ var item2 = new Item() { Id = 2 };
+ var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(source.Connect())
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ item2.RaiseAllPropertiesChanged();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ protected override IObservable> BuildUut(
+ IObservable> source,
+ TimeSpan? changeSetBuffer = null,
+ TimeSpan? propertyChangeThrottle = null,
+ IScheduler? scheduler = null)
+ => source.AutoRefresh(
+ changeSetBuffer: changeSetBuffer,
+ propertyChangeThrottle: propertyChangeThrottle,
+ scheduler: scheduler);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs
index b3d36aa41..be5b936a3 100644
--- a/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs
+++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs
@@ -1,127 +1,66 @@
-using System;
-using System.Linq;
-using System.Reactive.Linq;
-
-using DynamicData.Binding;
-using DynamicData.Tests.Domain;
-
-using FluentAssertions;
-
-using Xunit;
+using System.ComponentModel;
namespace DynamicData.Tests.Cache;
-public class AutoRefreshFixture
+public static partial class AutoRefreshFixture
{
- [Fact]
- public void AutoRefresh()
+ public enum NotificationStrategy
{
- var items = Enumerable.Range(1, 100).Select(i => new Person("Person" + i, 1)).ToArray();
-
- //result should only be true when all items are set to true
- using var cache = new SourceCache(m => m.Name);
- using var results = cache.Connect().AutoRefresh(p => p.Age).AsAggregator();
- cache.AddOrUpdate(items);
-
- results.Data.Count.Should().Be(100);
- results.Messages.Count.Should().Be(1);
-
- items[0].Age = 10;
- results.Data.Count.Should().Be(100);
- results.Messages.Count.Should().Be(2);
-
- results.Messages[1].First().Reason.Should().Be(ChangeReason.Refresh);
-
- //remove an item and check no change is fired
- var toRemove = items[1];
- cache.Remove(toRemove);
- results.Data.Count.Should().Be(99);
- results.Messages.Count.Should().Be(3);
- toRemove.Age = 100;
- results.Messages.Count.Should().Be(3);
-
- //add it back in and check it updates
- cache.AddOrUpdate(toRemove);
- results.Messages.Count.Should().Be(4);
- toRemove.Age = 101;
- results.Messages.Count.Should().Be(5);
-
- results.Messages.Last().First().Reason.Should().Be(ChangeReason.Refresh);
- }
-
- [Fact]
- public void AutoRefreshFromObservable()
- {
- var items = Enumerable.Range(1, 100).Select(i => new Person("Person" + i, 1)).ToArray();
-
- //result should only be true when all items are set to true
- using var cache = new SourceCache(m => m.Name);
- using var results = cache.Connect().AutoRefreshOnObservable(p => p.WhenAnyPropertyChanged()).AsAggregator();
- cache.AddOrUpdate(items);
-
- results.Data.Count.Should().Be(100);
- results.Messages.Count.Should().Be(1);
-
- items[0].Age = 10;
- results.Data.Count.Should().Be(100);
- results.Messages.Count.Should().Be(2);
-
- results.Messages[1].First().Reason.Should().Be(ChangeReason.Refresh);
-
- //remove an item and check no change is fired
- var toRemove = items[1];
- cache.Remove(toRemove);
- results.Data.Count.Should().Be(99);
- results.Messages.Count.Should().Be(3);
- toRemove.Age = 100;
- results.Messages.Count.Should().Be(3);
-
- //add it back in and check it updates
- cache.AddOrUpdate(toRemove);
- results.Messages.Count.Should().Be(4);
- toRemove.Age = 101;
- results.Messages.Count.Should().Be(5);
-
- results.Messages.Last().First().Reason.Should().Be(ChangeReason.Refresh);
+ Immediate,
+ Asynchronous
}
- [Fact]
- public void MakeSelectMagicWorkWithObservable()
+ public class Item
+ : INotifyPropertyChanged
{
- var initialItem = new IntHolder(1, "Initial Description");
-
- var sourceList = new SourceList();
- sourceList.Add(initialItem);
-
- var descriptionStream = sourceList.Connect().AutoRefresh(intHolder => intHolder!.Description).Transform(intHolder => intHolder!.Description, true).Do(x => { }) // <--- Add break point here to check the overload fixes it
- .Bind(out var resultCollection);
-
- using (descriptionStream.Subscribe())
+ public static int SelectId(Item item)
+ => item.Id;
+
+ public required int Id
{
- var newDescription = "New Description";
- initialItem.Description = newDescription;
-
- newDescription.Should().Be(resultCollection[0]);
- //Assert.AreEqual(newDescription, resultCollection[0]);
+ get => _id;
+ init => _id = value;
}
- }
-
- public class IntHolder(int value, string description) : AbstractNotifyPropertyChanged
- {
- public string _description_ = description;
-
- public int _value = value;
-
- public string Description
+
+ public bool HasSubscriptions
+ => PropertyChanged is not null;
+
+ public int OtherValue
{
- get => _description_;
- set => SetAndRaise(ref _description_, value);
+ get => _otherValue;
+ set
+ {
+ if (_otherValue == value)
+ return;
+
+ _otherValue = value;
+
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(OtherValue)));
+ }
}
-
+
public int Value
{
get => _value;
- set => SetAndRaise(ref _value, value);
+ set
+ {
+ if (_value == value)
+ return;
+
+ _value = value;
+
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
+ }
}
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void RaiseAllPropertiesChanged()
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
+
+ private readonly int _id;
+
+ private int _otherValue;
+ private int _value;
}
}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs
new file mode 100644
index 000000000..a09e0ea9c
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs
@@ -0,0 +1,777 @@
+using System;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+
+using Microsoft.Reactive.Testing;
+
+using FluentAssertions;
+using Xunit;
+
+using DynamicData.Tests.Utilities;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshOnObservableFixture
+{
+ public abstract class Base
+ {
+ [Fact]
+ public void ChangeSetBufferIsGiven_ReevaluatorNotificationsAreBufferedOnScheduler()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var scheduler = new TestScheduler();
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged,
+ changeSetBuffer: TimeSpan.FromSeconds(10),
+ scheduler: scheduler)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish reevaluator notification)
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the reevaluator notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, within buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("the buffer window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, to buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "a buffer window expired");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a reevaluator notification");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a reevaluator notification");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a reevaluator notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish reevaluator notification)
+ ++item1.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the reevaluator notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, within buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the buffer window has not yet ended");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (publish additional reevaluator notification)
+ ++item3.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Should().BeEmpty("the reevaluator notification should have been buffered");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (advance time, to buffer window)
+ scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "a buffer window expired");
+ results.RecordedChangeSets.Skip(2).First().Count.Should().Be(2, "2 items published a reevaluator notification");
+ results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(2, "2 items published a reevaluator notification");
+ results.RecordedChangeSets.Skip(2).First().Select(change => change.Current).Should().BeEquivalentTo(new[] { item1, item3 }, "items #2 and #3 published reevaluator notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (normal refresh)
+ source.Refresh(item2);
+
+ // Normal refreshes should not be buffered
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedChangeSets.Skip(3).First().Count.Should().Be(1, "1 item was refreshed, within the source");
+ results.RecordedChangeSets.Skip(3).First().Refreshes.Should().Be(1, "1 item was refreshed, within the source");
+ results.RecordedChangeSets.Skip(3).First().First().Current.Should().Be(item2, "item #2 was refreshed, within the source");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void ItemIsAdded_SubscribesToReevaluator()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item1.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item");
+ item2.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item");
+ item3.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item");
+ }
+
+ [Fact]
+ public void ItemIsMoved_NotificationPropagates()
+ {
+ // Setup
+ using var source = new Subject>();
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ var items = new[] { item1, item2, item3 };
+
+ var initialChangeset = new ChangeSet
- ()
+ {
+ new Change
- (reason: ChangeReason.Add, key: item1.Id, current: item1, index: 0),
+ new Change
- (reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1),
+ new Change
- (reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2)
+ };
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Prepend(initialChangeset),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added to the source");
+ results.RecordedItemsSorted.Should().BeEquivalentTo(
+ items,
+ options => options.WithStrictOrdering(),
+ "item indexes should propagate");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ source.OnNext(new ChangeSet
- ()
+ {
+ new Change
- (
+ key: item3.Id,
+ current: item3,
+ currentIndex: 0,
+ previousIndex: 2)
+ });
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "an item was moved within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ results.RecordedItemsSorted.Should().BeEquivalentTo(
+ new[] { item3, item1, item2 },
+ options => options.WithStrictOrdering(),
+ "an item was moved within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact]
+ public void ItemIsRefreshed_NotificationPropagates()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var reevaluatorInvocationCount = 0;
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: item =>
+ {
+ ++reevaluatorInvocationCount;
+ return Item.ObserveValueChanged(item);
+ })
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ reevaluatorInvocationCount.Should().Be(3, "the reevaluator should be invoked and subscribed to, for each added item");
+
+ // UUT Action
+ source.Refresh(item2);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item was refreshed within the source");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item was refreshed within the source");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 was refreshed within the source");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed, within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ reevaluatorInvocationCount.Should().Be(3, "the reevaluator should only be invoked for items being added to the collection.");
+ }
+
+ [Fact]
+ public void ItemIsRemoved_UnsubscribesFromReevaluator()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ source.Remove(item2);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was removed from the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item2.HasObservers.Should().BeFalse("removing an item should trigger unsubscription from its reevaluator");
+ item1.HasObservers.Should().BeTrue("the item was not removed from the source");
+ item3.HasObservers.Should().BeTrue("the item was not removed from the source");
+ }
+
+ [Fact]
+ public void ItemIsUpdated_ReInvokesReevaluator()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ using var item4 = new Item() { Id = 2 };
+ source.AddOrUpdate(item4);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was replaced within the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ item2.HasObservers.Should().BeFalse("replacing an item should trigger unsubscription from its reevaluator");
+ item4.HasObservers.Should().BeTrue("adding an item should invoke its reevaluator and subscribe to it");
+ item1.HasObservers.Should().BeTrue("the item was not removed from the source");
+ item3.HasObservers.Should().BeTrue("the item was not removed from the source");
+
+
+ // UUT Action (updated item publishes reevaluator notification)
+ ++item4.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(2).First().Count.Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(2).First().First().Current.Should().Be(item4, "item #4 published a reevaluation notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void ReevaluatorCompletesWhenNotOnlyItemInSource_CompletionWaitsForSourceCompletionAndOtherReevaluatorCompletions(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization & Action (initial completion)
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ item2.Complete();
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ item2.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("not all notification sources have completed");
+
+
+ // UUT Action (remaining reevaluator completions)
+ item1.Complete();
+ item3.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (source completion)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeTrue("all notification sources have completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void ReevaluatorCompletesWhenOnlyItemInSource_CompletionWaitsForSourceCompletion(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item = new Item() { Id = 1 };
+
+ source.AddOrUpdate(item);
+
+
+ // UUT Initialization & Action (reevaluator completion)
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ item.Complete();
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ item.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action (source completion)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeTrue("all notification sources have completed");
+ }
+
+ [Fact]
+ public void ReevaluatorEmitsAsynchronously_ItemRefreshes()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ ++item2.Value;
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a reevaluation notification");
+ results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a reevaluation notification");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Fact(Skip = "Existing defect, #1099")]
+ public void ReevaluatorEmitsImmediately_ItemDoesNotRefresh()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization & Action
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValue)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedChangeSets[0].Refreshes.Should().Be(0, "re-evaluation notifications should be ignored within the initial subscription frame");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+ }
+
+ [Theory(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void ReevaluatorFails_ErrorPropagates(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var error = new Exception("Test");
+
+
+ // UUT Initialization & Action
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ item2.SetError(error);
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ item2.SetError(error);
+
+ results.Error.Should().Be(error, "upstream errors should propagate downstream");
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ results.RecordedChangeSets.Should().BeEmpty("an error occurred during processing of the initial changeset");
+ else
+ {
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ }
+ }
+
+ [Fact(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")]
+ public void ReevaluatorThrows_ExceptionPropagates()
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ var error = new Exception("Test");
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: _ => throw error)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Should().BeEmpty("no initial changesets were published");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ using var item = new Item() { Id = 1 };
+ source.AddOrUpdate(item);
+
+ results.Error.Should().Be(error, "upstream errors should propagate downstream");
+ results.RecordedChangeSets.Should().BeEmpty("an error occurred during processing of the initial changeset");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceCompletesWhenEmpty_CompletionPropagates(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+
+ // UUT Initialization & Action
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.Complete();
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeTrue("all notification sources have completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceCompletesWhenNotEmpty_CompletionWaitsForReevaluatorCompletions(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+
+ // UUT Initialization & Action (source completion)
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.Complete();
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("not all notification sources have completed");
+
+
+ // UUT Action (initial reevaluator completion)
+ item2.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("not all notification sources have completed");
+
+
+ // UUT Action (remaining reevaluator completions)
+ item1.Complete();
+ item3.Complete();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeTrue("all notification sources have completed");
+ }
+
+ [Theory]
+ [InlineData(NotificationStrategy.Immediate)]
+ [InlineData(NotificationStrategy.Asynchronous)]
+ public void SourceFails_ErrorPropagates(NotificationStrategy notificationStrategy)
+ {
+ // Setup
+ using var source = new TestSourceCache
- (Item.SelectId);
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ source.AddOrUpdate(new[] { item1, item2, item3 });
+
+ var error = new Exception("Test");
+
+
+ // UUT Initialization & Action
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ source.SetError(error);
+
+ using var subscription = BuildUut(
+ source: source.Connect(),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ if (notificationStrategy is NotificationStrategy.Asynchronous)
+ source.SetError(error);
+
+ results.Error.Should().Be(error, "upstream errors should propagate downstream");
+ if (notificationStrategy is NotificationStrategy.Immediate)
+ results.RecordedChangeSets.Should().BeEmpty("an error occurred before the initial changeset");
+ else
+ {
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source");
+ }
+ }
+
+ [Fact]
+ public void SourceIsNull_ThrowsException()
+ => FluentActions.Invoking(() => BuildUut(
+ source: null!,
+ reevaluator: Item.ObserveValueChanged))
+ .Should()
+ .Throw();
+
+ [Fact]
+ public void SubscriptionIsDisposed_SubscriptionDisposalPropagates()
+ {
+ // Setup
+ using var source = new Subject>();
+
+ using var item1 = new Item() { Id = 1 };
+ using var item2 = new Item() { Id = 2 };
+ using var item3 = new Item() { Id = 3 };
+
+ var initialChangeset = new ChangeSet
- ()
+ {
+ new Change
- (reason: ChangeReason.Add, key: item1.Id, current: item1),
+ new Change
- (reason: ChangeReason.Add, key: item2.Id, current: item2),
+ new Change
- (reason: ChangeReason.Add, key: item3.Id, current: item3)
+ };
+
+
+ // UUT Initialization
+ using var subscription = BuildUut(
+ source: source.Prepend(initialChangeset),
+ reevaluator: Item.ObserveValueChanged)
+ .ValidateSynchronization()
+ .ValidateChangeSets(Item.SelectId)
+ .RecordCacheItems(out var results);
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were added to the source");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+
+ // UUT Action
+ subscription.Dispose();
+
+ results.Error.Should().BeNull();
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed");
+ results.HasCompleted.Should().BeFalse("the source has not completed");
+
+ source.HasObservers.Should().BeFalse("subscription disposal should propagate");
+ item1.HasObservers.Should().BeFalse("subscription disposal should propagate");
+ item2.HasObservers.Should().BeFalse("subscription disposal should propagate");
+ item3.HasObservers.Should().BeFalse("subscription disposal should propagate");
+ }
+
+ protected abstract IObservable> BuildUut(
+ IObservable> source,
+ Func
- > reevaluator,
+ TimeSpan? changeSetBuffer = null,
+ IScheduler? scheduler = null);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs
new file mode 100644
index 000000000..4fa7ec9d7
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+
+using FluentAssertions;
+using Xunit;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshOnObservableFixture
+{
+ public class WithKey
+ : Base
+ {
+ [Fact]
+ public void ReevaluatorIsNull_ThrowsException()
+ => FluentActions.Invoking(() => ObservableCacheEx.AutoRefreshOnObservable(
+ source: Observable.Never>(),
+ reevaluator: (null as Func
- >)!))
+ .Should()
+ .Throw();
+
+ protected override IObservable> BuildUut(
+ IObservable> source,
+ Func
- > reevaluator,
+ TimeSpan? changeSetBuffer = null,
+ IScheduler? scheduler = null)
+ => source.AutoRefreshOnObservable(
+ reevaluator: (item, _) => reevaluator.Invoke(item),
+ changeSetBuffer: changeSetBuffer,
+ scheduler: scheduler);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs
new file mode 100644
index 000000000..3e96d6e96
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Reactive;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+
+using FluentAssertions;
+using Xunit;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshOnObservableFixture
+{
+ public class WithoutKey
+ : Base
+ {
+ [Fact(Skip = "Existing defect: reevaluator is not null checked, throws NRW on first notification, instead")]
+ public void ReevaluatorIsNull_ThrowsException()
+ => FluentActions.Invoking(() => ObservableCacheEx.AutoRefreshOnObservable(
+ source: Observable.Never>(),
+ reevaluator: (null as Func
- >)!))
+ .Should()
+ .Throw();
+
+ protected override IObservable> BuildUut(
+ IObservable> source,
+ Func
- > reevaluator,
+ TimeSpan? changeSetBuffer = null,
+ IScheduler? scheduler = null)
+ => source.AutoRefreshOnObservable(
+ reevaluator: reevaluator,
+ changeSetBuffer: changeSetBuffer,
+ scheduler: scheduler);
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs
new file mode 100644
index 000000000..4e6f974a2
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using System.Threading;
+
+namespace DynamicData.Tests.Cache;
+
+public static partial class AutoRefreshOnObservableFixture
+{
+ public enum NotificationStrategy
+ {
+ Immediate,
+ Asynchronous
+ }
+
+ public sealed class Item
+ : IDisposable
+ {
+ public static IObservable ObserveValue(Item item)
+ => Observable.Create(observer =>
+ {
+ observer.OnNext(item._value);
+ return item._valueChanged.SubscribeSafe(observer);
+ });
+
+ public static IObservable ObserveValueChanged(Item item)
+ => item._valueChanged.Select(static _ => Unit.Default);
+
+ public static int SelectId(Item item)
+ => item.Id;
+
+ public Item()
+ => _valueChanged = new();
+
+ public required int Id
+ {
+ get => _id;
+ init => _id = value;
+ }
+
+ public bool HasObservers
+ => _valueChanged.HasObservers;
+
+ public int Value
+ {
+ get => _value;
+ set
+ {
+ if (_value == value)
+ return;
+
+ _value = value;
+ _valueChanged.OnNext(value);
+ }
+ }
+
+ public void Complete()
+ => _valueChanged.OnCompleted();
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _hasDisposed, true))
+ return;
+
+ _valueChanged.OnCompleted();
+ _valueChanged.Dispose();
+ }
+
+ public void SetError(Exception error)
+ => _valueChanged.OnError(error);
+
+ private readonly int _id;
+ private readonly Subject _valueChanged;
+
+ private bool _hasDisposed;
+ private int _value;
+ }
+}
diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs
new file mode 100644
index 000000000..e5440f71b
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.Cache;
+
+public static partial class FilterFixture
+{
+ public static partial class DynamicPredicate;
+}
diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs
new file mode 100644
index 000000000..97ddee168
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.Cache;
+
+public static partial class FilterFixture
+{
+ public static partial class DynamicPredicateAndReFiltering;
+}
diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs
new file mode 100644
index 000000000..5d191eb59
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.Cache;
+
+public static partial class FilterFixture
+{
+ public static partial class DynamicPredicateState;
+}
diff --git a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs
index f3d61ac0a..1ca4de0cd 100644
--- a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs
+++ b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs
@@ -6,6 +6,9 @@
using Xunit;
using DynamicData.Tests.Utilities;
+using DynamicData.Tests.Domain;
+using System.Collections.ObjectModel;
+using System.Linq;
namespace DynamicData.Tests.Cache;
@@ -86,5 +89,55 @@ protected override IObservable> BuildUut(
=> source.Filter(
filter: predicate,
suppressEmptyChangeSets: suppressEmptyChangeSets);
+ [Fact]
+ public void AutoRefreshRemoveKeyFilterUpdate_CollectionUpdated()
+ {
+ RandomPersonGenerator generator = new();
+ using var source = new SourceCache(p => p.Key);
+ var people = generator.Take(100).ToArray();
+ var average = people.Average(x => x.Age);
+ ReadOnlyObservableCollection collection;
+ using var subscription = source.Connect()
+ .AutoRefresh(x => x.Age)
+ .RemoveKey()
+ .Filter(x => x.Age < average)
+ .Bind(out collection)
+ .Subscribe();
+ source.AddOrUpdate(people);
+
+ Assert.Equivalent(people.Where(x => x.Age < average), collection);
+
+ foreach (var person in people)
+ {
+ person.Age = person.Age + 1;
+ }
+ Assert.Equivalent(people.Where(x => x.Age < average), collection);
+ }
+
+ [Fact]
+ public void AutoRefreshFilterRemoveKeyUpdate_CollectionUpdated()
+ {
+ RandomPersonGenerator generator = new();
+ using var source = new SourceCache(p => p.Key);
+ var people = generator.Take(100).ToArray();
+ var average = people.Average(x => x.Age);
+ ReadOnlyObservableCollection collection;
+ using var subscription = source.Connect()
+ .AutoRefresh(x => x.Age)
+ .Filter(x => x.Age < average)
+ .RemoveKey()
+ .Bind(out collection)
+ .Subscribe();
+ source.AddOrUpdate(people);
+
+ Assert.Equivalent(people.Where(x => x.Age < average), collection);
+
+ foreach (var person in people)
+ {
+ person.Age = person.Age + 1;
+ }
+ Assert.Equivalent(people.Where(x => x.Age < average), collection);
+ }
}
+
}
diff --git a/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs b/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs
index a86e7438f..5abf88761 100644
--- a/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs
+++ b/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs
@@ -4,6 +4,8 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
+using DynamicData.Tests.Utilities;
+
using FluentAssertions;
using Xunit;
@@ -16,9 +18,10 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
{
using var source = new Subject>();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
// Add items
@@ -31,8 +34,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
});
results.Error.Should().BeNull();
- results.Messages.Count.Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "2 items were added, with 1 excluded");
+ results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "2 items were added, with 1 excluded");
// Replace items, changing inclusion
@@ -45,8 +48,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
});
results.Error.Should().BeNull();
- results.Messages.Skip(1).Count().Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item4 }, "2 items were replaced, with 1 excluded");
+ results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4 }, "2 items were replaced, with 1 excluded");
// Replace items, not changing inclusion
@@ -59,8 +62,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
});
results.Error.Should().BeNull();
- results.Messages.Skip(2).Count().Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item6 }, "2 items were replaced, with 1 excluded");
+ results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item6 }, "2 items were replaced, with 1 excluded");
// Refresh items
@@ -71,8 +74,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
});
results.Error.Should().BeNull();
- results.Messages.Skip(3).Count().Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item6 }, "2 items were refreshed, with 1 excluded");
+ results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item6 }, "2 items were refreshed, with 1 excluded");
// Remove items
@@ -83,18 +86,18 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded(
});
results.Error.Should().BeNull();
- results.Messages.Skip(4).Count().Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEmpty("2 items were removed, with one excluded");
+ results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Should().BeEmpty("2 items were removed, with one excluded");
- results.Messages.SelectMany(static changes => changes).Should().AllSatisfy(
+ results.RecordedChangeSets.SelectMany(static changes => changes).Should().AllSatisfy(
change =>
{
change.CurrentIndex.Should().Be(-1);
change.PreviousIndex.Should().Be(-1);
},
because: "indexes should not be preserved");
- results.IsCompleted.Should().BeFalse();
+ results.HasCompleted.Should().BeFalse();
}
[Fact]
@@ -102,9 +105,10 @@ public void ItemsAreMoved_ChangesAreNotPropagated()
{
using var source = new Subject>();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
// Initial setup
var item1 = new Item() { Id = 1, IsIncluded = true };
@@ -116,18 +120,18 @@ public void ItemsAreMoved_ChangesAreNotPropagated()
new(reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1),
new(reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2)
});
- results.Messages.Clear();
+ var changeSetsBeforeMove = results.RecordedChangeSets.Count;
// Move items
source.OnNext(new ChangeSet
- ()
{
new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 2, previousIndex: 0),
- new(reason: ChangeReason.Moved, key: item2.Id, current: item1, previous: default, currentIndex: 0, previousIndex: 1)
+ new(reason: ChangeReason.Moved, key: item2.Id, current: item2, previous: default, currentIndex: 0, previousIndex: 1)
});
results.Error.Should().BeNull();
- results.Messages.Should().BeEmpty("move operations should not be propagated");
+ results.RecordedChangeSets.Skip(changeSetsBeforeMove).Should().BeEmpty("move operations should not be propagated");
}
[Fact]
@@ -144,9 +148,10 @@ public void PredicateThrows_ExceptionIsCaptured()
var error = new Exception();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: _ => throw error)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
var item1 = new Item() { Id = 1, IsIncluded = true };
@@ -156,8 +161,8 @@ public void PredicateThrows_ExceptionIsCaptured()
});
results.Error.Should().Be(error);
- results.Messages.Should().BeEmpty("no source operations should have been processed");
- results.IsCompleted.Should().BeFalse();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations should have been processed");
+ results.HasCompleted.Should().BeFalse();
}
[Fact]
@@ -165,9 +170,10 @@ public void SourceCompletes_CompletionIsPropagated()
{
using var source = new Subject>();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
var item1 = new Item() { Id = 1, IsIncluded = true };
@@ -178,11 +184,11 @@ public void SourceCompletes_CompletionIsPropagated()
source.OnCompleted();
results.Error.Should().BeNull();
- results.IsCompleted.Should().BeTrue();
- results.Messages.Count.Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+ results.HasCompleted.Should().BeTrue();
+ results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+
-
// Make sure no extraneous notifications are published.
var item2 = new Item() { Id = 2, IsIncluded = true };
source.OnNext(new ChangeSet
- ()
@@ -190,7 +196,7 @@ public void SourceCompletes_CompletionIsPropagated()
new(reason: ChangeReason.Add, key: item2.Id, current: item2)
});
- results.Messages.Skip(1).Should().BeEmpty("no source operations should have been processed");
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations should have been processed");
}
[Fact]
@@ -210,17 +216,16 @@ public void SourceCompletesImmediately_CompletionIsPropagated()
return Disposable.Empty;
});
- var error = new Exception();
-
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
results.Error.Should().BeNull();
- results.IsCompleted.Should().BeTrue();
- results.Messages.Count.Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+ results.HasCompleted.Should().BeTrue();
+ results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
}
[Fact]
@@ -230,9 +235,10 @@ public void SourceErrors_ErrorIsPropagated()
var error = new Exception();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
var item1 = new Item() { Id = 1, IsIncluded = true };
@@ -243,11 +249,11 @@ public void SourceErrors_ErrorIsPropagated()
source.OnError(error);
results.Error.Should().Be(error);
- results.IsCompleted.Should().BeFalse();
- results.Messages.Count.Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+ results.HasCompleted.Should().BeFalse();
+ results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+
-
// Make sure no extraneous notifications are published.
var item2 = new Item() { Id = 2, IsIncluded = true };
source.OnNext(new ChangeSet
- ()
@@ -255,7 +261,7 @@ public void SourceErrors_ErrorIsPropagated()
new(reason: ChangeReason.Add, key: item2.Id, current: item2)
});
- results.Messages.Skip(1).Should().BeEmpty("no source operations should have been processed");
+ results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations should have been processed");
}
[Fact]
@@ -276,15 +282,16 @@ public void SourceErrorsImmediately_ErrorIsPropagated()
return Disposable.Empty;
});
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
results.Error.Should().Be(error);
- results.IsCompleted.Should().BeFalse();
- results.Messages.Count.Should().Be(1, "1 source operation was performed");
- results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
+ results.HasCompleted.Should().BeFalse();
+ results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed");
+ results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added");
}
[Fact]
@@ -299,19 +306,20 @@ public void SuppressEmptyChangesetsIsFalse_EmptyChangesetsArePublished()
{
using var source = new Subject>();
- using var results = source
+ using var subscription = source
.FilterImmutable(
predicate: Item.Predicate,
suppressEmptyChangeSets: false)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
ManipulateExcludedItems(source);
results.Error.Should().BeNull();
- results.IsCompleted.Should().BeFalse();
- results.Messages.Count.Should().Be(5, "5 source operations were performed");
- results.Messages.Should().AllSatisfy(changes => changes.Should().BeEmpty(), "no included items were manipulated");
+ results.HasCompleted.Should().BeFalse();
+ results.RecordedChangeSets.Count.Should().Be(5, "5 source operations were performed");
+ results.RecordedChangeSets.Should().AllSatisfy(changes => changes.Should().BeEmpty(), "no included items were manipulated");
}
[Fact]
@@ -319,16 +327,17 @@ public void SuppressEmptyChangesetsIsTrue_EmptyChangesetsAreNotPublished()
{
using var source = new Subject>();
- using var results = source
+ using var subscription = source
.FilterImmutable(predicate: Item.Predicate)
- .AsAggregator();
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
ManipulateExcludedItems(source);
results.Error.Should().BeNull();
- results.IsCompleted.Should().BeFalse();
- results.Messages.Should().BeEmpty("no source operations should have generated changes");
+ results.HasCompleted.Should().BeFalse();
+ results.RecordedChangeSets.Should().BeEmpty("no source operations should have generated changes");
}
private static void ManipulateExcludedItems(ISubject> source)
@@ -364,6 +373,40 @@ private static void ManipulateExcludedItems(ISubject> sour
});
}
+ [Fact]
+ public void Update_PreviousMatchedCurrentDoesNot_EmitsRemoveCarryingPreviousAsCurrent()
+ {
+ // Per Change contract, a Remove change carries the item that was removed in Current.
+ // For an Update where Previous matched the predicate but Current does not, the item that
+ // leaves the filtered view is Previous (it was downstream; Current never reached downstream).
+ using var source = new Subject>();
+
+ using var subscription = source
+ .FilterImmutable(predicate: Item.Predicate)
+ .ValidateChangeSets(Item.KeySelector)
+ .RecordCacheItems(out var results);
+
+ var included = new Item() { Id = 1, IsIncluded = true };
+ var excluded = new Item() { Id = 1, IsIncluded = false };
+
+ source.OnNext(new ChangeSet
- ()
+ {
+ new(reason: ChangeReason.Add, key: included.Id, current: included, index: 0)
+ });
+
+ source.OnNext(new ChangeSet
- ()
+ {
+ new(reason: ChangeReason.Update, key: excluded.Id, current: excluded, previous: included, currentIndex: 0, previousIndex: 0)
+ });
+
+ var lastChangeSet = results.RecordedChangeSets[results.RecordedChangeSets.Count - 1];
+ lastChangeSet.Count.Should().Be(1);
+
+ var removeChange = lastChangeSet.Single();
+ removeChange.Reason.Should().Be(ChangeReason.Remove);
+ removeChange.Current.Should().BeSameAs(included, "Remove.Current must carry the item that left downstream (the previously-matching value), not the new value that never reached downstream");
+ }
+
private class Item
{
public static readonly Func
- KeySelector
diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs
index 37ca6b198..949ec35bf 100644
--- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs
+++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs
@@ -5,6 +5,7 @@
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
+using System.Reactive.Threading.Tasks;
using System.Threading.Tasks;
using Bogus;
using DynamicData.Kernel;
@@ -90,10 +91,11 @@ IObservable AddRemovePrices(Market market, int priceCount, int para
.Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler))
.Finally(market.PricesCache.Dispose);
- var merged = _marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices);
- using var priceResults = merged.AsAggregator();
-
+ var merged = _marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices).Publish();
var adding = true;
+ var cacheCompleted = merged.LastOrDefaultAsync().ToTask();
+ using var priceResults = merged.AsAggregator();
+ using var connect = merged.Connect();
// Start asynchrononously modifying the parent list and the child lists
using var addingSub = AddRemoveStress(marketCount, priceCount, Environment.ProcessorCount, TaskPoolScheduler.Default)
@@ -119,6 +121,9 @@ IObservable AddRemovePrices(Market market, int priceCount, int para
}
while (adding);
+ // Wait for the source cache to finish delivering all notifications.
+ await cacheCompleted;
+
// Verify the results
CheckResultContents(_marketCacheResults, priceResults);
}
diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs
index f7a4aa2a5..49f566907 100644
--- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs
+++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs
@@ -5,6 +5,7 @@
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
+using System.Reactive.Threading.Tasks;
using System.Threading.Tasks;
using Bogus;
using DynamicData.Kernel;
@@ -86,9 +87,11 @@ IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int par
.Parallelize(animalCount, parallel, obs => obs.StressAddRemove(owner.Animals, _ => GetRemoveTime(), scheduler))
.Finally(owner.Animals.Dispose);
- var mergeAnimals = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect());
-
+ var mergeAnimals = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).Publish();
var addingAnimals = true;
+ var cacheCompleted = mergeAnimals.LastOrDefaultAsync().ToTask();
+ using var animalResults = mergeAnimals.AsAggregator();
+ using var connect = mergeAnimals.Connect();
// Start asynchrononously modifying the parent list and the child lists
using var addAnimals = AddRemoveAnimalsStress(ownerCount, animalCount, Environment.ProcessorCount, TaskPoolScheduler.Default)
@@ -114,8 +117,12 @@ IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int par
}
while (addingAnimals);
- // Verify the results
- CheckResultContents();
+ // Wait for the source cache to finish delivering all notifications.
+ await cacheCompleted;
+
+ // Verify the results against the aggregator wired into the same Publish chain
+ // that cacheCompleted observes.
+ CheckResultContents(_animalOwners.Items, _animalOwnerResults, animalResults);
}
[Fact]
diff --git a/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs b/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs
new file mode 100644
index 000000000..04d27f619
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs
@@ -0,0 +1,95 @@
+#region
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reactive.Disposables;
+
+using DynamicData.Binding;
+using DynamicData.Tests.Domain;
+
+using FluentAssertions;
+
+using Xunit;
+
+#endregion
+
+namespace DynamicData.Tests.Cache;
+
+public class RemoveKeyFixture : IDisposable
+{
+ private readonly RandomPersonGenerator _generator = new();
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Handled with CompositeDisposable")]
+ private readonly ISourceCache _source;
+
+ private readonly CompositeDisposable _cleanup = new();
+
+ public RemoveKeyFixture()
+ {
+ _source = new SourceCache(p => p.Key);
+ _cleanup.Add(_source);
+ }
+
+ public void Dispose() => _cleanup.Dispose();
+
+ [Fact]
+ public void CacheRemoveKey_Add_KeyIsRemoved()
+ {
+ ReadOnlyObservableCollection collection;
+ _cleanup.Add(
+ _source.Connect()
+ .RemoveKey()
+ .Bind(out collection)
+ .Subscribe()
+ );
+ var people = _generator.Take(100).ToArray();
+ _source.AddOrUpdate(people);
+
+ Assert.Equivalent(people, collection);
+ }
+
+ [Fact]
+ public void CacheRemoveKey_Filter_ItemsFilterKeyIsRemoved()
+ {
+ var people = _generator.Take(100).ToArray();
+ var average = people.Average(x => x.Age);
+
+ ReadOnlyObservableCollection collection;
+ _cleanup.Add(
+ _source.Connect()
+ .RemoveKey()
+ .Filter(x => x.Age < average)
+ .Bind(out collection)
+ .Subscribe()
+ );
+ _source.AddOrUpdate(people);
+
+ Assert.Equivalent(people.Where(x => x.Age < average), collection);
+ }
+
+ [Fact]
+ public void CacheRemoveKey_AutoRefreshUpdateITems_CollectionUpdated()
+ {
+ ReadOnlyObservableCollection collection;
+ _cleanup.Add(
+ _source.Connect()
+ .AutoRefresh(x => x.Age)
+ .RemoveKey()
+ .Bind(out collection)
+ .Subscribe()
+ );
+ var people = _generator.Take(100).ToArray();
+ _source.AddOrUpdate(people);
+
+ Assert.Equivalent(people, collection);
+
+ foreach (var person in people)
+ {
+ person.Age = person.Age + 1;
+ }
+ Assert.Equivalent(people, collection);
+ }
+
+}
diff --git a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs
index bbc67f04d..378b7fa6d 100644
--- a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs
+++ b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs
@@ -72,8 +72,13 @@ public void AddMoreThanLimit()
[Fact]
public void AddMoreThanLimitInBatched()
{
- _source.AddOrUpdate(_generator.Take(10).ToArray());
- _source.AddOrUpdate(_generator.Take(10).ToArray());
+ // _generator.Take(N) draws random Person rows from a finite name pool; a second
+ // Take(10) call can produce keys that collide with the first batch, turning an
+ // Add into an Update and breaking the per-message Adds count. Draw a larger pool
+ // up front, dedupe by Key, then split into two non-overlapping batches of 10.
+ var people = _generator.Take(60).DistinctBy(p => p.Key).Take(20).ToArray();
+ _source.AddOrUpdate(people.Take(10).ToArray());
+ _source.AddOrUpdate(people.Skip(10).Take(10).ToArray());
_scheduler.Start();
@@ -96,12 +101,17 @@ public void InvokeLimitSizeToWhenOverLimit()
var removesTriggered = false;
var subscriber = _source.LimitSizeTo(10, _scheduler).Subscribe(removes => { removesTriggered = true; });
- _source.AddOrUpdate(_generator.Take(10).ToArray());
+ // _generator.Take(N) draws random Person rows from a finite name pool; a second
+ // Take(10) call can produce keys that collide with the first batch, turning an
+ // Add into an Update and breaking the per-message Adds count. Draw a larger pool
+ // up front, dedupe by Key, then split into two non-overlapping batches of 10.
+ var people = _generator.Take(60).DistinctBy(p => p.Key).Take(20).ToArray();
+ _source.AddOrUpdate(people.Take(10).ToArray());
_scheduler.AdvanceBy(TimeSpan.FromMilliseconds(150).Ticks);
removesTriggered.Should().BeFalse();
- _source.AddOrUpdate(_generator.Take(10).ToArray());
+ _source.AddOrUpdate(people.Skip(10).Take(10).ToArray());
_scheduler.AdvanceBy(TimeSpan.FromMilliseconds(150).Ticks);
diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs
new file mode 100644
index 000000000..4062e343b
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.Cache;
+
+public static partial class ToObservableChangeSetFixture
+{
+ public static partial class Items;
+}
diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs
new file mode 100644
index 000000000..cc57d44dc
--- /dev/null
+++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.Cache;
+
+public static partial class ToObservableChangeSetFixture
+{
+ public static partial class Sequences;
+}
diff --git a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs
index 7483eccb4..b1cedaefe 100644
--- a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs
+++ b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs
@@ -200,7 +200,7 @@ public void Update()
}
-
+
[Theory, InlineData(true), InlineData(false)]
public void TransformOnRefresh(bool transformOnRefresh)
@@ -215,7 +215,7 @@ public void TransformOnRefresh(bool transformOnRefresh)
results.Data.Count.Should().Be(1);
results.Data.Lookup("SomeOne").Value.AgeGroup.Should().Be("Child");
-
+
person.Age = 21;
@@ -224,7 +224,28 @@ public void TransformOnRefresh(bool transformOnRefresh)
}
-
+ [Fact]
+ public void TransformAsyncCancelsTokenOnUnSubscribe()
+ {
+ using var source = new SourceCache(p => p.Name);
+ var tcs = new TaskCompletionSource();
+ using var sub = source.Connect()
+ .TransformAsync(async (c, p, key, cancel) =>
+ {
+ using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false))
+ {
+ return await tcs.Task.ConfigureAwait(false);
+ }
+ })
+ .Subscribe();
+
+ source.AddOrUpdate(new Person());
+
+ sub.Dispose();
+ Assert.True(tcs.Task.IsCanceled);
+ }
+
+
[Theory, InlineData(10), InlineData(100)]
public async Task WithMaxConcurrency(int maxConcurrency)
@@ -232,7 +253,7 @@ public async Task WithMaxConcurrency(int maxConcurrency)
/* We need to test whether the max concurrency has any effect.
If maxConcurrency == 100, this test takes a little more than 100 ms
- If maxConcurrency = 10, this test takes a little more than 1s
+ If maxConcurrency = 10, this test takes a little more than 1s
So it works, but how can it be tested in a scientific way ??
*/
diff --git a/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs
index 6c1f34eff..b0b26cfb6 100644
--- a/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs
+++ b/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs
@@ -208,6 +208,29 @@ public void TransformOnRefresh(bool transformOnRefresh)
}
+ [Fact]
+ public void TransformSafeAsyncCancelsTokenOnUnSubscribe()
+ {
+ using var source = new SourceCache(p => p.Name);
+ var tcs = new TaskCompletionSource();
+ using var sub = source.Connect()
+ .TransformSafeAsync(async (c, p, key, cancel) =>
+ {
+ using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false))
+ {
+ return await tcs.Task.ConfigureAwait(false);
+ }
+ },
+ error => Assert.Fail($"Unexpected error: {error}")) // NOTE: Cancellation exception should not be called because the handler should be torn down with subscription
+ .Subscribe();
+
+ source.AddOrUpdate(new Person());
+
+ sub.Dispose();
+ Assert.True(tcs.Task.IsCanceled);
+ }
+
+
[Theory, InlineData(10), InlineData(100)]
public async Task WithMaxConcurrency(int maxConcurrency)
@@ -215,7 +238,7 @@ public async Task WithMaxConcurrency(int maxConcurrency)
/* We need to test whether the max concurrency has any effect.
If maxConcurrency == 100, this test takes a little more than 100 ms
- If maxConcurrency = 10, this test takes a little more than 1s
+ If maxConcurrency = 10, this test takes a little more than 1s
So it works, but how can it be tested in a scientific way ??
*/
diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs
new file mode 100644
index 000000000..c6531d5fa
--- /dev/null
+++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.List;
+
+public static partial class ToObservableChangeSetFixture
+{
+ public static partial class Items;
+}
diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs
new file mode 100644
index 000000000..1a6445a49
--- /dev/null
+++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs
@@ -0,0 +1,6 @@
+namespace DynamicData.Tests.List;
+
+public static partial class ToObservableChangeSetFixture
+{
+ public static partial class Sequences;
+}
diff --git a/src/DynamicData.Tests/List/TransformAsyncFixture.cs b/src/DynamicData.Tests/List/TransformAsyncFixture.cs
index 9d0bdb447..be4b8f623 100755
--- a/src/DynamicData.Tests/List/TransformAsyncFixture.cs
+++ b/src/DynamicData.Tests/List/TransformAsyncFixture.cs
@@ -1,7 +1,8 @@
using System;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
-
+using DynamicData.Kernel;
using DynamicData.Tests.Domain;
using FluentAssertions;
using Xunit;
@@ -165,4 +166,25 @@ private void TransformOnRefresh()
results.Messages.Last().First().Reason.Should().Be(ListChangeReason.Replace);
}
+
+ [Fact]
+ public void TransformAsyncCancelsTokenOnUnSubscribe()
+ {
+ using var source = new SourceList();
+ var tcs = new TaskCompletionSource();
+ using var sub = source.Connect()
+ .TransformAsync(async (c, p, count, cancel) =>
+ {
+ using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false))
+ {
+ return await tcs.Task.ConfigureAwait(false);
+ }
+ })
+ .Subscribe();
+
+ source.Add(new Person());
+
+ sub.Dispose();
+ Assert.True(tcs.Task.IsCanceled);
+ }
}
diff --git a/src/DynamicData/Binding/ObservablePropertyFactory.cs b/src/DynamicData/Binding/ObservablePropertyFactory.cs
index d9f9be00c..1c1416f4f 100644
--- a/src/DynamicData/Binding/ObservablePropertyFactory.cs
+++ b/src/DynamicData/Binding/ObservablePropertyFactory.cs
@@ -1,12 +1,16 @@
-// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
+// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
+using System.Collections.Generic;
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reactive;
+using System.Reactive.Disposables;
using System.Reactive.Linq;
+using DynamicData.Internal;
+
namespace DynamicData.Binding;
internal sealed class ObservablePropertyFactory
@@ -14,74 +18,258 @@ internal sealed class ObservablePropertyFactory
{
private readonly Func>> _factory;
- public ObservablePropertyFactory(Func valueAccessor, ObservablePropertyPart[] chain) =>
- _factory = (t, notifyInitial) =>
+ public ObservablePropertyFactory(Func valueAccessor, ObservablePropertyPart[] chain)
+ {
+ // chain is leaf-first (output of SplitIntoSteps). Reverse once to root-to-leaf order.
+ var rootToLeaf = chain.AsEnumerable().Reverse().ToArray();
+ _factory = (source, notifyInitial) => Observable.Create>(
+ observer => new DeepChainSubscription(observer, source, rootToLeaf, valueAccessor, notifyInitial));
+ }
+
+ public ObservablePropertyFactory(Expression> expression)
+ {
+ // Shallow form: single property, no chain. Used when depth == 1. Skips SharedDeliveryQueue
+ // and Observable.FromEventPattern in favour of a direct PropertyChanged += handler for
+ // the high-frequency single-property hot path.
+ var memberName = expression.GetProperty().Name;
+ var accessor = expression.Compile();
+ _factory = (source, notifyInitial) => Observable.Create>(
+ observer => new SinglePropertySubscription(observer, source, memberName, accessor, notifyInitial));
+ }
+
+ public IObservable> Create(TObject source, bool notifyInitial) => _factory(source, notifyInitial);
+
+ // Single-property subscription. Attaches a direct PropertyChanged handler and synchronizes events. Used for
+ // x => x.Prop (depth == 1) where Observable.FromEventPattern would be needless overhead on the hot path.
+ //
+ // notifyInitial only controls whether the constructor synthesises an initial emission. There
+ // is no equality dedup at the subscribe seam: a same-valued PropertyChanged firing in the
+ // subscribe window is a legitimate event and must be delivered. The "never drop events"
+ // contract takes precedence over avoiding a benign duplicate.
+ private sealed class SinglePropertySubscription : IDisposable
+ {
+ private readonly TObject _source;
+ private readonly string _memberName;
+ private readonly Func _accessor;
+ private readonly IObserver> _observer;
+ #if NET9_0_OR_GREATER
+ private readonly Lock _notificationGate;
+ #else
+ private readonly object _notificationGate;
+ #endif
+
+ public SinglePropertySubscription(
+ IObserver> observer,
+ TObject source,
+ string memberName,
+ Func accessor,
+ bool notifyInitial)
{
- // 1) notify when values have changed
- // 2) resubscribe when changed because it may be a child object which has changed
- var valueHasChanged = GetNotifiers(t, chain).Merge().Take(1).Repeat();
+ _source = source;
+ _memberName = memberName;
+ _accessor = accessor;
+ _observer = observer;
+ _notificationGate = new();
+
+ // Attach PropertyChanged handler FIRST so events during the initial read are not missed.
+ _source.PropertyChanged += OnPropertyChanged;
+
if (notifyInitial)
{
- valueHasChanged = Observable.Defer(() => Observable.Return(Unit.Default)).Concat(valueHasChanged);
+ EmitCurrent();
}
+ }
- return valueHasChanged.Select(_ => GetPropertyValue(t, chain, valueAccessor));
- };
+ public void Dispose()
+ {
+ _source.PropertyChanged -= OnPropertyChanged;
+ }
- public ObservablePropertyFactory(Expression> expression)
+ private void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
+ {
+ if (args.PropertyName == _memberName)
+ {
+ EmitCurrent();
+ }
+ }
+
+ // Reads the current property value and forwards it through the queue. The accessor is
+ // user code and may throw; that exception routes to OnError. The downstream OnNext call
+ // is NOT wrapped: per the Rx contract, if the user observer throws, the exception
+ // propagates back to whoever invoked the PropertyChanged setter, matching what a plain
+ // Subject.OnNext would do.
+ private void EmitCurrent()
+ {
+ lock (_notificationGate)
+ {
+ PropertyValue value;
+ try
+ {
+ value = new PropertyValue(_source, _accessor(_source));
+ }
+ catch (Exception ex)
+ {
+ _observer.OnError(ex);
+ return;
+ }
+
+ _observer.OnNext(value);
+ }
+ }
+ }
+
+ // Deep-chain subscription.
+ //
+ // notifyInitial only controls whether ProcessSignal emits the current chain value during
+ // the InitialSetupSignal pass. There is no equality dedup at the subscribe seam: every
+ // chain event is delivered.
+ private sealed class DeepChainSubscription : IDisposable
{
- // this overload is used for shallow observations i.e. depth = 1, so no need for re-subscriptions
- var member = expression.GetProperty();
- var accessor = expression.Compile();
+ // Sentinel signal value enqueued during subscribe to perform the initial chain setup.
+ private const int InitialSetupSignal = -1;
+
+ private readonly TObject _source;
+ private readonly ObservablePropertyPart[] _rootToLeaf;
+ private readonly Func _valueAccessor;
+ private readonly bool _notifyInitial;
+ private readonly IObserver> _observer;
+ #if NET9_0_OR_GREATER
+ private readonly Lock _notificationGate;
+ #else
+ private readonly object _notificationGate;
+ #endif
+ private readonly SerialDisposable[] _levelSlots;
+
+ // Pre-allocated per-level notifier callbacks. Indexed by level. ResubscribeFrom reuses
+ // these instead of allocating a fresh closure per re-walk.
+ private readonly Action[] _levelCallbacks;
- _factory = (t, notifyInitial) =>
+ public DeepChainSubscription(
+ IObserver> observer,
+ TObject source,
+ ObservablePropertyPart[] rootToLeaf,
+ Func valueAccessor,
+ bool notifyInitial)
{
- PropertyValue Factory() => new(t, accessor(t));
+ _source = source;
+ _rootToLeaf = rootToLeaf;
+ _valueAccessor = valueAccessor;
+ _notifyInitial = notifyInitial;
+ _observer = observer;
+ _notificationGate = new();
- var propertyChanged = Observable.FromEventPattern