From 4f4532e34220a0255a184ee83b8f9a0319458694 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Wed, 18 Mar 2026 22:53:05 -0500 Subject: [PATCH 1/3] test(sum): add more sum tests --- .../AggregationTests/SumFixture.cs | 865 ++++++++++++++---- 1 file changed, 712 insertions(+), 153 deletions(-) diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.cs index 6fecbcabc..ce51e0991 100644 --- a/src/DynamicData.Tests/AggregationTests/SumFixture.cs +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.cs @@ -1,7 +1,9 @@ using System; +using System.Linq; using DynamicData.Aggregation; using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; using FluentAssertions; @@ -9,219 +11,776 @@ namespace DynamicData.Tests.AggregationTests; -public class SumFixture : IDisposable +public class SumFixture { - private readonly SourceCache _source; + public class CacheSource + { + [Theory] + [InlineData(1, 10)] + [InlineData(3, 60)] + public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) + { + var ages = new[] { 10, 20, 30 }; + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action + for (var i = 0; i < itemCount; i++) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(itemCount, "each AddOrUpdate should produce a new sum emission"); + results.RecordedValues[^1].Should().Be(expectedSum, $"the sum of the first {itemCount} ages should be {expectedSum}"); + } + + [Theory] + [InlineData("A", 50)] + [InlineData("B", 40)] + [InlineData("C", 30)] + public void ItemIsRemoved_SumDecreases(string keyToRemove, int expectedSum) + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + + // UUT Action + source.Remove(keyToRemove); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); + results.RecordedValues[^1].Should().Be(expectedSum, $"removing '{keyToRemove}' should leave a sum of {expectedSum}"); + } + + [Fact] + public void ItemIsUpdated_SumReflectsNewValue() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(30, "the sum of ages 10 + 20 is 30"); + + // UUT Action: update "B" from age 20 to age 50 (same key, new value) + source.AddOrUpdate(new Person("B", 50)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the update"); + results.RecordedValues[^1].Should().Be(60, "updating 'B' from 20 to 50 should change the sum from 30 to 60"); + } + + [Fact] + public void MultipleChangesInBatch_SingleSumEmitted() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action: add 3 items in a single batch + 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("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("a batched edit should produce exactly one sum emission") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void NoItemsAdded_NoSumEmitted() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void AllItemsRemoved_SumReturnsToZero() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + + // UUT Action: remove all items in a single batch + source.Edit(updater => updater.Clear()); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); + results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); + } + + [Fact] + public void SourceCompletesAfterEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item") + .Which.Should().Be(10, "the sum of a single age of 10 is 10"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + } + + [Fact] + public void SourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceErrorsAfterEmitting_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Fact] + public void SourceErrorsWithoutEmitting_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void NullableValuesAreTreatedAsZero() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", new int?(10), "F", null)); + source.AddOrUpdate(new Person("B", null, "F", null)); + source.AddOrUpdate(new Person("C", new int?(30), "F", null)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.AgeNullable) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(40, "null values should be treated as zero, so the sum should be 10 + 0 + 30 = 40"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForInt() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60, "the int sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableInt() + { + using var source = new TestSourceCache(p => p.Name); + + 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)); + + using var subscription = source.Connect() + .Sum(p => p.AgeNullable) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60, "the nullable int sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForLong() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (long)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the long sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableLong() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (long?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDouble() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (double)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the double sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (double?)p.Age) + .RecordValues(out var results); - public SumFixture() => _source = new SourceCache(p => p.Name); + results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDecimal() + { + using var source = new TestSourceCache(p => p.Name); - [Fact] - public void AddedItemsContributeToSum() - { - var sum = 0; - double dev = 0; + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); - var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (int)0).Subscribe(x => dev = x); + using var subscription = source.Connect() + .Sum(p => (decimal)p.Age) + .RecordValues(out var results); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + results.RecordedValues[^1].Should().Be(60M, "the decimal sum of ages 10 + 20 + 30 is 60"); + } - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() + { + using var source = new TestSourceCache(p => p.Name); - [Fact] - public void AddedItemsContributeToSumLong() - { - long sum = 0; - double dev = 0; + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); - var accumulator = _source.Connect().Sum(p => Convert.ToInt64(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (long)0).Subscribe(x => dev = x); + using var subscription = source.Connect() + .Sum(p => (decimal?)p.Age) + .RecordValues(out var results); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of ages 10 + 20 + 30 is 60"); + } - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForFloat() + { + using var source = new TestSourceCache(p => p.Name); - [Fact] - public void AddedItemsContributeToSumFloat() - { - float sum = 0; - double dev = 0; + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (float)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the float sum of ages 10 + 20 + 30 is 60"); + } - var accumulator = _source.Connect().Sum(p => Convert.ToSingle(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (float)0).Subscribe(x => dev = x); + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() + { + using var source = new TestSourceCache(p => p.Name); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); + using var subscription = source.Connect() + .Sum(p => (float?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of ages 10 + 20 + 30 is 60"); + } } - [Fact] - public void AddedItemsContributeToSumDouble() + public class ListSource { - double sum = 0; - double dev = 0; + [Theory] + [InlineData(1, 10)] + [InlineData(3, 60)] + public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) + { + var items = new[] { 10, 20, 30 }; + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action + source.AddRange(items.Take(itemCount)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("an AddRange produces a single changeset") + .Which.Should().Be(expectedSum, $"the sum of the first {itemCount} items should be {expectedSum}"); + } + + [Theory] + [InlineData(0, 50)] + [InlineData(1, 40)] + [InlineData(2, 30)] + public void ItemIsRemoved_SumDecreases(int removalIndex, int expectedSum) + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.RemoveAt(removalIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); + results.RecordedValues[^1].Should().Be(expectedSum, $"removing item at index {removalIndex} should leave a sum of {expectedSum}"); + } + + [Fact] + public void ItemIsReplaced_SumReflectsReplacement() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action: replace item at index 1 (value 20) with 50 + source.ReplaceAt(1, 50); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the replacement"); + results.RecordedValues[^1].Should().Be(90, "replacing 20 with 50 should change the sum from 60 to 90"); + } + + [Fact] + public void ItemsAreCleared_SumReturnsToZero() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.Clear(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); + results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); + } + + [Fact] + public void NoItemsAdded_NoSumEmitted() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesAfterEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); + + // UUT Action + source.Complete(); - var accumulator = _source.Connect().Sum(p => Convert.ToDouble(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (double)0).Subscribe(x => dev = x); + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + } - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + [Fact] + public void SourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } + results.RecordedValues.Should().BeEmpty("no items were added to the source"); - [Fact] - public void AddedItemsContributeToSumDecimal() - { - decimal sum = 0; - decimal dev = 0; + // UUT Action + source.Complete(); - var accumulator = _source.Connect().Sum(p => Convert.ToDecimal(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (decimal)0).Subscribe(x => dev = x); + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + [Fact] + public void SourceErrorsAfterEmitting_ErrorPropagates() + { + using var source = new TestSourceList(); - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654752440084436210M, ""); - accumulator.Dispose(); - } + source.AddRange(new[] { 10, 20, 30 }); - [Fact] - public void AddedItemsContributeToSumNullable() - { - var sum = 0; + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); - var accumulator = _source.Connect().Sum(p => p.AgeNullable).Subscribe(x => sum = x); + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); - _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)); + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); - sum.Should().Be(60, "Accumulated value should be 60"); + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } - accumulator.Dispose(); - } + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForInt() + { + using var source = new TestSourceList(); - [Fact] - public void AddedItemsContributeToSumLongNullable() - { - long sum = 0; + source.AddRange(new[] { 10, 20, 30 }); - var accumulator = _source.Connect().Sum(p => (long?)(p.AgeNullable.HasValue ? Convert.ToInt64(p.AgeNullable) : default)).Subscribe(x => sum = x); + using var subscription = source.Connect() + .Sum(x => x) + .RecordValues(out var results); - _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)); + results.RecordedValues[^1].Should().Be(60, "the int sum of items 10 + 20 + 30 is 60"); + } - sum.Should().Be(60, "Accumulated value should be 60"); + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableInt() + { + using var source = new TestSourceList(); - accumulator.Dispose(); - } + source.AddRange(new[] { 10, 20, 30 }); - [Fact] - public void AddedItemsContributeToSumFloatNullable() - { - float sum = 0; + using var subscription = source.Connect() + .Sum(x => (int?)x) + .RecordValues(out var results); - var accumulator = _source.Connect().Sum(p => (float?)(p.AgeNullable.HasValue ? Convert.ToSingle(p.AgeNullable) : default)).Subscribe(x => sum = x); + results.RecordedValues[^1].Should().Be(60, "the nullable int sum of items 10 + 20 + 30 is 60"); + } - _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)); + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForLong() + { + using var source = new TestSourceList(); - sum.Should().Be(60, "Accumulated value should be 60"); + source.AddRange(new[] { 10, 20, 30 }); - accumulator.Dispose(); - } + using var subscription = source.Connect() + .Sum(x => (long)x) + .RecordValues(out var results); - [Fact] - public void AddedItemsContributeToSumDoubleNullable() - { - double sum = 0; + results.RecordedValues[^1].Should().Be(60L, "the long sum of items 10 + 20 + 30 is 60"); + } - var accumulator = _source.Connect().Sum(p => (double?)(p.AgeNullable.HasValue ? Convert.ToDouble(p.AgeNullable) : default)).Subscribe(x => sum = x); + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableLong() + { + using var source = new TestSourceList(); - _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)); + source.AddRange(new[] { 10, 20, 30 }); - sum.Should().Be(60, "Accumulated value should be 60"); + using var subscription = source.Connect() + .Sum(x => (long?)x) + .RecordValues(out var results); - accumulator.Dispose(); - } + results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of items 10 + 20 + 30 is 60"); + } - [Fact] - public void AddedItemsContributeToSumDecimalNullable() - { - decimal sum = 0; + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDouble() + { + using var source = new TestSourceList(); - var accumulator = _source.Connect().Sum(p => (decimal?)(p.AgeNullable.HasValue ? Convert.ToDecimal(p.AgeNullable) : default)).Subscribe(x => sum = x); + source.AddRange(new[] { 10, 20, 30 }); - _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)); + using var subscription = source.Connect() + .Sum(x => (double)x) + .RecordValues(out var results); - sum.Should().Be(60, "Accumulated value should be 60"); + results.RecordedValues[^1].Should().Be(60.0, "the double sum of items 10 + 20 + 30 is 60"); + } - accumulator.Dispose(); - } + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() + { + using var source = new TestSourceList(); - public void Dispose() => _source.Dispose(); + source.AddRange(new[] { 10, 20, 30 }); - [Fact] - public void InlineChangeReEvaluatesTotals() - { - var sum = 0; + using var subscription = source.Connect() + .Sum(x => (double?)x) + .RecordValues(out var results); - var somepropChanged = _source.Connect().WhenValueChanged(p => p.Age); + results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of items 10 + 20 + 30 is 60"); + } - var accumulator = _source.Connect().Sum(p => p.Age).InvalidateWhen(somepropChanged).Subscribe(x => sum = x); + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDecimal() + { + using var source = new TestSourceList(); - var personb = new Person("B", 5); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(personb); - _source.AddOrUpdate(new Person("C", 30)); + source.AddRange(new[] { 10, 20, 30 }); - sum.Should().Be(45, "Sum should be 45 after inline change"); + using var subscription = source.Connect() + .Sum(x => (decimal)x) + .RecordValues(out var results); - personb.Age = 20; + results.RecordedValues[^1].Should().Be(60M, "the decimal sum of items 10 + 20 + 30 is 60"); + } - sum.Should().Be(60, "Sum should be 60 after inline change"); - accumulator.Dispose(); - } + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() + { + using var source = new TestSourceList(); - [Fact] - public void RemoveProduceCorrectResult() - { - var sum = 0; + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (decimal?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForFloat() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (float)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the float sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() + { + using var source = new TestSourceList(); - var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x); + source.AddRange(new[] { 10, 20, 30 }); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); + using var subscription = source.Connect() + .Sum(x => (float?)x) + .RecordValues(out var results); - _source.Remove("A"); - sum.Should().Be(50, "Accumulated value should be 50 after remove"); - accumulator.Dispose(); + results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of items 10 + 20 + 30 is 60"); + } } } From 9d43012daab990a863d88e68582a1d10ce84480b Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Mon, 18 May 2026 22:38:31 -0400 Subject: [PATCH 2/3] chore: test renames --- src/DynamicData.Tests/AggregationTests/SumFixture.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.cs index ce51e0991..5a36d5ba6 100644 --- a/src/DynamicData.Tests/AggregationTests/SumFixture.cs +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.cs @@ -49,7 +49,7 @@ public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) [InlineData("A", 50)] [InlineData("B", 40)] [InlineData("C", 30)] - public void ItemIsRemoved_SumDecreases(string keyToRemove, int expectedSum) + public void ItemIsRemoved_SumReflectsRemoval(string keyToRemove, int expectedSum) { using var source = new TestSourceCache(p => p.Name); @@ -133,7 +133,7 @@ public void MultipleChangesInBatch_SingleSumEmitted() } [Fact] - public void NoItemsAdded_NoSumEmitted() + public void SourceIsEmpty_NoSumEmitted() { using var source = new TestSourceCache(p => p.Name); @@ -482,7 +482,7 @@ public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) [InlineData(0, 50)] [InlineData(1, 40)] [InlineData(2, 30)] - public void ItemIsRemoved_SumDecreases(int removalIndex, int expectedSum) + public void ItemIsRemoved_SumReflectsRemoval(int removalIndex, int expectedSum) { using var source = new TestSourceList(); @@ -559,7 +559,7 @@ public void ItemsAreCleared_SumReturnsToZero() } [Fact] - public void NoItemsAdded_NoSumEmitted() + public void SourceIsEmpty_NoSumEmitted() { using var source = new TestSourceList(); From 4802a94414973420d294a99bcce87e0e3f224fe0 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Mon, 18 May 2026 23:41:51 -0400 Subject: [PATCH 3/3] test(sum): split `SumFixture` into separate partial classes for cache and list sources --- .../{SumFixture.cs => SumFixture.ForCache.cs} | 419 ++++-------------- .../AggregationTests/SumFixture.ForList.cs | 413 +++++++++++++++++ 2 files changed, 488 insertions(+), 344 deletions(-) rename src/DynamicData.Tests/AggregationTests/{SumFixture.cs => SumFixture.ForCache.cs} (62%) create mode 100644 src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs similarity index 62% rename from src/DynamicData.Tests/AggregationTests/SumFixture.cs rename to src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs index 5a36d5ba6..078a8f1b0 100644 --- a/src/DynamicData.Tests/AggregationTests/SumFixture.cs +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using DynamicData.Aggregation; using DynamicData.Tests.Domain; @@ -11,9 +10,9 @@ namespace DynamicData.Tests.AggregationTests; -public class SumFixture +public partial class SumFixture { - public class CacheSource + public class ForCache { [Theory] [InlineData(1, 10)] @@ -221,6 +220,47 @@ public void SourceCompletesWithoutEmitting_CompletionPropagates() results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); } + [Fact] + public void SourceCompletesImmediately_InitialSumAndCompletionPropagate() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + source.Complete(); + + // UUT Construction: source is already completed, with pre-existing items. + // Subscription should produce both an initial sum and a completion, synchronously. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.Complete(); + + // UUT Construction: source is already completed, with no items. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + [Fact] public void SourceErrorsAfterEmitting_ErrorPropagates() { @@ -267,6 +307,26 @@ public void SourceErrorsWithoutEmitting_ErrorPropagates() results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); } + [Fact] + public void SourceFailsImmediately_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + var error = new Exception("Test error"); + source.SetError(error); + + // UUT Construction: source is already in error state. + // The error should propagate synchronously upon subscription. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + [Fact] public void NullableValuesAreTreatedAsZero() { @@ -288,20 +348,26 @@ public void NullableValuesAreTreatedAsZero() .Which.Should().Be(40, "null values should be treated as zero, so the sum should be 10 + 0 + 30 = 40"); } - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForInt() + [Theory] + [InlineData(new[] { 10, 20, 30 }, 60)] + [InlineData(new[] { int.MaxValue }, int.MaxValue)] + [InlineData(new[] { int.MinValue }, int.MinValue)] + [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)] + [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)] + public void ItemsAreAdded_SumIsCorrect_ForInt(int[] ages, int expectedSum) { using var source = new TestSourceCache(p => p.Name); - source.AddOrUpdate(new Person("A", 10)); - source.AddOrUpdate(new Person("B", 20)); - source.AddOrUpdate(new Person("C", 30)); + for (var i = 0; i < ages.Length; i++) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } using var subscription = source.Connect() .Sum(p => p.Age) .RecordValues(out var results); - results.RecordedValues[^1].Should().Be(60, "the int sum of ages 10 + 20 + 30 is 60"); + results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", ages)}] is {expectedSum}"); } [Fact] @@ -448,339 +514,4 @@ public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of ages 10 + 20 + 30 is 60"); } } - - public class ListSource - { - [Theory] - [InlineData(1, 10)] - [InlineData(3, 60)] - public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) - { - var items = new[] { 10, 20, 30 }; - using var source = new TestSourceList(); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().BeEmpty("no items have been added to the source"); - - // UUT Action - source.AddRange(items.Take(itemCount)); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().ContainSingle("an AddRange produces a single changeset") - .Which.Should().Be(expectedSum, $"the sum of the first {itemCount} items should be {expectedSum}"); - } - - [Theory] - [InlineData(0, 50)] - [InlineData(1, 40)] - [InlineData(2, 30)] - public void ItemIsRemoved_SumReflectsRemoval(int removalIndex, int expectedSum) - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") - .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); - - // UUT Action - source.RemoveAt(removalIndex); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); - results.RecordedValues[^1].Should().Be(expectedSum, $"removing item at index {removalIndex} should leave a sum of {expectedSum}"); - } - - [Fact] - public void ItemIsReplaced_SumReflectsReplacement() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") - .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); - - // UUT Action: replace item at index 1 (value 20) with 50 - source.ReplaceAt(1, 50); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the replacement"); - results.RecordedValues[^1].Should().Be(90, "replacing 20 with 50 should change the sum from 60 to 90"); - } - - [Fact] - public void ItemsAreCleared_SumReturnsToZero() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") - .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); - - // UUT Action - source.Clear(); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); - results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); - } - - [Fact] - public void SourceIsEmpty_NoSumEmitted() - { - using var source = new TestSourceList(); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); - } - - [Fact] - public void SourceCompletesAfterEmitting_CompletionPropagates() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeFalse("the source can still publish notifications"); - results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); - - // UUT Action - source.Complete(); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeTrue("the source has completed"); - } - - [Fact] - public void SourceCompletesWithoutEmitting_CompletionPropagates() - { - using var source = new TestSourceList(); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.RecordedValues.Should().BeEmpty("no items were added to the source"); - - // UUT Action - source.Complete(); - - results.Error.Should().BeNull("no errors should have occurred"); - results.HasCompleted.Should().BeTrue("the source has completed"); - results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); - } - - [Fact] - public void SourceErrorsAfterEmitting_ErrorPropagates() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - // UUT Construction - using var subscription = source.Connect() - .Sum(x => x) - .ValidateSynchronization() - .RecordValues(out var results); - - results.Error.Should().BeNull("no errors should have occurred"); - results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); - - // UUT Action - var error = new Exception("Test error"); - source.SetError(error); - - results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); - results.HasCompleted.Should().BeFalse("an error is not a completion"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForInt() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60, "the int sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForNullableInt() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (int?)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60, "the nullable int sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForLong() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (long)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60L, "the long sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForNullableLong() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (long?)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForDouble() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (double)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60.0, "the double sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (double?)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForDecimal() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (decimal)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60M, "the decimal sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (decimal?)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForFloat() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (float)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60F, "the float sum of items 10 + 20 + 30 is 60"); - } - - [Fact] - public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() - { - using var source = new TestSourceList(); - - source.AddRange(new[] { 10, 20, 30 }); - - using var subscription = source.Connect() - .Sum(x => (float?)x) - .RecordValues(out var results); - - results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of items 10 + 20 + 30 is 60"); - } - } } diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs new file mode 100644 index 000000000..9e9108602 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs @@ -0,0 +1,413 @@ +using System; +using System.Linq; + +using DynamicData.Aggregation; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class SumFixture +{ + public class ForList + { + [Theory] + [InlineData(1, 10)] + [InlineData(3, 60)] + public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) + { + var items = new[] { 10, 20, 30 }; + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action + source.AddRange(items.Take(itemCount)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("an AddRange produces a single changeset") + .Which.Should().Be(expectedSum, $"the sum of the first {itemCount} items should be {expectedSum}"); + } + + [Theory] + [InlineData(0, 50)] + [InlineData(1, 40)] + [InlineData(2, 30)] + public void ItemIsRemoved_SumReflectsRemoval(int removalIndex, int expectedSum) + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.RemoveAt(removalIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); + results.RecordedValues[^1].Should().Be(expectedSum, $"removing item at index {removalIndex} should leave a sum of {expectedSum}"); + } + + [Fact] + public void ItemIsReplaced_SumReflectsReplacement() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action: replace item at index 1 (value 20) with 50 + source.ReplaceAt(1, 50); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the replacement"); + results.RecordedValues[^1].Should().Be(90, "replacing 20 with 50 should change the sum from 60 to 90"); + } + + [Fact] + public void ItemsAreCleared_SumReturnsToZero() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.Clear(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); + results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); + } + + [Fact] + public void SourceIsEmpty_NoSumEmitted() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesAfterEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + } + + [Fact] + public void SourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesImmediately_InitialSumAndCompletionPropagate() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + source.Complete(); + + // UUT Construction: source is already completed, with pre-existing items. + // Subscription should produce both an initial sum and a completion, synchronously. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.Complete(); + + // UUT Construction: source is already completed, with no items. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceErrorsAfterEmitting_ErrorPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Fact] + public void SourceFailsImmediately_ErrorPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + var error = new Exception("Test error"); + source.SetError(error); + + // UUT Construction: source is already in error state. + // The error should propagate synchronously upon subscription. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Theory] + [InlineData(new[] { 10, 20, 30 }, 60)] + [InlineData(new[] { int.MaxValue }, int.MaxValue)] + [InlineData(new[] { int.MinValue }, int.MinValue)] + [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)] + [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)] + public void ItemsAreAdded_SumIsCorrect_ForInt(int[] values, int expectedSum) + { + using var source = new TestSourceList(); + + source.AddRange(values); + + using var subscription = source.Connect() + .Sum(x => x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", values)}] is {expectedSum}"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableInt() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (int?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60, "the nullable int sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForLong() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (long)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the long sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableLong() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (long?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDouble() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (double)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the double sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (double?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDecimal() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (decimal)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the decimal sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (decimal?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForFloat() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (float)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the float sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (float?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of items 10 + 20 + 30 is 60"); + } + } +}