From f84e8222e2940146a20e75dbeb94b9f37240532a Mon Sep 17 00:00:00 2001 From: JakenVeina Date: Sun, 22 Feb 2026 01:41:11 -0600 Subject: [PATCH] Rewrote testing for the list variant of the OnItemRefreshed() operator, in accordance with #1014. --- src/DynamicData.Tests/List/OnItemFixture.cs | 16 - .../List/OnItemRefreshedFixture.cs | 504 ++++++++++++++++++ .../Utilities/ListItemRecordingObserver.cs | 3 + .../Utilities/TestSourceList.cs | 50 +- 4 files changed, 547 insertions(+), 26 deletions(-) create mode 100644 src/DynamicData.Tests/List/OnItemRefreshedFixture.cs diff --git a/src/DynamicData.Tests/List/OnItemFixture.cs b/src/DynamicData.Tests/List/OnItemFixture.cs index ee22bab4d..dd26f56c2 100644 --- a/src/DynamicData.Tests/List/OnItemFixture.cs +++ b/src/DynamicData.Tests/List/OnItemFixture.cs @@ -22,22 +22,6 @@ public void OnItemAddCalled() Assert.True(called); } - [Fact] - public void OnItemRefreshedCalled() - { - var called = false; - var source = new SourceList(); - - var person = new Person("A", 1); - source.Add(person); - - source.Connect().AutoRefresh(x=>x.Age).OnItemRefreshed(_ => called = true).Subscribe(); - - person.Age += 1; - - Assert.True(called); - } - [Fact] public void OnItemRemovedCalled() { diff --git a/src/DynamicData.Tests/List/OnItemRefreshedFixture.cs b/src/DynamicData.Tests/List/OnItemRefreshedFixture.cs new file mode 100644 index 000000000..c044d8e89 --- /dev/null +++ b/src/DynamicData.Tests/List/OnItemRefreshedFixture.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.List; + +public class OnItemRefreshedFixture +{ + [Theory] + [InlineData(0, 0)] + [InlineData(1, 0)] + [InlineData(1, 1)] + [InlineData(5, 0)] + [InlineData(5, 2)] + [InlineData(5, 5)] + public void ItemIsAdded_RefreshActionIsNotInvoked( + int initialItemCount, + int insertionIndex) + { + using var source = new TestSourceList(); + + if (initialItemCount is not 0) + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + if (initialItemCount is 0) + results.RecordedChangeSets.Should().BeEmpty("there were no initial items to be published"); + else + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + source.Insert( + index: insertionIndex, + item: initialItemCount); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("an item was refreshed within the collection"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Theory] + [InlineData(2, 0, 1)] + [InlineData(2, 1, 0)] + [InlineData(5, 0, 4)] + [InlineData(5, 4, 0)] + [InlineData(5, 1, 3)] + [InlineData(5, 3, 1)] + public void ItemIsMoved_RefreshActionIsNotInvoked( + int initialItemCount, + int originalIndex, + int destinationIndex) + { + using var source = new TestSourceList(); + + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + source.Move( + original: originalIndex, + destination: destinationIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("an item was moved within the collection"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were removed from the collection"); + } + + [Theory] + [InlineData(1, 0)] + [InlineData(5, 0)] + [InlineData(5, 2)] + [InlineData(5, 4)] + public void ItemIsRefreshed_RefreshActionIsNotInvoked( + int initialItemCount, + int refreshIndex) + { + using var source = new TestSourceList(); + + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + var refreshedItem = source.Items[refreshIndex]; + source.Refresh(refreshIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("an item was refreshed within the collection"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEquivalentTo(new[] { refreshedItem } ,"1 item were refreshed within the collection"); + } + + [Theory] + [InlineData(1, 0)] + [InlineData(5, 0)] + [InlineData(5, 2)] + [InlineData(5, 4)] + public void ItemIsRemoved_RefreshActionIsInvoked( + int initialItemCount, + int removalIndex) + { + using var source = new TestSourceList(); + + if (initialItemCount is not 0) + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + var removedItem = source.Items[removalIndex]; + source.RemoveAt(removalIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("an item was removed from the collection"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Theory] + [InlineData(1, 0)] + [InlineData(5, 0)] + [InlineData(5, 2)] + [InlineData(5, 4)] + public void ItemIsReplaced_RefreshActionIsInvokedForOldItem( + int initialItemCount, + int replacementIndex) + { + using var source = new TestSourceList(); + + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + source.ReplaceAt( + index: replacementIndex, + item: initialItemCount); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("an item was replaced within the collection"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Theory] + [InlineData(1, 0, 1)] + [InlineData(5, 0, 1)] + [InlineData(5, 2, 1)] + [InlineData(5, 1, 3)] + [InlineData(5, 0, 5)] + public void ItemRangeIsRemoved_RefreshActionIsInvokedForEachItem( + int initialItemCount, + int removalIndex, + int removalCount) + { + using var source = new TestSourceList(); + + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + source.RemoveRange( + index: removalIndex, + count: removalCount); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle($"{removalCount} item{((removalCount is 1) ? "" : "s")} should have been removed"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + public void ItemsAreCleared_RefreshActionIsInvokedForEachItem(int initialItemCount) + { + using var source = new TestSourceList(); + + source.AddRange(Enumerable.Range(1, initialItemCount)); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + + + // UUT Action + source.Clear(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("all items in the collection should have been removed"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Fact] + public void SourceCompletesAsynchronously_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] + { + 1, + 2, + 3 + }); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items have been removed from the collection"); + + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedChangeSets.Should().BeEmpty("no changes were made to the collection"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Fact] + public void SourceCompletesImmediately_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] + { + 1, + 2, + 3 + }); + source.Complete(); + + var refreshActionInvocations = new List(); + + // UUT Construction & Action + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Fact] + public void SourceFailsAsynchronously_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] + { + 1, + 2, + 3 + }); + + var refreshActionInvocations = new List(); + + // UUT Construction + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedChangeSets.Should().ContainSingle("the initial items should have been published"); + results.RecordedItems.Should().BeEquivalentTo( + expectation: source.Items, + config: options => options.WithStrictOrdering(), + because: "all collection changes should propagate downstream"); + results.ClearChangeSets(); + + refreshActionInvocations.Should().BeEmpty("no items have been removed from the collection"); + + + // UUT Action + var error = new Exception(); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "errors within the stream should propagate"); + results.RecordedChangeSets.Should().BeEmpty("no changes were made to the collection"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } + + [Fact] + public void SourceFailsImmediately_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] + { + 1, + 2, + 3 + }); + var error = new Exception(); + source.SetError(error); + + var refreshActionInvocations = new List(); + + // UUT Construction & Action + using var subscription = source.Connect() + .OnItemRefreshed(refreshActionInvocations.Add) + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeSameAs(error, "errors within the stream should propagate"); + results.RecordedChangeSets.Should().BeEmpty("an error occurred during subscription"); + + refreshActionInvocations.Should().BeEmpty("no items were refreshed within the collection"); + } +} diff --git a/src/DynamicData.Tests/Utilities/ListItemRecordingObserver.cs b/src/DynamicData.Tests/Utilities/ListItemRecordingObserver.cs index 8fb48e85c..09eaa8819 100644 --- a/src/DynamicData.Tests/Utilities/ListItemRecordingObserver.cs +++ b/src/DynamicData.Tests/Utilities/ListItemRecordingObserver.cs @@ -23,6 +23,9 @@ public IReadOnlyList> RecordedChangeSets public IReadOnlyList RecordedItems => _recordedItems; + public void ClearChangeSets() + => _recordedChangeSets.Clear(); + protected override void OnNext(IChangeSet value) { if (!HasFinalized) diff --git a/src/DynamicData.Tests/Utilities/TestSourceList.cs b/src/DynamicData.Tests/Utilities/TestSourceList.cs index 6d3387d51..6f3a1e2c2 100644 --- a/src/DynamicData.Tests/Utilities/TestSourceList.cs +++ b/src/DynamicData.Tests/Utilities/TestSourceList.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; @@ -70,6 +71,19 @@ public IObservable> Preview(Func? predicate = null) _source.Preview(predicate), _refreshRequestedPreview)); + // TODO: Formally add this to ISourceList + public void Refresh() + { + var changeSet = new ChangeSet(_source.Items + .Select((item, index) => new Change( + reason: ListChangeReason.Refresh, + current: item, + index: index))); + + _refreshRequestedPreview.OnNext(changeSet); + _refreshRequested.OnNext(changeSet); + } + // TODO: Formally add this to ISourceList public void Refresh(int index) { @@ -115,14 +129,30 @@ private void AssertCanMutate() } private IObservable WrapStream(IObservable sourceStream) - => Observable - .Merge( - _error - .Select(static error => (error is not null) - ? Observable.Throw(error!) - : Observable.Empty()) - .Switch(), - sourceStream) - .TakeUntil(_hasCompleted - .Where(static hasCompleted => hasCompleted)); + => Observable.Create(downstreamObserver => + { + var hasCompleted = _hasCompleted + .Publish(); + + var subscription = Observable + .Merge( + _error + .Select(static error => (error is not null) + ? Observable.Throw(error!) + : Observable.Empty()) + .Switch(), + sourceStream) + .TakeUntil(hasCompleted + .Where(static hasCompleted => hasCompleted)) + .SubscribeSafe(downstreamObserver); + + // Make sure that an initial changeset gets published, before immediate completion. + var connection = hasCompleted.Connect(); + + return Disposable.Create(() => + { + connection.Dispose(); + subscription.Dispose(); + }); + }); }