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