From 7f501b003970bee9e90eef01d84319b21b71d76d Mon Sep 17 00:00:00 2001 From: Jake Meiergerd Date: Sat, 3 Oct 2026 02:18:42 -0500 Subject: [PATCH] Refactored a variety of multi-threading stress tests to be classified as Integration tests, and thus not be parallelizable. Also cleaned up organization within SourceCacheFixture and WhenPropertyChangedFixture, while I was pulling tests out of them anyway. --- ...pertyChangedBehaviorFixture.Conversions.cs | 344 ----- .../WhenPropertyChangedBehaviorFixture.cs | 624 --------- ...angedFixture.IntegrationTests.DeepChain.cs | 218 +++ ...ChangedFixture.IntegrationTests.Shallow.cs | 68 + ...PropertyChangedFixture.IntegrationTests.cs | 129 ++ ...rtyChangedFixture.UnitTests.Conversions.cs | 327 +++++ ...pertyChangedFixture.UnitTests.DeepChain.cs | 197 +++ ...ropertyChangedFixture.UnitTests.Shallow.cs | 135 ++ ...tyChangedFixture.UnitTests.Subscription.cs | 72 + .../WhenPropertyChangedFixture.UnitTests.cs | 49 + .../Binding/WhenPropertyChangedFixture.cs | 345 +++++ .../Binding/WhenPropertyChangedRaceFixture.cs | 554 -------- .../Cache/CrossCacheDeadlockStressTest.cs | 1 + .../Cache/DeadlockTortureTest.cs | 1 + .../Cache/MergeManyChangeSetsCacheFixture.cs | 890 ------------ ...ManyChangeSetsCacheSourceCompareFixture.cs | 1105 --------------- ...ets.WithSourceComparer.IntegrationTests.cs | 137 ++ ...ChangeSets.WithSourceComparer.UnitTests.cs | 1192 +++++++++++++++++ ...e.ForCacheChangeSets.WithSourceComparer.cs | 9 + ....WithoutSourceComparer.IntegrationTests.cs | 134 ++ ...ngeSets.WithoutSourceComparer.UnitTests.cs | 887 ++++++++++++ ...orCacheChangeSets.WithoutSourceComparer.cs | 9 + ...anyChangeSetsFixture.ForCacheChangeSets.cs | 31 + ...ture.ForListChangesets.IntegrationTests.cs | 116 ++ ...SetsFixture.ForListChangesets.UnitTests.cs | 540 ++++++++ ...ManyChangeSetsFixture.ForListChangesets.cs | 46 + .../Cache/MergeManyChangeSetsFixture.cs | 3 + .../Cache/MergeManyChangeSetsListFixture.cs | 480 ------- .../SourceCacheFixture.IntegrationTests.cs | 179 +++ .../Cache/SourceCacheFixture.UnitTests.cs | 261 ++++ .../Cache/SourceCacheFixture.cs | 424 +----- 31 files changed, 5089 insertions(+), 4418 deletions(-) delete mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.Conversions.cs delete mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.DeepChain.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.Shallow.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Conversions.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.DeepChain.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Shallow.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Subscription.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.cs delete mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs delete mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs delete mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheSourceCompareFixture.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.UnitTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.UnitTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.UnitTests.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.cs create mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.cs delete mode 100644 src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs create mode 100644 src/DynamicData.Tests/Cache/SourceCacheFixture.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/SourceCacheFixture.UnitTests.cs diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.Conversions.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.Conversions.cs deleted file mode 100644 index 8d5609604..000000000 --- a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.Conversions.cs +++ /dev/null @@ -1,344 +0,0 @@ -// 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.ComponentModel; -using System.Linq.Expressions; - -using DynamicData.Binding; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Binding; - -public sealed partial class WhenPropertyChangedBehaviorFixture -{ - /// Verifies that a numeric conversion is evaluated before reading a property of the converted value. - [Fact] - public void NumericConversionBeforePropertyAccess_InitialValue_IsObserved() - { - // Arrange - // An odd numerator produces an exactly representable, non-integral amount. - var amount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var model = new ObservablePrice { Amount = amount }; - var expectedScale = ((decimal)amount).Scale; - - // Act - using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) - .RecordValues(out var results); - - // Assert - results.Error.Should().BeNull(because: "the next property belongs to the converted decimal, not the source double"); - results.RecordedValues.Should().Equal(new[] { expectedScale }, because: "the initial value must match the supplied expression"); - results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); - } - - /// Verifies that property changes remain observable through a value-changing numeric conversion. - /// Whether subscribing requests an initial value notification. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void NumericConversionBeforePropertyAccess_PropertyChanges_AreObserved(bool notifyOnInitialValue) - { - // Arrange - // Odd quarters and eighths have distinct decimal scales without floating-point rounding. - var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; - var model = new ObservablePrice { Amount = initialAmount }; - var initialScale = ((decimal)initialAmount).Scale; - var changedScale = ((decimal)changedAmount).Scale; - var expectedScales = notifyOnInitialValue ? new[] { initialScale, changedScale } : new[] { changedScale }; - using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale, notifyOnInitialValue) - .RecordValues(out var results); - - // Act - model.Amount = changedAmount; - - // Assert - results.Error.Should().BeNull(because: "conversion must be applied on both initial and subsequent chain reads"); - results.RecordedValues.Should().Equal(expectedScales, because: "notifications must match the expression and initial-value option"); - results.HasCompleted.Should().BeFalse(because: "amount changes do not complete the observation"); - } - - /// Verifies that a numeric conversion at the end of a property path preserves initial and changed values. - [Fact] - public void NumericConversionAtLeaf_PropertyChanges_AreObserved() - { - // Arrange - var initialAmount = _randomizer.Double(); - var changedAmount = initialAmount + _randomizer.Double(1, 2); - var model = new ObservablePrice { Amount = initialAmount }; - var expectedAmounts = new[] { (decimal)initialAmount, (decimal)changedAmount }; - using var subscription = model.WhenValueChanged(static price => (decimal)price.Amount) - .RecordValues(out var results); - - // Act - model.Amount = changedAmount; - - // Assert - results.Error.Should().BeNull(because: "a conversion must also remain supported as the final expression step"); - results.RecordedValues.Should().Equal(expectedAmounts, because: "each observed value must be converted to the requested type"); - results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); - } - - /// Verifies that a reference cast preserves observation of properties on the runtime model. - [Fact] - public void ReferenceCastBeforePropertyAccess_PropertyChanges_AreObserved() - { - // Arrange - var person = Fakers.Person.Clone().WithSeed(_randomizer).Generate(); - INotifyPropertyChanged model = person; - var initialAge = person.Age; - var changedAge = initialAge + _randomizer.Int(1, byte.MaxValue); - using var subscription = model.WhenValueChanged(static source => ((Person)source).Age) - .RecordValues(out var results); - - // Act - person.Age = changedAge; - - // Assert - results.Error.Should().BeNull(because: "supported reference casts must preserve property observation"); - results.RecordedValues.Should().Equal(new[] { initialAge, changedAge }, because: "the runtime model remains the notification source"); - results.HasCompleted.Should().BeFalse(because: "further age changes remain observable"); - } - - /// Verifies that an interface cast preserves observation after replacing an intermediate object. - [Fact] - public void InterfaceCastBeforePropertyAccess_ReplacementChildChanges_AreObserved() - { - // Arrange - var initialAge = _randomizer.Int(1, byte.MaxValue); - var replacementAge = initialAge + _randomizer.Int(1, byte.MaxValue); - var changedAge = replacementAge + _randomizer.Int(1, byte.MaxValue); - var parent = new ParentModel { Child = new ChildModel { Age = initialAge } }; - var replacement = new ChildModel { Age = replacementAge }; - using var subscription = parent.WhenValueChanged(static source => ((IHasAge)source.Child!).Age) - .RecordValues(out var results); - parent.Child = replacement; - - // Act - replacement.Age = changedAge; - - // Assert - results.Error.Should().BeNull(because: "supported interface casts must preserve nested property observation"); - results.RecordedValues.Should().Equal(new[] { initialAge, replacementAge, changedAge }, - because: "the interface property must follow the replacement child and its subsequent changes"); - results.HasCompleted.Should().BeFalse(because: "further child changes remain observable"); - } - - /// Verifies that converted properties retain independent values and notification sources. - /// Whether to change the first observed property rather than the second. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void NumericConversionsOnDifferentProperties_Changes_AreIndependent(bool changeAmount) - { - // Arrange - var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var initialOtherAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; - var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 16d; - var model = new ObservablePrice { Amount = initialAmount, OtherAmount = initialOtherAmount }; - var initialScale = ((decimal)initialAmount).Scale; - var initialOtherScale = ((decimal)initialOtherAmount).Scale; - var changedScale = ((decimal)changedAmount).Scale; - var expectedScales = changeAmount ? new[] { initialScale, changedScale } : new[] { initialScale }; - var expectedOtherScales = changeAmount ? new[] { initialOtherScale } : new[] { initialOtherScale, changedScale }; - using var amountSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) - .RecordValues(out var amounts); - using var otherSubscription = model.WhenValueChanged(static price => ((decimal)price.OtherAmount).Scale) - .RecordValues(out var otherAmounts); - - // Act - if (changeAmount) - { - model.Amount = changedAmount; - } - else - { - model.OtherAmount = changedAmount; - } - - // Assert - amounts.Error.Should().BeNull(because: "the first converted property remains observable"); - otherAmounts.Error.Should().BeNull(because: "the second converted property remains observable"); - amounts.RecordedValues.Should().Equal(expectedScales, because: "the first path observes only its own property"); - otherAmounts.RecordedValues.Should().Equal(expectedOtherScales, because: "the second path observes only its own property"); - amounts.HasCompleted.Should().BeFalse(because: "the first observation remains active"); - otherAmounts.HasCompleted.Should().BeFalse(because: "the second observation remains active"); - } - - /// Verifies that different conversions of one property retain their own evaluation paths. - [Fact] - public void DifferentConversionPathsOnSameProperty_Changes_UseEachConversion() - { - // Arrange - var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; - var model = new ObservablePrice { Amount = initialAmount }; - var expectedScales = new[] { ((decimal)initialAmount).Scale, ((decimal)changedAmount).Scale }; - var expectedTruncatedScales = new[] { ((decimal)(long)initialAmount).Scale, ((decimal)(long)changedAmount).Scale }; - using var exactSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) - .RecordValues(out var exact); - using var truncatedSubscription = model.WhenValueChanged(static price => ((decimal)(long)price.Amount).Scale) - .RecordValues(out var truncated); - - // Act - model.Amount = changedAmount; - - // Assert - exact.Error.Should().BeNull(because: "the direct conversion remains observable"); - truncated.Error.Should().BeNull(because: "the conversion through an integer remains observable"); - exact.RecordedValues.Should().Equal(expectedScales, because: "the direct path preserves fractional digits"); - truncated.RecordedValues.Should().Equal(expectedTruncatedScales, because: "the integer conversion discards fractional digits"); - exact.HasCompleted.Should().BeFalse(because: "the direct observation remains active"); - truncated.HasCompleted.Should().BeFalse(because: "the integer-converted observation remains active"); - } - - /// Verifies that conversions to different result types do not share incompatible property factories. - [Fact] - public void LeafConversionsWithDifferentResultTypes_Changes_UseEachResultType() - { - // Arrange - var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); - var model = new ObservablePrice { Amount = initialAmount }; - var expectedDecimals = new[] { (decimal)initialAmount, (decimal)changedAmount }; - var expectedIntegers = new[] { (long)initialAmount, (long)changedAmount }; - using var decimalSubscription = model.WhenValueChanged(static price => (decimal)price.Amount) - .RecordValues(out var decimals); - using var integerSubscription = model.WhenValueChanged(static price => (long)price.Amount) - .RecordValues(out var integers); - - // Act - model.Amount = changedAmount; - - // Assert - decimals.Error.Should().BeNull(because: "the decimal result must have a compatible factory"); - integers.Error.Should().BeNull(because: "the integer result must have a compatible factory"); - decimals.RecordedValues.Should().Equal(expectedDecimals, because: "the decimal path preserves fractional values"); - integers.RecordedValues.Should().Equal(expectedIntegers, because: "the integer path truncates fractional values"); - decimals.HasCompleted.Should().BeFalse(because: "the decimal observation remains active"); - integers.HasCompleted.Should().BeFalse(because: "the integer observation remains active"); - } - - /// Verifies that conversion methods with matching source and result types retain distinct behavior. - [Fact] - public void ConversionMethodsWithSameSignature_Changes_UseEachMethod() - { - // Arrange - var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; - var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; - var model = new ObservablePrice { Amount = initialAmount }; - Func exactConversion = ConvertAmount; - Func truncatedConversion = TruncateAmount; - var parameter = Expression.Parameter(typeof(ObservablePrice), nameof(model)); - var amount = Expression.Property(parameter, nameof(ObservablePrice.Amount)); - var exactBody = Expression.Property(Expression.Convert(amount, typeof(decimal), exactConversion.Method), nameof(decimal.Scale)); - var truncatedBody = Expression.Property( - Expression.Convert(amount, typeof(decimal), truncatedConversion.Method), nameof(decimal.Scale)); - var exactPath = Expression.Lambda>(exactBody, parameter); - var truncatedPath = Expression.Lambda>(truncatedBody, parameter); - var expectedScales = new[] { exactConversion(initialAmount).Scale, exactConversion(changedAmount).Scale }; - var expectedTruncatedScales = new[] { truncatedConversion(initialAmount).Scale, truncatedConversion(changedAmount).Scale }; - using var exactSubscription = model.WhenValueChanged(exactPath) - .RecordValues(out var exact); - using var truncatedSubscription = model.WhenValueChanged(truncatedPath) - .RecordValues(out var truncated); - - // Act - model.Amount = changedAmount; - - // Assert - exact.Error.Should().BeNull(because: "the fractional conversion method remains observable"); - truncated.Error.Should().BeNull(because: "the truncating conversion method remains observable"); - exact.RecordedValues.Should().Equal(expectedScales, because: "the fractional conversion method belongs to this path"); - truncated.RecordedValues.Should().Equal(expectedTruncatedScales, because: "the truncating conversion method belongs to this path"); - exact.HasCompleted.Should().BeFalse(because: "the fractional observation remains active"); - truncated.HasCompleted.Should().BeFalse(because: "the truncating observation remains active"); - } - - /// Verifies that a user-defined conversion operator is applied before reading a property of its result. - [Fact] - public void UserDefinedConversionBeforePropertyAccess_PropertyChanges_AreObserved() - { - // Arrange - // A fractional amount proves the operator ran, because it keeps only whole units. - var initialAmount = _randomizer.Int(1, ushort.MaxValue) + 0.5d; - var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); - var model = new ObservablePrice { Amount = initialAmount }; - var expectedUnits = new[] { ((Money)initialAmount).WholeUnits, ((Money)changedAmount).WholeUnits }; - using var subscription = model.WhenValueChanged(static price => ((Money)price.Amount).WholeUnits) - .RecordValues(out var results); - - // Act - model.Amount = changedAmount; - - // Assert - results.Error.Should().BeNull(because: "a user-defined conversion operator must run before the next property is read"); - results.RecordedValues.Should().Equal(expectedUnits, because: "each observed value must be read from the converted result"); - results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); - } - - /// Verifies that a user-defined conversion keeps its own evaluation path alongside a built-in conversion. - [Fact] - public void UserDefinedAndBuiltInConversionsOnSameProperty_Changes_UseEachConversion() - { - // Arrange - var initialAmount = _randomizer.Int(1, ushort.MaxValue) + 0.5d; - var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); - var model = new ObservablePrice { Amount = initialAmount }; - var expectedUnits = new[] { ((Money)initialAmount).WholeUnits, ((Money)changedAmount).WholeUnits }; - var expectedScales = new[] { ((decimal)initialAmount).Scale, ((decimal)changedAmount).Scale }; - using var moneySubscription = model.WhenValueChanged(static price => ((Money)price.Amount).WholeUnits) - .RecordValues(out var units); - using var decimalSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) - .RecordValues(out var scales); - - // Act - model.Amount = changedAmount; - - // Assert - units.Error.Should().BeNull(because: "the user-defined conversion must have a compatible factory"); - scales.Error.Should().BeNull(because: "the built-in conversion must have a compatible factory"); - units.RecordedValues.Should().Equal(expectedUnits, because: "the user-defined path reports whole units"); - scales.RecordedValues.Should().Equal(expectedScales, because: "the built-in path reports the decimal scale"); - units.HasCompleted.Should().BeFalse(because: "the user-defined observation remains active"); - scales.HasCompleted.Should().BeFalse(because: "the built-in observation remains active"); - } - - /// Verifies that equivalent converted paths share a factory regardless of lambda parameter names. - [Fact] - public void EquivalentConversionPaths_DifferentParameterNames_ReuseFactory() - { - // Arrange - Expression> first = static price => ((decimal)price.Amount).Scale; - Expression> second = static otherPrice => ((decimal)otherPrice.Amount).Scale; - var firstFactory = ObservablePropertyFactoryCache.Instance.GetFactory(first); - - // Act - var secondFactory = ObservablePropertyFactoryCache.Instance.GetFactory(second); - - // Assert - secondFactory.Should().BeSameAs(firstFactory, because: "parameter names do not change a property's evaluation path"); - } - - private static decimal ConvertAmount(double amount) => (decimal)amount; - - private static decimal TruncateAmount(double amount) => decimal.Truncate((decimal)amount); - - /// A conversion target reachable only through a user-defined conversion operator. - private sealed class Money - { - private Money(double amount) => WholeUnits = (int)amount; - - /// Gets the whole units of the converted amount. - public int WholeUnits { get; } - - public static explicit operator Money(double amount) => new(amount); - } -} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs deleted file mode 100644 index 863cbf110..000000000 --- a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs +++ /dev/null @@ -1,624 +0,0 @@ -// 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.Concurrency; -using System.Reactive.Linq; - -using Bogus; - -using DynamicData.Binding; -using DynamicData.Tests.Utilities; -using FluentAssertions; - -using Xunit; -using Xunit.Abstractions; - -namespace DynamicData.Tests.Binding; - -/// -/// Single-threaded contract tests for : -/// handler attachment ordering, subscription cleanup, expression conversions, no-dedup semantics, and deep-chain re-walks on swaps. -/// -public sealed partial class WhenPropertyChangedBehaviorFixture -{ - /// An arbitrary observed value; these tests assert handler lifetime, not the value itself. - private const double ObservedAmount = 41.375; - - private readonly Randomizer _randomizer; - - /// Initializes deterministic inputs for property-observation contracts. - /// Receives the seed used to generate test inputs. - public WhenPropertyChangedBehaviorFixture(ITestOutputHelper output) - { - const int seed = 0x35C1_709B; - _randomizer = new Randomizer(seed); - output.WriteLine($"{nameof(WhenPropertyChangedBehaviorFixture)} seed: 0x{seed:X8}"); - } - - [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"); - } - - // https://github.com/reactivemarbles/DynamicData/issues/1149 - [Fact] - public void ExpressionContainsImplicitInterfaceCast() - { - var child = new ChildModel() - { - Age = 10 - }; - - using var subscription = ObserveAge(child) - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedValues.Should().ContainSingle("the initial value of the observed expression should have been published"); - results.RecordedValues[0].Should().Be(child.Age, "the initial value of the observed expression should have been published"); - - ++child.Age; - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedValues.Skip(1).Should().ContainSingle("the value of the observed expression changed once"); - results.RecordedValues[1].Should().Be(child.Age, "the correct value should have been published"); - - static IObservable ObserveAge(T source) - where T : IHasAge - => source.WhenValueChanged(source => source.Age); - } - - /// Verifies that a throwing initial observer leaves no property-change handler attached. - [Fact] - public void Shallow_InitialObserverThrows_DetachesHandler() - { - // Arrange - var model = new ObservablePrice { Amount = ObservedAmount }; - var error = new InvalidOperationException(); - var results = new ValueRecordingObserver(ImmediateScheduler.Instance); - IObserver observer = results; - var source = model.WhenValueChanged(static price => price.Amount); - - // Act - Action subscribe = () => - { - using var subscription = source.Subscribe(value => - { - observer.OnNext(value); - throw error; - }, observer.OnError); - }; - - // Assert - subscribe.Should().Throw(because: "observer failures must escape Subscribe") - .Which.Should().BeSameAs(error, because: "the original observer failure must be preserved"); - results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the failure occurs during initial delivery"); - results.Error.Should().BeNull(because: "an observer failure must not be converted into an OnError notification"); - model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); - model.HandlerCount.Should().Be(0, because: "a throwing Subscribe cannot return a disposable to its caller"); - } - - /// Verifies that a throwing initial observer releases property-change handlers at every chain level. - [Fact] - public void DeepChain_InitialObserverThrows_DetachesEveryHandler() - { - // Arrange - var leaf = new ObservablePrice { Amount = ObservedAmount }; - var child = new ObservablePrice { Child = leaf }; - var root = new ObservablePrice { Child = child }; - var models = new[] { root, child, leaf }; - var error = new InvalidOperationException(); - var results = new ValueRecordingObserver(ImmediateScheduler.Instance); - IObserver observer = results; - var source = root.WhenValueChanged(static price => price.Child!.Child!.Amount); - - // Act - Action subscribe = () => - { - using var subscription = source.Subscribe(value => - { - observer.OnNext(value); - throw error; - }, observer.OnError); - }; - - // Assert - subscribe.Should().Throw(because: "observer failures must escape Subscribe") - .Which.Should().BeSameAs(error, because: "the original observer failure must be preserved"); - results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the failure occurs during initial delivery"); - results.Error.Should().BeNull(because: "an observer failure must not be converted into an OnError notification"); - models.Should().OnlyContain(model => model.WasSubscribed, because: "each observable level must be registered before it is read"); - models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, - because: "failed initialization must release every handler, not just the root handler"); - } - - /// Verifies that an initial getter failure releases the handler when the default error handler throws. - [Fact] - public void Shallow_InitialGetterThrows_DefaultErrorHandler_DetachesHandler() - { - // Arrange - var error = new InvalidOperationException(); - var model = new ObservablePrice { Amount = ObservedAmount, ReadError = error }; - var source = model.WhenValueChanged(static price => price.Amount); - - // Act - Action subscribe = () => - { - using var subscription = source.Subscribe(); - }; - - // Assert - subscribe.Should().Throw(because: "the default Rx error handler must rethrow the getter failure") - .Which.Should().BeSameAs(error, because: "the original getter failure must be preserved"); - model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); - model.HandlerCount.Should().Be(0, because: "failed initialization must not retain the event handler"); - } - - /// Verifies that a failing chain getter releases every handler when the default error handler throws. - /// Whether subscribing requests an initial value notification. - /// Whether an intermediate getter fails before the leaf can be subscribed. - [Theory] - [InlineData(true, false)] - [InlineData(true, true)] - [InlineData(false, true)] - public void DeepChain_InitialGetterThrows_DefaultErrorHandler_DetachesEveryHandler(bool notifyOnInitialValue, bool failBeforeLeaf) - { - // Arrange - var error = new InvalidOperationException(); - var leaf = new ObservablePrice { Amount = ObservedAmount, ReadError = failBeforeLeaf ? null : error }; - var child = new ObservablePrice { Child = leaf, ChildReadError = failBeforeLeaf ? error : null }; - var root = new ObservablePrice { Child = child }; - var models = new[] { root, child, leaf }; - var source = root.WhenValueChanged(static price => price.Child!.Child!.Amount, notifyOnInitialValue); - - // Act - Action subscribe = () => - { - using var subscription = source.Subscribe(); - }; - - // Assert - subscribe.Should().Throw(because: "the default Rx error handler must rethrow initialization failures") - .Which.GetBaseException().Should().BeSameAs(error, because: "the failure must originate in the observed getter"); - root.WasSubscribed.Should().BeTrue(because: "the root handler must attach before its child is read"); - child.WasSubscribed.Should().BeTrue(because: "the intermediate handler must attach before its child is read"); - leaf.WasSubscribed.Should().Be(!failBeforeLeaf, because: "the leaf is reachable only if the intermediate getter succeeds"); - models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, - because: "failed initialization must release handlers at every visited level"); - } - - /// Verifies that a handled initial getter failure terminates observation and releases its event handler. - [Fact] - public void Shallow_InitialGetterThrows_ErrorIsRecordedAndHandlerDetached() - { - // Arrange - var error = new InvalidOperationException(); - var model = new ObservablePrice { Amount = ObservedAmount, ReadError = error }; - - // Act - using var subscription = model.WhenPropertyChanged(static price => price.Amount) - .RecordValues(out var results); - - // Assert - results.Error.Should().BeSameAs(error, because: "getter failures must be delivered through OnError"); - results.RecordedValues.Should().BeEmpty(because: "the initial getter did not produce a value"); - results.HasCompleted.Should().BeFalse(because: "OnError is the terminal notification"); - model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); - model.HandlerCount.Should().Be(0, because: "OnError must release the handler before Subscribe returns"); - } - - /// Verifies that a handled chain getter failure terminates observation and releases every event handler. - /// Whether subscribing requests an initial value notification. - /// Whether an intermediate getter fails before the leaf can be subscribed. - [Theory] - [InlineData(true, false)] - [InlineData(true, true)] - [InlineData(false, true)] - public void DeepChain_InitialGetterThrows_ErrorIsRecordedAndEveryHandlerDetached(bool notifyOnInitialValue, bool failBeforeLeaf) - { - // Arrange - var error = new InvalidOperationException(); - var leaf = new ObservablePrice { Amount = ObservedAmount, ReadError = failBeforeLeaf ? null : error }; - var child = new ObservablePrice { Child = leaf, ChildReadError = failBeforeLeaf ? error : null }; - var root = new ObservablePrice { Child = child }; - var models = new[] { root, child, leaf }; - - // Act - using var subscription = root.WhenPropertyChanged(static price => price.Child!.Child!.Amount, notifyOnInitialValue) - .RecordValues(out var results); - - // Assert - results.Error.Should().NotBeNull(because: "chain getter failures must be delivered through OnError"); - results.Error!.GetBaseException().Should().BeSameAs(error, because: "the failure must originate in the observed getter"); - results.RecordedValues.Should().BeEmpty(because: "the chain did not produce an obtainable value"); - results.HasCompleted.Should().BeFalse(because: "OnError is the terminal notification"); - root.WasSubscribed.Should().BeTrue(because: "the root handler must attach before its child is read"); - child.WasSubscribed.Should().BeTrue(because: "the intermediate handler must attach before its child is read"); - leaf.WasSubscribed.Should().Be(!failBeforeLeaf, because: "the leaf is reachable only if the intermediate getter succeeds"); - models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, - because: "OnError must release every handler before Subscribe returns"); - } - - /// Verifies that live property handlers belong to the returned subscription until it is disposed. - /// Whether the observed property is reached through intermediate objects. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void Subscription_ExplicitDisposal_ReleasesHandlers(bool deepChain) - { - // Arrange - var leaf = new ObservablePrice { Amount = ObservedAmount }; - var child = new ObservablePrice { Child = leaf }; - var root = new ObservablePrice { Child = child }; - var models = deepChain ? new[] { root, child, leaf } : new[] { leaf }; - var source = deepChain - ? root.WhenValueChanged(static price => price.Child!.Child!.Amount) - : leaf.WhenValueChanged(static price => price.Amount); - using var subscription = source.RecordValues(out var results); - var attachedHandlerCounts = models.Select(model => model.HandlerCount).ToArray(); - - // Act - subscription.Dispose(); - - // Assert - attachedHandlerCounts.Should().OnlyContain(count => count == 1, because: "each visited object must stay subscribed after initialization"); - models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, - because: "disposing the returned subscription must release every retained handler"); - results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "initialization must publish the observed value"); - results.Error.Should().BeNull(because: "explicit disposal is not an observation failure"); - results.HasCompleted.Should().BeFalse(because: "unsubscribing does not publish a completion notification"); - } - - /// Verifies that synchronous completion during initial delivery releases every property handler. - /// Whether the observed property is reached through intermediate objects. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void Subscription_SynchronousCompletion_ReleasesHandlers(bool deepChain) - { - // Arrange - var leaf = new ObservablePrice { Amount = ObservedAmount }; - var child = new ObservablePrice { Child = leaf }; - var root = new ObservablePrice { Child = child }; - var models = deepChain ? new[] { root, child, leaf } : new[] { leaf }; - var source = deepChain - ? root.WhenValueChanged(static price => price.Child!.Child!.Amount) - : leaf.WhenValueChanged(static price => price.Amount); - - // Act - using var subscription = source.Take(1) - .RecordValues(out var results); - - // Assert - results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the requested initial value must be delivered"); - results.Error.Should().BeNull(because: "taking an initial value is normal completion"); - results.HasCompleted.Should().BeTrue(because: "Take completes after receiving its requested value"); - models.Should().OnlyContain(model => model.WasSubscribed, because: "handlers must attach before initial delivery"); - models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, - because: "synchronous completion must release handlers before Subscribe returns"); - } - - /// - /// An observable input with numeric properties, whose custom event accessors expose property - /// subscription lifetimes and whose getters can be made to fail on demand. - /// - private sealed class ObservablePrice : INotifyPropertyChanged - { - private double _amount; - private ObservablePrice? _child; - private double _otherAmount; - private PropertyChangedEventHandler? _propertyChanged; - - /// - public event PropertyChangedEventHandler? PropertyChanged - { - add - { - WasSubscribed = true; - _propertyChanged += value; - } - - remove => _propertyChanged -= value; - } - - /// Gets or sets the amount and raises a property-change notification when set. - public double Amount - { - get => ReadError is null ? _amount : throw ReadError; - set - { - _amount = value; - _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Amount))); - } - } - - /// Gets the next object in a nested property path. - public ObservablePrice? Child - { - get => ChildReadError is null ? _child : throw ChildReadError; - init => _child = value; - } - - /// Gets an optional failure raised when reading . - public InvalidOperationException? ChildReadError { get; init; } - - /// Gets the number of event handlers retained by this object. - public int HandlerCount => _propertyChanged?.GetInvocationList().Length ?? 0; - - /// Gets an optional failure raised when reading . - public InvalidOperationException? ReadError { get; init; } - - /// Gets or sets a second amount, used to observe two converted paths on one object. - public double OtherAmount - { - get => _otherAmount; - set - { - _otherAmount = value; - _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(OtherAmount))); - } - } - - /// Gets whether any observer has registered a property-change handler. - public bool WasSubscribed { get; private set; } - } - - private interface IHasAge - : INotifyPropertyChanged - { - int Age { get; } - } - - 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 - : IHasAge - { - 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/WhenPropertyChangedFixture.IntegrationTests.DeepChain.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.DeepChain.cs new file mode 100644 index 000000000..eb6b35173 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.DeepChain.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Reactive; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class IntegrationTests + { + [Fact] + public async Task DeepChain_ConcurrentLeafMutationDuringInitialEmit_NotDropped() + { + // Deep-chain version of the above. The observer blocks inside its OnNext for the initial + // leaf value while a second thread mutates the leaf. + var parent = new ParentModel { Child = new ChildModel { Age = 10 } }; + + var whenSubscribing = new ManualResetEventSlim(); + var whenValueChanged = new ManualResetEventSlim(); + + var emissions = new List(); + var observer = Observer.Create>(pv => + { + emissions.Add(pv.Value); + whenSubscribing.Set(); + whenValueChanged.Wait(); + }); + + var source = parent.WhenPropertyChanged(static p => p.Child!.Age, notifyOnInitialValue: true); + + await Task.WhenAll( + Task.Run(() => + { + using var subscription = source.Subscribe(observer); + }), + Task.Run(() => + { + whenSubscribing.Wait(); + parent.Child!.Age = 20; + whenValueChanged.Set(); + })).WaitAsync(ConditionTimeout); + + emissions.Should().Equal(new[] { 10, 20 }); + } + + [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"); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.Shallow.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.Shallow.cs new file mode 100644 index 000000000..0545fdb8b --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.Shallow.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Reactive; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class IntegrationTests + { + [Fact] + public async Task Shallow_ConcurrentMutationDuringInitialEmit_NotDropped() + { + var item = new Item() + { + Id = 1, + Value = 10 + }; + + var whenSubscribing = new ManualResetEventSlim(); + var whenValueChanged = new ManualResetEventSlim(); + + var source = item.WhenPropertyChanged( + propertyAccessor: static item => item.Value, + notifyOnInitialValue: true); + + var observedValues = new List(); + var observer = Observer.Create>(propertyValue => + { + observedValues.Add(propertyValue.Value); + + whenSubscribing.Set(); + whenValueChanged.Wait(); + }); + + await Task.WhenAll( + Task.Run(() => + { + using var subscription = source.Subscribe(observer); + }), + Task.Run(() => + { + whenSubscribing.Wait(); + + item.Value = 20; + + whenValueChanged.Set(); + })); + + observedValues.Should().BeEquivalentTo( + expectation: new [] { 10, 20 }, + config: options => options.WithStrictOrdering(), + because: "All change events occurring after publication of the initial value should be captured and forwarded."); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.cs new file mode 100644 index 000000000..56e2acfd2 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.IntegrationTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public sealed partial class IntegrationTests + : IntegrationTestFixtureBase + { + private static readonly TimeSpan ConditionTimeout + = TimeSpan.FromSeconds(30); + + private static void WaitForCondition(Func condition, TimeSpan? timeout = null) => + SpinWait.SpinUntil(condition, timeout ?? ConditionTimeout); + + [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"); + } + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Conversions.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Conversions.cs new file mode 100644 index 000000000..a18655e0c --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Conversions.cs @@ -0,0 +1,327 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class UnitTests + { + [Fact] + public void NumericConversionBeforePropertyAccess_InitialValue_IsObserved() + { + // Arrange + // An odd numerator produces an exactly representable, non-integral amount. + var amount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var model = new ObservablePrice { Amount = amount }; + var expectedScale = ((decimal)amount).Scale; + + // Act + using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) + .RecordValues(out var results); + + // Assert + results.Error.Should().BeNull(because: "the next property belongs to the converted decimal, not the source double"); + results.RecordedValues.Should().Equal(new[] { expectedScale }, because: "the initial value must match the supplied expression"); + results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); + } + + /// Verifies that property changes remain observable through a value-changing numeric conversion. + /// Whether subscribing requests an initial value notification. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NumericConversionBeforePropertyAccess_PropertyChanges_AreObserved(bool notifyOnInitialValue) + { + // Arrange + // Odd quarters and eighths have distinct decimal scales without floating-point rounding. + var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; + var model = new ObservablePrice { Amount = initialAmount }; + var initialScale = ((decimal)initialAmount).Scale; + var changedScale = ((decimal)changedAmount).Scale; + var expectedScales = notifyOnInitialValue ? new[] { initialScale, changedScale } : new[] { changedScale }; + using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale, notifyOnInitialValue) + .RecordValues(out var results); + + // Act + model.Amount = changedAmount; + + // Assert + results.Error.Should().BeNull(because: "conversion must be applied on both initial and subsequent chain reads"); + results.RecordedValues.Should().Equal(expectedScales, because: "notifications must match the expression and initial-value option"); + results.HasCompleted.Should().BeFalse(because: "amount changes do not complete the observation"); + } + + /// Verifies that a numeric conversion at the end of a property path preserves initial and changed values. + [Fact] + public void NumericConversionAtLeaf_PropertyChanges_AreObserved() + { + // Arrange + var initialAmount = _randomizer.Double(); + var changedAmount = initialAmount + _randomizer.Double(1, 2); + var model = new ObservablePrice { Amount = initialAmount }; + var expectedAmounts = new[] { (decimal)initialAmount, (decimal)changedAmount }; + using var subscription = model.WhenValueChanged(static price => (decimal)price.Amount) + .RecordValues(out var results); + + // Act + model.Amount = changedAmount; + + // Assert + results.Error.Should().BeNull(because: "a conversion must also remain supported as the final expression step"); + results.RecordedValues.Should().Equal(expectedAmounts, because: "each observed value must be converted to the requested type"); + results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); + } + + /// Verifies that a reference cast preserves observation of properties on the runtime model. + [Fact] + public void ReferenceCastBeforePropertyAccess_PropertyChanges_AreObserved() + { + // Arrange + var person = Fakers.Person.Clone().WithSeed(_randomizer).Generate(); + INotifyPropertyChanged model = person; + var initialAge = person.Age; + var changedAge = initialAge + _randomizer.Int(1, byte.MaxValue); + using var subscription = model.WhenValueChanged(static source => ((Person)source).Age) + .RecordValues(out var results); + + // Act + person.Age = changedAge; + + // Assert + results.Error.Should().BeNull(because: "supported reference casts must preserve property observation"); + results.RecordedValues.Should().Equal(new[] { initialAge, changedAge }, because: "the runtime model remains the notification source"); + results.HasCompleted.Should().BeFalse(because: "further age changes remain observable"); + } + + /// Verifies that an interface cast preserves observation after replacing an intermediate object. + [Fact] + public void InterfaceCastBeforePropertyAccess_ReplacementChildChanges_AreObserved() + { + // Arrange + var initialAge = _randomizer.Int(1, byte.MaxValue); + var replacementAge = initialAge + _randomizer.Int(1, byte.MaxValue); + var changedAge = replacementAge + _randomizer.Int(1, byte.MaxValue); + var parent = new ParentModel { Child = new ChildModel { Age = initialAge } }; + var replacement = new ChildModel { Age = replacementAge }; + using var subscription = parent.WhenValueChanged(static source => ((IHasAge)source.Child!).Age) + .RecordValues(out var results); + parent.Child = replacement; + + // Act + replacement.Age = changedAge; + + // Assert + results.Error.Should().BeNull(because: "supported interface casts must preserve nested property observation"); + results.RecordedValues.Should().Equal(new[] { initialAge, replacementAge, changedAge }, + because: "the interface property must follow the replacement child and its subsequent changes"); + results.HasCompleted.Should().BeFalse(because: "further child changes remain observable"); + } + + /// Verifies that converted properties retain independent values and notification sources. + /// Whether to change the first observed property rather than the second. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NumericConversionsOnDifferentProperties_Changes_AreIndependent(bool changeAmount) + { + // Arrange + var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var initialOtherAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; + var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 16d; + var model = new ObservablePrice { Amount = initialAmount, OtherAmount = initialOtherAmount }; + var initialScale = ((decimal)initialAmount).Scale; + var initialOtherScale = ((decimal)initialOtherAmount).Scale; + var changedScale = ((decimal)changedAmount).Scale; + var expectedScales = changeAmount ? new[] { initialScale, changedScale } : new[] { initialScale }; + var expectedOtherScales = changeAmount ? new[] { initialOtherScale } : new[] { initialOtherScale, changedScale }; + using var amountSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) + .RecordValues(out var amounts); + using var otherSubscription = model.WhenValueChanged(static price => ((decimal)price.OtherAmount).Scale) + .RecordValues(out var otherAmounts); + + // Act + if (changeAmount) + { + model.Amount = changedAmount; + } + else + { + model.OtherAmount = changedAmount; + } + + // Assert + amounts.Error.Should().BeNull(because: "the first converted property remains observable"); + otherAmounts.Error.Should().BeNull(because: "the second converted property remains observable"); + amounts.RecordedValues.Should().Equal(expectedScales, because: "the first path observes only its own property"); + otherAmounts.RecordedValues.Should().Equal(expectedOtherScales, because: "the second path observes only its own property"); + amounts.HasCompleted.Should().BeFalse(because: "the first observation remains active"); + otherAmounts.HasCompleted.Should().BeFalse(because: "the second observation remains active"); + } + + /// Verifies that different conversions of one property retain their own evaluation paths. + [Fact] + public void DifferentConversionPathsOnSameProperty_Changes_UseEachConversion() + { + // Arrange + var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; + var model = new ObservablePrice { Amount = initialAmount }; + var expectedScales = new[] { ((decimal)initialAmount).Scale, ((decimal)changedAmount).Scale }; + var expectedTruncatedScales = new[] { ((decimal)(long)initialAmount).Scale, ((decimal)(long)changedAmount).Scale }; + using var exactSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) + .RecordValues(out var exact); + using var truncatedSubscription = model.WhenValueChanged(static price => ((decimal)(long)price.Amount).Scale) + .RecordValues(out var truncated); + + // Act + model.Amount = changedAmount; + + // Assert + exact.Error.Should().BeNull(because: "the direct conversion remains observable"); + truncated.Error.Should().BeNull(because: "the conversion through an integer remains observable"); + exact.RecordedValues.Should().Equal(expectedScales, because: "the direct path preserves fractional digits"); + truncated.RecordedValues.Should().Equal(expectedTruncatedScales, because: "the integer conversion discards fractional digits"); + exact.HasCompleted.Should().BeFalse(because: "the direct observation remains active"); + truncated.HasCompleted.Should().BeFalse(because: "the integer-converted observation remains active"); + } + + /// Verifies that conversions to different result types do not share incompatible property factories. + [Fact] + public void LeafConversionsWithDifferentResultTypes_Changes_UseEachResultType() + { + // Arrange + var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); + var model = new ObservablePrice { Amount = initialAmount }; + var expectedDecimals = new[] { (decimal)initialAmount, (decimal)changedAmount }; + var expectedIntegers = new[] { (long)initialAmount, (long)changedAmount }; + using var decimalSubscription = model.WhenValueChanged(static price => (decimal)price.Amount) + .RecordValues(out var decimals); + using var integerSubscription = model.WhenValueChanged(static price => (long)price.Amount) + .RecordValues(out var integers); + + // Act + model.Amount = changedAmount; + + // Assert + decimals.Error.Should().BeNull(because: "the decimal result must have a compatible factory"); + integers.Error.Should().BeNull(because: "the integer result must have a compatible factory"); + decimals.RecordedValues.Should().Equal(expectedDecimals, because: "the decimal path preserves fractional values"); + integers.RecordedValues.Should().Equal(expectedIntegers, because: "the integer path truncates fractional values"); + decimals.HasCompleted.Should().BeFalse(because: "the decimal observation remains active"); + integers.HasCompleted.Should().BeFalse(because: "the integer observation remains active"); + } + + /// Verifies that conversion methods with matching source and result types retain distinct behavior. + [Fact] + public void ConversionMethodsWithSameSignature_Changes_UseEachMethod() + { + // Arrange + var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d; + var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d; + var model = new ObservablePrice { Amount = initialAmount }; + Func exactConversion = ConvertAmount; + Func truncatedConversion = TruncateAmount; + var parameter = Expression.Parameter(typeof(ObservablePrice), nameof(model)); + var amount = Expression.Property(parameter, nameof(ObservablePrice.Amount)); + var exactBody = Expression.Property(Expression.Convert(amount, typeof(decimal), exactConversion.Method), nameof(decimal.Scale)); + var truncatedBody = Expression.Property( + Expression.Convert(amount, typeof(decimal), truncatedConversion.Method), nameof(decimal.Scale)); + var exactPath = Expression.Lambda>(exactBody, parameter); + var truncatedPath = Expression.Lambda>(truncatedBody, parameter); + var expectedScales = new[] { exactConversion(initialAmount).Scale, exactConversion(changedAmount).Scale }; + var expectedTruncatedScales = new[] { truncatedConversion(initialAmount).Scale, truncatedConversion(changedAmount).Scale }; + using var exactSubscription = model.WhenValueChanged(exactPath) + .RecordValues(out var exact); + using var truncatedSubscription = model.WhenValueChanged(truncatedPath) + .RecordValues(out var truncated); + + // Act + model.Amount = changedAmount; + + // Assert + exact.Error.Should().BeNull(because: "the fractional conversion method remains observable"); + truncated.Error.Should().BeNull(because: "the truncating conversion method remains observable"); + exact.RecordedValues.Should().Equal(expectedScales, because: "the fractional conversion method belongs to this path"); + truncated.RecordedValues.Should().Equal(expectedTruncatedScales, because: "the truncating conversion method belongs to this path"); + exact.HasCompleted.Should().BeFalse(because: "the fractional observation remains active"); + truncated.HasCompleted.Should().BeFalse(because: "the truncating observation remains active"); + } + + /// Verifies that a user-defined conversion operator is applied before reading a property of its result. + [Fact] + public void UserDefinedConversionBeforePropertyAccess_PropertyChanges_AreObserved() + { + // Arrange + // A fractional amount proves the operator ran, because it keeps only whole units. + var initialAmount = _randomizer.Int(1, ushort.MaxValue) + 0.5d; + var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); + var model = new ObservablePrice { Amount = initialAmount }; + var expectedUnits = new[] { ((Money)initialAmount).WholeUnits, ((Money)changedAmount).WholeUnits }; + using var subscription = model.WhenValueChanged(static price => ((Money)price.Amount).WholeUnits) + .RecordValues(out var results); + + // Act + model.Amount = changedAmount; + + // Assert + results.Error.Should().BeNull(because: "a user-defined conversion operator must run before the next property is read"); + results.RecordedValues.Should().Equal(expectedUnits, because: "each observed value must be read from the converted result"); + results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable"); + } + + /// Verifies that a user-defined conversion keeps its own evaluation path alongside a built-in conversion. + [Fact] + public void UserDefinedAndBuiltInConversionsOnSameProperty_Changes_UseEachConversion() + { + // Arrange + var initialAmount = _randomizer.Int(1, ushort.MaxValue) + 0.5d; + var changedAmount = initialAmount + _randomizer.Int(1, ushort.MaxValue); + var model = new ObservablePrice { Amount = initialAmount }; + var expectedUnits = new[] { ((Money)initialAmount).WholeUnits, ((Money)changedAmount).WholeUnits }; + var expectedScales = new[] { ((decimal)initialAmount).Scale, ((decimal)changedAmount).Scale }; + using var moneySubscription = model.WhenValueChanged(static price => ((Money)price.Amount).WholeUnits) + .RecordValues(out var units); + using var decimalSubscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale) + .RecordValues(out var scales); + + // Act + model.Amount = changedAmount; + + // Assert + units.Error.Should().BeNull(because: "the user-defined conversion must have a compatible factory"); + scales.Error.Should().BeNull(because: "the built-in conversion must have a compatible factory"); + units.RecordedValues.Should().Equal(expectedUnits, because: "the user-defined path reports whole units"); + scales.RecordedValues.Should().Equal(expectedScales, because: "the built-in path reports the decimal scale"); + units.HasCompleted.Should().BeFalse(because: "the user-defined observation remains active"); + scales.HasCompleted.Should().BeFalse(because: "the built-in observation remains active"); + } + + /// Verifies that equivalent converted paths share a factory regardless of lambda parameter names. + [Fact] + public void EquivalentConversionPaths_DifferentParameterNames_ReuseFactory() + { + // Arrange + Expression> first = static price => ((decimal)price.Amount).Scale; + Expression> second = static otherPrice => ((decimal)otherPrice.Amount).Scale; + var firstFactory = ObservablePropertyFactoryCache.Instance.GetFactory(first); + + // Act + var secondFactory = ObservablePropertyFactoryCache.Instance.GetFactory(second); + + // Assert + secondFactory.Should().BeSameAs(firstFactory, because: "parameter names do not change a property's evaluation path"); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.DeepChain.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.DeepChain.cs new file mode 100644 index 000000000..c3d950cb9 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.DeepChain.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Concurrency; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class UnitTests + { + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void DeepChain_InitialGetterThrows_DefaultErrorHandler_DetachesEveryHandler(bool notifyOnInitialValue, bool failBeforeLeaf) + { + // Arrange + var error = new InvalidOperationException(); + var leaf = new ObservablePrice { Amount = ObservedAmount, ReadError = failBeforeLeaf ? null : error }; + var child = new ObservablePrice { Child = leaf, ChildReadError = failBeforeLeaf ? error : null }; + var root = new ObservablePrice { Child = child }; + var models = new[] { root, child, leaf }; + var source = root.WhenValueChanged(static price => price.Child!.Child!.Amount, notifyOnInitialValue); + + // Act + Action subscribe = () => + { + using var subscription = source.Subscribe(); + }; + + // Assert + subscribe.Should().Throw(because: "the default Rx error handler must rethrow initialization failures") + .Which.GetBaseException().Should().BeSameAs(error, because: "the failure must originate in the observed getter"); + root.WasSubscribed.Should().BeTrue(because: "the root handler must attach before its child is read"); + child.WasSubscribed.Should().BeTrue(because: "the intermediate handler must attach before its child is read"); + leaf.WasSubscribed.Should().Be(!failBeforeLeaf, because: "the leaf is reachable only if the intermediate getter succeeds"); + models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, + because: "failed initialization must release handlers at every visited level"); + } + + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void DeepChain_InitialGetterThrows_ErrorIsRecordedAndEveryHandlerDetached(bool notifyOnInitialValue, bool failBeforeLeaf) + { + // Arrange + var error = new InvalidOperationException(); + var leaf = new ObservablePrice { Amount = ObservedAmount, ReadError = failBeforeLeaf ? null : error }; + var child = new ObservablePrice { Child = leaf, ChildReadError = failBeforeLeaf ? error : null }; + var root = new ObservablePrice { Child = child }; + var models = new[] { root, child, leaf }; + + // Act + using var subscription = root.WhenPropertyChanged(static price => price.Child!.Child!.Amount, notifyOnInitialValue) + .RecordValues(out var results); + + // Assert + results.Error.Should().NotBeNull(because: "chain getter failures must be delivered through OnError"); + results.Error!.GetBaseException().Should().BeSameAs(error, because: "the failure must originate in the observed getter"); + results.RecordedValues.Should().BeEmpty(because: "the chain did not produce an obtainable value"); + results.HasCompleted.Should().BeFalse(because: "OnError is the terminal notification"); + root.WasSubscribed.Should().BeTrue(because: "the root handler must attach before its child is read"); + child.WasSubscribed.Should().BeTrue(because: "the intermediate handler must attach before its child is read"); + leaf.WasSubscribed.Should().Be(!failBeforeLeaf, because: "the leaf is reachable only if the intermediate getter succeeds"); + models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, + because: "OnError must release every handler before Subscribe returns"); + } + + [Fact] + public void DeepChain_InitialObserverThrows_DetachesEveryHandler() + { + // Arrange + var leaf = new ObservablePrice { Amount = ObservedAmount }; + var child = new ObservablePrice { Child = leaf }; + var root = new ObservablePrice { Child = child }; + var models = new[] { root, child, leaf }; + var error = new InvalidOperationException(); + var results = new ValueRecordingObserver(ImmediateScheduler.Instance); + IObserver observer = results; + var source = root.WhenValueChanged(static price => price.Child!.Child!.Amount); + + // Act + Action subscribe = () => + { + using var subscription = source.Subscribe(value => + { + observer.OnNext(value); + throw error; + }, observer.OnError); + }; + + // Assert + subscribe.Should().Throw(because: "observer failures must escape Subscribe") + .Which.Should().BeSameAs(error, because: "the original observer failure must be preserved"); + results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the failure occurs during initial delivery"); + results.Error.Should().BeNull(because: "an observer failure must not be converted into an OnError notification"); + models.Should().OnlyContain(model => model.WasSubscribed, because: "each observable level must be registered before it is read"); + models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, + because: "failed initialization must release every handler, not just the root handler"); + } + + [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"); + } + + [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_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 }); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Shallow.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Shallow.cs new file mode 100644 index 000000000..bb60b30f7 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Shallow.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Concurrency; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class UnitTests + { + [Fact] + public void Shallow_InitialGetterThrows_DefaultErrorHandler_DetachesHandler() + { + // Arrange + var error = new InvalidOperationException(); + var model = new ObservablePrice { Amount = ObservedAmount, ReadError = error }; + var source = model.WhenValueChanged(static price => price.Amount); + + // Act + Action subscribe = () => + { + using var subscription = source.Subscribe(); + }; + + // Assert + subscribe.Should().Throw(because: "the default Rx error handler must rethrow the getter failure") + .Which.Should().BeSameAs(error, because: "the original getter failure must be preserved"); + model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); + model.HandlerCount.Should().Be(0, because: "failed initialization must not retain the event handler"); + } + + [Fact] + public void Shallow_InitialGetterThrows_ErrorIsRecordedAndHandlerDetached() + { + // Arrange + var error = new InvalidOperationException(); + var model = new ObservablePrice { Amount = ObservedAmount, ReadError = error }; + + // Act + using var subscription = model.WhenPropertyChanged(static price => price.Amount) + .RecordValues(out var results); + + // Assert + results.Error.Should().BeSameAs(error, because: "getter failures must be delivered through OnError"); + results.RecordedValues.Should().BeEmpty(because: "the initial getter did not produce a value"); + results.HasCompleted.Should().BeFalse(because: "OnError is the terminal notification"); + model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); + model.HandlerCount.Should().Be(0, because: "OnError must release the handler before Subscribe returns"); + } + + [Fact] + public void Shallow_InitialObserverThrows_DetachesHandler() + { + // Arrange + var model = new ObservablePrice { Amount = ObservedAmount }; + var error = new InvalidOperationException(); + var results = new ValueRecordingObserver(ImmediateScheduler.Instance); + IObserver observer = results; + var source = model.WhenValueChanged(static price => price.Amount); + + // Act + Action subscribe = () => + { + using var subscription = source.Subscribe(value => + { + observer.OnNext(value); + throw error; + }, observer.OnError); + }; + + // Assert + subscribe.Should().Throw(because: "observer failures must escape Subscribe") + .Which.Should().BeSameAs(error, because: "the original observer failure must be preserved"); + results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the failure occurs during initial delivery"); + results.Error.Should().BeNull(because: "an observer failure must not be converted into an OnError notification"); + model.WasSubscribed.Should().BeTrue(because: "registration must precede the initial value read"); + model.HandlerCount.Should().Be(0, because: "a throwing Subscribe cannot return a disposable to its caller"); + } + + [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 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 }); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Subscription.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Subscription.cs new file mode 100644 index 000000000..5cf37f3b9 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.Subscription.cs @@ -0,0 +1,72 @@ +using System.Linq; +using System.Reactive.Linq; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class UnitTests + { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Subscription_ExplicitDisposal_ReleasesHandlers(bool deepChain) + { + // Arrange + var leaf = new ObservablePrice { Amount = ObservedAmount }; + var child = new ObservablePrice { Child = leaf }; + var root = new ObservablePrice { Child = child }; + var models = deepChain ? new[] { root, child, leaf } : new[] { leaf }; + var source = deepChain + ? root.WhenValueChanged(static price => price.Child!.Child!.Amount) + : leaf.WhenValueChanged(static price => price.Amount); + using var subscription = source.RecordValues(out var results); + var attachedHandlerCounts = models.Select(model => model.HandlerCount).ToArray(); + + // Act + subscription.Dispose(); + + // Assert + attachedHandlerCounts.Should().OnlyContain(count => count == 1, because: "each visited object must stay subscribed after initialization"); + models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, + because: "disposing the returned subscription must release every retained handler"); + results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "initialization must publish the observed value"); + results.Error.Should().BeNull(because: "explicit disposal is not an observation failure"); + results.HasCompleted.Should().BeFalse(because: "unsubscribing does not publish a completion notification"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Subscription_SynchronousCompletion_ReleasesHandlers(bool deepChain) + { + // Arrange + var leaf = new ObservablePrice { Amount = ObservedAmount }; + var child = new ObservablePrice { Child = leaf }; + var root = new ObservablePrice { Child = child }; + var models = deepChain ? new[] { root, child, leaf } : new[] { leaf }; + var source = deepChain + ? root.WhenValueChanged(static price => price.Child!.Child!.Amount) + : leaf.WhenValueChanged(static price => price.Amount); + + // Act + using var subscription = source.Take(1) + .RecordValues(out var results); + + // Assert + results.RecordedValues.Should().Equal(new[] { ObservedAmount }, because: "the requested initial value must be delivered"); + results.Error.Should().BeNull(because: "taking an initial value is normal completion"); + results.HasCompleted.Should().BeTrue(because: "Take completes after receiving its requested value"); + models.Should().OnlyContain(model => model.WasSubscribed, because: "handlers must attach before initial delivery"); + models.Select(model => model.HandlerCount).Should().OnlyContain(count => count == 0, + because: "synchronous completion must release handlers before Subscribe returns"); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.cs new file mode 100644 index 000000000..a25da1c05 --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.UnitTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + public partial class UnitTests + { + private readonly Randomizer _randomizer + = new(0x35C1_709B); + + // https://github.com/reactivemarbles/DynamicData/issues/1149 + [Fact] + public void ExpressionContainsImplicitInterfaceCast() + { + var child = new ChildModel() + { + Age = 10 + }; + + using var subscription = ObserveAge(child) + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("the initial value of the observed expression should have been published"); + results.RecordedValues[0].Should().Be(child.Age, "the initial value of the observed expression should have been published"); + + ++child.Age; + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Skip(1).Should().ContainSingle("the value of the observed expression changed once"); + results.RecordedValues[1].Should().Be(child.Age, "the correct value should have been published"); + + static IObservable ObserveAge(T source) + where T : IHasAge + => source.WhenValueChanged(source => source.Age); + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.cs new file mode 100644 index 000000000..2bfded56b --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedFixture.cs @@ -0,0 +1,345 @@ +using System; +using System.ComponentModel; + +namespace DynamicData.Tests.Binding; + +public static partial class WhenPropertyChangedFixture +{ + private const double ObservedAmount = 41.375; + + private static decimal ConvertAmount(double amount) + => (decimal)amount; + + 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 decimal TruncateAmount(double amount) + => decimal.Truncate((decimal)amount); + + 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 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))); + } + } + } + + private sealed class Money + { + private Money(double amount) => WholeUnits = (int)amount; + + public int WholeUnits { get; } + + public static explicit operator Money(double amount) => new(amount); + } + + public sealed class ObservablePrice + : INotifyPropertyChanged + { + private double _amount; + private ObservablePrice? _child; + private double _otherAmount; + private PropertyChangedEventHandler? _propertyChanged; + + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + WasSubscribed = true; + _propertyChanged += value; + } + + remove => _propertyChanged -= value; + } + + public double Amount + { + get => ReadError is null ? _amount : throw ReadError; + set + { + _amount = value; + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Amount))); + } + } + + public ObservablePrice? Child + { + get => ChildReadError is null ? _child : throw ChildReadError; + init => _child = value; + } + + public InvalidOperationException? ChildReadError { get; init; } + + public int HandlerCount => _propertyChanged?.GetInvocationList().Length ?? 0; + + public InvalidOperationException? ReadError { get; init; } + + public double OtherAmount + { + get => _otherAmount; + set + { + _otherAmount = value; + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(OtherAmount))); + } + } + + public bool WasSubscribed { get; private set; } + } + + private interface IHasAge + : INotifyPropertyChanged + { + int Age { get; } + } + + 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 + : IHasAge + { + 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 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 deleted file mode 100644 index 5c8d073c8..000000000 --- a/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs +++ /dev/null @@ -1,554 +0,0 @@ -// 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 Shallow_ConcurrentMutationDuringInitialEmit_NotDropped() - { - var item = new Item() - { - Id = 1, - Value = 10 - }; - - var whenSubscribing = new ManualResetEventSlim(); - var whenValueChanged = new ManualResetEventSlim(); - - var source = item.WhenPropertyChanged( - propertyAccessor: static item => item.Value, - notifyOnInitialValue: true); - - var observedValues = new List(); - var observer = Observer.Create>(propertyValue => - { - observedValues.Add(propertyValue.Value); - - whenSubscribing.Set(); - whenValueChanged.Wait(); - }); - - await Task.WhenAll( - Task.Run(() => - { - using var subscription = source.Subscribe(observer); - }), - Task.Run(() => - { - whenSubscribing.Wait(); - - item.Value = 20; - - whenValueChanged.Set(); - })); - - observedValues.Should().BeEquivalentTo( - expectation: new [] { 10, 20 }, - config: options => options.WithStrictOrdering(), - because: "All change events occurring after publication of the initial value should be captured and forwarded."); - } - - [Fact] - public async Task DeepChain_ConcurrentLeafMutationDuringInitialEmit_NotDropped() - { - // Deep-chain version of the above. The observer blocks inside its OnNext for the initial - // leaf value while a second thread mutates the leaf. - var parent = new ParentModel { Child = new ChildModel { Age = 10 } }; - - var whenSubscribing = new ManualResetEventSlim(); - var whenValueChanged = new ManualResetEventSlim(); - - var emissions = new List(); - var observer = Observer.Create>(pv => - { - emissions.Add(pv.Value); - whenSubscribing.Set(); - whenValueChanged.Wait(); - }); - - var source = parent.WhenPropertyChanged(static p => p.Child!.Age, notifyOnInitialValue: true); - - await Task.WhenAll( - Task.Run(() => - { - using var subscription = source.Subscribe(observer); - }), - Task.Run(() => - { - whenSubscribing.Wait(); - parent.Child!.Age = 20; - whenValueChanged.Set(); - })).WaitAsync(ConditionTimeout); - - emissions.Should().Equal(new[] { 10, 20 }); - } - - [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/CrossCacheDeadlockStressTest.cs b/src/DynamicData.Tests/Cache/CrossCacheDeadlockStressTest.cs index 8915bc6bc..a26b0a90f 100644 --- a/src/DynamicData.Tests/Cache/CrossCacheDeadlockStressTest.cs +++ b/src/DynamicData.Tests/Cache/CrossCacheDeadlockStressTest.cs @@ -31,6 +31,7 @@ namespace DynamicData.Tests.Cache; /// but not hardcoded). Proves: no deadlocks, correct final state, Rx compliance. /// public sealed class CrossCacheDeadlockStressTest + : IntegrationTestFixtureBase { // ════════════════════════════════════════════════════════════════ // Bound constants — ONLY the seed and Min/Max bounds are hardcoded. diff --git a/src/DynamicData.Tests/Cache/DeadlockTortureTest.cs b/src/DynamicData.Tests/Cache/DeadlockTortureTest.cs index 4f4ddf9a4..deee5f15b 100644 --- a/src/DynamicData.Tests/Cache/DeadlockTortureTest.cs +++ b/src/DynamicData.Tests/Cache/DeadlockTortureTest.cs @@ -27,6 +27,7 @@ namespace DynamicData.Tests.Cache; /// On the PR branch (SynchronizeSafe queue-drain): no deadlock possible. /// public sealed class DeadlockTortureTest + : IntegrationTestFixtureBase { private const int ItemCount = 200; private const int Iterations = 50; diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs deleted file mode 100644 index 949ec35bf..000000000 --- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs +++ /dev/null @@ -1,890 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -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; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Cache; - -public sealed class MergeManyChangeSetsCacheFixture : IDisposable -{ -#if DEBUG - const int MarketCount = 5; - const int PricesPerMarket = 7; - const int RemoveCount = 3; -#else - const int MarketCount = 101; - const int PricesPerMarket = 103; - const int RemoveCount = 53; -#endif - - const int ItemIdStride = 1000; - const decimal BasePrice = 10m; - const decimal PriceOffset = 10m; - const decimal HighestPrice = BasePrice + PriceOffset + 1.0m; - const decimal LowestPrice = BasePrice - 1.0m; - - private readonly ISourceCache _marketCache = new SourceCache(p => p.Id); - - private readonly ChangeSetAggregator _marketCacheResults; - - private readonly Faker _marketFaker; - - private readonly Randomizer _randomizer; - - public MergeManyChangeSetsCacheFixture() - { - _randomizer = new(0x21123737); - _marketFaker = Fakers.Market.WithSeed(_randomizer); - _marketCacheResults = _marketCache.Connect().AsAggregator(); - } - - [Theory] - [InlineData(5, 7)] - [InlineData(10, 50)] -#if !DEBUG - [InlineData(10, 1_000)] - [InlineData(200, 500)] - [InlineData(1_000, 10)] -#endif - public async Task MultiThreadedStressTest(int marketCount, int priceCount) - { - var MaxAddTime = TimeSpan.FromSeconds(0.250); - var MaxRemoveTime = TimeSpan.FromSeconds(0.100); - - TimeSpan? GetRemoveTime() => _randomizer.Bool() ? _randomizer.TimeSpan(MaxRemoveTime) : null; - - IObservable AddRemoveStress(int marketCount, int priceCount, int parallel, IScheduler scheduler) => - Observable.Create(observer => new CompositeDisposable - ( - AddRemoveMarkets(marketCount, parallel, scheduler) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError), - - _marketCache.Connect() - .MergeMany(market => AddRemovePrices((Market)market, priceCount, parallel, scheduler)) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError, - onCompleted: observer.OnCompleted) - )); - - IObservable AddRemoveMarkets(int ownerCount, int parallel, IScheduler scheduler) => - _marketFaker.IntervalGenerate(MaxAddTime, scheduler) - .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(_marketCache, _ => GetRemoveTime(), scheduler)) - .Finally(_marketCache.Dispose); - - IObservable AddRemovePrices(Market market, int priceCount, int parallel, IScheduler scheduler) => - _randomizer.Interval(MaxAddTime, scheduler).Select(_ => market.CreateUniquePrice(_ => GetRandomPrice())) - .Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler)) - .Finally(market.PricesCache.Dispose); - - 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) - .Finally(() => adding = false) - .Subscribe(); - - // Subscribe / unsubscribe over and over while the collections are being modified - do - { - // Ensure items are being added asynchronously before subscribing to changes - await Task.Yield(); - - { - // Subscribe - var mergedSub = merged.Subscribe(); - - // Let other threads run - await Task.Yield(); - - // Unsubscribe - mergedSub.Dispose(); - } - } - while (adding); - - // Wait for the source cache to finish delivering all notifications. - await cacheCompleted; - - // Verify the results - CheckResultContents(_marketCacheResults, priceResults); - } - - [Fact] - public void NullChecks() - { - // having - var emptyChangeSetObs = Observable.Empty>(); - var nullChangeSetObs = (IObservable>)null!; - var emptyChildChangeSetObs = Observable.Empty>(); - var emptySelector = new Func>>(i => emptyChildChangeSetObs); - var emptyKeySelector = new Func>>((i, key) => emptyChildChangeSetObs); - var nullSelector = (Func>>)null!; - var nullKeySelector = (Func>>)null!; - var nullParentComparer = (IComparer)null!; - var emptyParentComparer = new NoOpComparer() as IComparer; - var nullChildComparer = (IComparer)null!; - var emptyChildComparer = new NoOpComparer() as IComparer; - var nullEqualityComparer = (IEqualityComparer)null!; - var emptyEqualityComparer = new NoOpEqualityComparer() as IEqualityComparer; - - // when - var actionDefault1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector); - var actionDefault2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector); - var actionDefault2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector); - var actionChildCompare1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, comparer: emptyChildComparer); - var actionChildCompare2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, comparer: emptyChildComparer); - var actionChildCompare2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, comparer: emptyChildComparer); - var actionChildCompare2c = () => emptyChangeSetObs.MergeManyChangeSets(emptyKeySelector, comparer: nullChildComparer); - - // then - emptyChangeSetObs.Should().NotBeNull(); - emptyChildChangeSetObs.Should().NotBeNull(); - emptyChildComparer.Should().NotBeNull(); - emptyEqualityComparer.Should().NotBeNull(); - emptyKeySelector.Should().NotBeNull(); - emptyParentComparer.Should().NotBeNull(); - emptySelector.Should().NotBeNull(); - nullChangeSetObs.Should().BeNull(); - nullChildComparer.Should().BeNull(); - nullEqualityComparer.Should().BeNull(); - nullKeySelector.Should().BeNull(); - nullParentComparer.Should().BeNull(); - nullSelector.Should().BeNull(); - - actionDefault1.Should().Throw(); - actionDefault2a.Should().Throw(); - actionDefault2b.Should().Throw(); - actionChildCompare1.Should().Throw(); - actionChildCompare2a.Should().Throw(); - actionChildCompare2b.Should().Throw(); - actionChildCompare2c.Should().Throw(); - } - - [Fact] - public void AbleToInvokeFactory() - { - // having - var invoked = false; - IObservable> factory(IMarket m) - { - invoked = true; - return m.LatestPrices; - } - using var sub = _marketCache.Connect().MergeManyChangeSets(factory).Subscribe(); - - // when - _marketCache.AddOrUpdate(new Market(0)); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - invoked.Should().BeTrue(); - } - - [Fact] - public void AbleToInvokeFactoryWithKey() - { - // having - var invoked = false; - IObservable> factory(IMarket m, Guid g) - { - invoked = true; - return m.LatestPrices; - } - using var sub = _marketCache.Connect().MergeManyChangeSets(factory).Subscribe(); - - // when - _marketCache.AddOrUpdate(new Market(0)); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - invoked.Should().BeTrue(); - } - - [Fact] - public void AllExistingSubItemsPresentInResult() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - AddUniquePrices(markets); - - // when - _marketCache.AddOrUpdate(markets); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(1); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - } - - [Fact] - public void AllNewSubItemsPresentInResult() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - - // when - AddUniquePrices(markets); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(MarketCount); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - } - - [Fact] - public void AllRefreshedSubItemsAreRefreshed() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - AddUniquePrices(markets); - - // when - markets.ForEach(m => m.RefreshAllPrices(GetRandomPrice)); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(MarketCount * 2); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(MarketCount * PricesPerMarket); - } - - [Fact] - public void AnyDuplicateKeyValuesShouldBeHidden() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - - // when - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - } - - [Fact] - public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - markets[1].RemoveAllPrices(); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - } - - [Fact] - public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - _marketCache.Remove(markets[0]); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Messages.Count.Should().Be(2); - results.Messages[1].Updates.Should().Be(PricesPerMarket); - } - - [Fact] - public void AnyDuplicateValuesShouldNotRefreshWhenHidden() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - markets[1].RefreshAllPrices(GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - } - - [Fact] - public void AnyRemovedSubItemIsRemoved() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - _marketCache.AddOrUpdate(markets); - AddUniquePrices(markets); - - // when - markets.ForEach(m => m.PricesCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount)))); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - results.Data.Count.Should().Be(MarketCount * (PricesPerMarket - RemoveCount)); - results.Messages.Count.Should().Be(MarketCount * 2); - results.Messages[0].Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(MarketCount * RemoveCount); - } - - [Fact] - public void AnySourceItemRemovedRemovesAllSourceValues() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - AddUniquePrices(markets); - _marketCache.AddOrUpdate(markets); - - // when - _marketCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount))); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount - RemoveCount); - results.Messages.Count.Should().Be(2); - results.Data.Count.Should().Be((MarketCount - RemoveCount) * PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(PricesPerMarket * RemoveCount); - } - - [Fact] - public void ClearingParentEmitsSingleChangeSet() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - AddUniquePrices(markets); - _marketCache.AddOrUpdate(markets); - - // when - _marketCache.Clear(); - - // then - _marketCacheResults.Data.Count.Should().Be(0); - results.Data.Count.Should().Be(0); - results.Messages.Count.Should().Be(2); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(0); - } - - [Fact] - public void ChangingSourceByUpdateRemovesPreviousAndAddsNewValues() - { - // having - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - var market = new Market(0); - market.SetPrices(0, PricesPerMarket * 2, GetRandomPrice); - _marketCache.AddOrUpdate(market); - var updatedMarket = new Market(market); - updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, GetRandomPrice); - - // when - _marketCache.AddOrUpdate(updatedMarket); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket * 2); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(PricesPerMarket); - results.Data.Items.Zip(updatedMarket.PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - } - - [Fact] - public void ComparerOnlyAddsBetterAddedValues() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketHigh = new Market(2); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(marketHigh); - - // when - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(3); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); - } - - [Fact] - public void ComparerOnlyAddsBetterExistingValues() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketHigh = new Market(2); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); - - // when - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(marketHigh); - - // then - _marketCacheResults.Data.Count.Should().Be(3); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); - } - - [Fact] - public void ComparerOnlyAddsBetterValuesOnSourceUpdate() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketLowLow = new Market(marketLow); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - marketLowLow.SetPrices(0, PricesPerMarket, LowestPrice - 1); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - - // when - _marketCache.AddOrUpdate(marketLowLow); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowLow.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(0); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void ComparerUpdatesToCorrectValueOnRefresh() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketFlipFlop = new Market(1); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketFlipFlop); - - // when - marketFlipFlop.RefreshAllPrices(LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketFlipFlop.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - highPriceResults.Summary.Overall.Refreshes.Should().Be(0); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void ComparerUpdatesToCorrectValueOnRemove() - { - // having - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketHigh = new Market(2); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(marketHigh); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); - - // when - _marketCache.Remove(marketLow); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); - } - - [Fact] - public void ComparerUpdatesToCorrectValueOnUpdate() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketFlipFlop = new Market(1); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketFlipFlop); - - // when - marketFlipFlop.UpdateAllPrices(LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketFlipFlop.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - highPriceResults.Summary.Overall.Refreshes.Should().Be(0); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void ComparerOnlyUpdatesVisibleValuesOnUpdate() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - - // when - marketLow.UpdateAllPrices(LowestPrice - 1); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(0); - highPriceResults.Summary.Overall.Refreshes.Should().Be(0); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void ComparerOnlyRefreshesVisibleValues() - { - // having - using var highPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer, MarketPrice.HighPriceCompare).AsAggregator(); - using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer, MarketPrice.LowPriceCompare).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - - // when - marketLow.RefreshAllPrices(LowestPrice - 1); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - lowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowPriceResults.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); - lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - highPriceResults.Data.Count.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - highPriceResults.Summary.Overall.Removes.Should().Be(0); - highPriceResults.Summary.Overall.Updates.Should().Be(0); - highPriceResults.Summary.Overall.Refreshes.Should().Be(0); - highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void EqualityComparerHidesUpdatesWithoutChanges() - { - // having - var market = new Market(0); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - market.SetPrices(0, PricesPerMarket, LowestPrice); - _marketCache.AddOrUpdate(market); - - // when - market.SetPrices(0, PricesPerMarket, LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket); - results.Messages.Count.Should().Be(1); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void EveryItemVisibleWhenSequenceCompletes() - { - // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket))); - - // when - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices).AsAggregator(); - DisposeMarkets(); - - // then - results.Data.Count.Should().Be(PricesPerMarket * MarketCount); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket * MarketCount); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void MergedObservableCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) - { - // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); - var hasSourceSequenceCompleted = false; - var hasMergedSequenceCompleted = false; - - using var cleanup = _marketCache.Connect().Do(_ => { }, () => hasSourceSequenceCompleted = true) - .MergeManyChangeSets(m => m.LatestPrices).Subscribe(_ => { }, () => hasMergedSequenceCompleted = true); - - // when - if (completeSource) - { - DisposeMarkets(); - } - - // then - hasSourceSequenceCompleted.Should().Be(completeSource); - hasMergedSequenceCompleted.Should().Be(completeSource && completeChildren); - } - - [Fact] - public void MergedObservableWillFailIfSourceFails() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - _marketCache.AddOrUpdate(markets); - var receivedError = default(Exception); - var expectedError = new Exception("Test exception"); - var throwObservable = Observable.Throw>(expectedError); - - using var cleanup = _marketCache.Connect().Concat(throwObservable) - .MergeManyChangeSets(m => m.LatestPrices).Subscribe(_ => { }, err => receivedError = err); - - // when - DisposeMarkets(); - - // then - receivedError.Should().Be(expectedError); - } - - [Fact] - public void MergeManyChangeSetsWorksCorrectlyWithValueTypes() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - _marketCache.AddOrUpdate(markets); - markets.ForEach(m => m.SetPrices(0, PricesPerMarket, GetRandomPrice)); - using var results = _marketCache.Connect() - .MergeManyChangeSets(m => m.LatestPrices.Transform(p => p.Price)) - .AsAggregator(); - - // when - markets.ForEach(m => m.RemoveAllPrices()); - - // then - results.Data.Count.Should().Be(0); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(PricesPerMarket); - } - - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void OrderOfChangesIsPreserved(bool removeFirst) - { - // Arrange - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - AddUniquePrices(markets); - _marketCache.AddOrUpdate(markets); - var markets2 = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - AddUniquePrices(markets2); - using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - (var firstReason, var nextReason, int expectedChanges) = removeFirst - ? (ChangeReason.Remove, ChangeReason.Add, 2 * MarketCount * PricesPerMarket) - : (ChangeReason.Add, ChangeReason.Remove, 3 * MarketCount * PricesPerMarket); - - // Act - _marketCache.Edit(updater => - { - if (removeFirst) - { - updater.Clear(); - updater.AddOrUpdate(markets2); - } - else - { - - updater.AddOrUpdate(markets2); - updater.Clear(); - } - }); - - // Assert - results.Messages.Count.Should().Be(2); - results.Messages[0].All(change => change.Reason is ChangeReason.Add).Should().BeTrue(); - results.Messages[1].Count.Should().Be(expectedChanges); - results.Messages[1].Take(MarketCount * PricesPerMarket).All(change => change.Reason == firstReason).Should().BeTrue(); - results.Messages[1].Skip(MarketCount * PricesPerMarket).All(change => change.Reason == nextReason).Should().BeTrue(); - } - - public void Dispose() - { - _marketCacheResults.Dispose(); - DisposeMarkets(); - } - - private void AddUniquePrices(Market[] markets) => markets.ForEach(m => m.AddUniquePrices(PricesPerMarket, _ => GetRandomPrice())); - - private void CheckResultContents(ChangeSetAggregator marketResults, ChangeSetAggregator priceResults) - { - var expectedMarkets = _marketCache.Items.ToList(); - var expectedPrices = expectedMarkets.SelectMany(market => ((Market)market).PricesCache.Items).ToList(); - - // These should be subsets of each other - expectedMarkets.Should().BeSubsetOf(marketResults.Data.Items); - marketResults.Data.Items.Count.Should().Be(expectedMarkets.Count); - - // These should be subsets of each other - expectedPrices.Should().BeSubsetOf(priceResults.Data.Items); - priceResults.Data.Items.Count.Should().Be(expectedPrices.Count); - } - - private void DisposeMarkets() - { - _marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); - _marketCache.Dispose(); - _marketCache.Clear(); - } - - private decimal GetRandomPrice() => MarketPrice.RandomPrice(_randomizer, BasePrice, PriceOffset); -} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheSourceCompareFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheSourceCompareFixture.cs deleted file mode 100644 index ce6cc89fd..000000000 --- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheSourceCompareFixture.cs +++ /dev/null @@ -1,1105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; -using Bogus; -using DynamicData.Kernel; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Cache; - -public sealed class MergeManyChangeSetsCacheSourceCompareFixture : IDisposable -{ -#if DEBUG - const int MarketCount = 5; - const int PricesPerMarket = 7; - const int RemoveCount = 3; -#else - const int MarketCount = 101; - const int PricesPerMarket = 103; - const int RemoveCount = 53; -#endif - - const int ItemIdStride = 1000; - const decimal BasePrice = 10m; - const decimal PriceOffset = 10m; - const decimal HighestPrice = BasePrice + PriceOffset + 1.0m; - const decimal LowestPrice = BasePrice - 1.0m; - - private readonly ISourceCache _marketCache = new SourceCache(p => p.Id); - - private readonly ChangeSetAggregator _marketCacheResults; - - private readonly Faker _marketFaker; - - private readonly Randomizer _randomizer; - - public MergeManyChangeSetsCacheSourceCompareFixture() - { - _randomizer = new(0x10012022); - _marketFaker = Fakers.Market.RuleFor(m => m.Rating, faker => faker.Random.Double(0, 5)).WithSeed(_randomizer); - _marketCacheResults = _marketCache.Connect().AsAggregator(); - } - - [Theory] - [InlineData(5, 7)] - [InlineData(10, 50)] -#if false && !DEBUG - [InlineData(100, 100)] - [InlineData(10, 1_000)] - [InlineData(1_000, 10)] -#endif - public async Task MultiThreadedStressTest(int marketCount, int priceCount) - { - const int MaxItemId = 50; - var MaxAddTime = TimeSpan.FromSeconds(0.250); - var MaxRemoveTime = TimeSpan.FromSeconds(0.100); - - TimeSpan? GetRemoveTime() => _randomizer.Bool() ? _randomizer.TimeSpan(MaxRemoveTime) : null; - - IObservable AddRemoveStress(int marketCount, int priceCount, int parallel, IScheduler scheduler) => - Observable.Create(observer => new CompositeDisposable - { - AddRemoveMarkets(marketCount, parallel, scheduler) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError), - _marketCache.Connect() - .MergeMany(market => AddRemovePrices((Market)market, priceCount, parallel, scheduler)) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError, - onCompleted: observer.OnCompleted) - }); - - IObservable AddRemoveMarkets(int ownerCount, int parallel, IScheduler scheduler) => - _marketFaker.IntervalGenerate(MaxAddTime, scheduler) - .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(_marketCache, _ => GetRemoveTime(), scheduler)) - .Finally(_marketCache.Dispose); - - IObservable AddRemovePrices(Market market, int priceCount, int parallel, IScheduler scheduler) => - _randomizer.Interval(MaxAddTime, scheduler).Select(_ => market.CreatePrice(_randomizer.Number(MaxItemId), GetRandomPrice())) - .Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler)) - .Finally(market.PricesCache.Dispose); - - var merged = _marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices, Market.RatingCompare, resortOnSourceRefresh: true).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) - .Finally(() => adding = false) - .Subscribe(); - - // Subscribe / unsubscribe over and over while the collections are being modified - do - { - // Ensure items are being added asynchronously before subscribing to changes - await Task.Yield(); - - { - // Subscribe - var mergedSub = merged.Subscribe(); - - // Let other threads run - await Task.Yield(); - - // Unsubscribe - mergedSub.Dispose(); - } - } - while (adding); - - // Wait for the source cache to finish delivering all notifications. - await cacheCompleted; - - // Verify the results - CheckResultContents(_marketCacheResults, priceResults, Market.RatingCompare); - } - - [Fact] - public void NullChecks() - { - // having - var emptyChangeSetObs = Observable.Empty>(); - var nullChangeSetObs = (IObservable>)null!; - var emptyChildChangeSetObs = Observable.Empty>(); - var emptySelector = new Func>>(i => emptyChildChangeSetObs); - var emptyKeySelector = new Func>>((i, key) => emptyChildChangeSetObs); - var nullSelector = (Func>>)null!; - var nullKeySelector = (Func>>)null!; - var nullParentComparer = (IComparer)null!; - var emptyParentComparer = new NoOpComparer() as IComparer; - var nullChildComparer = (IComparer)null!; - var emptyChildComparer = new NoOpComparer() as IComparer; - var nullEqualityComparer = (IEqualityComparer)null!; - var emptyEqualityComparer = new NoOpEqualityComparer() as IEqualityComparer; - - // when - var actionParentCompare1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, sourceComparer: emptyParentComparer); - var actionParentCompareKey1a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: emptyParentComparer); - var actionParentCompareKey1b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, sourceComparer: emptyParentComparer); - var actionParentCompareKey1c = () => emptyChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: nullParentComparer); - var actionParentCompare2 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); - var actionParentCompareKey2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); - var actionParentCompareKey2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); - - // then - emptyChangeSetObs.Should().NotBeNull(); - emptyChildChangeSetObs.Should().NotBeNull(); - emptyChildComparer.Should().NotBeNull(); - emptyEqualityComparer.Should().NotBeNull(); - emptyKeySelector.Should().NotBeNull(); - emptyParentComparer.Should().NotBeNull(); - emptySelector.Should().NotBeNull(); - nullChangeSetObs.Should().BeNull(); - nullChildComparer.Should().BeNull(); - nullEqualityComparer.Should().BeNull(); - nullKeySelector.Should().BeNull(); - nullParentComparer.Should().BeNull(); - nullSelector.Should().BeNull(); - - actionParentCompare1.Should().Throw(); - actionParentCompareKey1a.Should().Throw(); - actionParentCompareKey1b.Should().Throw(); - actionParentCompareKey1c.Should().Throw(); - actionParentCompare2.Should().Throw(); - actionParentCompareKey2a.Should().Throw(); - actionParentCompareKey2b.Should().Throw(); - } - - [Fact] - public void AbleToInvokeFactory() - { - // having - var invoked = false; - IObservable> factory(IMarket m) - { - invoked = true; - return m.LatestPrices; - } - using var sub = _marketCache.Connect().MergeManyChangeSets(factory, Market.RatingCompare).Subscribe(); - - // when - _marketCache.AddOrUpdate(new Market(0)); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - invoked.Should().BeTrue(); - } - - [Fact] - public void AbleToInvokeFactoryWithKey() - { - // having - var invoked = false; - IObservable> factory(IMarket m, Guid g) - { - invoked = true; - return m.LatestPrices; - } - using var sub = _marketCache.Connect().MergeManyChangeSets(factory, Market.RatingCompare).Subscribe(); - - // when - _marketCache.AddOrUpdate(new Market(0)); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - invoked.Should().BeTrue(); - } - - [Fact] - public void AllExistingSubItemsPresentInResult() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, GetRandomPrice)); - - // when - _marketCache.AddOrUpdate(markets); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(1); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AllNewSubItemsPresentInResult() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - _marketCache.AddOrUpdate(markets); - - // when - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, GetRandomPrice)); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(MarketCount); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AllRefreshedSubItemsAreRefreshed() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, GetRandomPrice)); - _marketCache.AddOrUpdate(markets); - - // when - markets.ForEach(m => m.RefreshAllPrices(GetRandomPrice)); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - results.Data.Count.Should().Be(MarketCount * PricesPerMarket); - results.Messages.Count.Should().Be(MarketCount + 1); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(MarketCount * PricesPerMarket); - } - - [Fact] - public void AnyDuplicateKeyValuesShouldBeHidden() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - - // when - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - markets[1].RemoveAllPrices(); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - _marketCache.Remove(markets[0]); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Messages.Count.Should().Be(2); - results.Messages[1].Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AnyDuplicateValuesShouldNotRefreshWhenHidden() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - markets[1].RefreshAllPrices(GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void SourceRefreshGeneratesUpdatesAsNeeded() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - SetRating(markets[1], 2.0); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void SourceRefreshDoesNothingIfDisabled() - { - // having - var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating(resortOnRefresh: false).AsAggregator(); - markets[0].Rating = 1.0; - _marketCache.AddOrUpdate(markets); - markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); - markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); - - // when - SetRating(markets[1], 2.0); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AnyRemovedSubItemIsRemoved() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - _marketCache.AddOrUpdate(markets); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, GetRandomPrice)); - - // when - markets.ForEach(m => m.PricesCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount)))); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount); - results.Data.Count.Should().Be(MarketCount * (PricesPerMarket - RemoveCount)); - results.Messages.Count.Should().Be(MarketCount * 2); - results.Messages[0].Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(MarketCount * RemoveCount); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void AnySourceItemRemovedRemovesAllSourceValues() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - using var results = ChangeSetByRating().AsAggregator(); - _marketCache.AddOrUpdate(markets); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, GetRandomPrice)); - - // when - _marketCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount))); - - // then - _marketCacheResults.Data.Count.Should().Be(MarketCount - RemoveCount); - results.Data.Count.Should().Be((MarketCount - RemoveCount) * PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(PricesPerMarket * RemoveCount); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void ChangingSourceByUpdateRemovesPreviousAndAddsNewValues() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - var market = new Market(0); - market.SetPrices(0, PricesPerMarket * 2, GetRandomPrice); - _marketCache.AddOrUpdate(market); - var updatedMarket = new Market(market); - updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, GetRandomPrice); - - // when - _marketCache.AddOrUpdate(updatedMarket); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket * 2); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(PricesPerMarket); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Zip(updatedMarket.PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); - } - - [Fact] - public void ChangingSourceByUpdateRemovesPreviousAndEmitsBetterValues() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - var market = new Market(0); - var marketWorse = new Market(1); - SetRating(marketWorse, -1); - market.SetPrices(0, PricesPerMarket * 2, GetRandomPrice); - marketWorse.SetPrices(0, PricesPerMarket * 2, GetRandomPrice); - _marketCache.AddOrUpdate(market); - _marketCache.AddOrUpdate(marketWorse); - - var updatedMarket = new Market(market); - updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, GetRandomPrice); - - // when - _marketCache.AddOrUpdate(updatedMarket); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket * 3); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Take(PricesPerMarket).Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketWorse.Id)); - results.Data.Items.Skip(PricesPerMarket).Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(updatedMarket.Id)); - } - - [Fact] - public void UpdatesToCorrectValueOnRemove() - { - // having - var marketOriginal = new Market(0); - var marketBetter = new Market(1); - var marketBest = new Market(2); - marketBetter.Rating = 1.0; - marketBest.Rating = 5.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketBetter.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketBest.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketBest); - _marketCache.AddOrUpdate(marketBetter); - using var results = ChangeSetByRating(false).AsAggregator(); - - // when - _marketCache.Remove(marketBest); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - } - - [Fact] - public void OnlyUpdatesOnDuplicateIfNewItemIsFromBetterParent() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - using var resultsLow = ChangeSetByLowRating(false).AsAggregator(); - var marketOriginal = new Market(0); - var marketBetter = new Market(1); - marketBetter.Rating = 1.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketBetter.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - - // when - _marketCache.AddOrUpdate(marketBetter); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void BestChoiceFromDuplicatesSelectedWhenChangeSetCreated() - { - // having - var marketOriginal = new Market(0); - var marketBetter = new Market(1); - marketBetter.Rating = 1.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketBetter.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketBetter); - - // when - using var results = ChangeSetByRating(false).AsAggregator(); - using var resultsLow = ChangeSetByLowRating(false).AsAggregator(); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void OnlyAddsBetterValuesOnSourceUpdate() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - using var resultsLow = ChangeSetByLowRating(false).AsAggregator(); - var marketOriginal = new Market(0); - var marketBetter = new Market(1); - marketBetter.Rating = 1.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketBetter); - - // when - marketBetter.SetPrices(0, PricesPerMarket, GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void UpdatesToCorrectValueOnRefresh() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - using var resultsLow = ChangeSetByLowRating(false).AsAggregator(); - using var resultsRefresh = ChangeSetByRating(true).AsAggregator(); - using var resultsLowRefresh = ChangeSetByLowRating(true).AsAggregator(); - var marketOriginal = new Market(0); - var marketBetter = new Market(1); - marketBetter.Rating = -1.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketBetter.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketBetter); - - // when - SetRating(marketBetter, 2.0); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - _marketCacheResults.Summary.Overall.Refreshes.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Updates.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - resultsRefresh.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsRefresh.Summary.Overall.Updates.Should().Be(PricesPerMarket); - resultsRefresh.Summary.Overall.Removes.Should().Be(0); - resultsRefresh.Summary.Overall.Refreshes.Should().Be(0); - resultsRefresh.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); - resultsLowRefresh.Data.Count.Should().Be(PricesPerMarket); - resultsLowRefresh.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLowRefresh.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - resultsLowRefresh.Summary.Overall.Removes.Should().Be(0); - resultsLowRefresh.Summary.Overall.Refreshes.Should().Be(0); - resultsLowRefresh.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - } - - [Fact] - public void ChildComparerUpdatesToCorrectValueOnUpdate() - { - // having - using var resultsLow = ChangeSetByLowRating(false).AsAggregator(); - using var resultsLowPrice = ChangeSetByRatingThenLowPrice(false).AsAggregator(); - using var resultsHighPrice = ChangeSetByRatingThenHighPrice(false).AsAggregator(); - var marketOriginal = new Market(0); - var marketHighest = new Market(1); - var marketLowest = new Market(2); - marketLowest.Rating = marketHighest.Rating = 1.0; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketHighest.SetPrices(0, PricesPerMarket, HighestPrice); - marketLowest.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketHighest); - _marketCache.AddOrUpdate(marketLowest); - - // when - marketLowest.UpdateAllPrices(LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(3); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - - resultsLowPrice.Data.Count.Should().Be(PricesPerMarket); - resultsLowPrice.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLowPrice.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - resultsLowPrice.Summary.Overall.Removes.Should().Be(0); - resultsLowPrice.Summary.Overall.Refreshes.Should().Be(0); - resultsLowPrice.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); - - resultsHighPrice.Data.Count.Should().Be(PricesPerMarket); - resultsHighPrice.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsHighPrice.Summary.Overall.Updates.Should().Be(PricesPerMarket); - resultsHighPrice.Summary.Overall.Removes.Should().Be(0); - resultsHighPrice.Summary.Overall.Refreshes.Should().Be(0); - resultsHighPrice.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHighest.Id)); - } - - [Fact] - public void ChildComparerOnlyUpdatesVisibleValuesOnUpdate() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - using var lowRatingLowPriceResults = ChangeSetByLowRatingThenLowPrice(false).AsAggregator(); - using var lowRatingHighPriceResults = ChangeSetByLowRatingThenHighPrice(false).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketLowest = new Market(2); - - marketLowest.Rating = marketLow.Rating = -1; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLow.SetPrices(0, PricesPerMarket, LowestPrice); - marketLowest.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(marketLowest); - - // when - marketLowest.UpdateAllPrices(LowestPrice - 1); - - // then - _marketCacheResults.Data.Count.Should().Be(3); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - lowRatingLowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowRatingLowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowRatingLowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowRatingLowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - lowRatingLowPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowRatingLowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); - lowRatingHighPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowRatingHighPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowRatingHighPriceResults.Summary.Overall.Removes.Should().Be(0); - lowRatingHighPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - lowRatingHighPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowRatingHighPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - } - - [Fact] - public void ChildComparerOnlyRefreshesVisibleValues() - { - // having - using var results = ChangeSetByRating(false).AsAggregator(); - using var lowRatingLowPriceResults = ChangeSetByLowRatingThenLowPrice(false).AsAggregator(); - using var lowRatingHighPriceResults = ChangeSetByLowRatingThenHighPrice(false).AsAggregator(); - var marketOriginal = new Market(0); - var marketLow = new Market(1); - var marketLowest = new Market(2); - - marketLowest.Rating = marketLow.Rating = -1; - marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLow.SetPrices(0, PricesPerMarket, GetRandomPrice); - marketLowest.SetPrices(0, PricesPerMarket, LowestPrice); - _marketCache.AddOrUpdate(marketOriginal); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(marketLowest); - - // when - marketLowest.RefreshAllPrices(LowestPrice - 1); - - // then - _marketCacheResults.Data.Count.Should().Be(3); - results.Data.Count.Should().Be(PricesPerMarket); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); - lowRatingLowPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowRatingLowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowRatingLowPriceResults.Summary.Overall.Removes.Should().Be(0); - lowRatingLowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - lowRatingLowPriceResults.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); - lowRatingLowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); - lowRatingHighPriceResults.Data.Count.Should().Be(PricesPerMarket); - lowRatingHighPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); - lowRatingHighPriceResults.Summary.Overall.Removes.Should().Be(0); - lowRatingHighPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); - lowRatingHighPriceResults.Summary.Overall.Refreshes.Should().Be(0); - lowRatingHighPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); - } - - [Fact] - public void EqualityComparerHidesUpdatesWithoutChanges() - { - // having - var market = new Market(0); - using var results = CreateChangeSet("Equality Compare", Market.RatingCompare, equalityComparer: MarketPrice.EqualityComparer, resortOnRefresh: true).AsAggregator(); - market.SetPrices(0, PricesPerMarket, LowestPrice); - _marketCache.AddOrUpdate(market); - - // when - market.SetPrices(0, PricesPerMarket, LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(1); - results.Data.Count.Should().Be(PricesPerMarket); - results.Messages.Count.Should().Be(1); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void EqualityComparerAndChildComparerWorkTogetherForUpdates() - { - // having - using var resultsLow = ChangeSetByLowRating().AsAggregator(); - using var resultsRecent = ChangeSetByRatingThenRecent().AsAggregator(); - using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp().AsAggregator(); - var marketLow = new Market(0); - var market = new Market(1); - marketLow.Rating = -1; - marketLow.SetPrices(0, PricesPerMarket, GetRandomPrice); - market.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(market); - market.SetPrices(0, PricesPerMarket, LowestPrice); - - // when - market.UpdateAllPrices(LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Messages.Count.Should().Be(1); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsRecent.Messages.Count.Should().Be(3); - resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsRecent.Summary.Overall.Removes.Should().Be(0); - resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - resultsRecent.Summary.Overall.Refreshes.Should().Be(0); - resultsTimeStamp.Messages.Count.Should().Be(4); - resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); - resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(0); - } - - [Fact] - public void EqualityComparerAndChildComparerWorkTogetherForRefreshes() - { - // having - using var resultsLow = ChangeSetByLowRating().AsAggregator(); - using var resultsRecent = ChangeSetByRatingThenRecent().AsAggregator(); - using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp().AsAggregator(); - var marketLow = new Market(0); - var market = new Market(1); - marketLow.Rating = -1; - marketLow.SetPrices(0, PricesPerMarket, GetRandomPrice); - market.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(market); - market.SetPrices(0, PricesPerMarket, LowestPrice); - // Update again, but only the timestamp will change, so resultsRecent will ignore - market.SetPrices(0, PricesPerMarket, LowestPrice); - - // when - // resultsRecent won't see the refresh because it ignored the update - // resultsTimeStamp will see the refreshes because it didn't - market.RefreshAllPrices(LowestPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Messages.Count.Should().Be(1); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsRecent.Messages.Count.Should().Be(4); - resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsRecent.Summary.Overall.Removes.Should().Be(0); - resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); - resultsRecent.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); - resultsTimeStamp.Messages.Count.Should().Be(5); - resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); - resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); - } - - [Fact] - public void EqualityComparerAndChildComparerRefreshesBecomeUpdates() - { - // having - using var resultsLow = ChangeSetByLowRating().AsAggregator(); - using var resultsRecent = ChangeSetByRatingThenRecent().AsAggregator(); - using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp().AsAggregator(); - var marketLow = new Market(0); - var market = new Market(1); - marketLow.Rating = -1; - marketLow.SetPrices(0, PricesPerMarket, GetRandomPrice); - market.SetPrices(0, PricesPerMarket, GetRandomPrice); - _marketCache.AddOrUpdate(marketLow); - _marketCache.AddOrUpdate(market); - market.SetPrices(0, PricesPerMarket, LowestPrice); - // Update again, but only the timestamp will change, so resultsRecent will ignore - market.SetPrices(0, PricesPerMarket, LowestPrice); - - // when - // resultsRecent won't see the refresh because it ignored the update - // resultsTimeStamp will see the refreshes because it didn't - market.RefreshAllPrices(GetRandomPrice); - - // then - _marketCacheResults.Data.Count.Should().Be(2); - resultsLow.Data.Count.Should().Be(PricesPerMarket); - resultsLow.Messages.Count.Should().Be(1); - resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsLow.Summary.Overall.Removes.Should().Be(0); - resultsLow.Summary.Overall.Updates.Should().Be(0); - resultsLow.Summary.Overall.Refreshes.Should().Be(0); - resultsRecent.Messages.Count.Should().Be(4); - resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsRecent.Summary.Overall.Removes.Should().Be(0); - resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - resultsRecent.Summary.Overall.Refreshes.Should().Be(0); - resultsTimeStamp.Messages.Count.Should().Be(5); - resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); - resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); - resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); - resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); - } - - [Fact] - public void EveryItemVisibleWhenSequenceCompletes() - { - // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket))); - - // when - using var results = ChangeSetByRating(false).AsAggregator(); - DisposeMarkets(); - - // then - results.Data.Count.Should().Be(PricesPerMarket * MarketCount); - results.Summary.Overall.Adds.Should().Be(PricesPerMarket * MarketCount); - results.Summary.Overall.Removes.Should().Be(0); - results.Summary.Overall.Updates.Should().Be(0); - results.Summary.Overall.Refreshes.Should().Be(0); - } - - [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void MergedObservableCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) - { - // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); - var hasSourceSequenceCompleted = false; - var hasMergedSequenceCompleted = false; - - using var cleanup = _marketCache.Connect().Do(_ => { }, () => hasSourceSequenceCompleted = true) - .MergeManyChangeSets(m => m.LatestPrices, Market.RatingCompare).Subscribe(_ => { }, () => hasMergedSequenceCompleted = true); - - // when - if (completeSource) - { - DisposeMarkets(); - } - - // then - hasSourceSequenceCompleted.Should().Be(completeSource); - hasMergedSequenceCompleted.Should().Be(completeSource && completeChildren); - } - - [Fact] - public void MergedObservableWillFailIfSourceFails() - { - // having - var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); - _marketCache.AddOrUpdate(markets); - var receivedError = default(Exception); - var expectedError = new Exception("Test exception"); - var throwObservable = Observable.Throw>(expectedError); - - using var cleanup = _marketCache.Connect().Concat(throwObservable) - .MergeManyChangeSets(m => m.LatestPrices, Market.RatingCompare).Subscribe(_ => { }, err => receivedError = err); - - // when - DisposeMarkets(); - - // then - receivedError.Should().Be(expectedError); - } - - private IObservable> CreateChangeSet(string name, IComparer? sourceComp = null, IComparer? childCompare = null, IEqualityComparer? equalityComparer = null, bool resortOnRefresh = true) => - _marketCache.Connect() - .DebugSpy(name) - .MergeManyChangeSets(m => m.LatestPrices.DebugSpy($"{name} [{m.Name} Prices]"), sourceComp ?? Market.RatingCompare, resortOnSourceRefresh: resortOnRefresh, equalityComparer, childCompare) - .DebugSpy($"{name} [Results]"); - - private IObservable> ChangeSetByRating(bool resortOnRefresh = true) => CreateChangeSet("Rating", resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByRatingThenHighPrice(bool resortOnRefresh = true) => CreateChangeSet("Rating | High", Market.RatingCompare, MarketPrice.HighPriceCompare, resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByRatingThenLowPrice(bool resortOnRefresh = true) => CreateChangeSet("Rating | Low", Market.RatingCompare, MarketPrice.LowPriceCompare, resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByRatingThenRecent(bool resortOnRefresh = true) => CreateChangeSet("Rating | Recent", Market.RatingCompare, MarketPrice.LatestPriceCompare, equalityComparer: MarketPrice.EqualityComparer, resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByRatingThenTimeStamp(bool resortOnRefresh = true) => CreateChangeSet("Rating | Timestamp", Market.RatingCompare, MarketPrice.LatestPriceCompare, equalityComparer: MarketPrice.EqualityComparerWithTimeStamp, resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByLowRating(bool resortOnRefresh = true) => CreateChangeSet("Low Rating", Market.RatingCompare.Invert(), resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByLowRatingThenHighPrice(bool resortOnRefresh = true) => CreateChangeSet("Low Rating | High", Market.RatingCompare.Invert(), MarketPrice.HighPriceCompare, resortOnRefresh: resortOnRefresh); - private IObservable> ChangeSetByLowRatingThenLowPrice(bool resortOnRefresh = true) => CreateChangeSet("Low Rating | Low", Market.RatingCompare.Invert(), MarketPrice.LowPriceCompare, resortOnRefresh: resortOnRefresh); - - private IMarket SetRating(IMarket market, double newRating) - { - market.Rating = newRating; - _marketCache.Refresh(market); - return market; - } - - public void Dispose() - { - _marketCacheResults.Dispose(); - DisposeMarkets(); - } - - private void CheckResultContents(ChangeSetAggregator marketResults, ChangeSetAggregator priceResults, IComparer comparer) - { - var expectedMarkets = _marketCache.Items.ToList(); - - // These should be subsets of each other - expectedMarkets.Should().BeSubsetOf(marketResults.Data.Items); - marketResults.Data.Items.Count.Should().Be(expectedMarkets.Count); - - // Pair up all the Markets/Prices, Group them by ItemId, and sort each Group by the Market comparer - // Then pull out the first value from each group, which should be the price from the best market for each ItemId - var expectedPrices = expectedMarkets.Select(m => (Market)m).SelectMany(m => m.PricesCache.Items.Select(mp => (Market: m, MarketPrice: mp))) - .GroupBy(tuple => tuple.MarketPrice.ItemId) - .Select(group => group.OrderBy(tuple => tuple.Market, comparer).Select(tuple => tuple.MarketPrice).First()) - .ToList(); - - // These should be subsets of each other - expectedPrices.Should().BeSubsetOf(priceResults.Data.Items); - priceResults.Data.Items.Count.Should().Be(expectedPrices.Count); - } - - private void DisposeMarkets() - { - _marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); - _marketCache.Dispose(); - _marketCache.Clear(); - } - - private decimal GetRandomPrice() => MarketPrice.RandomPrice(_randomizer, BasePrice, PriceOffset); -} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.IntegrationTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.IntegrationTests.cs new file mode 100644 index 000000000..73d513786 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.IntegrationTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithSourceComparer + { + public sealed class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory] + [InlineData(5, 7)] + [InlineData(10, 50)] + public async Task MultiThreadedStressTest(int marketCount, int priceCount) + { + const int MaxItemId = 50; + var MaxAddTime = TimeSpan.FromSeconds(0.250); + var MaxRemoveTime = TimeSpan.FromSeconds(0.100); + + var randomizer = new Randomizer(0x10012022); + var marketFaker = Fakers.Market.RuleFor(m => m.Rating, faker => faker.Random.Double(0, 5)).WithSeed(randomizer); + + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var merged = marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices, Market.RatingCompare, resortOnSourceRefresh: true).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) + .Finally(() => adding = false) + .Subscribe(); + + // Subscribe / unsubscribe over and over while the collections are being modified + do + { + // Ensure items are being added asynchronously before subscribing to changes + await Task.Yield(); + + { + // Subscribe + var mergedSub = merged.Subscribe(); + + // Let other threads run + await Task.Yield(); + + // Unsubscribe + mergedSub.Dispose(); + } + } + while (adding); + + // Wait for the source cache to finish delivering all notifications. + await cacheCompleted; + + // Verify the results + CheckResultContents(marketCache, marketCacheResults, priceResults, Market.RatingCompare); + + + TimeSpan? GetRemoveTime() => randomizer.Bool() ? randomizer.TimeSpan(MaxRemoveTime) : null; + + IObservable AddRemoveStress(int marketCount, int priceCount, int parallel, IScheduler scheduler) => + Observable.Create(observer => new CompositeDisposable + { + AddRemoveMarkets(marketCount, parallel, scheduler) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError), + marketCache.Connect() + .MergeMany(market => AddRemovePrices((Market)market, priceCount, parallel, scheduler)) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError, + onCompleted: observer.OnCompleted) + }); + + IObservable AddRemoveMarkets(int ownerCount, int parallel, IScheduler scheduler) => + marketFaker.IntervalGenerate(MaxAddTime, scheduler) + .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(marketCache, _ => GetRemoveTime(), scheduler)) + .Finally(marketCache.Dispose); + + IObservable AddRemovePrices(Market market, int priceCount, int parallel, IScheduler scheduler) => + randomizer.Interval(MaxAddTime, scheduler).Select(_ => market.CreatePrice(randomizer.Number(MaxItemId), GetRandomPrice(randomizer))) + .Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler)) + .Finally(market.PricesCache.Dispose); + } + + private static void CheckResultContents( + SourceCache marketCache, + ChangeSetAggregator marketResults, + ChangeSetAggregator priceResults, + IComparer comparer) + { + var expectedMarkets = marketCache.Items.ToList(); + + // These should be subsets of each other + expectedMarkets.Should().BeSubsetOf(marketResults.Data.Items); + marketResults.Data.Items.Count.Should().Be(expectedMarkets.Count); + + // Pair up all the Markets/Prices, Group them by ItemId, and sort each Group by the Market comparer + // Then pull out the first value from each group, which should be the price from the best market for each ItemId + var expectedPrices = expectedMarkets.Select(m => (Market)m).SelectMany(m => m.PricesCache.Items.Select(mp => (Market: m, MarketPrice: mp))) + .GroupBy(tuple => tuple.MarketPrice.ItemId) + .Select(group => group.OrderBy(tuple => tuple.Market, comparer).Select(tuple => tuple.MarketPrice).First()) + .ToList(); + + // These should be subsets of each other + expectedPrices.Should().BeSubsetOf(priceResults.Data.Items); + priceResults.Data.Items.Count.Should().Be(expectedPrices.Count); + } + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.UnitTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.UnitTests.cs new file mode 100644 index 000000000..641cdd499 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.UnitTests.cs @@ -0,0 +1,1192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithSourceComparer + { + public class UnitTests + { + [Fact] + public void AbleToInvokeFactory() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var invoked = false; + IObservable> factory(IMarket m) + { + invoked = true; + return m.LatestPrices; + } + using var sub = marketCache.Connect().MergeManyChangeSets(factory, Market.RatingCompare).Subscribe(); + + // when + marketCache.AddOrUpdate(new Market(0)); + + // then + marketCacheResults.Data.Count.Should().Be(1); + invoked.Should().BeTrue(); + } + + [Fact] + public void AbleToInvokeFactoryWithKey() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var invoked = false; + IObservable> factory(IMarket m, Guid g) + { + invoked = true; + return m.LatestPrices; + } + using var sub = marketCache.Connect().MergeManyChangeSets(factory, Market.RatingCompare).Subscribe(); + + // when + marketCache.AddOrUpdate(new Market(0)); + + // then + marketCacheResults.Data.Count.Should().Be(1); + invoked.Should().BeTrue(); + } + + [Fact] + public void AllExistingSubItemsPresentInResult() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, () => GetRandomPrice(randomizer))); + + // when + marketCache.AddOrUpdate(markets); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(1); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AllNewSubItemsPresentInResult() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + marketCache.AddOrUpdate(markets); + + // when + markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, () => GetRandomPrice(randomizer))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(MarketCount); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AllRefreshedSubItemsAreRefreshed() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, () => GetRandomPrice(randomizer))); + marketCache.AddOrUpdate(markets); + + // when + markets.ForEach(m => m.RefreshAllPrices(() => GetRandomPrice(randomizer))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(MarketCount + 1); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(MarketCount * PricesPerMarket); + } + + [Fact] + public void AnyDuplicateKeyValuesShouldBeHidden() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + + // when + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + markets[1].RemoveAllPrices(); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + marketCache.Remove(markets[0]); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Messages.Count.Should().Be(2); + results.Messages[1].Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AnyDuplicateValuesShouldNotRefreshWhenHidden() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + markets[1].RefreshAllPrices(() => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AnyRemovedSubItemIsRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + marketCache.AddOrUpdate(markets); + markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, () => GetRandomPrice(randomizer))); + + // when + markets.ForEach(m => m.PricesCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount)))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + results.Data.Count.Should().Be(MarketCount * (PricesPerMarket - RemoveCount)); + results.Messages.Count.Should().Be(MarketCount * 2); + results.Messages[0].Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(MarketCount * RemoveCount); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void AnySourceItemRemovedRemovesAllSourceValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + marketCache.AddOrUpdate(markets); + markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.SetPrices(m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket, () => GetRandomPrice(randomizer))); + + // when + marketCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount - RemoveCount); + results.Data.Count.Should().Be((MarketCount - RemoveCount) * PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(PricesPerMarket * RemoveCount); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void BestChoiceFromDuplicatesSelectedWhenChangeSetCreated() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var marketOriginal = new Market(0); + var marketBetter = new Market(1); + marketBetter.Rating = 1.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketBetter.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketBetter); + + // when + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var resultsLow = ChangeSetByLowRating(marketCache, false).AsAggregator(); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void ChangingSourceByUpdateRemovesPreviousAndAddsNewValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + var market = new Market(0); + market.SetPrices(0, PricesPerMarket * 2, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(market); + var updatedMarket = new Market(market); + updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, () => GetRandomPrice(randomizer)); + + // when + marketCache.AddOrUpdate(updatedMarket); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket * 2); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(PricesPerMarket); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Zip(updatedMarket.PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + } + + [Fact] + public void ChangingSourceByUpdateRemovesPreviousAndEmitsBetterValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + var market = new Market(0); + var marketWorse = new Market(1); + SetRating(marketCache, marketWorse, -1); + market.SetPrices(0, PricesPerMarket * 2, () => GetRandomPrice(randomizer)); + marketWorse.SetPrices(0, PricesPerMarket * 2, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(market); + marketCache.AddOrUpdate(marketWorse); + + var updatedMarket = new Market(market); + updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, () => GetRandomPrice(randomizer)); + + // when + marketCache.AddOrUpdate(updatedMarket); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket * 3); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Take(PricesPerMarket).Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketWorse.Id)); + results.Data.Items.Skip(PricesPerMarket).Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(updatedMarket.Id)); + } + + [Fact] + public void ChildComparerOnlyRefreshesVisibleValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var lowRatingLowPriceResults = ChangeSetByLowRatingThenLowPrice(marketCache, false).AsAggregator(); + using var lowRatingHighPriceResults = ChangeSetByLowRatingThenHighPrice(marketCache, false).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketLowest = new Market(2); + + marketLowest.Rating = marketLow.Rating = -1; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLow.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLowest.SetPrices(0, PricesPerMarket, LowestPrice); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(marketLowest); + + // when + marketLowest.RefreshAllPrices(LowestPrice - 1); + + // then + marketCacheResults.Data.Count.Should().Be(3); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + lowRatingLowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowRatingLowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowRatingLowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowRatingLowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + lowRatingLowPriceResults.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); + lowRatingLowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); + lowRatingHighPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowRatingHighPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowRatingHighPriceResults.Summary.Overall.Removes.Should().Be(0); + lowRatingHighPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowRatingHighPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowRatingHighPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + } + + [Fact] + public void ChildComparerOnlyUpdatesVisibleValuesOnUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var lowRatingLowPriceResults = ChangeSetByLowRatingThenLowPrice(marketCache, false).AsAggregator(); + using var lowRatingHighPriceResults = ChangeSetByLowRatingThenHighPrice(marketCache, false).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketLowest = new Market(2); + + marketLowest.Rating = marketLow.Rating = -1; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketLowest.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(marketLowest); + + // when + marketLowest.UpdateAllPrices(LowestPrice - 1); + + // then + marketCacheResults.Data.Count.Should().Be(3); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + lowRatingLowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowRatingLowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowRatingLowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowRatingLowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + lowRatingLowPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowRatingLowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); + lowRatingHighPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowRatingHighPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowRatingHighPriceResults.Summary.Overall.Removes.Should().Be(0); + lowRatingHighPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + lowRatingHighPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowRatingHighPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + } + + [Fact] + public void ChildComparerUpdatesToCorrectValueOnUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var resultsLow = ChangeSetByLowRating(marketCache, false).AsAggregator(); + using var resultsLowPrice = ChangeSetByRatingThenLowPrice(marketCache, false).AsAggregator(); + using var resultsHighPrice = ChangeSetByRatingThenHighPrice(marketCache, false).AsAggregator(); + var marketOriginal = new Market(0); + var marketHighest = new Market(1); + var marketLowest = new Market(2); + marketLowest.Rating = marketHighest.Rating = 1.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketHighest.SetPrices(0, PricesPerMarket, HighestPrice); + marketLowest.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketHighest); + marketCache.AddOrUpdate(marketLowest); + + // when + marketLowest.UpdateAllPrices(LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(3); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + + resultsLowPrice.Data.Count.Should().Be(PricesPerMarket); + resultsLowPrice.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLowPrice.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + resultsLowPrice.Summary.Overall.Removes.Should().Be(0); + resultsLowPrice.Summary.Overall.Refreshes.Should().Be(0); + resultsLowPrice.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowest.Id)); + + resultsHighPrice.Data.Count.Should().Be(PricesPerMarket); + resultsHighPrice.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsHighPrice.Summary.Overall.Updates.Should().Be(PricesPerMarket); + resultsHighPrice.Summary.Overall.Removes.Should().Be(0); + resultsHighPrice.Summary.Overall.Refreshes.Should().Be(0); + resultsHighPrice.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHighest.Id)); + } + + [Fact] + public void EqualityComparerAndChildComparerRefreshesBecomeUpdates() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var resultsLow = ChangeSetByLowRating(marketCache).AsAggregator(); + using var resultsRecent = ChangeSetByRatingThenRecent(marketCache).AsAggregator(); + using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp(marketCache).AsAggregator(); + var marketLow = new Market(0); + var market = new Market(1); + marketLow.Rating = -1; + marketLow.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + market.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(market); + market.SetPrices(0, PricesPerMarket, LowestPrice); + // Update again, but only the timestamp will change, so resultsRecent will ignore + market.SetPrices(0, PricesPerMarket, LowestPrice); + + // when + // resultsRecent won't see the refresh because it ignored the update + // resultsTimeStamp will see the refreshes because it didn't + market.RefreshAllPrices(() => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Messages.Count.Should().Be(1); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsRecent.Messages.Count.Should().Be(4); + resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsRecent.Summary.Overall.Removes.Should().Be(0); + resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + resultsRecent.Summary.Overall.Refreshes.Should().Be(0); + resultsTimeStamp.Messages.Count.Should().Be(5); + resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); + resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); + } + + [Fact] + public void EqualityComparerAndChildComparerWorkTogetherForRefreshes() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var resultsLow = ChangeSetByLowRating(marketCache).AsAggregator(); + using var resultsRecent = ChangeSetByRatingThenRecent(marketCache).AsAggregator(); + using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp(marketCache).AsAggregator(); + var marketLow = new Market(0); + var market = new Market(1); + marketLow.Rating = -1; + marketLow.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + market.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(market); + market.SetPrices(0, PricesPerMarket, LowestPrice); + // Update again, but only the timestamp will change, so resultsRecent will ignore + market.SetPrices(0, PricesPerMarket, LowestPrice); + + // when + // resultsRecent won't see the refresh because it ignored the update + // resultsTimeStamp will see the refreshes because it didn't + market.RefreshAllPrices(LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(2); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Messages.Count.Should().Be(1); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsRecent.Messages.Count.Should().Be(4); + resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsRecent.Summary.Overall.Removes.Should().Be(0); + resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + resultsRecent.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); + resultsTimeStamp.Messages.Count.Should().Be(5); + resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); + resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); + } + + [Fact] + public void EqualityComparerAndChildComparerWorkTogetherForUpdates() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var resultsLow = ChangeSetByLowRating(marketCache).AsAggregator(); + using var resultsRecent = ChangeSetByRatingThenRecent(marketCache).AsAggregator(); + using var resultsTimeStamp = ChangeSetByRatingThenTimeStamp(marketCache).AsAggregator(); + var marketLow = new Market(0); + var market = new Market(1); + marketLow.Rating = -1; + marketLow.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + market.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(market); + market.SetPrices(0, PricesPerMarket, LowestPrice); + + // when + market.UpdateAllPrices(LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(2); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Messages.Count.Should().Be(1); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsRecent.Messages.Count.Should().Be(3); + resultsRecent.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsRecent.Summary.Overall.Removes.Should().Be(0); + resultsRecent.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + resultsRecent.Summary.Overall.Refreshes.Should().Be(0); + resultsTimeStamp.Messages.Count.Should().Be(4); + resultsTimeStamp.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsTimeStamp.Summary.Overall.Removes.Should().Be(0); + resultsTimeStamp.Summary.Overall.Updates.Should().Be(PricesPerMarket * 3); + resultsTimeStamp.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void EqualityComparerHidesUpdatesWithoutChanges() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var market = new Market(0); + using var results = CreateChangeSet(marketCache, "Equality Compare", Market.RatingCompare, equalityComparer: MarketPrice.EqualityComparer, resortOnRefresh: true).AsAggregator(); + market.SetPrices(0, PricesPerMarket, LowestPrice); + marketCache.AddOrUpdate(market); + + // when + market.SetPrices(0, PricesPerMarket, LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket); + results.Messages.Count.Should().Be(1); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void EveryItemVisibleWhenSequenceCompletes() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(() => GetRandomPrice(randomizer), n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket))); + + // when + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + + // then + results.Data.Count.Should().Be(PricesPerMarket * MarketCount); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket * MarketCount); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void MergedObservableCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(() => GetRandomPrice(randomizer), n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); + var hasSourceSequenceCompleted = false; + var hasMergedSequenceCompleted = false; + + using var cleanup = marketCache.Connect().Do(_ => { }, () => hasSourceSequenceCompleted = true) + .MergeManyChangeSets(m => m.LatestPrices, Market.RatingCompare).Subscribe(_ => { }, () => hasMergedSequenceCompleted = true); + + // when + if (completeSource) + { + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + } + + // then + hasSourceSequenceCompleted.Should().Be(completeSource); + hasMergedSequenceCompleted.Should().Be(completeSource && completeChildren); + } + + [Fact] + public void MergedObservableWillFailIfSourceFails() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + marketCache.AddOrUpdate(markets); + var receivedError = default(Exception); + var expectedError = new Exception("Test exception"); + var throwObservable = Observable.Throw>(expectedError); + + using var cleanup = marketCache.Connect().Concat(throwObservable) + .MergeManyChangeSets(m => m.LatestPrices, Market.RatingCompare).Subscribe(_ => { }, err => receivedError = err); + + // when + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + + // then + receivedError.Should().Be(expectedError); + } + + [Fact] + public void NullChecks() + { + // having + var emptyChangeSetObs = Observable.Empty>(); + var nullChangeSetObs = (IObservable>)null!; + var emptyChildChangeSetObs = Observable.Empty>(); + var emptySelector = new Func>>(i => emptyChildChangeSetObs); + var emptyKeySelector = new Func>>((i, key) => emptyChildChangeSetObs); + var nullSelector = (Func>>)null!; + var nullKeySelector = (Func>>)null!; + var nullParentComparer = (IComparer)null!; + var emptyParentComparer = new NoOpComparer() as IComparer; + var nullChildComparer = (IComparer)null!; + var emptyChildComparer = new NoOpComparer() as IComparer; + var nullEqualityComparer = (IEqualityComparer)null!; + var emptyEqualityComparer = new NoOpEqualityComparer() as IEqualityComparer; + + // when + var actionParentCompare1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, sourceComparer: emptyParentComparer); + var actionParentCompareKey1a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: emptyParentComparer); + var actionParentCompareKey1b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, sourceComparer: emptyParentComparer); + var actionParentCompareKey1c = () => emptyChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: nullParentComparer); + var actionParentCompare2 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); + var actionParentCompareKey2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); + var actionParentCompareKey2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, sourceComparer: emptyParentComparer, equalityComparer: emptyEqualityComparer); + + // then + emptyChangeSetObs.Should().NotBeNull(); + emptyChildChangeSetObs.Should().NotBeNull(); + emptyChildComparer.Should().NotBeNull(); + emptyEqualityComparer.Should().NotBeNull(); + emptyKeySelector.Should().NotBeNull(); + emptyParentComparer.Should().NotBeNull(); + emptySelector.Should().NotBeNull(); + nullChangeSetObs.Should().BeNull(); + nullChildComparer.Should().BeNull(); + nullEqualityComparer.Should().BeNull(); + nullKeySelector.Should().BeNull(); + nullParentComparer.Should().BeNull(); + nullSelector.Should().BeNull(); + + actionParentCompare1.Should().Throw(); + actionParentCompareKey1a.Should().Throw(); + actionParentCompareKey1b.Should().Throw(); + actionParentCompareKey1c.Should().Throw(); + actionParentCompare2.Should().Throw(); + actionParentCompareKey2a.Should().Throw(); + actionParentCompareKey2b.Should().Throw(); + } + + [Fact] + public void OnlyAddsBetterValuesOnSourceUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var resultsLow = ChangeSetByLowRating(marketCache, false).AsAggregator(); + var marketOriginal = new Market(0); + var marketBetter = new Market(1); + marketBetter.Rating = 1.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketBetter); + + // when + marketBetter.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void OnlyUpdatesOnDuplicateIfNewItemIsFromBetterParent() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var resultsLow = ChangeSetByLowRating(marketCache, false).AsAggregator(); + var marketOriginal = new Market(0); + var marketBetter = new Market(1); + marketBetter.Rating = 1.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketBetter.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + + // when + marketCache.AddOrUpdate(marketBetter); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Updates.Should().Be(0); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void SourceRefreshDoesNothingIfDisabled() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache, resortOnRefresh: false).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + SetRating(marketCache, markets[1], 2.0); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void SourceRefreshGeneratesUpdatesAsNeeded() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = ChangeSetByRating(marketCache).AsAggregator(); + markets[0].Rating = 1.0; + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + SetRating(marketCache, markets[1], 2.0); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void UpdatesToCorrectValueOnRefresh() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + using var resultsLow = ChangeSetByLowRating(marketCache, false).AsAggregator(); + using var resultsRefresh = ChangeSetByRating(marketCache, true).AsAggregator(); + using var resultsLowRefresh = ChangeSetByLowRating(marketCache, true).AsAggregator(); + var marketOriginal = new Market(0); + var marketBetter = new Market(1); + marketBetter.Rating = -1.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketBetter.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketBetter); + + // when + SetRating(marketCache, marketBetter, 2.0); + + // then + marketCacheResults.Data.Count.Should().Be(2); + marketCacheResults.Summary.Overall.Refreshes.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + resultsLow.Data.Count.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Updates.Should().Be(PricesPerMarket); + resultsLow.Summary.Overall.Removes.Should().Be(0); + resultsLow.Summary.Overall.Refreshes.Should().Be(0); + resultsLow.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + resultsRefresh.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsRefresh.Summary.Overall.Updates.Should().Be(PricesPerMarket); + resultsRefresh.Summary.Overall.Removes.Should().Be(0); + resultsRefresh.Summary.Overall.Refreshes.Should().Be(0); + resultsRefresh.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + resultsLowRefresh.Data.Count.Should().Be(PricesPerMarket); + resultsLowRefresh.Summary.Overall.Adds.Should().Be(PricesPerMarket); + resultsLowRefresh.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + resultsLowRefresh.Summary.Overall.Removes.Should().Be(0); + resultsLowRefresh.Summary.Overall.Refreshes.Should().Be(0); + resultsLowRefresh.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + [Fact] + public void UpdatesToCorrectValueOnRemove() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x10012022); + + // having + var marketOriginal = new Market(0); + var marketBetter = new Market(1); + var marketBest = new Market(2); + marketBetter.Rating = 1.0; + marketBest.Rating = 5.0; + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketBetter.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketBest.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketBest); + marketCache.AddOrUpdate(marketBetter); + using var results = ChangeSetByRating(marketCache, false).AsAggregator(); + + // when + marketCache.Remove(marketBest); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketBetter.Id)); + } + + private static IObservable> CreateChangeSet( + SourceCache marketCache, + string name, + IComparer? sourceComp = null, + IComparer? childCompare = null, + IEqualityComparer? equalityComparer = null, + bool resortOnRefresh = true) => + marketCache.Connect() + .DebugSpy(name) + .MergeManyChangeSets(m => m.LatestPrices.DebugSpy($"{name} [{m.Name} Prices]"), sourceComp ?? Market.RatingCompare, resortOnSourceRefresh: resortOnRefresh, equalityComparer, childCompare) + .DebugSpy($"{name} [Results]"); + + private static IObservable> ChangeSetByRating( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Rating", + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByRatingThenHighPrice( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Rating | High", + sourceComp: Market.RatingCompare, + childCompare: MarketPrice.HighPriceCompare, + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByRatingThenLowPrice( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Rating | Low", + sourceComp: Market.RatingCompare, + childCompare: MarketPrice.LowPriceCompare, + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByRatingThenRecent( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Rating | Recent", + sourceComp: Market.RatingCompare, + childCompare: MarketPrice.LatestPriceCompare, + equalityComparer: MarketPrice.EqualityComparer, + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByRatingThenTimeStamp( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Rating | Timestamp", + sourceComp: Market.RatingCompare, + childCompare: MarketPrice.LatestPriceCompare, + equalityComparer: MarketPrice.EqualityComparerWithTimeStamp, + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByLowRating( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Low Rating", + sourceComp: Market.RatingCompare.Invert(), + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByLowRatingThenHighPrice( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Low Rating | High", + sourceComp: Market.RatingCompare.Invert(), + childCompare: MarketPrice.HighPriceCompare, + resortOnRefresh: resortOnRefresh); + + private static IObservable> ChangeSetByLowRatingThenLowPrice( + SourceCache marketCache, + bool resortOnRefresh = true) + => CreateChangeSet( + marketCache: marketCache, + name: "Low Rating | Low", + sourceComp: Market.RatingCompare.Invert(), + childCompare: MarketPrice.LowPriceCompare, + resortOnRefresh: resortOnRefresh); + + private static IMarket SetRating( + SourceCache marketCache, + IMarket market, + double newRating) + { + market.Rating = newRating; + marketCache.Refresh(market); + return market; + } + + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.cs new file mode 100644 index 000000000..fa9b0bbd5 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithSourceComparer.cs @@ -0,0 +1,9 @@ +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithSourceComparer; + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.IntegrationTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.IntegrationTests.cs new file mode 100644 index 000000000..b13e201eb --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.IntegrationTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithoutSourceComparer + { + public sealed class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory] + [InlineData(5, 7)] + [InlineData(10, 50)] + #if !DEBUG + [InlineData(10, 1_000)] + [InlineData(200, 500)] + [InlineData(1_000, 10)] + #endif + public async Task MultiThreadedStressTest(int marketCount, int priceCount) + { + var MaxAddTime = TimeSpan.FromSeconds(0.250); + var MaxRemoveTime = TimeSpan.FromSeconds(0.100); + + var randomizer = new Randomizer(0x21123737); + var marketFaker = Fakers.Market.WithSeed(randomizer); + + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().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) + .Finally(() => adding = false) + .Subscribe(); + + // Subscribe / unsubscribe over and over while the collections are being modified + do + { + // Ensure items are being added asynchronously before subscribing to changes + await Task.Yield(); + + { + // Subscribe + var mergedSub = merged.Subscribe(); + + // Let other threads run + await Task.Yield(); + + // Unsubscribe + mergedSub.Dispose(); + } + } + while (adding); + + // Wait for the source cache to finish delivering all notifications. + await cacheCompleted; + + // Verify the results + CheckResultContents(marketCache, marketCacheResults, priceResults); + + + TimeSpan? GetRemoveTime() => randomizer.Bool() ? randomizer.TimeSpan(MaxRemoveTime) : null; + + IObservable AddRemoveStress(int marketCount, int priceCount, int parallel, IScheduler scheduler) => + Observable.Create(observer => new CompositeDisposable + ( + AddRemoveMarkets(marketCount, parallel, scheduler) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError), + + marketCache.Connect() + .MergeMany(market => AddRemovePrices((Market)market, priceCount, parallel, scheduler)) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError, + onCompleted: observer.OnCompleted) + )); + + IObservable AddRemoveMarkets(int ownerCount, int parallel, IScheduler scheduler) => + marketFaker.IntervalGenerate(MaxAddTime, scheduler) + .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(marketCache, _ => GetRemoveTime(), scheduler)) + .Finally(marketCache.Dispose); + + IObservable AddRemovePrices(Market market, int priceCount, int parallel, IScheduler scheduler) => + randomizer.Interval(MaxAddTime, scheduler).Select(_ => market.CreateUniquePrice(_ => GetRandomPrice(randomizer))) + .Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler)) + .Finally(market.PricesCache.Dispose); + } + } + + private static void CheckResultContents( + SourceCache marketCache, + ChangeSetAggregator marketResults, + ChangeSetAggregator priceResults) + { + var expectedMarkets = marketCache.Items.ToList(); + var expectedPrices = expectedMarkets.SelectMany(market => ((Market)market).PricesCache.Items).ToList(); + + // These should be subsets of each other + expectedMarkets.Should().BeSubsetOf(marketResults.Data.Items); + marketResults.Data.Items.Count.Should().Be(expectedMarkets.Count); + + // These should be subsets of each other + expectedPrices.Should().BeSubsetOf(priceResults.Data.Items); + priceResults.Data.Items.Count.Should().Be(expectedPrices.Count); + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.UnitTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.UnitTests.cs new file mode 100644 index 000000000..6ee493b6d --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.UnitTests.cs @@ -0,0 +1,887 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithoutSourceComparer + { + public class UnitTests + { + [Fact] + public void AbleToInvokeFactory() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var invoked = false; + IObservable> factory(IMarket m) + { + invoked = true; + return m.LatestPrices; + } + using var sub = marketCache.Connect().MergeManyChangeSets(factory).Subscribe(); + + // when + marketCache.AddOrUpdate(new Market(0)); + + // then + marketCacheResults.Data.Count.Should().Be(1); + invoked.Should().BeTrue(); + } + + [Fact] + public void AbleToInvokeFactoryWithKey() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var invoked = false; + IObservable> factory(IMarket m, Guid g) + { + invoked = true; + return m.LatestPrices; + } + using var sub = marketCache.Connect().MergeManyChangeSets(factory).Subscribe(); + + // when + marketCache.AddOrUpdate(new Market(0)); + + // then + marketCacheResults.Data.Count.Should().Be(1); + invoked.Should().BeTrue(); + } + + [Fact] + public void AllExistingSubItemsPresentInResult() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + AddUniquePrices(markets, randomizer); + + // when + marketCache.AddOrUpdate(markets); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(1); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + } + + [Fact] + public void AllNewSubItemsPresentInResult() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + + // when + AddUniquePrices(markets, randomizer); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + markets.Sum(m => m.PricesCache.Count).Should().Be(MarketCount * PricesPerMarket); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(MarketCount); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + } + + [Fact] + public void AllRefreshedSubItemsAreRefreshed() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + AddUniquePrices(markets, randomizer); + + // when + markets.ForEach(m => m.RefreshAllPrices(() => GetRandomPrice(randomizer))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + results.Data.Count.Should().Be(MarketCount * PricesPerMarket); + results.Messages.Count.Should().Be(MarketCount * 2); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(MarketCount * PricesPerMarket); + } + + [Fact] + public void AnyDuplicateKeyValuesShouldBeHidden() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + + // when + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + } + + [Fact] + public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + markets[1].RemoveAllPrices(); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + } + + [Fact] + public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + marketCache.Remove(markets[0]); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket); + results.Data.Items.Zip(markets[1].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + results.Messages.Count.Should().Be(2); + results.Messages[1].Updates.Should().Be(PricesPerMarket); + } + + [Fact] + public void AnyDuplicateValuesShouldNotRefreshWhenHidden() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, 2).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + markets[0].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + markets[1].SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + + // when + markets[1].RefreshAllPrices(() => GetRandomPrice(randomizer)); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Refreshes.Should().Be(0); + results.Data.Items.Zip(markets[0].PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + } + + [Fact] + public void AnyRemovedSubItemIsRemoved() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + marketCache.AddOrUpdate(markets); + AddUniquePrices(markets, randomizer); + + // when + markets.ForEach(m => m.PricesCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount)))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount); + results.Data.Count.Should().Be(MarketCount * (PricesPerMarket - RemoveCount)); + results.Messages.Count.Should().Be(MarketCount * 2); + results.Messages[0].Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(MarketCount * RemoveCount); + } + + [Fact] + public void AnySourceItemRemovedRemovesAllSourceValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + AddUniquePrices(markets, randomizer); + marketCache.AddOrUpdate(markets); + + // when + marketCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount))); + + // then + marketCacheResults.Data.Count.Should().Be(MarketCount - RemoveCount); + results.Messages.Count.Should().Be(2); + results.Data.Count.Should().Be((MarketCount - RemoveCount) * PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(PricesPerMarket * RemoveCount); + } + + [Fact] + public void ChangingSourceByUpdateRemovesPreviousAndAddsNewValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + var market = new Market(0); + market.SetPrices(0, PricesPerMarket * 2, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(market); + var updatedMarket = new Market(market); + updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, () => GetRandomPrice(randomizer)); + + // when + marketCache.AddOrUpdate(updatedMarket); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket * 2); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket * 3); + results.Summary.Overall.Updates.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(PricesPerMarket); + results.Data.Items.Zip(updatedMarket.PricesCache.Items).ForEach(pair => pair.First.Should().Be(pair.Second)); + } + + [Fact] + public void ClearingParentEmitsSingleChangeSet() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + AddUniquePrices(markets, randomizer); + marketCache.AddOrUpdate(markets); + + // when + marketCache.Clear(); + + // then + marketCacheResults.Data.Count.Should().Be(0); + results.Data.Count.Should().Be(0); + results.Messages.Count.Should().Be(2); + results.Summary.Overall.Adds.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(MarketCount * PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(0); + } + + [Fact] + public void ComparerOnlyAddsBetterAddedValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketHigh = new Market(2); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(marketHigh); + + // when + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(3); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); + } + + [Fact] + public void ComparerOnlyAddsBetterExistingValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketHigh = new Market(2); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); + + // when + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(marketHigh); + + // then + marketCacheResults.Data.Count.Should().Be(3); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); + } + + [Fact] + public void ComparerOnlyAddsBetterValuesOnSourceUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketLowLow = new Market(marketLow); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketLowLow.SetPrices(0, PricesPerMarket, LowestPrice - 1); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + + // when + marketCache.AddOrUpdate(marketLowLow); + + // then + marketCacheResults.Data.Count.Should().Be(2); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLowLow.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(0); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void ComparerOnlyRefreshesVisibleValues() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + + // when + marketLow.RefreshAllPrices(LowestPrice - 1); + + // then + marketCacheResults.Data.Count.Should().Be(2); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Refreshes.Should().Be(PricesPerMarket); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(0); + highPriceResults.Summary.Overall.Refreshes.Should().Be(0); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void ComparerOnlyUpdatesVisibleValuesOnUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + + // when + marketLow.UpdateAllPrices(LowestPrice - 1); + + // then + marketCacheResults.Data.Count.Should().Be(2); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketLow.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(0); + highPriceResults.Summary.Overall.Refreshes.Should().Be(0); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void ComparerUpdatesToCorrectValueOnRefresh() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketFlipFlop = new Market(1); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketFlipFlop); + + // when + marketFlipFlop.RefreshAllPrices(LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(2); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketFlipFlop.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + highPriceResults.Summary.Overall.Refreshes.Should().Be(0); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void ComparerUpdatesToCorrectValueOnRemove() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketLow = new Market(1); + var marketHigh = new Market(2); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketLow); + marketCache.AddOrUpdate(marketHigh); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); + + // when + marketCache.Remove(marketLow); + + // then + marketCacheResults.Data.Count.Should().Be(2); + results.Data.Count.Should().Be(PricesPerMarket); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Updates.Should().Be(0); + results.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketHigh.Id)); + } + + [Fact] + public void ComparerUpdatesToCorrectValueOnUpdate() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + var randomizer = new Randomizer(0x21123737); + + // having + using var highPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); + using var lowPriceResults = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); + var marketOriginal = new Market(0); + var marketFlipFlop = new Market(1); + marketOriginal.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer)); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); + marketCache.AddOrUpdate(marketOriginal); + marketCache.AddOrUpdate(marketFlipFlop); + + // when + marketFlipFlop.UpdateAllPrices(LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(2); + lowPriceResults.Data.Count.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Removes.Should().Be(0); + lowPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket); + lowPriceResults.Summary.Overall.Refreshes.Should().Be(0); + lowPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketFlipFlop.Id)); + highPriceResults.Data.Count.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Adds.Should().Be(PricesPerMarket); + highPriceResults.Summary.Overall.Removes.Should().Be(0); + highPriceResults.Summary.Overall.Updates.Should().Be(PricesPerMarket * 2); + highPriceResults.Summary.Overall.Refreshes.Should().Be(0); + highPriceResults.Data.Items.Select(cp => cp.MarketId).ForEach(guid => guid.Should().Be(marketOriginal.Id)); + } + + [Fact] + public void EqualityComparerHidesUpdatesWithoutChanges() + { + using var marketCache = new SourceCache(p => p.Id); + using var marketCacheResults = marketCache.Connect().AsAggregator(); + + // having + var market = new Market(0); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + market.SetPrices(0, PricesPerMarket, LowestPrice); + marketCache.AddOrUpdate(market); + + // when + market.SetPrices(0, PricesPerMarket, LowestPrice); + + // then + marketCacheResults.Data.Count.Should().Be(1); + results.Data.Count.Should().Be(PricesPerMarket); + results.Messages.Count.Should().Be(1); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Fact] + public void EveryItemVisibleWhenSequenceCompletes() + { + using var marketCache = new SourceCache(p => p.Id); + + var randomizer = new Randomizer(0x21123737); + + // having + marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(() => GetRandomPrice(randomizer), n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket))); + + // when + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices).AsAggregator(); + + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + + // then + results.Data.Count.Should().Be(PricesPerMarket * MarketCount); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket * MarketCount); + results.Summary.Overall.Removes.Should().Be(0); + results.Summary.Overall.Updates.Should().Be(0); + results.Summary.Overall.Refreshes.Should().Be(0); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void MergedObservableCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) + { + using var marketCache = new SourceCache(p => p.Id); + + var randomizer = new Randomizer(0x21123737); + + // having + marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(() => GetRandomPrice(randomizer), n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); + var hasSourceSequenceCompleted = false; + var hasMergedSequenceCompleted = false; + + using var cleanup = marketCache.Connect().Do(_ => { }, () => hasSourceSequenceCompleted = true) + .MergeManyChangeSets(m => m.LatestPrices).Subscribe(_ => { }, () => hasMergedSequenceCompleted = true); + + // when + if (completeSource) + { + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + } + + // then + hasSourceSequenceCompleted.Should().Be(completeSource); + hasMergedSequenceCompleted.Should().Be(completeSource && completeChildren); + } + + [Fact] + public void MergedObservableWillFailIfSourceFails() + { + using var marketCache = new SourceCache(p => p.Id); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + marketCache.AddOrUpdate(markets); + var receivedError = default(Exception); + var expectedError = new Exception("Test exception"); + var throwObservable = Observable.Throw>(expectedError); + + using var cleanup = marketCache.Connect().Concat(throwObservable) + .MergeManyChangeSets(m => m.LatestPrices).Subscribe(_ => { }, err => receivedError = err); + + // when + marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + marketCache.Dispose(); + + // then + receivedError.Should().Be(expectedError); + } + + [Fact] + public void MergeManyChangeSetsWorksCorrectlyWithValueTypes() + { + using var marketCache = new SourceCache(p => p.Id); + + var randomizer = new Randomizer(0x21123737); + + // having + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + marketCache.AddOrUpdate(markets); + markets.ForEach(m => m.SetPrices(0, PricesPerMarket, () => GetRandomPrice(randomizer))); + using var results = marketCache.Connect() + .MergeManyChangeSets(m => m.LatestPrices.Transform(p => p.Price)) + .AsAggregator(); + + // when + markets.ForEach(m => m.RemoveAllPrices()); + + // then + results.Data.Count.Should().Be(0); + results.Summary.Overall.Adds.Should().Be(PricesPerMarket); + results.Summary.Overall.Removes.Should().Be(PricesPerMarket); + } + + [Fact] + public void NullChecks() + { + // having + var emptyChangeSetObs = Observable.Empty>(); + var nullChangeSetObs = (IObservable>)null!; + var emptyChildChangeSetObs = Observable.Empty>(); + var emptySelector = new Func>>(i => emptyChildChangeSetObs); + var emptyKeySelector = new Func>>((i, key) => emptyChildChangeSetObs); + var nullSelector = (Func>>)null!; + var nullKeySelector = (Func>>)null!; + var nullParentComparer = (IComparer)null!; + var emptyParentComparer = new NoOpComparer() as IComparer; + var nullChildComparer = (IComparer)null!; + var emptyChildComparer = new NoOpComparer() as IComparer; + var nullEqualityComparer = (IEqualityComparer)null!; + var emptyEqualityComparer = new NoOpEqualityComparer() as IEqualityComparer; + + // when + var actionDefault1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector); + var actionDefault2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector); + var actionDefault2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector); + var actionChildCompare1 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector, comparer: emptyChildComparer); + var actionChildCompare2a = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector, comparer: emptyChildComparer); + var actionChildCompare2b = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector, comparer: emptyChildComparer); + var actionChildCompare2c = () => emptyChangeSetObs.MergeManyChangeSets(emptyKeySelector, comparer: nullChildComparer); + + // then + emptyChangeSetObs.Should().NotBeNull(); + emptyChildChangeSetObs.Should().NotBeNull(); + emptyChildComparer.Should().NotBeNull(); + emptyEqualityComparer.Should().NotBeNull(); + emptyKeySelector.Should().NotBeNull(); + emptyParentComparer.Should().NotBeNull(); + emptySelector.Should().NotBeNull(); + nullChangeSetObs.Should().BeNull(); + nullChildComparer.Should().BeNull(); + nullEqualityComparer.Should().BeNull(); + nullKeySelector.Should().BeNull(); + nullParentComparer.Should().BeNull(); + nullSelector.Should().BeNull(); + + actionDefault1.Should().Throw(); + actionDefault2a.Should().Throw(); + actionDefault2b.Should().Throw(); + actionChildCompare1.Should().Throw(); + actionChildCompare2a.Should().Throw(); + actionChildCompare2b.Should().Throw(); + actionChildCompare2c.Should().Throw(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OrderOfChangesIsPreserved(bool removeFirst) + { + using var marketCache = new SourceCache(p => p.Id); + + var randomizer = new Randomizer(0x21123737); + + // Arrange + var markets = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + AddUniquePrices(markets, randomizer); + marketCache.AddOrUpdate(markets); + var markets2 = Enumerable.Range(0, MarketCount).Select(n => new Market(n)).ToArray(); + AddUniquePrices(markets2, randomizer); + using var results = marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); + (var firstReason, var nextReason, int expectedChanges) = removeFirst + ? (ChangeReason.Remove, ChangeReason.Add, 2 * MarketCount * PricesPerMarket) + : (ChangeReason.Add, ChangeReason.Remove, 3 * MarketCount * PricesPerMarket); + + // Act + marketCache.Edit(updater => + { + if (removeFirst) + { + updater.Clear(); + updater.AddOrUpdate(markets2); + } + else + { + + updater.AddOrUpdate(markets2); + updater.Clear(); + } + }); + + // Assert + results.Messages.Count.Should().Be(2); + results.Messages[0].All(change => change.Reason is ChangeReason.Add).Should().BeTrue(); + results.Messages[1].Count.Should().Be(expectedChanges); + results.Messages[1].Take(MarketCount * PricesPerMarket).All(change => change.Reason == firstReason).Should().BeTrue(); + results.Messages[1].Skip(MarketCount * PricesPerMarket).All(change => change.Reason == nextReason).Should().BeTrue(); + } + + private static void AddUniquePrices( + Market[] markets, + Randomizer randomizer) + => markets.ForEach(m => m.AddUniquePrices(PricesPerMarket, _ => GetRandomPrice(randomizer))); + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.cs new file mode 100644 index 000000000..2a25f0b2c --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.WithoutSourceComparer.cs @@ -0,0 +1,9 @@ +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + public static partial class WithoutSourceComparer; + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.cs new file mode 100644 index 000000000..dc14907d2 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForCacheChangeSets.cs @@ -0,0 +1,31 @@ +using Bogus; + +using DynamicData.Tests.Domain; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForCacheChangeSets + { + #if DEBUG + private const int MarketCount = 5; + private const int PricesPerMarket = 7; + private const int RemoveCount = 3; + #else + private const int MarketCount = 101; + private const int PricesPerMarket = 103; + private const int RemoveCount = 53; + #endif + + private const int ItemIdStride = 1000; + private const decimal BasePrice = 10m; + private const decimal PriceOffset = 10m; + private const decimal HighestPrice = BasePrice + PriceOffset + 1.0m; + private const decimal LowestPrice = BasePrice - 1.0m; + + private static decimal GetRandomPrice(Randomizer randomizer) + => MarketPrice.RandomPrice(randomizer, BasePrice, PriceOffset); + + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.IntegrationTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.IntegrationTests.cs new file mode 100644 index 000000000..81b341fb6 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.IntegrationTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; + +using Bogus; + +using Xunit; + +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForListChangesets + { + public sealed class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory] + [InlineData(5, 7)] + [InlineData(10, 50)] + #if !DEBUG + [InlineData(10, 1_000)] + [InlineData(200, 500)] + [InlineData(1_000, 10)] + #endif + public async Task MultiThreadedStressTest(int ownerCount, int animalCount) + { + var MaxAddTime = TimeSpan.FromSeconds(0.250); + var MaxRemoveTime = TimeSpan.FromSeconds(0.100); + + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + + 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 asynchronously modifying the parent list and the child lists + using var addAnimals = AddRemoveAnimalsStress(ownerCount, animalCount, Environment.ProcessorCount, TaskPoolScheduler.Default) + .Finally(() => addingAnimals = false) + .Subscribe(); + + // Subscribe / unsubscribe over and over while the collections are being modified + do + { + // Ensure items are being added asynchronously before subscribing to the animal changes + await Task.Yield(); + + { + // Subscribe + var mergedSub = mergeAnimals.Subscribe(); + + // Let other threads run + await Task.Yield(); + + // Unsubscribe + mergedSub.Dispose(); + } + } + while (addingAnimals); + + // 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); + + + TimeSpan? GetRemoveTime() => randomizer.Bool() ? randomizer.TimeSpan(MaxRemoveTime) : null; + + IObservable AddRemoveAnimalsStress(int ownerCount, int animalCount, int parallel, IScheduler scheduler) => + Observable.Create(observer => new CompositeDisposable + ( + AddRemoveOwners(ownerCount, parallel, scheduler) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError), + + animalOwners.Connect() + .MergeMany(owner => AddRemoveAnimals(owner, animalCount, parallel, scheduler)) + .Subscribe( + onNext: static _ => { }, + onError: observer.OnError, + onCompleted: observer.OnCompleted) + )); + + IObservable AddRemoveOwners(int ownerCount, int parallel, IScheduler scheduler) => + animalOwnerFaker.IntervalGenerate(randomizer, MaxAddTime, scheduler) + .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(animalOwners, _ => GetRemoveTime(), scheduler)) + .Finally(animalOwners.Dispose); + + IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int parallel, IScheduler scheduler) => + animalFaker.IntervalGenerate(randomizer, MaxAddTime, scheduler) + .Parallelize(animalCount, parallel, obs => obs.StressAddRemove(owner.Animals, _ => GetRemoveTime(), scheduler)) + .Finally(owner.Animals.Dispose); + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.UnitTests.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.UnitTests.cs new file mode 100644 index 000000000..240cd2111 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.UnitTests.cs @@ -0,0 +1,540 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; + +using Bogus; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForListChangesets + { + public class UnitTests + { + [Fact] + public void NullChecks() + { + // Arrange + var emptyChangeSetObs = Observable.Empty>(); + var nullChangeSetObs = (IObservable>)null!; + var emptyKeySelector = new Func>>((_, _) => Observable.Empty>()); + var nullKeySelector = (Func>>)null!; + var emptySelector = new Func>>(i => Observable.Empty>()); + var nullSelector = (Func>>)null!; + + // Act + var checkParam1 = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector); + var checkParam2 = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector); + var checkParam3 = () => nullChangeSetObs.MergeManyChangeSets(emptySelector); + var checkParam4 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector); + + // Assert + emptyChangeSetObs.Should().NotBeNull(); + emptyKeySelector.Should().NotBeNull(); + emptySelector.Should().NotBeNull(); + nullChangeSetObs.Should().BeNull(); + nullKeySelector.Should().BeNull(); + nullSelector.Should().BeNull(); + + checkParam1.Should().Throw(); + checkParam2.Should().Throw(); + checkParam3.Should().Throw(); + checkParam4.Should().Throw(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ResultCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + // Act + animalOwners.Items.Skip(completeChildren ? 0 : 1).ForEach(owner => owner.Dispose()); + if (completeSource) + { + animalOwners.Dispose(); + } + + // Assert + animalOwnerResults.IsCompleted.Should().Be(completeSource); + animalResults.IsCompleted.Should().Be(completeSource && completeChildren); + } + + [Fact] + public void ResultContainsAllInitialChildren() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + // Act + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(1); + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsChildrenFromAddedParents() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var addThis = animalOwnerFaker.Generate(); + + // Act + animalOwners.AddOrUpdate(addThis); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount + 1); + animalResults.Messages.Count.Should().Be(2); + addThis.Animals.Items.ForEach(added => animalResults.Data.Items.Should().Contain(added)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsChildrenAddedWithAddRange() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var initialCount = animalOwners.Items.Sum(owner => owner.Animals.Count); + var totalAdded = new List(); + + // Act + animalOwners.Items.ForEach(owner => owner.Animals.AddRange(animalFaker.Generate(AddRangeSize).With(added => totalAdded.AddRange(added)))); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(1 + InitialOwnerCount); // Initial + 1 for each Range Added + totalAdded.ForEach(animal => animalResults.Data.Items.Should().Contain(animal)); + animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + totalAdded.Count); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsChildrenAddedWithInsert() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var insertIndex = randomizer.Number(randomOwner.Animals.Items.Count); + var insertThis = animalFaker.Generate(); + var initialCount = animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.Insert(insertIndex, insertThis); + + // Assert + randomOwner.Animals.Items.ElementAt(insertIndex).Should().Be(insertThis); + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + animalResults.Data.Items.Should().Contain(insertThis); + animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + 1); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsCorrectItemsAfterChildClear() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var removedAnimals = randomOwner.Animals.Items.ToList(); + + // Act + randomOwner.Animals.Clear(); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + randomOwner.Animals.Count.Should().Be(0); + removedAnimals.ForEach(removed => animalResults.Data.Items.Should().NotContain(removed)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsCorrectItemsAfterChildReplacement() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var replaceThis = randomizer.ListItem(randomOwner.Animals.Items.ToList()); + var withThis = animalFaker.Generate(); + + // Act + randomOwner.Animals.Replace(replaceThis, withThis); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + randomOwner.Animals.Items.Should().NotContain(replaceThis); + randomOwner.Animals.Items.Should().Contain(withThis); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultContainsCorrectItemsAfterParentUpdate() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var replaceThis = randomizer.ListItem(animalOwners.Items.ToList()); + var withThis = CreateWithSameId(animalOwnerFaker, replaceThis); + + // Act + animalOwners.AddOrUpdate(withThis); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); // Owner Count should not change + animalResults.Messages.Count.Should().Be(2); // 2 = Initial Add and one changeset with remove old items / add new items + replaceThis.Animals.Items.ForEach(removed => animalResults.Data.Items.Should().NotContain(removed)); + withThis.Animals.Items.ForEach(added => animalResults.Data.Items.Should().Contain(added)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + + replaceThis.Dispose(); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsBatchRemoved() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var removeThese = randomizer.ListItems(animalOwners.Items.ToList(), RemoveRangeSize); + + // Act + animalOwners.Remove(removeThese); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - RemoveRangeSize); + animalResults.Messages.Count.Should().Be(2); + removeThese.SelectMany(owner => owner.Animals.Items).ForEach(removed => animalResults.Data.Items.Should().NotContain(removed)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + + removeThese.ForEach(owner => owner.Dispose()); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsRemovedWithRemove() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var removeThis = randomizer.ListItem(animalOwners.Items.ToList()); + + // Act + animalOwners.Remove(removeThis); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - 1); + animalResults.Messages.Count.Should().Be(2); + removeThis.Animals.Items.ForEach(removed => animalResults.Data.Items.Should().NotContain(removed)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + + removeThis.Dispose(); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemove() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var removeThis = randomizer.ListItem(randomOwner.Animals.Items.ToList()); + var initialCount = animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.Remove(removeThis); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + animalResults.Data.Items.Should().NotContain(removeThis); + animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveAt() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var removeIndex = randomizer.Number(randomOwner.Animals.Count - 1); + var removeThis = randomOwner.Animals.Items.ElementAt(removeIndex); + var initialCount = animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.RemoveAt(removeIndex); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + animalResults.Data.Items.Should().NotContain(removeThis); + animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveMany() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var removeCount = randomizer.Number(1, randomOwner.Animals.Count - 1); + var removeThese = randomizer.ListItems(randomOwner.Animals.Items.ToList(), removeCount); + + // Act + randomOwner.Animals.RemoveMany(removeThese); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveRange() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var randomOwner = randomizer.ListItem(animalOwners.Items.ToList()); + var removeCount = randomizer.Number(1, randomOwner.Animals.Count - 1); + var removeIndex = randomizer.Number(randomOwner.Animals.Count - removeCount - 1); + var removeThese = randomOwner.Animals.Items.Skip(removeIndex).Take(removeCount); + + // Act + randomOwner.Animals.RemoveRange(removeIndex, removeCount); + + // Assert + animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + animalResults.Messages.Count.Should().Be(2); + removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + } + + [Fact] + public void ResultEmptyIfSourceIsCleared() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + using var animalOwnerResults = animalOwners.Connect().AsAggregator(); + using var animalResults = animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + var items = animalOwners.Items.ToList(); + + // Act + animalOwners.Clear(); + + // Assert + animalOwnerResults.Data.Count.Should().Be(0); + animalResults.Data.Count.Should().Be(0); + + CheckResultContents(animalOwners.Items, animalOwnerResults, animalResults); + + items.ForEach(owner => owner.Dispose()); + } + + [Fact] + public void ResultFailsIfSourceFails() + { + using var animalOwners = new SourceCache(o => o.Id); + + var randomizer = new Randomizer(0x01221948); + var animalFaker = Fakers.Animal.Clone().WithSeed(randomizer); + var animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(randomizer).WithInitialAnimals(animalFaker); + + // Arrange + animalOwners.AddOrUpdate(animalOwnerFaker.Generate(InitialOwnerCount)); + + var expectedError = new Exception("Expected"); + var throwObservable = Observable.Throw>(expectedError); + using var results = animalOwners.Connect().Concat(throwObservable).MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + // Act + animalOwners.Dispose(); + + // Assert + results.Exception.Should().Be(expectedError); + } + + private static AnimalOwner CreateWithSameId( + Faker animalOwnerFaker, + AnimalOwner original) + { + var newOwner = animalOwnerFaker.Generate(); + var sameId = new AnimalOwner(newOwner.Name, original.Id); + sameId.Animals.AddRange(newOwner.Animals.Items); + return sameId; + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.cs new file mode 100644 index 000000000..296474d5c --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.ForListChangesets.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using FluentAssertions; + +using DynamicData.Tests.Domain; + +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture +{ + public static partial class ForListChangesets + { + #if DEBUG + private const int InitialOwnerCount = 7; + private const int AddRangeSize = 5; + private const int RemoveRangeSize = 3; + #else + private const int InitialOwnerCount = 103; + private const int AddRangeSize = 53; + private const int RemoveRangeSize = 37; + #endif + + private static void CheckResultContents( + IReadOnlyList owners, + ChangeSetAggregator ownerResults, + ChangeSetAggregator animalResults) + { + var expectedOwners = owners.ToList(); + + // These should be subsets of each other + expectedOwners.Should().BeSubsetOf(ownerResults.Data.Items); + ownerResults.Data.Items.Count.Should().Be(expectedOwners.Count); + + // All owner animals should be in the results + foreach (var owner in owners) + { + owner.Animals.Items.Should().BeSubsetOf(animalResults.Data.Items); + } + + // Results should not have more than the total number of animals + animalResults.Data.Count.Should().Be(owners.Sum(owner => owner.Animals.Count)); + } + } +} diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.cs new file mode 100644 index 000000000..5887b6d61 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsFixture.cs @@ -0,0 +1,3 @@ +namespace DynamicData.Tests.Cache; + +public static partial class MergeManyChangeSetsFixture; diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs deleted file mode 100644 index 49f566907..000000000 --- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs +++ /dev/null @@ -1,480 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -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; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; -using FluentAssertions; -using Xunit; - -namespace DynamicData.Tests.Cache; - -public sealed class MergeManyChangeSetsListFixture : IDisposable -{ -#if DEBUG - const int InitialOwnerCount = 7; - const int AddRangeSize = 5; - const int RemoveRangeSize = 3; -#else - const int InitialOwnerCount = 103; - const int AddRangeSize = 53; - const int RemoveRangeSize = 37; -#endif - - private readonly ISourceCache _animalOwners = new SourceCache(o => o.Id); - private readonly ChangeSetAggregator _animalOwnerResults; - private readonly ChangeSetAggregator _animalResults; - private readonly Faker _animalOwnerFaker; - private readonly Faker _animalFaker; - private readonly Randomizer _randomizer; - - public MergeManyChangeSetsListFixture() - { - _randomizer = new Randomizer(0x01221948); - _animalFaker = Fakers.Animal.Clone().WithSeed(_randomizer); - _animalOwnerFaker = Fakers.AnimalOwner.Clone().WithSeed(_randomizer).WithInitialAnimals(_animalFaker); - _animalOwners.AddOrUpdate(_animalOwnerFaker.Generate(InitialOwnerCount)); - - _animalOwnerResults = _animalOwners.Connect().AsAggregator(); - _animalResults = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); - } - - [Theory] - [InlineData(5, 7)] - [InlineData(10, 50)] -#if !DEBUG - [InlineData(10, 1_000)] - [InlineData(200, 500)] - [InlineData(1_000, 10)] -#endif - public async Task MultiThreadedStressTest(int ownerCount, int animalCount) - { - var MaxAddTime = TimeSpan.FromSeconds(0.250); - var MaxRemoveTime = TimeSpan.FromSeconds(0.100); - - TimeSpan? GetRemoveTime() => _randomizer.Bool() ? _randomizer.TimeSpan(MaxRemoveTime) : null; - - IObservable AddRemoveAnimalsStress(int ownerCount, int animalCount, int parallel, IScheduler scheduler) => - Observable.Create(observer => new CompositeDisposable - ( - AddRemoveOwners(ownerCount, parallel, scheduler) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError), - - _animalOwners.Connect() - .MergeMany(owner => AddRemoveAnimals(owner, animalCount, parallel, scheduler)) - .Subscribe( - onNext: static _ => { }, - onError: observer.OnError, - onCompleted: observer.OnCompleted) - )); - - IObservable AddRemoveOwners(int ownerCount, int parallel, IScheduler scheduler) => - _animalOwnerFaker.IntervalGenerate(_randomizer, MaxAddTime, scheduler) - .Parallelize(ownerCount, parallel, obs => obs.StressAddRemove(_animalOwners, _ => GetRemoveTime(), scheduler)) - .Finally(_animalOwners.Dispose); - - IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int parallel, IScheduler scheduler) => - _animalFaker.IntervalGenerate(_randomizer, MaxAddTime, scheduler) - .Parallelize(animalCount, parallel, obs => obs.StressAddRemove(owner.Animals, _ => GetRemoveTime(), scheduler)) - .Finally(owner.Animals.Dispose); - - 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) - .Finally(() => addingAnimals = false) - .Subscribe(); - - // Subscribe / unsubscribe over and over while the collections are being modified - do - { - // Ensure items are being added asynchronously before subscribing to the animal changes - await Task.Yield(); - - { - // Subscribe - var mergedSub = mergeAnimals.Subscribe(); - - // Let other threads run - await Task.Yield(); - - // Unsubscribe - mergedSub.Dispose(); - } - } - while (addingAnimals); - - // 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] - public void NullChecks() - { - // Arrange - var emptyChangeSetObs = Observable.Empty>(); - var nullChangeSetObs = (IObservable>)null!; - var emptyKeySelector = new Func>>((_, _) => Observable.Empty>()); - var nullKeySelector = (Func>>)null!; - var emptySelector = new Func>>(i => Observable.Empty>()); - var nullSelector = (Func>>)null!; - - // Act - var checkParam1 = () => nullChangeSetObs.MergeManyChangeSets(emptyKeySelector); - var checkParam2 = () => emptyChangeSetObs.MergeManyChangeSets(nullKeySelector); - var checkParam3 = () => nullChangeSetObs.MergeManyChangeSets(emptySelector); - var checkParam4 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector); - - // Assert - emptyChangeSetObs.Should().NotBeNull(); - emptyKeySelector.Should().NotBeNull(); - emptySelector.Should().NotBeNull(); - nullChangeSetObs.Should().BeNull(); - nullKeySelector.Should().BeNull(); - nullSelector.Should().BeNull(); - - checkParam1.Should().Throw(); - checkParam2.Should().Throw(); - checkParam3.Should().Throw(); - checkParam4.Should().Throw(); - } - - [Fact] - public void ResultContainsAllInitialChildren() - { - // Arrange - - // Act - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(1); - CheckResultContents(); - } - - [Fact] - public void ResultContainsChildrenFromAddedParents() - { - // Arrange - var addThis = _animalOwnerFaker.Generate(); - - // Act - _animalOwners.AddOrUpdate(addThis); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount + 1); - _animalResults.Messages.Count.Should().Be(2); - addThis.Animals.Items.ForEach(added => _animalResults.Data.Items.Should().Contain(added)); - CheckResultContents(); - } - - [Fact] - public void ResultDoesNotContainChildrenFromParentsRemovedWithRemove() - { - // Arrange - var removeThis = _randomizer.ListItem(_animalOwners.Items.ToList()); - - // Act - _animalOwners.Remove(removeThis); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - 1); - _animalResults.Messages.Count.Should().Be(2); - removeThis.Animals.Items.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); - CheckResultContents(); - removeThis.Dispose(); - } - - [Fact] - public void ResultDoesNotContainChildrenFromParentsBatchRemoved() - { - // Arrange - var removeThese = _randomizer.ListItems(_animalOwners.Items.ToList(), RemoveRangeSize); - - // Act - _animalOwners.Remove(removeThese); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - RemoveRangeSize); - _animalResults.Messages.Count.Should().Be(2); - removeThese.SelectMany(owner => owner.Animals.Items).ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); - CheckResultContents(); - removeThese.ForEach(owner => owner.Dispose()); - } - - [Fact] - public void ResultContainsCorrectItemsAfterParentUpdate() - { - // Arrange - var replaceThis = _randomizer.ListItem(_animalOwners.Items.ToList()); - var withThis = CreateWithSameId(replaceThis); - - // Act - _animalOwners.AddOrUpdate(withThis); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); // Owner Count should not change - _animalResults.Messages.Count.Should().Be(2); // 2 = Initial Add and one changeset with remove old items / add new items - replaceThis.Animals.Items.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); - withThis.Animals.Items.ForEach(added => _animalResults.Data.Items.Should().Contain(added)); - CheckResultContents(); - replaceThis.Dispose(); - } - - [Fact] - public void ResultEmptyIfSourceIsCleared() - { - // Arrange - var items = _animalOwners.Items.ToList(); - - // Act - _animalOwners.Clear(); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(0); - _animalResults.Data.Count.Should().Be(0); - CheckResultContents(); - items.ForEach(owner => owner.Dispose()); - } - - [Fact] - public void ResultContainsChildrenAddedWithAddRange() - { - // Arrange - var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); - var totalAdded = new List(); - - // Act - _animalOwners.Items.ForEach(owner => owner.Animals.AddRange(_animalFaker.Generate(AddRangeSize).With(added => totalAdded.AddRange(added)))); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(1 + InitialOwnerCount); // Initial + 1 for each Range Added - totalAdded.ForEach(animal => _animalResults.Data.Items.Should().Contain(animal)); - _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + totalAdded.Count); - CheckResultContents(); - } - - [Fact] - public void ResultContainsChildrenAddedWithInsert() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var insertIndex = _randomizer.Number(randomOwner.Animals.Items.Count); - var insertThis = _animalFaker.Generate(); - var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); - - // Act - randomOwner.Animals.Insert(insertIndex, insertThis); - - // Assert - randomOwner.Animals.Items.ElementAt(insertIndex).Should().Be(insertThis); - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - _animalResults.Data.Items.Should().Contain(insertThis); - _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + 1); - CheckResultContents(); - } - - [Fact] - public void ResultDoesNotContainChildrenRemovedWithRemove() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var removeThis = _randomizer.ListItem(randomOwner.Animals.Items.ToList()); - var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); - - // Act - randomOwner.Animals.Remove(removeThis); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - _animalResults.Data.Items.Should().NotContain(removeThis); - _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); - CheckResultContents(); - } - - [Fact] - public void ResultDoesNotContainChildrenRemovedWithRemoveAt() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var removeIndex = _randomizer.Number(randomOwner.Animals.Count - 1); - var removeThis = randomOwner.Animals.Items.ElementAt(removeIndex); - var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); - - // Act - randomOwner.Animals.RemoveAt(removeIndex); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - _animalResults.Data.Items.Should().NotContain(removeThis); - _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); - CheckResultContents(); - } - - [Fact] - public void ResultDoesNotContainChildrenRemovedWithRemoveRange() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var removeCount = _randomizer.Number(1, randomOwner.Animals.Count - 1); - var removeIndex = _randomizer.Number(randomOwner.Animals.Count - removeCount - 1); - var removeThese = randomOwner.Animals.Items.Skip(removeIndex).Take(removeCount); - - // Act - randomOwner.Animals.RemoveRange(removeIndex, removeCount); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); - CheckResultContents(); - } - - [Fact] - public void ResultDoesNotContainChildrenRemovedWithRemoveMany() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var removeCount = _randomizer.Number(1, randomOwner.Animals.Count - 1); - var removeThese = _randomizer.ListItems(randomOwner.Animals.Items.ToList(), removeCount); - - // Act - randomOwner.Animals.RemoveMany(removeThese); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); - CheckResultContents(); - } - - [Fact] - public void ResultContainsCorrectItemsAfterChildReplacement() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var replaceThis = _randomizer.ListItem(randomOwner.Animals.Items.ToList()); - var withThis = _animalFaker.Generate(); - - // Act - randomOwner.Animals.Replace(replaceThis, withThis); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - randomOwner.Animals.Items.Should().NotContain(replaceThis); - randomOwner.Animals.Items.Should().Contain(withThis); - CheckResultContents(); - } - - [Fact] - public void ResultContainsCorrectItemsAfterChildClear() - { - // Arrange - var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); - var removedAnimals = randomOwner.Animals.Items.ToList(); - - // Act - randomOwner.Animals.Clear(); - - // Assert - _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); - _animalResults.Messages.Count.Should().Be(2); - randomOwner.Animals.Count.Should().Be(0); - removedAnimals.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); - CheckResultContents(); - } - - [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void ResultCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) - { - // Arrange - - // Act - _animalOwners.Items.Skip(completeChildren ? 0 : 1).ForEach(owner => owner.Dispose()); - if (completeSource) - { - _animalOwners.Dispose(); - } - - // Assert - _animalOwnerResults.IsCompleted.Should().Be(completeSource); - _animalResults.IsCompleted.Should().Be(completeSource && completeChildren); - } - - [Fact] - public void ResultFailsIfSourceFails() - { - // Arrange - var expectedError = new Exception("Expected"); - var throwObservable = Observable.Throw>(expectedError); - using var results = _animalOwners.Connect().Concat(throwObservable).MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); - - // Act - _animalOwners.Dispose(); - - // Assert - results.Exception.Should().Be(expectedError); - } - - public void Dispose() - { - _animalOwners.Items.ForEach(owner => owner.Dispose()); - _animalOwnerResults.Dispose(); - _animalResults.Dispose(); - _animalOwners.Dispose(); - } - - private AnimalOwner CreateWithSameId(AnimalOwner original) - { - var newOwner = _animalOwnerFaker.Generate(); - var sameId = new AnimalOwner(newOwner.Name, original.Id); - sameId.Animals.AddRange(newOwner.Animals.Items); - return sameId; - } - - private void CheckResultContents() => CheckResultContents(_animalOwners.Items, _animalOwnerResults, _animalResults); - - private static void CheckResultContents(IEnumerable owners, ChangeSetAggregator ownerResults, ChangeSetAggregator animalResults) - { - var expectedOwners = owners.ToList(); - - // These should be subsets of each other - expectedOwners.Should().BeSubsetOf(ownerResults.Data.Items); - ownerResults.Data.Items.Count.Should().Be(expectedOwners.Count); - - // All owner animals should be in the results - foreach (var owner in owners) - { - owner.Animals.Items.Should().BeSubsetOf(animalResults.Data.Items); - } - - // Results should not have more than the total number of animals - animalResults.Data.Count.Should().Be(owners.Sum(owner => owner.Animals.Count)); - } -} diff --git a/src/DynamicData.Tests/Cache/SourceCacheFixture.IntegrationTests.cs b/src/DynamicData.Tests/Cache/SourceCacheFixture.IntegrationTests.cs new file mode 100644 index 000000000..4aaf9d5ac --- /dev/null +++ b/src/DynamicData.Tests/Cache/SourceCacheFixture.IntegrationTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Cache; + +public static partial class SourceCacheFixture +{ + public sealed class IntegrationTests + : IntegrationTestFixtureBase + { + [Fact] + public void ConnectDuringDeliveryDoesNotDuplicate() + { + // Exploits the dequeue-to-OnNext window. Thread A writes two items in + // separate batches. The first delivery is held by a slow subscriber. + // While item1 delivery is blocked, item2 is committed to ReaderWriter + // and sitting in the queue. Thread B calls Connect(), takes a snapshot + // (sees both items), subscribes to _changes, then item2 is delivered + // via OnNext — producing a duplicate if not guarded by a generation counter. + using var cache = new SourceCache(static x => x.Key); + + using var delivering = new ManualResetEventSlim(false); + using var item2Written = new ManualResetEventSlim(false); + using var connectDone = new ManualResetEventSlim(false); + + var firstDelivery = true; + + // First subscriber: blocks on the first delivery to create the window + using var slowSub = cache.Connect().Subscribe(_ => + { + if (firstDelivery) + { + firstDelivery = false; + delivering.Set(); + + // Wait until item2 has been written and the Connect has subscribed + connectDone.Wait(TimeSpan.FromSeconds(5)); + } + }); + + // Write item1 on a background thread — delivery starts, slow subscriber blocks + var writeTask = Task.Run(() => + { + cache.AddOrUpdate(new TestItem("k1", "v1")); + }); + + // Wait for delivery of item1 to be in progress (slow sub is blocking) + delivering.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("delivery should have started"); + + // Now write item2 on another thread. It will acquire the lock, commit to + // ReaderWriter, enqueue a notification, and return. The notification sits + // in the queue because the deliverer (Thread A) is blocked by the slow sub. + var writeTask2 = Task.Run(() => + { + cache.AddOrUpdate(new TestItem("k2", "v2")); + item2Written.Set(); + }); + item2Written.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("item2 should have been written"); + + // Now Connect on the main thread. The snapshot from ReaderWriter includes + // BOTH k1 and k2. The subscription to _changes is added. When the slow + // subscriber unblocks, item2's notification will be delivered via OnNext + // and the new subscriber will see k2 again — a duplicate Add. + var addCounts = new Dictionary(); + using var newSub = cache.Connect().Subscribe(changes => + { + foreach (var c in changes) + { + if (c.Reason == ChangeReason.Add) + { + var key = c.Current.Key; + addCounts[key] = addCounts.GetValueOrDefault(key) + 1; + } + } + }); + + // Unblock the slow subscriber — delivery resumes, item2 delivered + connectDone.Set(); + writeTask.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("writeTask should complete"); + writeTask2.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("writeTask2 should complete"); + + // Each key should appear exactly once in the new subscriber's view + addCounts.GetValueOrDefault("k1").Should().Be(1, "k1 should appear once (snapshot only)"); + addCounts.GetValueOrDefault("k2").Should().Be(1, "k2 should appear once, not duplicated from snapshot + queued delivery"); + } + + [Fact] + public async Task DirectCrossWriteDoesNotDeadlock() + { + const int iterations = 50; + + for (var iter = 0; iter < iterations; iter++) + { + using var cacheA = new SourceCache(static x => x.Key); + using var cacheB = new SourceCache(static x => x.Key); + + // Bidirectional: A items flow into B, B items flow into A. + // Filter by prefix prevents infinite feedback. + using var aToB = cacheA.Connect() + .Filter(static x => x.Key.StartsWith('a')) + .Transform(static (item, _) => new TestItem("from-a-" + item.Key, item.Value)) + .PopulateInto(cacheB); + + using var bToA = cacheB.Connect() + .Filter(static x => x.Key.StartsWith('b')) + .Transform(static (item, _) => new TestItem("from-b-" + item.Key, item.Value)) + .PopulateInto(cacheA); + + using var barrier = new Barrier(2); + + var taskA = Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < 1000; i++) + { + cacheA.AddOrUpdate(new TestItem("a" + i, "V" + i)); + } + }); + + var taskB = Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < 1000; i++) + { + cacheB.AddOrUpdate(new TestItem("b" + i, "V" + i)); + } + }); + + var completed = Task.WhenAll(taskA, taskB); + var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(60))); + + finished.Should().BeSameAs(completed, $"iteration {iter}: bidirectional cross-cache writes should not deadlock"); + } + } + + [Fact] + public async Task MultiCacheFanInDoesNotDeadlock() + { + const int itemCount = 100; + + using var cacheA = new SourceCache(static x => x.Key); + using var cacheB = new SourceCache(static x => x.Key); + using var destination = new SourceCache(static x => x.Key); + using var subA = cacheA.Connect().PopulateInto(destination); + using var subB = cacheB.Connect().PopulateInto(destination); + using var results = destination.Connect().AsAggregator(); + + var taskA = Task.Run(() => + { + for (var i = 0; i < itemCount; i++) + { + cacheA.AddOrUpdate(new TestItem($"a-{i}", $"ValueA-{i}")); + } + }); + + var taskB = Task.Run(() => + { + for (var i = 0; i < itemCount; i++) + { + cacheB.AddOrUpdate(new TestItem($"b-{i}", $"ValueB-{i}")); + } + }); + + var completed = Task.WhenAll(taskA, taskB); + var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(10))); + + finished.Should().BeSameAs(completed, "concurrent edits with cross-cache subscribers should not deadlock"); + results.Error.Should().BeNull(); + results.Data.Count.Should().Be(itemCount * 2, "all items from both caches should arrive in the destination"); + results.Data.Items.Should().BeEquivalentTo([.. cacheA.Items, .. cacheB.Items], "all items should be in the destination"); + } + } +} diff --git a/src/DynamicData.Tests/Cache/SourceCacheFixture.UnitTests.cs b/src/DynamicData.Tests/Cache/SourceCacheFixture.UnitTests.cs new file mode 100644 index 000000000..20bf0772a --- /dev/null +++ b/src/DynamicData.Tests/Cache/SourceCacheFixture.UnitTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +using FluentAssertions; + +using Xunit; + +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class SourceCacheFixture +{ + public class UnitTests + { + [Fact] + public void CanHandleABatchOfUpdates() + { + using var source = new SourceCache(p => p.Key); + using var results = source.Connect().AsAggregator(); + + source.Edit( + updater => + { + var torequery = new Person("Adult1", 44); + + updater.AddOrUpdate(new Person("Adult1", 40)); + updater.AddOrUpdate(new Person("Adult1", 41)); + updater.AddOrUpdate(new Person("Adult1", 42)); + updater.AddOrUpdate(new Person("Adult1", 43)); + updater.Refresh(torequery); + updater.Remove(torequery); + updater.Refresh(torequery); + }); + + results.Summary.Overall.Count.Should().Be(6, "Should be 6 up`dates"); + results.Messages.Count.Should().Be(1, "Should be 1 message"); + results.Messages[0].Adds.Should().Be(1, "Should be 1 update"); + results.Messages[0].Updates.Should().Be(3, "Should be 3 updates"); + results.Messages[0].Removes.Should().Be(1, "Should be 1 remove"); + results.Messages[0].Refreshes.Should().Be(1, "Should be 1 evaluate"); + + results.Data.Count.Should().Be(0, "Should be 1 item in` the cache"); + } + + [Fact] + public void ConnectContinuesToWorkNormallyAfterAFailedEdit() + { + using var source = new SourceCache(static item => item); + + source.AddOrUpdate(1); + + source.Invoking(source => source.Edit(_ => throw new Exception("Test"))) + .Should().Throw() + .WithMessage("Test"); + + using var subscription = source.Connect().RecordCacheItems(out var results); + + results.Error.Should().BeNull("new subscribers should not receive previous errors"); + results.RecordedChangeSets.Should().ContainSingle("an initial changeset should have been published."); + } + + // Covers https://github.com/reactivemarbles/DynamicData/issues/1129 + [Fact] + public void ConnectDuringEditsDoesNotDuplicate() + { + using var items = new SourceCache(static item => item); + + using var subscriptions = new CompositeDisposable(); + + // An initial subscription is required to initiate internal buffering of changes, during the upcoming .Edit(). + // That is, we want there to be changes buffered, internally, when the mid-edit subscription comes in, to + // ensure that they don't get duplicated. This is the scenario that came in up #1129. + subscriptions.Add(items + .Connect() + .Subscribe()); + + CacheItemRecordingObserver? results = null; + + items.Edit(inner => + { + inner.AddOrUpdate(1); + + subscriptions.Add(items + .Connect() + .ValidateChangeSets(static item => item) + .RecordCacheItems(out results)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); + + inner.AddOrUpdate(2); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); + + // Explicitly doing a nested edit, as that system is closely intertwined with the edit-tracking system that + // .Connect() uses. + items.Remove(item: 1); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); + }); + + results.Should().NotBeNull("the edit delegate should have been invoked"); + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedChangeSets.Should().ContainSingle("subscribers should only receive a single initial changeset"); + results.RecordedItemsByKey.Should().BeEquivalentTo( + new Dictionary() { [2] = 2 }, + options => options.WithoutStrictOrdering(), + "all items in the source should have propagated downstream"); + + results.HasCompleted.Should().BeFalse("the source has not yet completed"); + } + + [Fact] + public void CountChanged() + { + using var source = new SourceCache(p => p.Key); + using var results = source.Connect().AsAggregator(); + + var count = 0; + var invoked = 0; + using (source.CountChanged.Subscribe( + c => + { + count = c; + invoked++; + })) + { + invoked.Should().Be(1); + count.Should().Be(0); + + source.AddOrUpdate(new RandomPersonGenerator().Take(100)); + invoked.Should().Be(2); + count.Should().Be(100); + + source.Clear(); + invoked.Should().Be(3); + count.Should().Be(0); + } + } + + [Fact] + public void CountChangedShouldAlwaysInvokeUponSubscription() + { + using var source = new SourceCache(p => p.Key); + + int? result = null; + var subscription = source.CountChanged.Subscribe(count => result = count); + + result.HasValue.Should().BeTrue(); + + if (result is null) + { + throw new InvalidOperationException(nameof(result)); + } + + result.Value.Should().Be(0, "Count should be zero"); + + subscription.Dispose(); + } + + [Fact] + public void CountChangedShouldReflectContentsOfCacheInvokeUponSubscription() + { + using var source = new SourceCache(p => p.Key); + + var generator = new RandomPersonGenerator(); + int? result = null; + var subscription = source.CountChanged.Subscribe(count => result = count); + + source.AddOrUpdate(generator.Take(100)); + + if (result is null) + { + throw new InvalidOperationException(nameof(result)); + } + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(100, "Count should be 100"); + subscription.Dispose(); + } + + [Fact] + public void EmptyChanges() + { + using var source = new SourceCache(p => p.Key); + + IChangeSet? change = null; + + using var subscription = source.Connect(suppressEmptyChangeSets: false) + .Subscribe(c=> change = c); + + change.Should().NotBeNull(); + change!.Count.Should().Be(0); + } + + [Fact] + public void EmptyChangesWithFilter() + { + using var source = new SourceCache(p => p.Key); + + IChangeSet? change = null; + + using var subscription = source.Connect(p=>p.Age == 20, suppressEmptyChangeSets: false) + .Subscribe(c => change = c); + + change.Should().NotBeNull(); + change!.Count.Should().Be(0); + } + + [Fact] + public void StaticFilterRemove() + { + var cache = new SourceCache(x => x.Id); + + var above5 = cache.Connect(x => x.Value > 5).AsObservableCache(); + var below5 = cache.Connect(x => x.Value <= 5).AsObservableCache(); + + cache.AddOrUpdate(Enumerable.Range(1,10).Select(i=> new SomeObject(i,i))); + + + above5.Items.Should().BeEquivalentTo(Enumerable.Range(6, 5).Select(i => new SomeObject(i, i))); + below5.Items.Should().BeEquivalentTo(Enumerable.Range(1, 5).Select(i => new SomeObject(i, i))); + + //should move from above 5 to below 5 + cache.AddOrUpdate(new SomeObject(6,-1)); + + above5.Count.Should().Be(4); + below5.Count.Should().Be(6); + + + above5.Items.Should().BeEquivalentTo(Enumerable.Range(7, 4).Select(i => new SomeObject(i, i))); + below5.Items.Should().BeEquivalentTo(Enumerable.Range(1, 6).Select(i => new SomeObject(i, i == 6 ? -1 : i))); + } + + [Fact] + public void SubscribeDisposesCorrectly() + { + using var source = new SourceCache(p => p.Key); + + var called = false; + var errored = false; + var completed = false; + var subscription = source.Connect().Finally(() => completed = true).Subscribe(updates => { called = true; }, ex => errored = true, () => completed = true); + source.AddOrUpdate(new Person("Adult1", 40)); + + subscription.Dispose(); + source.Dispose(); + + errored.Should().BeFalse(); + called.Should().BeTrue(); + completed.Should().BeTrue(); + } + } +} diff --git a/src/DynamicData.Tests/Cache/SourceCacheFixture.cs b/src/DynamicData.Tests/Cache/SourceCacheFixture.cs index 38fc02e73..ea9de54ed 100644 --- a/src/DynamicData.Tests/Cache/SourceCacheFixture.cs +++ b/src/DynamicData.Tests/Cache/SourceCacheFixture.cs @@ -1,426 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; +namespace DynamicData.Tests.Cache; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Cache; - -public class SourceCacheFixture : IDisposable +public static partial class SourceCacheFixture { - private readonly ChangeSetAggregator _results; - - private readonly ISourceCache _source; - - public SourceCacheFixture() - { - _source = new SourceCache(p => p.Key); - _results = _source.Connect().AsAggregator(); - } - - [Fact] - public void CanHandleABatchOfUpdates() - { - _source.Edit( - updater => - { - var torequery = new Person("Adult1", 44); - - updater.AddOrUpdate(new Person("Adult1", 40)); - updater.AddOrUpdate(new Person("Adult1", 41)); - updater.AddOrUpdate(new Person("Adult1", 42)); - updater.AddOrUpdate(new Person("Adult1", 43)); - updater.Refresh(torequery); - updater.Remove(torequery); - updater.Refresh(torequery); - }); - - _results.Summary.Overall.Count.Should().Be(6, "Should be 6 up`dates"); - _results.Messages.Count.Should().Be(1, "Should be 1 message"); - _results.Messages[0].Adds.Should().Be(1, "Should be 1 update"); - _results.Messages[0].Updates.Should().Be(3, "Should be 3 updates"); - _results.Messages[0].Removes.Should().Be(1, "Should be 1 remove"); - _results.Messages[0].Refreshes.Should().Be(1, "Should be 1 evaluate"); - - _results.Data.Count.Should().Be(0, "Should be 1 item in` the cache"); - } - - [Fact] - public void CountChanged() - { - var count = 0; - var invoked = 0; - using (_source.CountChanged.Subscribe( - c => - { - count = c; - invoked++; - })) - { - invoked.Should().Be(1); - count.Should().Be(0); - - _source.AddOrUpdate(new RandomPersonGenerator().Take(100)); - invoked.Should().Be(2); - count.Should().Be(100); - - _source.Clear(); - invoked.Should().Be(3); - count.Should().Be(0); - } - } - - [Fact] - public void CountChangedShouldAlwaysInvokeUponeSubscription() - { - int? result = null; - var subscription = _source.CountChanged.Subscribe(count => result = count); - - result.HasValue.Should().BeTrue(); - - if (result is null) - { - throw new InvalidOperationException(nameof(result)); - } - - result.Value.Should().Be(0, "Count should be zero"); - - subscription.Dispose(); - } - - [Fact] - public void CountChangedShouldReflectContentsOfCacheInvokeUponSubscription() - { - var generator = new RandomPersonGenerator(); - int? result = null; - var subscription = _source.CountChanged.Subscribe(count => result = count); - - _source.AddOrUpdate(generator.Take(100)); - - if (result is null) - { - throw new InvalidOperationException(nameof(result)); - } - - result.HasValue.Should().BeTrue(); - result.Value.Should().Be(100, "Count should be 100"); - subscription.Dispose(); - } - - public void Dispose() - { - _source.Dispose(); - _results.Dispose(); - } - - [Fact] - public void SubscribesDisposesCorrectly() - { - var called = false; - var errored = false; - var completed = false; - var subscription = _source.Connect().Finally(() => completed = true).Subscribe(updates => { called = true; }, ex => errored = true, () => completed = true); - _source.AddOrUpdate(new Person("Adult1", 40)); - - subscription.Dispose(); - _source.Dispose(); - - errored.Should().BeFalse(); - called.Should().BeTrue(); - completed.Should().BeTrue(); - } - - [Fact] - public void EmptyChanges() - { - IChangeSet? change = null; - - using var subscription = _source.Connect(suppressEmptyChangeSets: false) - .Subscribe(c=> change = c); - - change.Should().NotBeNull(); - change!.Count.Should().Be(0); - - } - - [Fact] - public void EmptyChangesWithFilter() - { - IChangeSet? change = null; - - using var subscription = _source.Connect(p=>p.Age == 20, suppressEmptyChangeSets: false) - .Subscribe(c => change = c); - - change.Should().NotBeNull(); - change!.Count.Should().Be(0); - } - - [Fact] - public void StaticFilterRemove() - { - var cache = new SourceCache(x => x.Id); - - var above5 = cache.Connect(x => x.Value > 5).AsObservableCache(); - var below5 = cache.Connect(x => x.Value <= 5).AsObservableCache(); - - cache.AddOrUpdate(Enumerable.Range(1,10).Select(i=> new SomeObject(i,i))); - - - above5.Items.Should().BeEquivalentTo(Enumerable.Range(6, 5).Select(i => new SomeObject(i, i))); - below5.Items.Should().BeEquivalentTo(Enumerable.Range(1, 5).Select(i => new SomeObject(i, i))); - - //should move from above 5 to below 5 - cache.AddOrUpdate(new SomeObject(6,-1)); - - above5.Count.Should().Be(4); - below5.Count.Should().Be(6); - - - above5.Items.Should().BeEquivalentTo(Enumerable.Range(7, 4).Select(i => new SomeObject(i, i))); - below5.Items.Should().BeEquivalentTo(Enumerable.Range(1, 6).Select(i => new SomeObject(i, i == 6 ? -1 : i))); - } - - public record class SomeObject(int Id, int Value); - - [Fact] - public async Task MultiCacheFanInDoesNotDeadlock() - { - const int itemCount = 100; - - using var cacheA = new SourceCache(static x => x.Key); - using var cacheB = new SourceCache(static x => x.Key); - using var destination = new SourceCache(static x => x.Key); - using var subA = cacheA.Connect().PopulateInto(destination); - using var subB = cacheB.Connect().PopulateInto(destination); - using var results = destination.Connect().AsAggregator(); - - var taskA = Task.Run(() => - { - for (var i = 0; i < itemCount; i++) - { - cacheA.AddOrUpdate(new TestItem($"a-{i}", $"ValueA-{i}")); - } - }); - - var taskB = Task.Run(() => - { - for (var i = 0; i < itemCount; i++) - { - cacheB.AddOrUpdate(new TestItem($"b-{i}", $"ValueB-{i}")); - } - }); - - var completed = Task.WhenAll(taskA, taskB); - var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(10))); - - finished.Should().BeSameAs(completed, "concurrent edits with cross-cache subscribers should not deadlock"); - results.Error.Should().BeNull(); - results.Data.Count.Should().Be(itemCount * 2, "all items from both caches should arrive in the destination"); - results.Data.Items.Should().BeEquivalentTo([.. cacheA.Items, .. cacheB.Items], "all items should be in the destination"); - } - - [Fact] - public async Task DirectCrossWriteDoesNotDeadlock() - { - const int iterations = 50; - - for (var iter = 0; iter < iterations; iter++) - { - using var cacheA = new SourceCache(static x => x.Key); - using var cacheB = new SourceCache(static x => x.Key); - - // Bidirectional: A items flow into B, B items flow into A. - // Filter by prefix prevents infinite feedback. - using var aToB = cacheA.Connect() - .Filter(static x => x.Key.StartsWith('a')) - .Transform(static (item, _) => new TestItem("from-a-" + item.Key, item.Value)) - .PopulateInto(cacheB); - - using var bToA = cacheB.Connect() - .Filter(static x => x.Key.StartsWith('b')) - .Transform(static (item, _) => new TestItem("from-b-" + item.Key, item.Value)) - .PopulateInto(cacheA); - - using var barrier = new Barrier(2); - - var taskA = Task.Run(() => - { - barrier.SignalAndWait(); - for (var i = 0; i < 1000; i++) - { - cacheA.AddOrUpdate(new TestItem("a" + i, "V" + i)); - } - }); - - var taskB = Task.Run(() => - { - barrier.SignalAndWait(); - for (var i = 0; i < 1000; i++) - { - cacheB.AddOrUpdate(new TestItem("b" + i, "V" + i)); - } - }); - - var completed = Task.WhenAll(taskA, taskB); - var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(60))); - - finished.Should().BeSameAs(completed, $"iteration {iter}: bidirectional cross-cache writes should not deadlock"); - } - } - - [Fact] - public void ConnectDuringDeliveryDoesNotDuplicate() - { - // Exploits the dequeue-to-OnNext window. Thread A writes two items in - // separate batches. The first delivery is held by a slow subscriber. - // While item1 delivery is blocked, item2 is committed to ReaderWriter - // and sitting in the queue. Thread B calls Connect(), takes a snapshot - // (sees both items), subscribes to _changes, then item2 is delivered - // via OnNext — producing a duplicate if not guarded by a generation counter. - using var cache = new SourceCache(static x => x.Key); - - using var delivering = new ManualResetEventSlim(false); - using var item2Written = new ManualResetEventSlim(false); - using var connectDone = new ManualResetEventSlim(false); - - var firstDelivery = true; - - // First subscriber: blocks on the first delivery to create the window - using var slowSub = cache.Connect().Subscribe(_ => - { - if (firstDelivery) - { - firstDelivery = false; - delivering.Set(); - - // Wait until item2 has been written and the Connect has subscribed - connectDone.Wait(TimeSpan.FromSeconds(5)); - } - }); - - // Write item1 on a background thread — delivery starts, slow subscriber blocks - var writeTask = Task.Run(() => - { - cache.AddOrUpdate(new TestItem("k1", "v1")); - }); - - // Wait for delivery of item1 to be in progress (slow sub is blocking) - delivering.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("delivery should have started"); - - // Now write item2 on another thread. It will acquire the lock, commit to - // ReaderWriter, enqueue a notification, and return. The notification sits - // in the queue because the deliverer (Thread A) is blocked by the slow sub. - var writeTask2 = Task.Run(() => - { - cache.AddOrUpdate(new TestItem("k2", "v2")); - item2Written.Set(); - }); - item2Written.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("item2 should have been written"); - - // Now Connect on the main thread. The snapshot from ReaderWriter includes - // BOTH k1 and k2. The subscription to _changes is added. When the slow - // subscriber unblocks, item2's notification will be delivered via OnNext - // and the new subscriber will see k2 again — a duplicate Add. - var addCounts = new Dictionary(); - using var newSub = cache.Connect().Subscribe(changes => - { - foreach (var c in changes) - { - if (c.Reason == ChangeReason.Add) - { - var key = c.Current.Key; - addCounts[key] = addCounts.GetValueOrDefault(key) + 1; - } - } - }); - - // Unblock the slow subscriber — delivery resumes, item2 delivered - connectDone.Set(); - writeTask.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("writeTask should complete"); - writeTask2.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("writeTask2 should complete"); - - // Each key should appear exactly once in the new subscriber's view - addCounts.GetValueOrDefault("k1").Should().Be(1, "k1 should appear once (snapshot only)"); - addCounts.GetValueOrDefault("k2").Should().Be(1, "k2 should appear once, not duplicated from snapshot + queued delivery"); - } - - // Covers https://github.com/reactivemarbles/DynamicData/issues/1129 - [Fact] - public void ConnectDuringEditsDoesNotDuplicate() - { - using var items = new SourceCache(static item => item); - - using var subscriptions = new CompositeDisposable(); - - // An initial subscription is required to initiate internal buffering of changes, during the upcoming .Edit(). - // That is, we want there to be changes buffered, internally, when the mid-edit subscription comes in, to - // ensure that they don't get duplicated. This is the scenario that came in up #1129. - subscriptions.Add(items - .Connect() - .Subscribe()); - - CacheItemRecordingObserver? results = null; - - items.Edit(inner => - { - inner.AddOrUpdate(1); - - subscriptions.Add(items - .Connect() - .ValidateChangeSets(static item => item) - .RecordCacheItems(out results)); - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); - - inner.AddOrUpdate(2); - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); - - // Explicitly doing a nested edit, as that system is closely intertwined with the edit-tracking system that - // .Connect() uses. - items.Remove(item: 1); - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit"); - }); - - results.Should().NotBeNull("the edit delegate should have been invoked"); - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedChangeSets.Should().ContainSingle("subscribers should only receive a single initial changeset"); - results.RecordedItemsByKey.Should().BeEquivalentTo( - new Dictionary() { [2] = 2 }, - options => options.WithoutStrictOrdering(), - "all items in the source should have propagated downstream"); - - results.HasCompleted.Should().BeFalse("the source has not yet completed"); - } - - [Fact] - public void ConnectContinuesToWorkNormallyAfterAFailedEdit() - { - using var source = new SourceCache(static item => item); - - source.AddOrUpdate(1); - - source.Invoking(source => source.Edit(_ => throw new Exception("Test"))) - .Should().Throw() - .WithMessage("Test"); - - using var subscription = source.Connect().RecordCacheItems(out var results); - - results.Error.Should().BeNull("new subscribers should not receive previous errors"); - results.RecordedChangeSets.Should().ContainSingle("an initial changeset should have been published."); - } + public record SomeObject(int Id, int Value); private sealed record TestItem(string Key, string Value); }