From 269828b13b7461084b00ae107225f15b5e661634 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Tue, 7 Nov 2023 14:43:19 -0800 Subject: [PATCH] Feature: MergeManyChangeSets with Parent Item Comparison (#750) Implementation of Parent Sorted MergeManyChangeSets --- .../Cache/MergeChangeSetsFixture.cs | 324 ++---- .../Cache/MergeManyCacheChangeSetsFixture.cs | 317 ++---- ...ManyCacheChangeSetsSourceCompareFixture.cs | 997 ++++++++++++++++++ src/DynamicData.Tests/Domain/Market.cs | 133 +++ src/DynamicData.Tests/Domain/MarketPrice.cs | 82 ++ .../Utilities/ComparerExtensions.cs | 32 + .../Utilities/FunctionalExtensions.cs | 12 + .../Utilities/ObservableExtensions.cs | 21 + .../Cache/Internal/ChangeSetMergeTracker.cs | 59 +- .../MergeManyCacheChangeSetsSourceCompare.cs | 149 +++ src/DynamicData/Cache/ObservableCacheEx.cs | 206 +++- 11 files changed, 1851 insertions(+), 481 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsSourceCompareFixture.cs create mode 100644 src/DynamicData.Tests/Domain/Market.cs create mode 100644 src/DynamicData.Tests/Domain/MarketPrice.cs create mode 100644 src/DynamicData.Tests/Utilities/ComparerExtensions.cs create mode 100644 src/DynamicData.Tests/Utilities/FunctionalExtensions.cs create mode 100644 src/DynamicData.Tests/Utilities/ObservableExtensions.cs create mode 100644 src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs diff --git a/src/DynamicData.Tests/Cache/MergeChangeSetsFixture.cs b/src/DynamicData.Tests/Cache/MergeChangeSetsFixture.cs index 1ea1a331a..e9bb54e0c 100644 --- a/src/DynamicData.Tests/Cache/MergeChangeSetsFixture.cs +++ b/src/DynamicData.Tests/Cache/MergeChangeSetsFixture.cs @@ -1,22 +1,29 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reactive.Linq; using DynamicData.Kernel; +using DynamicData.Tests.Domain; using DynamicData.Tests.Utilities; using FluentAssertions; using Microsoft.Reactive.Testing; + using Xunit; namespace DynamicData.Tests.Cache; -public sealed class MergeChangeSetsFixture : IDisposable +public sealed partial class MergeChangeSetsFixture : 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; @@ -28,6 +35,8 @@ public sealed class MergeChangeSetsFixture : IDisposable private readonly List _marketList = new(); + private static decimal GetRandomPrice() => MarketPrice.RandomPrice(Random, BasePrice, PriceOffset); + public MergeChangeSetsFixture() { } @@ -151,7 +160,7 @@ public void AllExistingItemsPresentInResult() { // having _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); // when using var pricesCache = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsObservableCache(); @@ -177,7 +186,7 @@ public void AllNewSubItemsPresentInResult() using var results = pricesCache.Connect().AsAggregator(); // when - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); // then _marketList.Count.Should().Be(MarketCount); @@ -196,10 +205,10 @@ public void AllRefreshedSubItemsAreRefreshed() // having _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); // when - _marketList.ForEach(m => m.RefreshAllPrices(Random)); + _marketList.ForEach(m => m.RefreshAllPrices(GetRandomPrice)); // then _marketList.Count.Should().Be(MarketCount); @@ -219,8 +228,8 @@ public void AnyDuplicateKeyValuesShouldBeHidden() using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); // when - _marketList[0].AddRandomPrices(Random, 0, PricesPerMarket); - _marketList[1].AddRandomPrices(Random, 0, PricesPerMarket); + _marketList[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + _marketList[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // then _marketList.Count.Should().Be(2); @@ -237,8 +246,8 @@ public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() // having _marketList.AddRange(Enumerable.Range(0, 2).Select(n => new Market(n))); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - _marketList[0].AddRandomPrices(Random, 0, PricesPerMarket); - _marketList[1].AddRandomPrices(Random, 0, PricesPerMarket); + _marketList[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + _marketList[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when _marketList[1].RemoveAllPrices(); @@ -258,8 +267,8 @@ public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() // having _marketList.AddRange(Enumerable.Range(0, 2).Select(n => new Market(n))); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - _marketList[0].AddRandomPrices(Random, 0, PricesPerMarket); - _marketList[1].AddRandomPrices(Random, 0, PricesPerMarket); + _marketList[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + _marketList[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when _marketList[0].RemoveAllPrices(); @@ -277,11 +286,11 @@ public void AnyDuplicateValuesShouldNotRefreshWhenHidden() // having _marketList.AddRange(Enumerable.Range(0, 2).Select(n => new Market(n))); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - _marketList[0].AddRandomPrices(Random, 0, PricesPerMarket); - _marketList[1].AddRandomPrices(Random, 0, PricesPerMarket); + _marketList[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + _marketList[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when - _marketList[1].RefreshAllPrices(Random); + _marketList[1].RefreshAllPrices(GetRandomPrice); // then _marketList.Count.Should().Be(2); @@ -296,7 +305,7 @@ public void AnyRemovedSubItemIsRemoved() // having _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); // when _marketList.ForEach(m => m.PricesCache.Edit(updater => updater.RemoveKeys(updater.Keys.Take(RemoveCount).ToList()))); @@ -320,11 +329,11 @@ public void ComparerOnlyAddsBetterAddedValues() var others = new[] { marketLow.LatestPrices, marketHigh.LatestPrices }; using var highPriceResults = marketOriginal.LatestPrices.MergeChangeSets(others, MarketPrice.HighPriceCompare).AsAggregator(); using var lowPriceResults = marketOriginal.LatestPrices.MergeChangeSets(others, MarketPrice.LowPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); // when - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // then _marketList.Count.Should().Be(3); @@ -346,9 +355,9 @@ public void ComparerOnlyAddsBetterExistingValues() var marketLow = Add(new Market(1)); var marketHigh = Add(new Market(2)); var others = new[] { marketLow.LatestPrices, marketHigh.LatestPrices }; - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // when using var highPriceResults = marketOriginal.LatestPrices.MergeChangeSets(others, MarketPrice.HighPriceCompare).AsAggregator(); @@ -374,8 +383,8 @@ public void ComparerUpdatesToCorrectValueOnRefresh() var marketFlipFlop = Add(new Market(1)); using var highPriceResults = marketOriginal.LatestPrices.MergeChangeSets(marketFlipFlop.LatestPrices, MarketPrice.HighPriceCompare).AsAggregator(); using var lowPriceResults = marketOriginal.LatestPrices.MergeChangeSets(marketFlipFlop.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketFlipFlop.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); // when marketFlipFlop.RefreshAllPrices(LowestPrice); @@ -406,9 +415,9 @@ public void ComparerUpdatesToCorrectValueOnRemove() using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); using var lowPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.LowPriceCompare).AsAggregator(); using var highPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.HighPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // when marketLow.RemoveAllPrices(); @@ -439,8 +448,8 @@ public void ComparerUpdatesToCorrectValueOnUpdate() var marketFlipFlop = Add(new Market(1)); using var highPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.HighPriceCompare).AsAggregator(); using var lowPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.LowPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketFlipFlop.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); // when marketFlipFlop.UpdateAllPrices(LowestPrice); @@ -469,8 +478,8 @@ public void ComparerOnlyUpdatesVisibleValuesOnUpdate() var marketLow = Add(new Market(1)); using var highPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.HighPriceCompare).AsAggregator(); using var lowPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.LowPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); // when marketLow.UpdateAllPrices(LowestPrice - 1); @@ -499,8 +508,8 @@ public void ComparerOnlyRefreshesVisibleValues() var marketLow = Add(new Market(1)); using var highPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, MarketPrice.HighPriceCompare).AsAggregator(); using var lowPriceResults = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, MarketPrice.LowPriceCompare).AsAggregator(); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); // when marketLow.RefreshAllPrices(LowestPrice - 1); @@ -527,7 +536,7 @@ public void EnumObservableUsesTheScheduler() // having var scheduler = new TestScheduler(); _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); using var pricesCache = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, scheduler).AsObservableCache(); using var results = pricesCache.Connect().AsAggregator(); @@ -551,7 +560,7 @@ public void EnumObservableUsesTheSchedulerAndEmitsAll() // having var scheduler = new TestScheduler(); _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); using var pricesCache = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, scheduler).AsObservableCache(); using var results = pricesCache.Connect().AsAggregator(); @@ -575,10 +584,10 @@ public void EqualityComparerHidesUpdatesWithoutChanges() // having var market = Add(new Market(0)); using var results = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); - market.UpdatePrices(0, PricesPerMarket, LowestPrice); + market.SetPrices(0, PricesPerMarket, LowestPrice); // when - market.UpdatePrices(0, PricesPerMarket, LowestPrice); + market.SetPrices(0, PricesPerMarket, LowestPrice); // then _marketList.Count.Should().Be(1); @@ -599,11 +608,11 @@ public void EqualityComparerAndComparerWorkTogetherForUpdates() var results = market1.LatestPrices.MergeChangeSets(market2.LatestPrices, MarketPrice.EqualityComparer, MarketPrice.LatestPriceCompare).AsAggregator(); var resultsTimeStamp = market1.LatestPrices.MergeChangeSets(market2.LatestPrices, MarketPrice.EqualityComparerWithTimeStamp, MarketPrice.LatestPriceCompare).AsAggregator(); - market1.AddRandomPrices(Random, 0, PricesPerMarket); - market2.UpdatePrices(0, PricesPerMarket, LowestPrice); + market1.SetPrices(0, PricesPerMarket, GetRandomPrice); + market2.SetPrices(0, PricesPerMarket, LowestPrice); // when - market2.UpdatePrices(0, PricesPerMarket, LowestPrice); + market2.SetPrices(0, PricesPerMarket, LowestPrice); // then _marketList.Count.Should().Be(2); @@ -629,10 +638,10 @@ public void EqualityComparerAndComparerWorkTogetherForRefreshes() var results1 = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, MarketPrice.LatestPriceCompare).AsAggregator(); var results2 = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparerWithTimeStamp, MarketPrice.LatestPriceCompare).AsAggregator(); - market1.AddRandomPrices(Random, 0, PricesPerMarket); - market2.UpdatePrices(0, PricesPerMarket, LowestPrice); + market1.SetPrices(0, PricesPerMarket, GetRandomPrice); + market2.SetPrices(0, PricesPerMarket, LowestPrice); // Update again, but only the timestamp will change, so results1 will ignore - market2.UpdatePrices(0, PricesPerMarket, LowestPrice); + market2.SetPrices(0, PricesPerMarket, LowestPrice); // when // results1 won't see the refresh because it ignored the update @@ -663,15 +672,15 @@ public void EqualityComparerAndComparerRefreshesBecomeUpdates() var results1 = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer, MarketPrice.LatestPriceCompare).AsAggregator(); var results2 = _marketList.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparerWithTimeStamp, MarketPrice.LatestPriceCompare).AsAggregator(); - market1.AddRandomPrices(Random, 0, PricesPerMarket); - market2.UpdatePrices(0, PricesPerMarket, LowestPrice - 1); + market1.SetPrices(0, PricesPerMarket, GetRandomPrice); + market2.SetPrices(0, PricesPerMarket, LowestPrice - 1); // Update again, but only the timestamp will change, so results1 will ignore - market2.UpdatePrices(0, PricesPerMarket, LowestPrice - 1); + market2.SetPrices(0, PricesPerMarket, LowestPrice - 1); // when // results1 will see this as an update because it ignored the last update // results2 will see the refreshes - market2.RefreshAllPrices(Random); + market2.RefreshAllPrices(GetRandomPrice); // then _marketList.Count.Should().Be(2); @@ -692,7 +701,7 @@ public void EqualityComparerAndComparerRefreshesBecomeUpdates() public void EveryItemVisibleWhenSequenceCompletes() { // having - var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket)).ToList(); + var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket)).ToList(); // when using var results = fixedMarketList.Select(m => m.LatestPrices).MergeChangeSets(completable: true).AsAggregator(); @@ -712,7 +721,7 @@ public void EveryItemVisibleWhenSequenceCompletes() public void MergedObservableCompletesWhenAllSourcesComplete(bool completeSources) { // having - var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeSources)).ToList(); + var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeSources)).ToList(); // when using var results = fixedMarketList.Select(m => m.LatestPrices).MergeChangeSets(completable: true).AsAggregator(); @@ -729,7 +738,7 @@ public void MergedObservableCompletesWhenAllSourcesComplete(bool completeSources public void MergedObservableRespectsCompletableFlag(bool completeSource, bool completeChildren) { // having - var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren)).ToList(); + var fixedMarketList = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren)).ToList(); // when using var results = fixedMarketList.Select(m => m.LatestPrices).MergeChangeSets(completable: completeSource).AsAggregator(); @@ -749,7 +758,7 @@ public void ObservableObservableContainsAllAddedValues() Enumerable.Range(0, MarketCount).ForEach(n => scheduler.AdvanceBy(Interval.Ticks)); // when - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); // then results.Data.Count.Should().Be(MarketCount * PricesPerMarket); @@ -765,7 +774,7 @@ public void ObservableObservableContainsAllExistingValues() // having var scheduler = new TestScheduler(); _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); var marketObs = Observable.Interval(Interval, scheduler).Select(n => _marketList[(int)n]); using var results = marketObs.Select(m => m.LatestPrices).MergeChangeSets(MarketPrice.EqualityComparer).AsAggregator(); @@ -785,7 +794,7 @@ public void MergedObservableWillFailIfAnyChangeChangeSetFails() { // having _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); var expectedError = new Exception("Test exception"); var enumObservable = _marketList.Select(m => m.LatestPrices).Append(Observable.Throw>(expectedError)); @@ -804,7 +813,7 @@ public void ObservableObservableWillFailIfSourceFails() { // having _marketList.AddRange(Enumerable.Range(0, MarketCount).Select(n => new Market(n))); - _marketList.ForEach((m, index) => m.AddUniquePrices(Random, index, PricesPerMarket)); + _marketList.ForEach((m, index) => m.AddUniquePrices(index, PricesPerMarket, ItemIdStride, GetRandomPrice)); var expectedError = new Exception("Test exception"); var observables = _marketList.Select(m => m.LatestPrices).ToObservable().Concat(Observable.Throw>>(expectedError)); @@ -826,7 +835,7 @@ public void ObservableObservableWillFailIfSourceFails() public void ObservableObservableCompletesIfAndOnlyIfSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) { // having - var fixedMarkets = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren)); + var fixedMarkets = Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren)); var observableObservable = fixedMarkets.Select(m => m.LatestPrices).ToObservable(); if (!completeSource) { @@ -850,207 +859,4 @@ private Market Add(Market addThis) _marketList.Add(addThis); return addThis; } - - private interface IMarket - { - public string Name { get; } - - public Guid Id { get; } - - public IObservable> LatestPrices { get; } - } - - private class Market : IMarket, IDisposable - { - private readonly ISourceCache _latestPrices = new SourceCache(p => p.ItemId); - - private Market(string name, Guid id) - { - Name = name; - Id = id; - } - - public Market(Market market) : this(market.Name, market.Id) - { - } - - public Market(int name) : this($"Market #{name}", Guid.NewGuid()) - { - } - - public string Name { get; } - - public Guid Id { get; } - - public IObservable> LatestPrices => _latestPrices.Connect(); - - public ISourceCache PricesCache => _latestPrices; - - public MarketPrice CreatePrice(int itemId, decimal price) => new(itemId, price, Id); - - public Market AddRandomIdPrices(Random r, int count, int minId, int maxId) - { - _latestPrices.AddOrUpdate(Enumerable.Range(0, int.MaxValue).Select(_ => r.Next(minId, maxId)).Distinct().Take(count).Select(id => CreatePrice(id, RandomPrice(r)))); - return this; - } - - public Market AddRandomPrices(Random r, int minId, int maxId) - { - _latestPrices.AddOrUpdate(Enumerable.Range(minId, (maxId - minId)).Select(id => CreatePrice(id, RandomPrice(r)))); - return this; - } - - public Market AddUniquePrices(Random r, int section, int count) => AddRandomPrices(r, section * ItemIdStride, (section * ItemIdStride) + count); - - public Market RefreshAllPrices(decimal newPrice) - { - _latestPrices.Edit(updater => updater.Items.ForEach(cp => - { - cp.Price = newPrice; - updater.Refresh(cp); - })); - - return this; - } - - public Market RefreshAllPrices(Random r) => RefreshAllPrices(RandomPrice(r)); - - public Market RefreshPrice(int id, decimal newPrice) - { - _latestPrices.Edit(updater => updater.Lookup(id).IfHasValue(cp => - { - cp.Price = newPrice; - updater.Refresh(cp); - })); - return this; - } - - public void RemoveAllPrices() => this.With(_ => _latestPrices.Clear()); - - public void RemovePrice(int itemId) => this.With(_ => _latestPrices.Remove(itemId)); - - public Market UpdateAllPrices(decimal newPrice) => this.With(_ => _latestPrices.Edit(updater => updater.AddOrUpdate(updater.Items.Select(cp => CreatePrice(cp.ItemId, newPrice))))); - - public Market UpdatePrices(int minId, int maxId, decimal newPrice) => this.With(_ => _latestPrices.AddOrUpdate(Enumerable.Range(minId, (maxId - minId)).Select(id => CreatePrice(id, newPrice)))); - - public void Dispose() => _latestPrices.Dispose(); - } - - private static decimal RandomPrice(Random r) => BasePrice + ((decimal)r.NextDouble() * PriceOffset); - - private class MarketPrice - { - public static IEqualityComparer EqualityComparer { get; } = new CurrentPriceEqualityComparer(); - public static IEqualityComparer EqualityComparerWithTimeStamp { get; } = new TimeStampPriceEqualityComparer(); - public static IComparer HighPriceCompare { get; } = new HighestPriceComparer(); - public static IComparer LowPriceCompare { get; } = new LowestPriceComparer(); - public static IComparer LatestPriceCompare { get; } = new LatestPriceComparer(); - - private decimal _price; - - public MarketPrice(int itemId, decimal price, Guid marketId) - { - ItemId = itemId; - MarketId = marketId; - Price = price; - } - - public decimal Price - { - get => _price; - set - { - _price = value; - TimeStamp = DateTimeOffset.UtcNow; - } - } - - public DateTimeOffset TimeStamp { get; private set; } - - public Guid MarketId { get; } - - public int ItemId { get; } - - public override string ToString() => $"{ItemId:D5} - {Price:c} ({MarketId}) [{TimeStamp:HH:mm:ss.fffffff}]"; - - private class CurrentPriceEqualityComparer : IEqualityComparer - { - public virtual bool Equals([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) => x.MarketId.Equals(x.MarketId) && (x.ItemId == y.ItemId) && (x.Price == y.Price); - public int GetHashCode([DisallowNull] MarketPrice obj) => throw new NotImplementedException(); - } - - private class TimeStampPriceEqualityComparer : CurrentPriceEqualityComparer, IEqualityComparer - { - public override bool Equals([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) => base.Equals(x, y) && (x.TimeStamp == y.TimeStamp); - } - - private class LowestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return x.Price.CompareTo(y.Price); - } - } - - private class HighestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return y.Price.CompareTo(x.Price); - } - } - - private class LatestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return y.TimeStamp.CompareTo(x.TimeStamp); - } - } - } - - private class FixedMarket : IMarket - { - public FixedMarket(Random r, int minId, int maxId, bool completable = true) - { - Id = Guid.NewGuid(); - LatestPrices = Enumerable.Range(minId, maxId - minId) - .Select(id => new MarketPrice(id, RandomPrice(r), Id)) - .AsObservableChangeSet(cp => cp.ItemId, completable: completable); - } - - public IObservable> LatestPrices { get; } - - public string Name => Id.ToString("B"); - - public Guid Id { get; } - } - - class NoOpComparer : IComparer - { - public int Compare(T x, T y) => throw new NotImplementedException(); - } - - class NoOpEqualityComparer : IEqualityComparer - { - public bool Equals(T x, T y) => throw new NotImplementedException(); - public int GetHashCode([DisallowNull] T obj) => throw new NotImplementedException(); - } -} - -internal static class Extensions -{ - public static T With(this T item, Action action) - { - action(item); - return item; - } - - public static IObservable ForceFail(this IObservable source, int count, Exception? e) => - (e is not null) - ? source.Take(count).Concat(Observable.Throw(e)) - : source; } diff --git a/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsFixture.cs b/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsFixture.cs index 0b083f01d..ca3396c0d 100644 --- a/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsFixture.cs +++ b/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsFixture.cs @@ -1,29 +1,37 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reactive.Linq; using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; using FluentAssertions; using Xunit; -using Xunit.Sdk; namespace DynamicData.Tests.Cache; public sealed class MergeManyCacheChangeSetsFixture : 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 static readonly Random Random = new Random(0x21123737); + private static readonly Random Random = new (0x21123737); + + private static decimal GetRandomPrice() => MarketPrice.RandomPrice(Random, BasePrice, PriceOffset); private readonly ISourceCache _marketCache = new SourceCache(p => p.Id); @@ -34,6 +42,57 @@ public MergeManyCacheChangeSetsFixture() _marketCacheResults = _marketCache.Connect().AsAggregator(); } + [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() { @@ -52,11 +111,6 @@ IObservable> factory(IMarket m) // then _marketCacheResults.Data.Count.Should().Be(1); invoked.Should().BeTrue(); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets((Func>>)null!, comparer: null!)); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets(_ => Observable.Return(ChangeSet.Empty), comparer: null!)); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets((Func>>)null!, null!, null!)); - Assert.Throws(() => ObservableCacheEx.MergeManyChangeSets(null!, (Func>>)null!, comparer: null!)); - Assert.Throws(() => ObservableCacheEx.MergeManyChangeSets(null!, (Func>>)null!, null!, null!)); } [Fact] @@ -77,11 +131,6 @@ IObservable> factory(IMarket m, Guid g) // then _marketCacheResults.Data.Count.Should().Be(1); invoked.Should().BeTrue(); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets((Func>>)null!, comparer: null!)); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets((_, _) => Observable.Return(ChangeSet.Empty), comparer: null!)); - Assert.Throws(() => _marketCache.Connect().MergeManyChangeSets((Func>>)null!, null!, null!)); - Assert.Throws(() => ObservableCacheEx.MergeManyChangeSets(null!, (Func>>)null!, comparer: null!)); - Assert.Throws(() => ObservableCacheEx.MergeManyChangeSets(null!, (Func>>)null!, null!, null!)); } [Fact] @@ -90,7 +139,7 @@ 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(); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.AddRandomPrices(Random, m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket)); + 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); @@ -114,7 +163,7 @@ public void AllNewSubItemsPresentInResult() _marketCache.AddOrUpdate(markets); // when - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.AddRandomPrices(Random, m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket)); + 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); @@ -133,10 +182,10 @@ public void AllRefreshedSubItemsAreRefreshed() 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); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.AddRandomPrices(Random, m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket)); + 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.RefreshAllPrices(Random)); + markets.ForEach(m => m.RefreshAllPrices(GetRandomPrice)); // then _marketCacheResults.Data.Count.Should().Be(MarketCount); @@ -157,8 +206,8 @@ public void AnyDuplicateKeyValuesShouldBeHidden() _marketCache.AddOrUpdate(markets); // when - markets[0].AddRandomPrices(Random, 0, PricesPerMarket); - markets[1].AddRandomPrices(Random, 0, PricesPerMarket); + markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // then _marketCacheResults.Data.Count.Should().Be(2); @@ -176,8 +225,8 @@ public void AnyDuplicateValuesShouldBeNoOpWhenRemoved() 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].AddRandomPrices(Random, 0, PricesPerMarket); - markets[1].AddRandomPrices(Random, 0, PricesPerMarket); + markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when markets[1].RemoveAllPrices(); @@ -198,8 +247,8 @@ public void AnyDuplicateValuesShouldBeUnhiddenWhenOtherIsRemoved() 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].AddRandomPrices(Random, 0, PricesPerMarket); - markets[1].AddRandomPrices(Random, 0, PricesPerMarket); + markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when _marketCache.Remove(markets[0]); @@ -219,11 +268,11 @@ public void AnyDuplicateValuesShouldNotRefreshWhenHidden() 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].AddRandomPrices(Random, 0, PricesPerMarket); - markets[1].AddRandomPrices(Random, 0, PricesPerMarket); + markets[0].SetPrices(0, PricesPerMarket, GetRandomPrice); + markets[1].SetPrices(0, PricesPerMarket, GetRandomPrice); // when - markets[1].RefreshAllPrices(Random); + markets[1].RefreshAllPrices(GetRandomPrice); // then _marketCacheResults.Data.Count.Should().Be(2); @@ -239,7 +288,7 @@ public void AnyRemovedSubItemIsRemoved() 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); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.AddRandomPrices(Random, m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket)); + 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)))); @@ -260,7 +309,7 @@ public void AnySourceItemRemovedRemovesAllSourceValues() 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); - markets.Select((m, index) => new { Market = m, Index = index }).ForEach(m => m.Market.AddRandomPrices(Random, m.Index * ItemIdStride, (m.Index * ItemIdStride) + PricesPerMarket)); + 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))); @@ -278,10 +327,10 @@ public void ChangingSourceByUpdateRemovesPreviousAndAddsNewValues() // having using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); var market = new Market(0); - market.AddRandomPrices(Random, 0, PricesPerMarket * 2); + market.SetPrices(0, PricesPerMarket * 2, GetRandomPrice); _marketCache.AddOrUpdate(market); var updatedMarket = new Market(market); - updatedMarket.AddRandomPrices(Random, PricesPerMarket, PricesPerMarket * 3); + updatedMarket.SetPrices(PricesPerMarket, PricesPerMarket * 3, GetRandomPrice); // when _marketCache.AddOrUpdate(updatedMarket); @@ -304,14 +353,14 @@ public void ComparerOnlyAddsBetterAddedValues() var marketOriginal = new Market(0); var marketLow = new Market(1); var marketHigh = new Market(2); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketLow); _marketCache.AddOrUpdate(marketHigh); // when - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // then _marketCacheResults.Data.Count.Should().Be(3); @@ -334,10 +383,10 @@ public void ComparerOnlyAddsBetterExistingValues() var marketOriginal = new Market(0); var marketLow = new Market(1); var marketHigh = new Market(2); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); _marketCache.AddOrUpdate(marketOriginal); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // when _marketCache.AddOrUpdate(marketLow); @@ -364,9 +413,9 @@ public void ComparerOnlyAddsBetterValuesOnSourceUpdate() var marketOriginal = new Market(0); var marketLow = new Market(1); var marketLowLow = new Market(marketLow); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketLowLow.UpdatePrices(0, PricesPerMarket, LowestPrice - 1); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketLowLow.SetPrices(0, PricesPerMarket, LowestPrice - 1); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketLow); @@ -395,8 +444,8 @@ public void ComparerUpdatesToCorrectValueOnRefresh() using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); var marketOriginal = new Market(0); var marketFlipFlop = new Market(1); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketFlipFlop.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketFlipFlop); @@ -429,12 +478,12 @@ public void ComparerUpdatesToCorrectValueOnRemove() var marketOriginal = new Market(0); var marketLow = new Market(1); var marketHigh = new Market(2); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketLow); _marketCache.AddOrUpdate(marketHigh); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); - marketHigh.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); + marketHigh.SetPrices(0, PricesPerMarket, HighestPrice); // when _marketCache.Remove(marketLow); @@ -465,8 +514,8 @@ public void ComparerUpdatesToCorrectValueOnUpdate() using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); var marketOriginal = new Market(0); var marketFlipFlop = new Market(1); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketFlipFlop.UpdatePrices(0, PricesPerMarket, HighestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketFlipFlop.SetPrices(0, PricesPerMarket, HighestPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketFlipFlop); @@ -497,8 +546,8 @@ public void ComparerOnlyUpdatesVisibleValuesOnUpdate() using var lowPriceResults = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.LowPriceCompare).AsAggregator(); var marketOriginal = new Market(0); var marketLow = new Market(1); - marketOriginal.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketLow); @@ -529,8 +578,8 @@ public void ComparerOnlyRefreshesVisibleValues() 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.AddRandomPrices(Random, 0, PricesPerMarket); - marketLow.UpdatePrices(0, PricesPerMarket, LowestPrice); + marketOriginal.SetPrices(0, PricesPerMarket, GetRandomPrice); + marketLow.SetPrices(0, PricesPerMarket, LowestPrice); _marketCache.AddOrUpdate(marketOriginal); _marketCache.AddOrUpdate(marketLow); @@ -559,11 +608,11 @@ public void EqualityComparerHidesUpdatesWithoutChanges() // having var market = new Market(0); using var results = _marketCache.Connect().MergeManyChangeSets(m => m.LatestPrices, MarketPrice.EqualityComparer).AsAggregator(); - market.UpdatePrices(0, PricesPerMarket, LowestPrice); + market.SetPrices(0, PricesPerMarket, LowestPrice); _marketCache.AddOrUpdate(market); // when - market.UpdatePrices(0, PricesPerMarket, LowestPrice); + market.SetPrices(0, PricesPerMarket, LowestPrice); // then _marketCacheResults.Data.Count.Should().Be(1); @@ -579,7 +628,7 @@ public void EqualityComparerHidesUpdatesWithoutChanges() public void EveryItemVisibleWhenSequenceCompletes() { // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket))); + _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(); @@ -601,7 +650,7 @@ public void EveryItemVisibleWhenSequenceCompletes() public void MergedObservableCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) { // having - _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(Random, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); + _marketCache.AddOrUpdate(Enumerable.Range(0, MarketCount).Select(n => new FixedMarket(GetRandomPrice, n * ItemIdStride, (n * ItemIdStride) + PricesPerMarket, completable: completeChildren))); var hasSourceSequenceCompleted = false; var hasMergedSequenceCompleted = false; @@ -651,162 +700,4 @@ private void DisposeMarkets() _marketCache.Dispose(); _marketCache.Clear(); } - - private interface IMarket - { - public string Name { get; } - - public Guid Id { get; } - - public IObservable> LatestPrices { get; } - } - - private class Market : IMarket, IDisposable - { - private readonly ISourceCache _latestPrices = new SourceCache(p => p.ItemId); - - private Market(string name, Guid id) - { - Name = name; - Id = id; - } - - public Market(Market market) : this(market.Name, market.Id) - { - } - - public Market(int name) : this($"Market #{name}", Guid.NewGuid()) - { - } - - public string Name { get; } - - public Guid Id { get; } - - public IObservable> LatestPrices => _latestPrices.Connect(); - - public ISourceCache PricesCache => _latestPrices; - - public MarketPrice CreatePrice(int itemId, decimal price) => new (itemId, price, Id); - - public void AddRandomIdPrices(Random r, int count, int minId, int maxId) => - _latestPrices.AddOrUpdate(Enumerable.Range(0, int.MaxValue).Select(_ => r.Next(minId, maxId)).Distinct().Take(count).Select(id => CreatePrice(id, RandomPrice(r)))); - - public void AddRandomPrices(Random r, int minId, int maxId) => - _latestPrices.AddOrUpdate(Enumerable.Range(minId, (maxId - minId)).Select(id => CreatePrice(id, RandomPrice(r)))); - - public void RefreshAllPrices(decimal newPrice) => - _latestPrices.Edit(updater => updater.Items.ForEach(cp => - { - cp.Price = newPrice; - updater.Refresh(cp); - })); - - public void RefreshAllPrices(Random r) => RefreshAllPrices(RandomPrice(r)); - - public void RefreshPrice(int id, decimal newPrice) => - _latestPrices.Edit(updater => updater.Lookup(id).IfHasValue(cp => - { - cp.Price = newPrice; - updater.Refresh(cp); - })); - - public void RemoveAllPrices() => _latestPrices.Clear(); - - public void RemovePrice(int itemId) => _latestPrices.Remove(itemId); - - public void UpdateAllPrices(decimal newPrice) => - _latestPrices.Edit(updater => updater.AddOrUpdate(updater.Items.Select(cp => CreatePrice(cp.ItemId, newPrice)))); - - public void UpdatePrices(int minId, int maxId, decimal newPrice) => - _latestPrices.AddOrUpdate(Enumerable.Range(minId, (maxId - minId)).Select(id => CreatePrice(id, newPrice))); - - public void Dispose() => _latestPrices.Dispose(); - } - - private static decimal RandomPrice(Random r) => BasePrice + ((decimal)r.NextDouble() * PriceOffset); - - private class MarketPrice - { - public static IEqualityComparer EqualityComparer { get; } = new CurrentPriceEqualityComparer(); - public static IComparer HighPriceCompare { get; } = new HighestPriceComparer(); - public static IComparer LowPriceCompare { get; } = new LowestPriceComparer(); - public static IComparer LatestPriceCompare { get; } = new LatestPriceComparer(); - - private decimal _price; - - public MarketPrice(int itemId, decimal price, Guid marketId) - { - ItemId = itemId; - MarketId = marketId; - Price = price; - } - - public decimal Price - { - get => _price; - set - { - _price = value; - TimeStamp = DateTimeOffset.UtcNow; - } - } - - public DateTimeOffset TimeStamp { get; private set; } - - public Guid MarketId { get; } - - public int ItemId { get; } - - private class CurrentPriceEqualityComparer : IEqualityComparer - { - public bool Equals([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) => x.MarketId.Equals(x.MarketId) && (x.ItemId == y.ItemId) && (x.Price == y.Price); - public int GetHashCode([DisallowNull] MarketPrice obj) => throw new NotImplementedException(); - } - - private class LowestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return x.Price.CompareTo(y.Price); - } - } - - private class HighestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return y.Price.CompareTo(x.Price); - } - } - - private class LatestPriceComparer : IComparer - { - public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) - { - Debug.Assert(x.ItemId == y.ItemId); - return x.TimeStamp.CompareTo(y.TimeStamp); - } - } - } - - private class FixedMarket : IMarket - { - public FixedMarket(Random r, int minId, int maxId, bool completable = true) - { - Id = Guid.NewGuid(); - LatestPrices = Enumerable.Range(minId, maxId - minId) - .Select(id => new MarketPrice(id, RandomPrice(r), Id)) - .AsObservableChangeSet(cp => cp.ItemId, completable: completable); - } - - public IObservable> LatestPrices { get; } - - public string Name => Id.ToString("B"); - - public Guid Id { get; } - } - } diff --git a/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsSourceCompareFixture.cs b/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsSourceCompareFixture.cs new file mode 100644 index 000000000..e3fd02908 --- /dev/null +++ b/src/DynamicData.Tests/Cache/MergeManyCacheChangeSetsSourceCompareFixture.cs @@ -0,0 +1,997 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Cache; + +public sealed class MergeManyCacheChangeSetsSourceCompareFixture : 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 static readonly Random Random = new (0x10012022); + + private static decimal GetRandomPrice() => MarketPrice.RandomPrice(Random, BasePrice, PriceOffset); + + private readonly ISourceCache _marketCache = new SourceCache(p => p.Id); + + private readonly ChangeSetAggregator _marketCacheResults; + + public MergeManyCacheChangeSetsSourceCompareFixture() + { + _marketCacheResults = _marketCache.Connect().AsAggregator(); + } + + [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(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 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(); + _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.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 = 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(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(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 DisposeMarkets() + { + _marketCache.Items.ForEach(m => (m as IDisposable)?.Dispose()); + _marketCache.Dispose(); + _marketCache.Clear(); + } +} diff --git a/src/DynamicData.Tests/Domain/Market.cs b/src/DynamicData.Tests/Domain/Market.cs new file mode 100644 index 000000000..1e8b25c47 --- /dev/null +++ b/src/DynamicData.Tests/Domain/Market.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Linq; +using DynamicData.Kernel; +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Domain; + +internal interface IMarket +{ + public string Name { get; } + + public double Rating { get; set; } + + public Guid Id { get; } + + public IObservable> LatestPrices { get; } +} + +internal class Market : IMarket, IDisposable +{ + private readonly ISourceCache _latestPrices = new SourceCache(p => p.ItemId); + + public static IComparer RatingCompare { get; } = new RatingComparer(); + + private Market(string name, Guid id) + { + Name = name; + Id = id; + } + + public Market(Market market) : this(market.Name, market.Id) + { + } + + public Market(int name) : this($"Market #{name}", Guid.NewGuid()) + { + } + + public string Name { get; } + + public Guid Id { get; } + + public double Rating { get; set; } + + public IObservable> LatestPrices => _latestPrices.Connect(); + + public ISourceCache PricesCache => _latestPrices; + + public MarketPrice CreatePrice(int itemId, decimal price) => new(itemId, price, Id); + + public Market AddRandomIdPrices(Random r, int count, int minId, int maxId, Func randPrices) + { + _latestPrices.AddOrUpdate(Enumerable.Range(0, int.MaxValue).Select(_ => r.Next(minId, maxId)).Distinct().Take(count).Select(id => CreatePrice(id, randPrices()))); + return this; + } + + public Market AddUniquePrices(int section, int count, int stride, Func getPrice) => SetPrices(section * stride, section * stride + count, getPrice); + + public Market RefreshPrice(int id, decimal newPrice) => this.With(_ => + _latestPrices.Edit(updater => updater.Lookup(id).IfHasValue(cp => + { + cp.Price = newPrice; + updater.Refresh(cp); + }))); + + public Market RefreshAllPrices(Func getNewPrice) => this.With(_ => + _latestPrices.Edit(updater => updater.Items.ForEach(cp => + { + cp.Price = getNewPrice(cp.ItemId); + updater.Refresh(cp); + }))); + + public Market RefreshAllPrices(Func getNewPrice) => RefreshAllPrices(_ => getNewPrice()); + + public Market RefreshAllPrices(decimal newPrice) => RefreshAllPrices(_ => newPrice); + + public void RemoveAllPrices() => this.With(_ => _latestPrices.Clear()); + + public void RemovePrice(int itemId) => this.With(_ => _latestPrices.Remove(itemId)); + + public Market UpdateAllPrices(Func getNewPrice) => this.With(_ => + _latestPrices.Edit(updater => updater.AddOrUpdate(updater.Items.Select(cp => CreatePrice(cp.ItemId, getNewPrice(cp.ItemId)))))); + + public Market UpdateAllPrices(Func getNewPrice) => UpdateAllPrices(_ => getNewPrice()); + + public Market UpdateAllPrices(decimal newPrice) => UpdateAllPrices(_ => newPrice); + + public Market SetPrices(int minId, int maxId, Func getPrice) => this.With(_ => + _latestPrices.AddOrUpdate(Enumerable.Range(minId, maxId - minId).Select(id => CreatePrice(id, getPrice(id))))); + + public Market SetPrices(int minId, int maxId, Func getPrice) => SetPrices(minId, maxId, i => getPrice()); + + public Market SetPrices(int minId, int maxId, decimal newPrice) => SetPrices(minId, maxId, _ => newPrice); + + public void Dispose() => _latestPrices.Dispose(); + + public override string ToString() => $"Market '{Name}' [{Id}] (Rating: {Rating})"; + + private class RatingComparer : IComparer + { + public int Compare([DisallowNull] IMarket x, [DisallowNull] IMarket y) + { + // Higher ratings go first + return y.Rating.CompareTo(x.Rating); + } + } +} + + +internal class FixedMarket : IMarket +{ + public FixedMarket(Func getPrice, int minId, int maxId, bool completable = true) + { + Id = Guid.NewGuid(); + LatestPrices = Enumerable.Range(minId, maxId - minId) + .Select(id => new MarketPrice(id, getPrice(), Id)) + .AsObservableChangeSet(cp => cp.ItemId, completable: completable); + } + + public IObservable> LatestPrices { get; } + + public string Name => Id.ToString("B"); + + public double Rating { get; set; } + + public Guid Id { get; } + + public override string ToString() => $"Fixed Market '{Name}' (Rating: {Rating})"; +} diff --git a/src/DynamicData.Tests/Domain/MarketPrice.cs b/src/DynamicData.Tests/Domain/MarketPrice.cs new file mode 100644 index 000000000..05dfaa97b --- /dev/null +++ b/src/DynamicData.Tests/Domain/MarketPrice.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace DynamicData.Tests.Domain; + +internal class MarketPrice +{ + public static IEqualityComparer EqualityComparer { get; } = new CurrentPriceEqualityComparer(); + public static IEqualityComparer EqualityComparerWithTimeStamp { get; } = new TimeStampPriceEqualityComparer(); + public static IComparer HighPriceCompare { get; } = new HighestPriceComparer(); + public static IComparer LowPriceCompare { get; } = new LowestPriceComparer(); + public static IComparer LatestPriceCompare { get; } = new LatestPriceComparer(); + + private decimal _price; + + public MarketPrice(int itemId, decimal price, Guid marketId) + { + ItemId = itemId; + MarketId = marketId; + Price = price; + } + + public decimal Price + { + get => _price; + set + { + _price = value; + TimeStamp = DateTimeOffset.UtcNow; + } + } + + public DateTimeOffset TimeStamp { get; private set; } + + public Guid MarketId { get; } + + public int ItemId { get; } + + public override string ToString() => $"{ItemId:D5} - {Price:c} ({MarketId}) [{TimeStamp:HH:mm:ss.fffffff}]"; + + public static decimal RandomPrice(Random r, decimal basePrice, decimal offset) => basePrice + (decimal)r.NextDouble() * offset; + + private class CurrentPriceEqualityComparer : IEqualityComparer + { + public virtual bool Equals([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) => x.MarketId.Equals(x.MarketId) && x.ItemId == y.ItemId && x.Price == y.Price; + public int GetHashCode([DisallowNull] MarketPrice obj) => throw new NotImplementedException(); + } + + private class TimeStampPriceEqualityComparer : CurrentPriceEqualityComparer, IEqualityComparer + { + public override bool Equals([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) => base.Equals(x, y) && x.TimeStamp == y.TimeStamp; + } + + private class LowestPriceComparer : IComparer + { + public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) + { + Debug.Assert(x.ItemId == y.ItemId); + return x.Price.CompareTo(y.Price); + } + } + + private class HighestPriceComparer : IComparer + { + public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) + { + Debug.Assert(x.ItemId == y.ItemId); + return y.Price.CompareTo(x.Price); + } + } + + private class LatestPriceComparer : IComparer + { + public int Compare([DisallowNull] MarketPrice x, [DisallowNull] MarketPrice y) + { + Debug.Assert(x.ItemId == y.ItemId); + return y.TimeStamp.CompareTo(x.TimeStamp); + } + } +} diff --git a/src/DynamicData.Tests/Utilities/ComparerExtensions.cs b/src/DynamicData.Tests/Utilities/ComparerExtensions.cs new file mode 100644 index 000000000..1908f4cf8 --- /dev/null +++ b/src/DynamicData.Tests/Utilities/ComparerExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace DynamicData.Tests.Utilities; + +internal class NoOpComparer : IComparer +{ + public int Compare(T x, T y) => throw new NotImplementedException(); +} + +internal class NoOpEqualityComparer : IEqualityComparer +{ + public bool Equals(T x, T y) => throw new NotImplementedException(); + public int GetHashCode([DisallowNull] T obj) => throw new NotImplementedException(); +} + + +internal class InvertedComparer : IComparer +{ + private readonly IComparer _original; + + public InvertedComparer(IComparer original) => _original = original; + + public int Compare(T x, T y) => _original.Compare(x, y) * -1; +} + + +internal static class ComparerExtensions +{ + public static IComparer Invert(this IComparer comparer) => new InvertedComparer(comparer); +} diff --git a/src/DynamicData.Tests/Utilities/FunctionalExtensions.cs b/src/DynamicData.Tests/Utilities/FunctionalExtensions.cs new file mode 100644 index 000000000..cb3c819cf --- /dev/null +++ b/src/DynamicData.Tests/Utilities/FunctionalExtensions.cs @@ -0,0 +1,12 @@ +using System; + +namespace DynamicData.Tests.Utilities; + +internal static class FunctionalExtensions +{ + public static T With(this T item, Action action) + { + action(item); + return item; + } +} diff --git a/src/DynamicData.Tests/Utilities/ObservableExtensions.cs b/src/DynamicData.Tests/Utilities/ObservableExtensions.cs new file mode 100644 index 000000000..f1f622c38 --- /dev/null +++ b/src/DynamicData.Tests/Utilities/ObservableExtensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Linq; +using System.Reactive.Linq; + +namespace DynamicData.Tests.Utilities; + +internal static class ObservableExtensions +{ + /// + /// Forces the given observable to fail after the specified number events if an exception is provided. + /// + /// Observable type. + /// Source Observable. + /// Number of events before failing. + /// Exception to fail with. + /// The new Observable. + public static IObservable ForceFail(this IObservable source, int count, Exception? e) => + e is not null + ? source.Take(count).Concat(Observable.Throw(e)) + : source; +} diff --git a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs index 9adefe1a6..e5b7aeaba 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs @@ -48,6 +48,30 @@ public void RemoveItems(IEnumerable> items, IObserve EmitChanges(observer); } + public void RefreshItems(IEnumerable keys, IObserver> observer) + { + var sourceCaches = _selectCaches().ToArray(); + + // Update the Published Value for each item being removed + if (keys is IList list) + { + // zero allocation enumerator + foreach (var key in EnumerableIList.Create(list)) + { + ForceEvaluate(sourceCaches, key); + } + } + else + { + foreach (var key in keys) + { + ForceEvaluate(sourceCaches, key); + } + } + + EmitChanges(observer); + } + public void ProcessChangeSet(IChangeSet changes, IObserver> observer) { var sourceCaches = _selectCaches().ToArray(); @@ -125,10 +149,13 @@ private void OnItemUpdated(ChangeSetCache[] sources, TObject item return; } + // If the Previous value is missing or is the same as the current value + bool isUpdatingCurrent = !prev.HasValue || CheckEquality(prev.Value, cached.Value); + if (_comparer is null) { - // If the current value (or there is no way to tell) is being replaced by a different value - if ((!prev.HasValue || CheckEquality(prev.Value, cached.Value)) && !CheckEquality(item, cached.Value)) + // If not using the comparer and the current value is being replaced by a different value + if (isUpdatingCurrent && !CheckEquality(item, cached.Value)) { // Update to the new value _resultCache.AddOrUpdate(item, key); @@ -136,14 +163,16 @@ private void OnItemUpdated(ChangeSetCache[] sources, TObject item } else { - // The current value is being replaced (or there is no way to tell), so do a full update to select the best one from all the choices - if (!prev.HasValue || CheckEquality(prev.Value, cached.Value)) + // If using the comparer and the current value is one being updated + if (isUpdatingCurrent) { + // The known best value has been replaced, so pick a new one from all the choices UpdateToBestValue(sources, key, cached); } else { - // If the current value isn't being replaced, check to see if the replacement value is better than the current one + // If the current value isn't being replaced, its only required to check to see if the + // new value is better than the current one if (ShouldReplace(item, cached.Value)) { _resultCache.AddOrUpdate(item, key); @@ -172,10 +201,24 @@ private void OnItemRefreshed(ChangeSetCache[] sources, TObject it } } + private void ForceEvaluate(ChangeSetCache[] sources, TKey key) + { + var cached = _resultCache.Lookup(key); + + // Received a refresh change for a key that hasn't been seen yet + // Nothing can be done, so ignore it + if (!cached.HasValue) + { + return; + } + + UpdateToBestValue(sources, key, cached); + } + private bool UpdateToBestValue(ChangeSetCache[] sources, TKey key, Optional current) { // Determine which value should be the one seen downstream - var candidate = SelectValue(sources, key); + var candidate = LookupBestValue(sources, key); if (candidate.HasValue) { // If there isn't a current value @@ -201,7 +244,7 @@ private bool UpdateToBestValue(ChangeSetCache[] sources, TKey key return true; } - private Optional SelectValue(ChangeSetCache[] sources, TKey key) + private Optional LookupBestValue(ChangeSetCache[] sources, TKey key) { if (sources.Length == 0) { @@ -219,7 +262,7 @@ private Optional SelectValue(ChangeSetCache[] sources, T } private bool CheckEquality(TObject left, TObject right) => - ReferenceEquals(left, right) || (_equalityComparer?.Equals(left, right) ?? (_comparer?.Compare(left, right) == 0)); + ReferenceEquals(left, right) || (_equalityComparer?.Equals(left, right) ?? false); // Return true if candidate should replace current as the observed downstream value private bool ShouldReplace(TObject candidate, TObject current) => diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs new file mode 100644 index 000000000..c9f56e30b --- /dev/null +++ b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2011-2023 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.Diagnostics.CodeAnalysis; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace DynamicData.Cache.Internal; + +/// +/// Alternate version of MergeManyCacheChangeSets that uses a Comparer of the source, not the destination type +/// So that items from the most important source go into the resulting changeset. +/// +internal sealed class MergeManyCacheChangeSetsSourceCompare + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull +{ + private readonly IObservable> _source; + + private readonly Func>> _changeSetSelector; + + private readonly IComparer? _comparer; + + private readonly IEqualityComparer? _equalityComparer; + + private readonly bool _reevalOnRefresh; + + public MergeManyCacheChangeSetsSourceCompare(IObservable> source, Func>> selector, IComparer parentCompare, IEqualityComparer? equalityComparer, IComparer? childCompare, bool reevalOnRefresh = false) + { + _source = source; + _changeSetSelector = (obj, key) => selector(obj, key).Transform(dest => new ParentChildEntry(obj, dest)); + _comparer = (childCompare is null) ? new ParentOnlyCompare(parentCompare) : new ParentChildCompare(parentCompare, childCompare); + _equalityComparer = (equalityComparer != null) ? new ParentChildEqualityCompare(equalityComparer) : null; + _reevalOnRefresh = reevalOnRefresh; + } + + public IObservable> Run() + { + return Observable.Create>( + observer => + { + var locker = new object(); + + // Transform to an observable cache of merge containers. + var sourceCacheOfCaches = _source + .Transform((obj, key) => new ChangeSetCache(_changeSetSelector(obj, key))) + .Synchronize(locker) + .AsObservableCache(); + + var shared = sourceCacheOfCaches.Connect().Publish(); + + // this is manages all of the changes + var changeTracker = new ChangeSetMergeTracker(() => sourceCacheOfCaches.Items, _comparer, _equalityComparer); + + // merge the items back together + var allChanges = shared.MergeMany(mc => mc.Source) + .Synchronize(locker) + .Subscribe( + changes => changeTracker.ProcessChangeSet(changes, observer), + observer.OnError, + observer.OnCompleted); + + // when a source item is removed, all of its sub-items need to be removed + var removedItems = shared + .OnItemRemoved(mc => changeTracker.RemoveItems(mc.Cache.KeyValues, observer)) + .OnItemUpdated((_, prev) => changeTracker.RemoveItems(prev.Cache.KeyValues, observer)) + .Subscribe(); + + // If requested, when the source sees a refresh event, re-evaluate all the keys associated with that source because the priority may have changed + // Because the comparison is based on the parent, which has just been refreshed. + var refreshItems = _reevalOnRefresh + ? shared.OnItemRefreshed(mc => changeTracker.RefreshItems(mc.Cache.Keys, observer)).Subscribe() + : Disposable.Empty; + + return new CompositeDisposable(sourceCacheOfCaches, allChanges, removedItems, refreshItems, shared.Connect()); + }).Transform(entry => entry.Child); + } + + private sealed class ParentChildEntry + { + public ParentChildEntry(TObject parent, TDestination child) + { + Parent = parent; + Child = child; + } + + public TObject Parent { get; } + + public TDestination Child { get; } + } + + private sealed class ParentChildCompare : Comparer + { + private readonly IComparer _comparerParent; + private readonly IComparer _comparerChild; + + public ParentChildCompare(IComparer comparerParent, IComparer comparerChild) + { + _comparerParent = comparerParent; + _comparerChild = comparerChild; + } + + public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => _comparerParent.Compare(x.Parent, y.Parent) switch + { + 0 => _comparerChild.Compare(x.Child, y.Child), + int i => i, + }, + (null, null) => 0, + (null, not null) => 1, + (not null, null) => -1, + }; + } + + private sealed class ParentOnlyCompare : Comparer + { + private readonly IComparer _comparerParent; + + public ParentOnlyCompare(IComparer comparer) => _comparerParent = comparer; + + public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => _comparerParent.Compare(x.Parent, y.Parent), + (null, null) => 0, + (null, not null) => 1, + (not null, null) => -1, + }; + } + + private sealed class ParentChildEqualityCompare : EqualityComparer + { + private readonly IEqualityComparer _comparer; + + public ParentChildEqualityCompare(IEqualityComparer comparer) => _comparer = comparer; + + public override bool Equals(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => _comparer.Equals(x.Child, y.Child), + (null, null) => true, + _ => false, + }; + + public override int GetHashCode(ParentChildEntry obj) => _comparer.GetHashCode(obj.Child); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 480e969ab..11cdb311b 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -27,6 +27,7 @@ namespace DynamicData; public static class ObservableCacheEx { private const int DefaultSortResetThreshold = 100; + private const bool DefaultResortOnSourceRefresh = true; /// /// Inject side effects into the stream using the specified adaptor. @@ -3300,7 +3301,6 @@ public static IObservable> MergeManyCh where TDestination : notnull where TDestinationKey : notnull { - if (source == null) throw new ArgumentNullException(nameof(source)); if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); return source.MergeManyChangeSets((t, _) => observableSelector(t), comparer); @@ -3380,6 +3380,210 @@ public static IObservable> MergeManyCh return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); } + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + return source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional boolean to indicate whether or not a refresh event in the parent stream should re-evaluate item priorities. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional boolean to indicate whether or not a refresh event in the parent stream should re-evaluate item priorities. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + return source.MergeManyChangeSets(observableSelector, sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional instance to determine if two elements are the same. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional instance to determine if two elements are the same. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + return source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional boolean to indicate whether or not a refresh event in the parent stream should re-evaluate item priorities. + /// Optional instance to determine if two elements are the same. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer, childComparer); + } + + /// + /// Overload of that + /// will handle key collisions by using an instance that operates on the sources, so that the values from the preferred source take precedent over other values with the same. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The type of the destination key. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// instance to determine which source elements child to use when two sources provide a child element with the same key. + /// Optional boolean to indicate whether or not a refresh event in the parent stream should re-evaluate item priorities. + /// Optional instance to determine if two elements are the same. + /// Optional fallback instance to determine which child element to emit if the sources compare to be the same. + /// The result from merging the child changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + if (sourceComparer == null) throw new ArgumentNullException(nameof(sourceComparer)); + + return new MergeManyCacheChangeSetsSourceCompare(source, observableSelector, sourceComparer, equalityComparer, childComparer, resortOnSourceRefresh).Run(); + } + /// /// Dynamically merges the observable which is selected from each item in the stream, and un-merges the item /// when it is no longer part of the stream.