From ff4d251341946ee2cdbc005fcdb17743c1804068 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Thu, 13 Aug 2026 22:32:17 -0400 Subject: [PATCH 1/3] test: better Average tests --- .../AverageFixture.ForCache.cs | 408 ++++++++++++++++++ .../AverageFixture.ForList.cs | 378 ++++++++++++++++ .../AggregationTests/AverageFixture.cs | 217 ---------- 3 files changed, 786 insertions(+), 217 deletions(-) create mode 100644 src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs create mode 100644 src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs delete mode 100644 src/DynamicData.Tests/AggregationTests/AverageFixture.cs diff --git a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs new file mode 100644 index 000000000..d260259d7 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs @@ -0,0 +1,408 @@ +using System; + +using DynamicData.Aggregation; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class AverageFixture +{ + public class ForCache + { + [Theory] + [InlineData(1, 10.0)] + [InlineData(2, 15.0)] + [InlineData(3, 20.0)] + public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) + { + var ages = new[] { 10, 20, 30 }; + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().BeEmpty("no items have been added"); + + for (var i = 0; i < itemCount; ++i) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().HaveCount(itemCount, "each edit should produce one average"); + results.RecordedValues[^1].Should().Be(expectedAverage); + } + + [Theory] + [InlineData("A", 25.0)] + [InlineData("B", 20.0)] + [InlineData("C", 15.0)] + public void ItemIsRemoved_AverageReflectsRemoval(string key, double expectedAverage) + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + + source.Remove(key); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().Equal(20.0, expectedAverage); + } + + [Fact] + public void ItemIsUpdated_AverageReflectsReplacement() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + source.AddOrUpdate(new Person("B", 50)); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(15.0, 30.0); + } + + [Fact] + public void MultipleChangesInBatch_SingleAverageIsEmitted() + { + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().ContainSingle("one change set should produce one average") + .Which.Should().Be(20.0); + } + + [Fact] + public void SourceIsEmpty_NoAverageIsEmitted() + { + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect() + .Avg(person => person.Age, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().BeEmpty("an empty source publishes no change set"); + } + + [Fact] + public void AllItemsAreRemoved_ConfiguredEmptyValueIsEmitted() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(person => person.Age, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Edit(updater => updater.Clear()); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + [Fact] + public void NullableValuesAreCountedAsZero() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", new int?(10))); + source.AddOrUpdate(new Person("B", null)); + source.AddOrUpdate(new Person("C", new int?(20))); + + using var subscription = source.Connect() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, + "null contributes zero while the item remains in the denominator"); + } + + [Fact] + public void AllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", null)); + source.AddOrUpdate(new Person("B", null)); + + using var subscription = source.Connect() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, + "a non-empty collection of null projections is not empty"); + } + + [Fact] + public void NumericOverloadsProduceExpectedAverages() + { + using var source = CreatePopulatedSource(); + var changes = source.Connect(); + + using var intSubscription = changes.Avg(person => person.Age, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long)person.Age, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double)person.Age, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal)person.Age, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float)person.Age, emptyValue: -5F).RecordValues(out var floats); + + source.Edit(updater => updater.Clear()); + + ints.RecordedValues.Should().Equal(20.0, -1.0); + longs.RecordedValues.Should().Equal(20.0, -2.0); + doubles.RecordedValues.Should().Equal(20.0, -3.0); + decimals.RecordedValues.Should().Equal(20M, -4M); + floats.RecordedValues.Should().Equal(20F, -5F); + } + + [Fact] + public void NullableNumericOverloadsProduceExpectedAverages() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", new int?(10))); + source.AddOrUpdate(new Person("B", null)); + var changes = source.Connect(); + + using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); + + source.Edit(updater => updater.Clear()); + + ints.RecordedValues.Should().Equal(5.0, -1.0); + longs.RecordedValues.Should().Equal(5.0, -2.0); + doubles.RecordedValues.Should().Equal(5.0, -3.0); + decimals.RecordedValues.Should().Equal(5M, -4M); + floats.RecordedValues.Should().Equal(5F, -5F); + } + + [Fact] + public void IntegerAverageCanBeFractional() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 11)); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); + } + + [Fact] + public void InvalidateWhenResubscribesAndReevaluatesValues() + { + using var source = new TestSourceCache(person => person.Name); + var person = new Person("B", 5); + var invalidation = source.Connect().WhenValueChanged(item => item.Age, notifyOnInitialValue: false); + + using var subscription = source.Connect() + .Avg(item => item.Age) + .InvalidateWhen(invalidation) + .RecordValues(out var results); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(person); + source.AddOrUpdate(new Person("C", 30)); + person.Age = 20; + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(10.0, 7.5, 15.0, 20.0); + } + + [Fact] + public void ItemIsRefreshed_LegacyAverageDoesNotReevaluateMutatedValue() + { + using var source = new TestSourceCache(person => person.Name); + var person = new Person("A", 10); + source.AddOrUpdate(person); + + using var subscription = source.Connect() + .Avg(item => item.Age) + .RecordValues(out var results); + + person.Age = 40; + source.Refresh(person); + + // The legacy aggregate adapter discards Refresh details. + results.RecordedValues.Should().Equal(10.0, 10.0); + } + + [Fact] + public void AggregateChangeSetOverload_PreservesAverageBehavior() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .ForAggregation() + .Avg(person => person.Age, emptyValue: -1) + .RecordValues(out var results); + + source.Edit(updater => updater.Clear()); + + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + [Fact] + public void SourceCompletes_CompletionPropagates() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void EmptySourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().BeEmpty(); + } + + [Fact] + public void AlreadyCompletedSource_InitialAverageAndCompletionPropagate() + { + using var source = CreatePopulatedSource(); + source.Complete(); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void SourceErrors_ErrorPropagates() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void AlreadyFaultedSource_ErrorPropagatesImmediately() + { + using var source = CreatePopulatedSource(); + var error = new Exception("Test error"); + source.SetError(error); + + using var subscription = source.Connect() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + } + + [Fact] + public void DisposedSubscriptionReceivesNoFurtherValues() + { + using var source = new TestSourceCache(person => person.Name); + var subscription = source.Connect() + .Avg(person => person.Age) + .RecordValues(out var results); + + source.AddOrUpdate(new Person("A", 10)); + subscription.Dispose(); + source.AddOrUpdate(new Person("B", 20)); + + results.RecordedValues.Should().Equal(10.0); + } + + [Fact] + public void MultipleSubscriptionsMaintainIndependentState() + { + using var source = new TestSourceCache(person => person.Name); + var averages = source.Connect().Avg(person => person.Age); + + using var firstSubscription = averages.RecordValues(out var first); + source.AddOrUpdate(new Person("A", 10)); + using var secondSubscription = averages.RecordValues(out var second); + source.AddOrUpdate(new Person("B", 20)); + + first.RecordedValues.Should().Equal(10.0, 15.0); + second.RecordedValues.Should().Equal(10.0, 15.0); + } + + private static TestSourceCache CreatePopulatedSource() + { + var source = new TestSourceCache(person => person.Name); + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + return source; + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs new file mode 100644 index 000000000..bc39d275d --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs @@ -0,0 +1,378 @@ +using System; + +using DynamicData.Aggregation; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class AverageFixture +{ + public class ForList + { + [Theory] + [InlineData(1, 10.0)] + [InlineData(2, 15.0)] + [InlineData(3, 20.0)] + public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) + { + var values = new[] { 10, 20, 30 }; + using var source = new TestSourceList(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().BeEmpty("no items have been added"); + + source.AddRange(values[..itemCount]); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().ContainSingle("AddRange produces one change set") + .Which.Should().Be(expectedAverage); + } + + [Theory] + [InlineData(0, 25.0)] + [InlineData(1, 20.0)] + [InlineData(2, 15.0)] + public void ItemIsRemoved_AverageReflectsRemoval(int index, double expectedAverage) + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.RemoveAt(index); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, expectedAverage); + } + + [Fact] + public void ItemIsReplaced_AverageReflectsReplacement() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.ReplaceAt(1, 50); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, 30.0); + } + + [Fact] + public void ItemsAreRemovedAsRange_AverageReflectsRemovals() + { + using var source = new TestSourceList(); + source.AddRange(new[] { 10, 20, 30, 100 }); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.RemoveRange(index: 1, count: 2); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(40.0, 55.0); + } + + [Fact] + public void ItemsAreCleared_ConfiguredEmptyValueIsEmitted() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Clear(); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + [Fact] + public void SourceIsEmpty_NoAverageIsEmitted() + { + using var source = new TestSourceList(); + + using var subscription = source.Connect() + .Avg(value => value, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().BeEmpty("an empty source publishes no change set"); + } + + [Fact] + public void MoveDoesNotChangeAverageButStillEmits() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Move(2, 0); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, 20.0); + } + + [Fact] + public void ItemIsRefreshed_LegacyAverageDoesNotReevaluateMutatedValue() + { + using var source = new TestSourceList(); + var person = new Person("A", 10); + source.Add(person); + + using var subscription = source.Connect() + .Avg(item => item.Age) + .RecordValues(out var results); + + person.Age = 40; + source.Refresh(0); + + // The legacy aggregate adapter discards Refresh details. + results.RecordedValues.Should().Equal(10.0, 10.0); + } + + [Fact] + public void NullableValuesAreCountedAsZero() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", new int?(10)), + new Person("B", null), + new Person("C", new int?(20)), + }); + + using var subscription = source.Connect() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, + "null contributes zero while the item remains in the denominator"); + } + + [Fact] + public void AllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", null), + new Person("B", null), + }); + + using var subscription = source.Connect() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, + "a non-empty collection of null projections is not empty"); + } + + [Fact] + public void NumericOverloadsProduceExpectedAverages() + { + using var source = CreatePopulatedSource(); + var changes = source.Connect(); + + using var intSubscription = changes.Avg(value => value, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(value => (long)value, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(value => (double)value, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(value => (decimal)value, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(value => (float)value, emptyValue: -5F).RecordValues(out var floats); + + source.Clear(); + + ints.RecordedValues.Should().Equal(20.0, -1.0); + longs.RecordedValues.Should().Equal(20.0, -2.0); + doubles.RecordedValues.Should().Equal(20.0, -3.0); + decimals.RecordedValues.Should().Equal(20M, -4M); + floats.RecordedValues.Should().Equal(20F, -5F); + } + + [Fact] + public void NullableNumericOverloadsProduceExpectedAverages() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", new int?(10)), + new Person("B", null), + }); + var changes = source.Connect(); + + using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); + + source.Clear(); + + ints.RecordedValues.Should().Equal(5.0, -1.0); + longs.RecordedValues.Should().Equal(5.0, -2.0); + doubles.RecordedValues.Should().Equal(5.0, -3.0); + decimals.RecordedValues.Should().Equal(5M, -4M); + floats.RecordedValues.Should().Equal(5F, -5F); + } + + [Fact] + public void IntegerAverageCanBeFractional() + { + using var source = new TestSourceList(); + source.AddRange(new[] { 10, 11 }); + + using var subscription = source.Connect() + .Avg(value => value) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); + } + + [Fact] + public void SourceCompletes_CompletionPropagates() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void EmptySourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().BeEmpty(); + } + + [Fact] + public void AlreadyCompletedSource_InitialAverageAndCompletionPropagate() + { + using var source = CreatePopulatedSource(); + source.Complete(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void SourceErrors_ErrorPropagates() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void AlreadyFaultedSource_ErrorPropagatesImmediately() + { + using var source = CreatePopulatedSource(); + var error = new Exception("Test error"); + source.SetError(error); + + using var subscription = source.Connect() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + } + + [Fact] + public void DisposedSubscriptionReceivesNoFurtherValues() + { + using var source = new TestSourceList(); + var subscription = source.Connect() + .Avg(value => value) + .RecordValues(out var results); + + source.Add(10); + subscription.Dispose(); + source.Add(20); + + results.RecordedValues.Should().Equal(10.0); + } + + [Fact] + public void MultipleSubscriptionsMaintainIndependentState() + { + using var source = new TestSourceList(); + var averages = source.Connect().Avg(value => value); + + using var firstSubscription = averages.RecordValues(out var first); + source.Add(10); + using var secondSubscription = averages.RecordValues(out var second); + source.Add(20); + + first.RecordedValues.Should().Equal(10.0, 15.0); + second.RecordedValues.Should().Equal(10.0, 15.0); + } + + private static TestSourceList CreatePopulatedSource() + { + var source = new TestSourceList(); + source.AddRange(new[] { 10, 20, 30 }); + return source; + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/AverageFixture.cs b/src/DynamicData.Tests/AggregationTests/AverageFixture.cs deleted file mode 100644 index 400461a92..000000000 --- a/src/DynamicData.Tests/AggregationTests/AverageFixture.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; - -using DynamicData.Aggregation; -using DynamicData.Tests.Domain; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.AggregationTests; - -public class AverageFixture : IDisposable -{ - private readonly SourceCache _source; - - public AverageFixture() => _source = new SourceCache(p => p.Name); - - [Fact] - public void AddedItemsContributeToSum() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => p.Age).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumLong() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => Convert.ToInt64(p.Age)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumFloat() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => Convert.ToSingle(p.Age)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDouble() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => Convert.ToDouble(p.Age)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDecimal() - { - decimal avg = 0; - - var accumulator = _source.Connect().Avg(p => Convert.ToDecimal(p.Age)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullable() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => p.AgeNullable).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullableLong() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => (long?)(p.AgeNullable.HasValue ? Convert.ToInt64(p.AgeNullable) : default)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullableFloat() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => (float?)(p.AgeNullable.HasValue ? Convert.ToSingle(p.AgeNullable) : default)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullableDouble() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => (double?)(p.AgeNullable.HasValue ? Convert.ToDouble(p.AgeNullable) : default)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullableDecimal() - { - decimal avg = 0; - - var accumulator = _source.Connect().Avg(p => (decimal?)(p.AgeNullable.HasValue ? Convert.ToDecimal(p.AgeNullable) : default)).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - avg.Should().Be(20, "Average value should be 20"); - - accumulator.Dispose(); - } - - public void Dispose() => _source.Dispose(); - - [Fact] - public void InlineChangeReEvaluatesTotals() - { - double avg = 0; - - var somepropChanged = _source.Connect().WhenValueChanged(p => p.Age); - - var accumulator = _source.Connect().Avg(p => p.Age).InvalidateWhen(somepropChanged).Subscribe(x => avg = x); - - var personb = new Person("B", 5); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(personb); - _source.AddOrUpdate(new Person("C", 30)); - - avg.Should().Be(15, "Sum should be 15 after inline change"); - - personb.Age = 20; - - avg.Should().Be(20, "Sum should be 20 after inline change"); - accumulator.Dispose(); - } - - [Fact] - public void RemoveProduceCorrectResult() - { - double avg = 0; - - var accumulator = _source.Connect().Avg(p => p.Age).Subscribe(x => avg = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - _source.Remove("A"); - avg.Should().Be(25, "Average value should be 25 after remove"); - accumulator.Dispose(); - } -} From b6ce70262ab29f1c843e99b94eb3eb2bde8f4e34 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Thu, 10 Sep 2026 06:35:00 -0400 Subject: [PATCH 2/3] test: address Avg fixture review feedback --- .../AverageFixture.ForCache.cs | 408 ------------------ .../AverageFixture.ForList.cs | 378 ---------------- .../AggregationTests/AvgFixture.ForCache.cs | 401 +++++++++++++++++ .../AggregationTests/AvgFixture.ForList.cs | 401 +++++++++++++++++ .../AggregationTests/AvgFixture.cs | 3 + 5 files changed, 805 insertions(+), 786 deletions(-) delete mode 100644 src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs delete mode 100644 src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs create mode 100644 src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs create mode 100644 src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs create mode 100644 src/DynamicData.Tests/AggregationTests/AvgFixture.cs diff --git a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs deleted file mode 100644 index d260259d7..000000000 --- a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForCache.cs +++ /dev/null @@ -1,408 +0,0 @@ -using System; - -using DynamicData.Aggregation; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.AggregationTests; - -public partial class AverageFixture -{ - public class ForCache - { - [Theory] - [InlineData(1, 10.0)] - [InlineData(2, 15.0)] - [InlineData(3, 20.0)] - public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) - { - var ages = new[] { 10, 20, 30 }; - using var source = new TestSourceCache(person => person.Name); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().BeEmpty("no items have been added"); - - for (var i = 0; i < itemCount; ++i) - { - source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); - } - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().HaveCount(itemCount, "each edit should produce one average"); - results.RecordedValues[^1].Should().Be(expectedAverage); - } - - [Theory] - [InlineData("A", 25.0)] - [InlineData("B", 20.0)] - [InlineData("C", 15.0)] - public void ItemIsRemoved_AverageReflectsRemoval(string key, double expectedAverage) - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - - source.Remove(key); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().Equal(20.0, expectedAverage); - } - - [Fact] - public void ItemIsUpdated_AverageReflectsReplacement() - { - using var source = new TestSourceCache(person => person.Name); - source.AddOrUpdate(new Person("A", 10)); - source.AddOrUpdate(new Person("B", 20)); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - source.AddOrUpdate(new Person("B", 50)); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(15.0, 30.0); - } - - [Fact] - public void MultipleChangesInBatch_SingleAverageIsEmitted() - { - using var source = new TestSourceCache(person => person.Name); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Edit(updater => - { - updater.AddOrUpdate(new Person("A", 10)); - updater.AddOrUpdate(new Person("B", 20)); - updater.AddOrUpdate(new Person("C", 30)); - }); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().ContainSingle("one change set should produce one average") - .Which.Should().Be(20.0); - } - - [Fact] - public void SourceIsEmpty_NoAverageIsEmitted() - { - using var source = new TestSourceCache(person => person.Name); - - using var subscription = source.Connect() - .Avg(person => person.Age, emptyValue: -1) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().BeEmpty("an empty source publishes no change set"); - } - - [Fact] - public void AllItemsAreRemoved_ConfiguredEmptyValueIsEmitted() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(person => person.Age, emptyValue: -1) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Edit(updater => updater.Clear()); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(20.0, -1.0); - } - - [Fact] - public void NullableValuesAreCountedAsZero() - { - using var source = new TestSourceCache(person => person.Name); - source.AddOrUpdate(new Person("A", new int?(10))); - source.AddOrUpdate(new Person("B", null)); - source.AddOrUpdate(new Person("C", new int?(20))); - - using var subscription = source.Connect() - .Avg(person => person.AgeNullable, emptyValue: -1) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, - "null contributes zero while the item remains in the denominator"); - } - - [Fact] - public void AllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() - { - using var source = new TestSourceCache(person => person.Name); - source.AddOrUpdate(new Person("A", null)); - source.AddOrUpdate(new Person("B", null)); - - using var subscription = source.Connect() - .Avg(person => person.AgeNullable, emptyValue: -1) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, - "a non-empty collection of null projections is not empty"); - } - - [Fact] - public void NumericOverloadsProduceExpectedAverages() - { - using var source = CreatePopulatedSource(); - var changes = source.Connect(); - - using var intSubscription = changes.Avg(person => person.Age, emptyValue: -1).RecordValues(out var ints); - using var longSubscription = changes.Avg(person => (long)person.Age, emptyValue: -2L).RecordValues(out var longs); - using var doubleSubscription = changes.Avg(person => (double)person.Age, emptyValue: -3.0).RecordValues(out var doubles); - using var decimalSubscription = changes.Avg(person => (decimal)person.Age, emptyValue: -4M).RecordValues(out var decimals); - using var floatSubscription = changes.Avg(person => (float)person.Age, emptyValue: -5F).RecordValues(out var floats); - - source.Edit(updater => updater.Clear()); - - ints.RecordedValues.Should().Equal(20.0, -1.0); - longs.RecordedValues.Should().Equal(20.0, -2.0); - doubles.RecordedValues.Should().Equal(20.0, -3.0); - decimals.RecordedValues.Should().Equal(20M, -4M); - floats.RecordedValues.Should().Equal(20F, -5F); - } - - [Fact] - public void NullableNumericOverloadsProduceExpectedAverages() - { - using var source = new TestSourceCache(person => person.Name); - source.AddOrUpdate(new Person("A", new int?(10))); - source.AddOrUpdate(new Person("B", null)); - var changes = source.Connect(); - - using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); - using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); - using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); - using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); - using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); - - source.Edit(updater => updater.Clear()); - - ints.RecordedValues.Should().Equal(5.0, -1.0); - longs.RecordedValues.Should().Equal(5.0, -2.0); - doubles.RecordedValues.Should().Equal(5.0, -3.0); - decimals.RecordedValues.Should().Equal(5M, -4M); - floats.RecordedValues.Should().Equal(5F, -5F); - } - - [Fact] - public void IntegerAverageCanBeFractional() - { - using var source = new TestSourceCache(person => person.Name); - source.AddOrUpdate(new Person("A", 10)); - source.AddOrUpdate(new Person("B", 11)); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); - } - - [Fact] - public void InvalidateWhenResubscribesAndReevaluatesValues() - { - using var source = new TestSourceCache(person => person.Name); - var person = new Person("B", 5); - var invalidation = source.Connect().WhenValueChanged(item => item.Age, notifyOnInitialValue: false); - - using var subscription = source.Connect() - .Avg(item => item.Age) - .InvalidateWhen(invalidation) - .RecordValues(out var results); - - source.AddOrUpdate(new Person("A", 10)); - source.AddOrUpdate(person); - source.AddOrUpdate(new Person("C", 30)); - person.Age = 20; - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(10.0, 7.5, 15.0, 20.0); - } - - [Fact] - public void ItemIsRefreshed_LegacyAverageDoesNotReevaluateMutatedValue() - { - using var source = new TestSourceCache(person => person.Name); - var person = new Person("A", 10); - source.AddOrUpdate(person); - - using var subscription = source.Connect() - .Avg(item => item.Age) - .RecordValues(out var results); - - person.Age = 40; - source.Refresh(person); - - // The legacy aggregate adapter discards Refresh details. - results.RecordedValues.Should().Equal(10.0, 10.0); - } - - [Fact] - public void AggregateChangeSetOverload_PreservesAverageBehavior() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .ForAggregation() - .Avg(person => person.Age, emptyValue: -1) - .RecordValues(out var results); - - source.Edit(updater => updater.Clear()); - - results.RecordedValues.Should().Equal(20.0, -1.0); - } - - [Fact] - public void SourceCompletes_CompletionPropagates() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Complete(); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void EmptySourceCompletesWithoutEmitting_CompletionPropagates() - { - using var source = new TestSourceCache(person => person.Name); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Complete(); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().BeEmpty(); - } - - [Fact] - public void AlreadyCompletedSource_InitialAverageAndCompletionPropagate() - { - using var source = CreatePopulatedSource(); - source.Complete(); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void SourceErrors_ErrorPropagates() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - var error = new Exception("Test error"); - source.SetError(error); - - results.Error.Should().BeSameAs(error); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void AlreadyFaultedSource_ErrorPropagatesImmediately() - { - using var source = CreatePopulatedSource(); - var error = new Exception("Test error"); - source.SetError(error); - - using var subscription = source.Connect() - .Avg(person => person.Age) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeSameAs(error); - results.HasCompleted.Should().BeFalse(); - } - - [Fact] - public void DisposedSubscriptionReceivesNoFurtherValues() - { - using var source = new TestSourceCache(person => person.Name); - var subscription = source.Connect() - .Avg(person => person.Age) - .RecordValues(out var results); - - source.AddOrUpdate(new Person("A", 10)); - subscription.Dispose(); - source.AddOrUpdate(new Person("B", 20)); - - results.RecordedValues.Should().Equal(10.0); - } - - [Fact] - public void MultipleSubscriptionsMaintainIndependentState() - { - using var source = new TestSourceCache(person => person.Name); - var averages = source.Connect().Avg(person => person.Age); - - using var firstSubscription = averages.RecordValues(out var first); - source.AddOrUpdate(new Person("A", 10)); - using var secondSubscription = averages.RecordValues(out var second); - source.AddOrUpdate(new Person("B", 20)); - - first.RecordedValues.Should().Equal(10.0, 15.0); - second.RecordedValues.Should().Equal(10.0, 15.0); - } - - private static TestSourceCache CreatePopulatedSource() - { - var source = new TestSourceCache(person => person.Name); - source.Edit(updater => - { - updater.AddOrUpdate(new Person("A", 10)); - updater.AddOrUpdate(new Person("B", 20)); - updater.AddOrUpdate(new Person("C", 30)); - }); - return source; - } - } -} diff --git a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs deleted file mode 100644 index bc39d275d..000000000 --- a/src/DynamicData.Tests/AggregationTests/AverageFixture.ForList.cs +++ /dev/null @@ -1,378 +0,0 @@ -using System; - -using DynamicData.Aggregation; -using DynamicData.Tests.Domain; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.AggregationTests; - -public partial class AverageFixture -{ - public class ForList - { - [Theory] - [InlineData(1, 10.0)] - [InlineData(2, 15.0)] - [InlineData(3, 20.0)] - public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) - { - var values = new[] { 10, 20, 30 }; - using var source = new TestSourceList(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().BeEmpty("no items have been added"); - - source.AddRange(values[..itemCount]); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().ContainSingle("AddRange produces one change set") - .Which.Should().Be(expectedAverage); - } - - [Theory] - [InlineData(0, 25.0)] - [InlineData(1, 20.0)] - [InlineData(2, 15.0)] - public void ItemIsRemoved_AverageReflectsRemoval(int index, double expectedAverage) - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.RemoveAt(index); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(20.0, expectedAverage); - } - - [Fact] - public void ItemIsReplaced_AverageReflectsReplacement() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.ReplaceAt(1, 50); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(20.0, 30.0); - } - - [Fact] - public void ItemsAreRemovedAsRange_AverageReflectsRemovals() - { - using var source = new TestSourceList(); - source.AddRange(new[] { 10, 20, 30, 100 }); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.RemoveRange(index: 1, count: 2); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(40.0, 55.0); - } - - [Fact] - public void ItemsAreCleared_ConfiguredEmptyValueIsEmitted() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value, emptyValue: -1) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Clear(); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(20.0, -1.0); - } - - [Fact] - public void SourceIsEmpty_NoAverageIsEmitted() - { - using var source = new TestSourceList(); - - using var subscription = source.Connect() - .Avg(value => value, emptyValue: -1) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().BeEmpty("an empty source publishes no change set"); - } - - [Fact] - public void MoveDoesNotChangeAverageButStillEmits() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Move(2, 0); - - results.Error.Should().BeNull(); - results.RecordedValues.Should().Equal(20.0, 20.0); - } - - [Fact] - public void ItemIsRefreshed_LegacyAverageDoesNotReevaluateMutatedValue() - { - using var source = new TestSourceList(); - var person = new Person("A", 10); - source.Add(person); - - using var subscription = source.Connect() - .Avg(item => item.Age) - .RecordValues(out var results); - - person.Age = 40; - source.Refresh(0); - - // The legacy aggregate adapter discards Refresh details. - results.RecordedValues.Should().Equal(10.0, 10.0); - } - - [Fact] - public void NullableValuesAreCountedAsZero() - { - using var source = new TestSourceList(); - source.AddRange(new[] - { - new Person("A", new int?(10)), - new Person("B", null), - new Person("C", new int?(20)), - }); - - using var subscription = source.Connect() - .Avg(person => person.AgeNullable, emptyValue: -1) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, - "null contributes zero while the item remains in the denominator"); - } - - [Fact] - public void AllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() - { - using var source = new TestSourceList(); - source.AddRange(new[] - { - new Person("A", null), - new Person("B", null), - }); - - using var subscription = source.Connect() - .Avg(person => person.AgeNullable, emptyValue: -1) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, - "a non-empty collection of null projections is not empty"); - } - - [Fact] - public void NumericOverloadsProduceExpectedAverages() - { - using var source = CreatePopulatedSource(); - var changes = source.Connect(); - - using var intSubscription = changes.Avg(value => value, emptyValue: -1).RecordValues(out var ints); - using var longSubscription = changes.Avg(value => (long)value, emptyValue: -2L).RecordValues(out var longs); - using var doubleSubscription = changes.Avg(value => (double)value, emptyValue: -3.0).RecordValues(out var doubles); - using var decimalSubscription = changes.Avg(value => (decimal)value, emptyValue: -4M).RecordValues(out var decimals); - using var floatSubscription = changes.Avg(value => (float)value, emptyValue: -5F).RecordValues(out var floats); - - source.Clear(); - - ints.RecordedValues.Should().Equal(20.0, -1.0); - longs.RecordedValues.Should().Equal(20.0, -2.0); - doubles.RecordedValues.Should().Equal(20.0, -3.0); - decimals.RecordedValues.Should().Equal(20M, -4M); - floats.RecordedValues.Should().Equal(20F, -5F); - } - - [Fact] - public void NullableNumericOverloadsProduceExpectedAverages() - { - using var source = new TestSourceList(); - source.AddRange(new[] - { - new Person("A", new int?(10)), - new Person("B", null), - }); - var changes = source.Connect(); - - using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); - using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); - using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); - using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); - using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); - - source.Clear(); - - ints.RecordedValues.Should().Equal(5.0, -1.0); - longs.RecordedValues.Should().Equal(5.0, -2.0); - doubles.RecordedValues.Should().Equal(5.0, -3.0); - decimals.RecordedValues.Should().Equal(5M, -4M); - floats.RecordedValues.Should().Equal(5F, -5F); - } - - [Fact] - public void IntegerAverageCanBeFractional() - { - using var source = new TestSourceList(); - source.AddRange(new[] { 10, 11 }); - - using var subscription = source.Connect() - .Avg(value => value) - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); - } - - [Fact] - public void SourceCompletes_CompletionPropagates() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Complete(); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void EmptySourceCompletesWithoutEmitting_CompletionPropagates() - { - using var source = new TestSourceList(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - source.Complete(); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().BeEmpty(); - } - - [Fact] - public void AlreadyCompletedSource_InitialAverageAndCompletionPropagate() - { - using var source = CreatePopulatedSource(); - source.Complete(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull(); - results.HasCompleted.Should().BeTrue(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void SourceErrors_ErrorPropagates() - { - using var source = CreatePopulatedSource(); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - var error = new Exception("Test error"); - source.SetError(error); - - results.Error.Should().BeSameAs(error); - results.HasCompleted.Should().BeFalse(); - results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); - } - - [Fact] - public void AlreadyFaultedSource_ErrorPropagatesImmediately() - { - using var source = CreatePopulatedSource(); - var error = new Exception("Test error"); - source.SetError(error); - - using var subscription = source.Connect() - .Avg(value => value) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeSameAs(error); - results.HasCompleted.Should().BeFalse(); - } - - [Fact] - public void DisposedSubscriptionReceivesNoFurtherValues() - { - using var source = new TestSourceList(); - var subscription = source.Connect() - .Avg(value => value) - .RecordValues(out var results); - - source.Add(10); - subscription.Dispose(); - source.Add(20); - - results.RecordedValues.Should().Equal(10.0); - } - - [Fact] - public void MultipleSubscriptionsMaintainIndependentState() - { - using var source = new TestSourceList(); - var averages = source.Connect().Avg(value => value); - - using var firstSubscription = averages.RecordValues(out var first); - source.Add(10); - using var secondSubscription = averages.RecordValues(out var second); - source.Add(20); - - first.RecordedValues.Should().Equal(10.0, 15.0); - second.RecordedValues.Should().Equal(10.0, 15.0); - } - - private static TestSourceList CreatePopulatedSource() - { - var source = new TestSourceList(); - source.AddRange(new[] { 10, 20, 30 }); - return source; - } - } -} diff --git a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs new file mode 100644 index 000000000..89f68308f --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs @@ -0,0 +1,401 @@ +using System; + +using DynamicData.Aggregation; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class AvgFixture +{ + public class ForCache + { + // Behavioral coverage lives on the inner aggregate overload; ForChangeSet checks forwarding. + public class ForAggregateChangeSet + { + [Theory] + [InlineData(1, 10.0)] + [InlineData(2, 15.0)] + [InlineData(3, 20.0)] + public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) + { + var ages = new[] { 10, 20, 30 }; + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + // Initial-empty behavior is covered separately by the known-defect test. + var initialValueCount = results.RecordedValues.Count; + + for (var i = 0; i < itemCount; ++i) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().HaveCount(initialValueCount + itemCount, "each edit should produce one average"); + results.RecordedValues[^1].Should().Be(expectedAverage); + } + + [Theory] + [InlineData("A", 25.0)] + [InlineData("B", 20.0)] + [InlineData("C", 15.0)] + public void ItemIsRemoved_AverageReflectsRemoval(string key, double expectedAverage) + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + + source.Remove(key); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().Equal(20.0, expectedAverage); + } + + [Fact] + public void ItemIsUpdated_AverageReflectsReplacement() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + source.AddOrUpdate(new Person("B", 50)); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(15.0, 30.0); + } + + [Fact] + public void MultipleChangesInBatch_SingleAverageIsEmitted() + { + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + var initialValueCount = results.RecordedValues.Count; + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().HaveCount(initialValueCount + 1, "one change set should produce one average"); + results.RecordedValues[^1].Should().Be(20.0); + } + + [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + public void SourceIsEmpty_ConfiguredEmptyValueIsEmitted() + { + using var source = new TestSourceCache(person => person.Name); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(-1.0); + } + + [Fact] + public void AllItemsAreRemoved_ConfiguredEmptyValueIsEmitted() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Edit(updater => updater.Clear()); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + // Legacy behavior: nullable projections are coalesced to zero and remain in the denominator. + [Fact] + public void LegacyNullableValuesAreCountedAsZero() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", new int?(10))); + source.AddOrUpdate(new Person("B", null)); + source.AddOrUpdate(new Person("C", new int?(20))); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, + "null contributes zero while the item remains in the denominator"); + } + + // Legacy behavior: null coalescing is currently part of the public operator behavior. + [Fact] + public void LegacyAllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", null)); + source.AddOrUpdate(new Person("B", null)); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, + "a non-empty collection of null projections is not empty"); + } + + [Fact] + public void NumericOverloadsProduceExpectedAverages() + { + using var source = CreatePopulatedSource(); + var changes = source.Connect().ForAggregation(); + + using var intSubscription = changes.Avg(person => person.Age, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long)person.Age, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double)person.Age, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal)person.Age, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float)person.Age, emptyValue: -5F).RecordValues(out var floats); + + source.Edit(updater => updater.Clear()); + + ints.RecordedValues.Should().Equal(20.0, -1.0); + longs.RecordedValues.Should().Equal(20.0, -2.0); + doubles.RecordedValues.Should().Equal(20.0, -3.0); + decimals.RecordedValues.Should().Equal(20M, -4M); + floats.RecordedValues.Should().Equal(20F, -5F); + } + + [Fact] + public void LegacyNullableNumericOverloadsProduceExpectedAverages() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", new int?(10))); + source.AddOrUpdate(new Person("B", null)); + var changes = source.Connect().ForAggregation(); + + using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); + + source.Edit(updater => updater.Clear()); + + ints.RecordedValues.Should().Equal(5.0, -1.0); + longs.RecordedValues.Should().Equal(5.0, -2.0); + doubles.RecordedValues.Should().Equal(5.0, -3.0); + decimals.RecordedValues.Should().Equal(5M, -4M); + floats.RecordedValues.Should().Equal(5F, -5F); + } + + [Fact] + public void IntegerAverageCanBeFractional() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 11)); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); + } + + [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details.")] + public void ItemIsRefreshed_AverageReevaluatesMutatedValue() + { + using var source = new TestSourceCache(person => person.Name); + var person = new Person("A", 10); + source.AddOrUpdate(person); + + using var subscription = source.Connect().ForAggregation() + .Avg(item => item.Age) + .RecordValues(out var results); + + person.Age = 40; + source.Refresh(person); + + results.RecordedValues.Should().Equal(10.0, 40.0); + } + + [Theory] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void SourceCompletes_CompletionPropagates(StreamCompletionStrategy completionStrategy) + { + using var source = CreatePopulatedSource(); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.Complete(); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void EmptySourceCompletes_ConfiguredEmptyValueAndCompletionPropagate(StreamCompletionStrategy completionStrategy) + { + using var source = new TestSourceCache(person => person.Name); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.Complete(); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(-1.0); + } + + [Theory] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void SourceFails_ErrorPropagates(StreamCompletionStrategy completionStrategy) + { + using var source = CreatePopulatedSource(); + var error = new Exception("Test error"); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.SetError(error); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.SetError(error); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + if (completionStrategy is StreamCompletionStrategy.Immediate) + results.RecordedValues.Should().BeEmpty(); + else + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void DisposedSubscriptionReceivesNoFurtherValues() + { + using var source = new TestSourceCache(person => person.Name); + var subscription = source.Connect().ForAggregation() + .Avg(person => person.Age) + .RecordValues(out var results); + + source.AddOrUpdate(new Person("A", 10)); + var valueCountAtDisposal = results.RecordedValues.Count; + subscription.Dispose(); + source.AddOrUpdate(new Person("B", 20)); + + results.RecordedValues.Should().HaveCount(valueCountAtDisposal); + results.RecordedValues[^1].Should().Be(10.0); + } + + [Fact] + public void MultipleSubscriptionsMaintainIndependentState() + { + using var source = new TestSourceCache(person => person.Name); + source.AddOrUpdate(new Person("A", 10)); + var averages = source.Connect().ForAggregation().Avg(person => person.Age); + + using var firstSubscription = averages.RecordValues(out var first); + using var secondSubscription = averages.RecordValues(out var second); + source.AddOrUpdate(new Person("B", 20)); + + first.RecordedValues.Should().Equal(10.0, 15.0); + second.RecordedValues.Should().Equal(10.0, 15.0); + } + + private static TestSourceCache CreatePopulatedSource() + { + var source = new TestSourceCache(person => person.Name); + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + return source; + } + + } + + // The aggregate fixture above contains the shared behavioral coverage. + public class ForChangeSet + { + [Fact] + public void ChangeSetOverload_ForwardsToAggregateOverload() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(person => person.Age, emptyValue: -1) + .RecordValues(out var results); + + source.Edit(updater => updater.Clear()); + + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + private static TestSourceCache CreatePopulatedSource() + { + var source = new TestSourceCache(person => person.Name); + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + return source; + } + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs new file mode 100644 index 000000000..e09d65f57 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs @@ -0,0 +1,401 @@ +using System; + +using DynamicData.Aggregation; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class AvgFixture +{ + public class ForList + { + // Behavioral coverage lives on the inner aggregate overload; ForChangeSet checks forwarding. + public class ForAggregateChangeSet + { + [Theory] + [InlineData(1, 10.0)] + [InlineData(2, 15.0)] + [InlineData(3, 20.0)] + public void ItemsAreAdded_AverageReflectsAllItems(int itemCount, double expectedAverage) + { + var values = new[] { 10, 20, 30 }; + using var source = new TestSourceList(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + // Initial-empty behavior is covered separately by the known-defect test. + var initialValueCount = results.RecordedValues.Count; + + source.AddRange(values[..itemCount]); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().HaveCount(initialValueCount + 1, "AddRange produces one change set"); + results.RecordedValues[^1].Should().Be(expectedAverage); + } + + [Theory] + [InlineData(0, 25.0)] + [InlineData(1, 20.0)] + [InlineData(2, 15.0)] + public void ItemIsRemoved_AverageReflectsRemoval(int index, double expectedAverage) + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.RemoveAt(index); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, expectedAverage); + } + + [Fact] + public void ItemIsReplaced_AverageReflectsReplacement() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.ReplaceAt(1, 50); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, 30.0); + } + + [Fact] + public void ItemsAreRemovedAsRange_AverageReflectsRemovals() + { + using var source = new TestSourceList(); + source.AddRange(new[] { 10, 20, 30, 100 }); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.RemoveRange(index: 1, count: 2); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(40.0, 55.0); + } + + [Fact] + public void ItemsAreCleared_ConfiguredEmptyValueIsEmitted() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Clear(); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + public void SourceIsEmpty_ConfiguredEmptyValueIsEmitted() + { + using var source = new TestSourceList(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeFalse(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(-1.0); + } + + [Fact] + public void MoveDoesNotChangeAverageButStillEmits() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + source.Move(2, 0); + + results.Error.Should().BeNull(); + results.RecordedValues.Should().Equal(20.0, 20.0); + } + + [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details.")] + public void ItemIsRefreshed_AverageReevaluatesMutatedValue() + { + using var source = new TestSourceList(); + var person = new Person("A", 10); + source.Add(person); + + using var subscription = source.Connect().ForAggregation() + .Avg(item => item.Age) + .RecordValues(out var results); + + person.Age = 40; + source.Refresh(0); + + results.RecordedValues.Should().Equal(10.0, 40.0); + } + + // Legacy behavior: nullable projections are coalesced to zero and remain in the denominator. + [Fact] + public void LegacyNullableValuesAreCountedAsZero() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", new int?(10)), + new Person("B", null), + new Person("C", new int?(20)), + }); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.0, + "null contributes zero while the item remains in the denominator"); + } + + // Legacy behavior: null coalescing is currently part of the public operator behavior. + [Fact] + public void LegacyAllValuesAreNull_ZeroIsEmittedInsteadOfEmptyValue() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", null), + new Person("B", null), + }); + + using var subscription = source.Connect().ForAggregation() + .Avg(person => person.AgeNullable, emptyValue: -1) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(0.0, + "a non-empty collection of null projections is not empty"); + } + + [Fact] + public void NumericOverloadsProduceExpectedAverages() + { + using var source = CreatePopulatedSource(); + var changes = source.Connect().ForAggregation(); + + using var intSubscription = changes.Avg(value => value, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(value => (long)value, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(value => (double)value, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(value => (decimal)value, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(value => (float)value, emptyValue: -5F).RecordValues(out var floats); + + source.Clear(); + + ints.RecordedValues.Should().Equal(20.0, -1.0); + longs.RecordedValues.Should().Equal(20.0, -2.0); + doubles.RecordedValues.Should().Equal(20.0, -3.0); + decimals.RecordedValues.Should().Equal(20M, -4M); + floats.RecordedValues.Should().Equal(20F, -5F); + } + + [Fact] + public void LegacyNullableNumericOverloadsProduceExpectedAverages() + { + using var source = new TestSourceList(); + source.AddRange(new[] + { + new Person("A", new int?(10)), + new Person("B", null), + }); + var changes = source.Connect().ForAggregation(); + + using var intSubscription = changes.Avg(person => person.AgeNullable, emptyValue: -1).RecordValues(out var ints); + using var longSubscription = changes.Avg(person => (long?)person.AgeNullable, emptyValue: -2L).RecordValues(out var longs); + using var doubleSubscription = changes.Avg(person => (double?)person.AgeNullable, emptyValue: -3.0).RecordValues(out var doubles); + using var decimalSubscription = changes.Avg(person => (decimal?)person.AgeNullable, emptyValue: -4M).RecordValues(out var decimals); + using var floatSubscription = changes.Avg(person => (float?)person.AgeNullable, emptyValue: -5F).RecordValues(out var floats); + + source.Clear(); + + ints.RecordedValues.Should().Equal(5.0, -1.0); + longs.RecordedValues.Should().Equal(5.0, -2.0); + doubles.RecordedValues.Should().Equal(5.0, -3.0); + decimals.RecordedValues.Should().Equal(5M, -4M); + floats.RecordedValues.Should().Equal(5F, -5F); + } + + [Fact] + public void IntegerAverageCanBeFractional() + { + using var source = new TestSourceList(); + source.AddRange(new[] { 10, 11 }); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); + } + + [Theory] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void SourceCompletes_CompletionPropagates(StreamCompletionStrategy completionStrategy) + { + using var source = CreatePopulatedSource(); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.Complete(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void EmptySourceCompletes_ConfiguredEmptyValueAndCompletionPropagate(StreamCompletionStrategy completionStrategy) + { + using var source = new TestSourceList(); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.Complete(); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value, emptyValue: -1) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.HasCompleted.Should().BeTrue(); + results.RecordedValues.Should().ContainSingle().Which.Should().Be(-1.0); + } + + [Theory] + [InlineData(StreamCompletionStrategy.Asynchronous)] + [InlineData(StreamCompletionStrategy.Immediate)] + public void SourceFails_ErrorPropagates(StreamCompletionStrategy completionStrategy) + { + using var source = CreatePopulatedSource(); + var error = new Exception("Test error"); + + if (completionStrategy is StreamCompletionStrategy.Immediate) + source.SetError(error); + + using var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .ValidateSynchronization() + .RecordValues(out var results); + + if (completionStrategy is StreamCompletionStrategy.Asynchronous) + source.SetError(error); + + results.Error.Should().BeSameAs(error); + results.HasCompleted.Should().BeFalse(); + if (completionStrategy is StreamCompletionStrategy.Immediate) + results.RecordedValues.Should().BeEmpty(); + else + results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); + } + + [Fact] + public void DisposedSubscriptionReceivesNoFurtherValues() + { + using var source = new TestSourceList(); + var subscription = source.Connect().ForAggregation() + .Avg(value => value) + .RecordValues(out var results); + + source.Add(10); + var valueCountAtDisposal = results.RecordedValues.Count; + subscription.Dispose(); + source.Add(20); + + results.RecordedValues.Should().HaveCount(valueCountAtDisposal); + results.RecordedValues[^1].Should().Be(10.0); + } + + [Fact] + public void MultipleSubscriptionsMaintainIndependentState() + { + using var source = new TestSourceList(); + source.Add(10); + var averages = source.Connect().ForAggregation().Avg(value => value); + + using var firstSubscription = averages.RecordValues(out var first); + using var secondSubscription = averages.RecordValues(out var second); + source.Add(20); + + first.RecordedValues.Should().Equal(10.0, 15.0); + second.RecordedValues.Should().Equal(10.0, 15.0); + } + + private static TestSourceList CreatePopulatedSource() + { + var source = new TestSourceList(); + source.AddRange(new[] { 10, 20, 30 }); + return source; + } + + } + + // The aggregate fixture above contains the shared behavioral coverage. + public class ForChangeSet + { + [Fact] + public void ChangeSetOverload_ForwardsToAggregateOverload() + { + using var source = CreatePopulatedSource(); + + using var subscription = source.Connect() + .Avg(value => value, emptyValue: -1) + .RecordValues(out var results); + + source.Clear(); + + results.RecordedValues.Should().Equal(20.0, -1.0); + } + + private static TestSourceList CreatePopulatedSource() + { + var source = new TestSourceList(); + source.AddRange(new[] { 10, 20, 30 }); + return source; + } + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/AvgFixture.cs b/src/DynamicData.Tests/AggregationTests/AvgFixture.cs new file mode 100644 index 000000000..dc6f2bc80 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/AvgFixture.cs @@ -0,0 +1,3 @@ +namespace DynamicData.Tests.AggregationTests; + +public partial class AvgFixture; From c01d204ae038ce53aa31b8197c844f894f243901 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Thu, 10 Sep 2026 23:41:01 -0400 Subject: [PATCH 3/3] test: document when to re-enable skipped Avg tests --- .../AggregationTests/AvgFixture.ForCache.cs | 6 +++--- .../AggregationTests/AvgFixture.ForList.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs index 89f68308f..20defed81 100644 --- a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs +++ b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForCache.cs @@ -110,7 +110,7 @@ public void MultipleChangesInBatch_SingleAverageIsEmitted() results.RecordedValues[^1].Should().Be(20.0); } - [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty. Re-enable once the operator has been rewritten and fixed.")] public void SourceIsEmpty_ConfiguredEmptyValueIsEmitted() { using var source = new TestSourceCache(person => person.Name); @@ -232,7 +232,7 @@ public void IntegerAverageCanBeFractional() results.RecordedValues.Should().ContainSingle().Which.Should().Be(10.5); } - [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details.")] + [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details. Re-enable once the operator has been rewritten and fixed.")] public void ItemIsRefreshed_AverageReevaluatesMutatedValue() { using var source = new TestSourceCache(person => person.Name); @@ -272,7 +272,7 @@ public void SourceCompletes_CompletionPropagates(StreamCompletionStrategy comple results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); } - [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty. Re-enable once the operator has been rewritten and fixed.")] [InlineData(StreamCompletionStrategy.Asynchronous)] [InlineData(StreamCompletionStrategy.Immediate)] public void EmptySourceCompletes_ConfiguredEmptyValueAndCompletionPropagate(StreamCompletionStrategy completionStrategy) diff --git a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs index e09d65f57..9ff237b97 100644 --- a/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs +++ b/src/DynamicData.Tests/AggregationTests/AvgFixture.ForList.cs @@ -112,7 +112,7 @@ public void ItemsAreCleared_ConfiguredEmptyValueIsEmitted() results.RecordedValues.Should().Equal(20.0, -1.0); } - [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [Fact(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty. Re-enable once the operator has been rewritten and fixed.")] public void SourceIsEmpty_ConfiguredEmptyValueIsEmitted() { using var source = new TestSourceList(); @@ -143,7 +143,7 @@ public void MoveDoesNotChangeAverageButStillEmits() results.RecordedValues.Should().Equal(20.0, 20.0); } - [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details.")] + [Fact(Skip = "Existing defect: the legacy aggregate adapter discards Refresh details. Re-enable once the operator has been rewritten and fixed.")] public void ItemIsRefreshed_AverageReevaluatesMutatedValue() { using var source = new TestSourceList(); @@ -282,7 +282,7 @@ public void SourceCompletes_CompletionPropagates(StreamCompletionStrategy comple results.RecordedValues.Should().ContainSingle().Which.Should().Be(20.0); } - [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty.")] + [Theory(Skip = "Existing defect: Avg does not emit emptyValue when the source is initially empty. Re-enable once the operator has been rewritten and fixed.")] [InlineData(StreamCompletionStrategy.Asynchronous)] [InlineData(StreamCompletionStrategy.Immediate)] public void EmptySourceCompletes_ConfiguredEmptyValueAndCompletionPropagate(StreamCompletionStrategy completionStrategy)