From f2430c15fb4a5680dd7e57c267d210bddc17241b Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 20 Sep 2026 22:06:09 -0700 Subject: [PATCH 1/2] Strengthen filtering assertions and indexed identity coverage --- .../Cache/FilterFixture.Static.cs | 84 +++++++++++++++---- .../List/FilterFixture.Static.cs | 80 ++++++++++++++++++ 2 files changed, 148 insertions(+), 16 deletions(-) diff --git a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs index a596f3460..d149d0523 100644 --- a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs +++ b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs @@ -4,12 +4,16 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using Randomizer = Bogus.Randomizer; using FluentAssertions; using Xunit; +using Xunit.Abstractions; using DynamicData.Tests.Domain; using DynamicData.Tests.Utilities; +using Person = DynamicData.Tests.Domain.Person; + namespace DynamicData.Tests.Cache; public static partial class FilterFixture @@ -17,6 +21,11 @@ public static partial class FilterFixture public sealed class Static : Base { + private const int RemoveKeySeed = 0x1165; + + public Static(ITestOutputHelper output) + => output.WriteLine($"Bogus seed: {RemoveKeySeed}"); + [Fact] public void FilterIsNull_ThrowsException() => FluentActions.Invoking(static () => ObservableCacheEx.Filter( @@ -89,54 +98,97 @@ protected override IObservable> BuildUut( => source.Filter( filter: predicate, suppressEmptyChangeSets: suppressEmptyChangeSets); + + /// + /// List filtering after RemoveKey must remove every item that stops matching, not retain a stale superset. + /// [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); + // Arrange: all generated items start below a derived threshold. + var randomizer = new Randomizer(RemoveKeySeed); + using var source = new TestSourceCache(static person => person.Key); + var people = Fakers.Person.Clone() + .UseSeed(randomizer.Int()) + .Generate(randomizer.Int(3, 8)) + .ToArray(); + var exclusiveAge = people.Max(static person => person.Age) + 1; ReadOnlyObservableCollection collection; using var subscription = source.Connect() .AutoRefresh(x => x.Age) .RemoveKey() - .Filter(x => x.Age < average) + .Filter(person => person.Age < exclusiveAge) + .ValidateSynchronization() + .ValidateChangeSets() .Bind(out collection) .Subscribe(); + + // Act: add the initial matching collection. source.AddOrUpdate(people); - Assert.Equivalent(people.Where(x => x.Age < average), collection); + // Assert: the complete initial membership is present. + Assert.Equivalent(people, collection, strict: true); + + // Act: guarantee one removal while other matching items remain. + people[0].Age = exclusiveAge; + // Assert: a stale entry must not be accepted as an extra member. + Assert.Equivalent(people.Where(person => person.Age < exclusiveAge), collection, strict: true); + + // Act: exclude every remaining item. foreach (var person in people) { - person.Age = person.Age + 1; + person.Age = exclusiveAge; } - Assert.Equivalent(people.Where(x => x.Age < average), collection); + + // Assert: strict comparison also rejects stale items when the expectation is empty. + Assert.Equivalent(people.Where(person => person.Age < exclusiveAge), collection, strict: true); } + /// + /// Cache filtering before RemoveKey must bind exactly the remaining items, including an empty final result. + /// [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); + // Arrange: all generated items start below a derived threshold. + var randomizer = new Randomizer(RemoveKeySeed); + using var source = new TestSourceCache(static person => person.Key); + var people = Fakers.Person.Clone() + .UseSeed(randomizer.Int()) + .Generate(randomizer.Int(3, 8)) + .ToArray(); + var exclusiveAge = people.Max(static person => person.Age) + 1; ReadOnlyObservableCollection collection; using var subscription = source.Connect() .AutoRefresh(x => x.Age) - .Filter(x => x.Age < average) + .Filter(person => person.Age < exclusiveAge) + .ValidateSynchronization() + .ValidateChangeSets(static person => person.Key) .RemoveKey() .Bind(out collection) .Subscribe(); + + // Act: add the initial matching collection. source.AddOrUpdate(people); - Assert.Equivalent(people.Where(x => x.Age < average), collection); + // Assert: the complete initial membership is present. + Assert.Equivalent(people, collection, strict: true); + // Act: guarantee one removal while other matching items remain. + people[0].Age = exclusiveAge; + + // Assert: a stale entry must not be accepted as an extra member. + Assert.Equivalent(people.Where(person => person.Age < exclusiveAge), collection, strict: true); + + // Act: exclude every remaining item. foreach (var person in people) { - person.Age = person.Age + 1; + person.Age = exclusiveAge; } - Assert.Equivalent(people.Where(x => x.Age < average), collection); + + // Assert: strict comparison also rejects stale items when the expectation is empty. + Assert.Equivalent(people.Where(person => person.Age < exclusiveAge), collection, strict: true); } } diff --git a/src/DynamicData.Tests/List/FilterFixture.Static.cs b/src/DynamicData.Tests/List/FilterFixture.Static.cs index 927deae5d..625261896 100644 --- a/src/DynamicData.Tests/List/FilterFixture.Static.cs +++ b/src/DynamicData.Tests/List/FilterFixture.Static.cs @@ -3,8 +3,10 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using Bogus; using FluentAssertions; using Xunit; +using Xunit.Abstractions; using DynamicData.Tests.Utilities; @@ -14,6 +16,84 @@ public static partial class FilterFixture { public class Static { + private const int IdentitySeed = 0x1165; + + public Static(ITestOutputHelper output) + => output.WriteLine($"Bogus seed: {IdentitySeed}"); + + /// + /// Indexed list changes address one occurrence, even when values are equal or share the same reference. + /// Refresh, replacement, movement, and removal must preserve the other slots' identities and inclusion states. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EqualItemsHaveIndexedChanges_OnlyTheSpecifiedSlotChanges(bool useSameReference) + { + // Arrange: seed the list with two equal occurrences before subscribing. + var randomizer = new Randomizer(IdentitySeed); + using var source = new TestSourceList(); + var first = new Item { Id = randomizer.Int(), IsIncluded = true }; + var second = useSameReference ? first : first with { }; + var replacement = new Item { Id = randomizer.Int(), IsIncluded = true }; + Assert.Equal(first, second); + + source.AddRange(new[] { first, second }); + + using var subscription = source.Connect() + .Filter(Item.FilterByIsIncluded) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + // Assert the initial snapshot retains both occurrences. + Assert.Null(results.Error); + Assert.Collection(results.RecordedItems, + item => Assert.Same(first, item), + item => Assert.Same(second, item)); + + // Act: re-evaluate only the second slot, even when both slots reference the mutated object. + second.IsIncluded = false; + source.Refresh(1); + + // Assert: the first slot retains its prior inclusion state until notified. + Assert.Null(results.Error); + Assert.Same(first, Assert.Single(results.RecordedItems)); + + // Act: replace the excluded slot with an included item. + source.Edit(items => items[1] = replacement); + + // Assert: the new reference is inserted without replacing the untouched occurrence. + Assert.Null(results.Error); + Assert.Collection(results.RecordedItems, + item => Assert.Same(first, item), + item => Assert.Same(replacement, item)); + + // Act: move the replacement ahead of the original occurrence. + source.Edit(items => items.Move(1, 0)); + + // Assert: both exact references follow their indexed positions. + Assert.Null(results.Error); + Assert.Collection(results.RecordedItems, + item => Assert.Same(replacement, item), + item => Assert.Same(first, item)); + + // Act: remove the original occurrence at its new position. + source.RemoveAt(1); + + // Assert: only the replacement remains. + Assert.Null(results.Error); + Assert.Same(replacement, Assert.Single(results.RecordedItems)); + + // Act: refresh the remaining slot after excluding it. + replacement.IsIncluded = false; + source.Refresh(0); + + // Assert: all notified exclusions have propagated. + Assert.Null(results.Error); + Assert.Empty(results.RecordedItems); + } + [Fact] public void DuplicateItemsAreAdded_ItemsAreTrackedSeparately() { From b3d86d77348459c90778f0be406c1f0d8368180b Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 21 Sep 2026 07:34:18 -0700 Subject: [PATCH 2/2] Remove indexed identity coverage unrelated to the strict assertion fix EqualItemsHaveIndexedChanges_OnlyTheSpecifiedSlotChanges covers indexed list identity, which belongs to a separate issue rather than the strict filtering assertion correction. Remove the test along with the seed constant, test output constructor, and imports that existed solely to support it, restoring the list fixture to its previous state. The cache fixture retains only the strict Assert.Equivalent changes and their direct setup. --- .../List/FilterFixture.Static.cs | 80 ------------------- 1 file changed, 80 deletions(-) diff --git a/src/DynamicData.Tests/List/FilterFixture.Static.cs b/src/DynamicData.Tests/List/FilterFixture.Static.cs index 625261896..927deae5d 100644 --- a/src/DynamicData.Tests/List/FilterFixture.Static.cs +++ b/src/DynamicData.Tests/List/FilterFixture.Static.cs @@ -3,10 +3,8 @@ using System.Reactive.Linq; using System.Reactive.Subjects; -using Bogus; using FluentAssertions; using Xunit; -using Xunit.Abstractions; using DynamicData.Tests.Utilities; @@ -16,84 +14,6 @@ public static partial class FilterFixture { public class Static { - private const int IdentitySeed = 0x1165; - - public Static(ITestOutputHelper output) - => output.WriteLine($"Bogus seed: {IdentitySeed}"); - - /// - /// Indexed list changes address one occurrence, even when values are equal or share the same reference. - /// Refresh, replacement, movement, and removal must preserve the other slots' identities and inclusion states. - /// - [Theory] - [InlineData(false)] - [InlineData(true)] - public void EqualItemsHaveIndexedChanges_OnlyTheSpecifiedSlotChanges(bool useSameReference) - { - // Arrange: seed the list with two equal occurrences before subscribing. - var randomizer = new Randomizer(IdentitySeed); - using var source = new TestSourceList(); - var first = new Item { Id = randomizer.Int(), IsIncluded = true }; - var second = useSameReference ? first : first with { }; - var replacement = new Item { Id = randomizer.Int(), IsIncluded = true }; - Assert.Equal(first, second); - - source.AddRange(new[] { first, second }); - - using var subscription = source.Connect() - .Filter(Item.FilterByIsIncluded) - .ValidateSynchronization() - .ValidateChangeSets() - .RecordListItems(out var results); - - // Assert the initial snapshot retains both occurrences. - Assert.Null(results.Error); - Assert.Collection(results.RecordedItems, - item => Assert.Same(first, item), - item => Assert.Same(second, item)); - - // Act: re-evaluate only the second slot, even when both slots reference the mutated object. - second.IsIncluded = false; - source.Refresh(1); - - // Assert: the first slot retains its prior inclusion state until notified. - Assert.Null(results.Error); - Assert.Same(first, Assert.Single(results.RecordedItems)); - - // Act: replace the excluded slot with an included item. - source.Edit(items => items[1] = replacement); - - // Assert: the new reference is inserted without replacing the untouched occurrence. - Assert.Null(results.Error); - Assert.Collection(results.RecordedItems, - item => Assert.Same(first, item), - item => Assert.Same(replacement, item)); - - // Act: move the replacement ahead of the original occurrence. - source.Edit(items => items.Move(1, 0)); - - // Assert: both exact references follow their indexed positions. - Assert.Null(results.Error); - Assert.Collection(results.RecordedItems, - item => Assert.Same(replacement, item), - item => Assert.Same(first, item)); - - // Act: remove the original occurrence at its new position. - source.RemoveAt(1); - - // Assert: only the replacement remains. - Assert.Null(results.Error); - Assert.Same(replacement, Assert.Single(results.RecordedItems)); - - // Act: refresh the remaining slot after excluding it. - replacement.IsIncluded = false; - source.Refresh(0); - - // Assert: all notified exclusions have propagated. - Assert.Null(results.Error); - Assert.Empty(results.RecordedItems); - } - [Fact] public void DuplicateItemsAreAdded_ItemsAreTrackedSeparately() {