From c727c008e3172ecc44e0ee102fb2528f797df4b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:26:30 +0000 Subject: [PATCH 1/2] Clone the items when IEnumerable.DeepClone() is called, not on each enumeration DeepClone(this IEnumerable) returned source.Select(DeepClone), a lazy view over the live source. Each enumeration cloned every item again, so edits to the cloned items were lost on the next read, and the clone followed later changes to the source. Materialize the clones when the method is called. Also null-check the source there and in Stack.DeepClone, like the other overloads. Fixes #80 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaXGMeYeXB1J32kSrLGhgJ --- DeepClone.Test/SpecializedCollectionTests.cs | 65 ++++++++++++++++++++ DeepClone/DeepCloneContainerExtensions.cs | 23 +++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/DeepClone.Test/SpecializedCollectionTests.cs b/DeepClone.Test/SpecializedCollectionTests.cs index 6679852..4a779d0 100644 --- a/DeepClone.Test/SpecializedCollectionTests.cs +++ b/DeepClone.Test/SpecializedCollectionTests.cs @@ -377,6 +377,71 @@ public void ConcurrentDictionary_DeepClone_ShouldCreateIndependentCopy() Assert.HasCount(2, original); Assert.IsFalse(original.ContainsKey(3), "Original should not contain key 3 added to clone"); } + +#pragma warning disable CA1851 // Enumerating the clone more than once is what these tests check + /// + /// Tests that edits to the items of a cloned sequence survive a second enumeration. + /// + [TestMethod] + public void Enumerable_DeepClone_ShouldKeepEditsAcrossEnumerations() + { + // Arrange + List original = [new() { Id = 1, Name = "Item1" }]; + + // Act + IEnumerable clone = original.DeepClone(); + foreach (SimpleObject item in clone) + { + item.Name = "Edited"; + } + + // Assert + Assert.AreEqual("Edited", clone.First().Name); + Assert.AreSame(clone.First(), clone.First()); + Assert.AreEqual("Item1", original[0].Name); + } + + /// + /// Tests that a cloned sequence does not change when the source changes afterwards. + /// + [TestMethod] + public void Enumerable_DeepClone_ShouldNotTrackSourceChanges() + { + // Arrange + List original = + [ + new() { Id = 1, Name = "Item1" }, + new() { Id = 2, Name = "Item2" }, + ]; + + // Act + IEnumerable clone = original.DeepClone(); + original.Clear(); + + // Assert + Assert.AreEqual(2, clone.Count()); + } +#pragma warning restore CA1851 + + /// + /// Tests that deep cloning a null sequence throws ArgumentNullException. + /// + [TestMethod] + public void Enumerable_DeepClone_NullSource_ShouldThrow() + { + IEnumerable source = null!; + Assert.ThrowsExactly(() => source.DeepClone()); + } + + /// + /// Tests that deep cloning a null stack throws ArgumentNullException. + /// + [TestMethod] + public void Stack_DeepClone_NullSource_ShouldThrow() + { + Stack source = null!; + Assert.ThrowsExactly(() => source.DeepClone()); + } } #pragma warning restore IDE0007 diff --git a/DeepClone/DeepCloneContainerExtensions.cs b/DeepClone/DeepCloneContainerExtensions.cs index 11d5f80..3d46d5f 100644 --- a/DeepClone/DeepCloneContainerExtensions.cs +++ b/DeepClone/DeepCloneContainerExtensions.cs @@ -104,8 +104,9 @@ public static void DeepCloneFrom(this IDictionary de /// A new collection containing deep clones of the original items if they implement IDeepCloneable, /// otherwise containing the original items. /// - /// This method returns an IEnumerable sequence of cloned items. To get a specific collection type, - /// you'll need to convert the result (for example using ToList() or ToArray()). + /// The items are cloned when this method is called, so the result is a snapshot: it does not change + /// when the source changes, and enumerating it again yields the same cloned instances. To get a + /// specific collection type, convert the result (for example using ToList() or ToArray()). /// /// Example usage: /// @@ -113,8 +114,13 @@ public static void DeepCloneFrom(this IDictionary de /// var clonedArray = originalArray.DeepClone().ToArray(); /// /// - public static IEnumerable DeepClone(this IEnumerable source) => - source.Select(DeepClone); + /// Thrown if source is null. + public static IEnumerable DeepClone(this IEnumerable source) + { + Ensure.NotNull(source); + + return [.. source.Select(DeepClone)]; + } /// /// Deep clones a dictionary. @@ -314,6 +320,11 @@ public static SortedSet DeepClone(this SortedSet source) /// var clonedStack = originalStack.DeepClone(); /// /// - public static Stack DeepClone(this Stack source) => - new(source.Reverse().Select(DeepClone)); + /// Thrown if source is null. + public static Stack DeepClone(this Stack source) + { + Ensure.NotNull(source); + + return new(source.Reverse().Select(DeepClone)); + } } From bf18c04434846c3fe8a82b19ed8538d1e068e2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:27:17 +0000 Subject: [PATCH 2/2] Move the IEnumerable clone tests into their own file Keeps them clear of the tests other open PRs append to SpecializedCollectionTests.cs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaXGMeYeXB1J32kSrLGhgJ --- DeepClone.Test/EnumerableCloneTests.cs | 75 ++++++++++++++++++++ DeepClone.Test/SpecializedCollectionTests.cs | 65 ----------------- 2 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 DeepClone.Test/EnumerableCloneTests.cs diff --git a/DeepClone.Test/EnumerableCloneTests.cs b/DeepClone.Test/EnumerableCloneTests.cs new file mode 100644 index 0000000..73714d7 --- /dev/null +++ b/DeepClone.Test/EnumerableCloneTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.DeepClone.Test; + +/// +/// Tests for deep cloning plain sequences and stacks through their extension methods. +/// +[TestClass] +public class EnumerableCloneTests +{ +#pragma warning disable CA1851 // Enumerating the clone more than once is what these tests check + /// + /// Tests that edits to the items of a cloned sequence survive a second enumeration. + /// + [TestMethod] + public void Enumerable_DeepClone_ShouldKeepEditsAcrossEnumerations() + { + // Arrange + List original = [new() { Id = 1, Name = "Item1" }]; + + // Act + IEnumerable clone = original.DeepClone(); + foreach (SimpleObject item in clone) + { + item.Name = "Edited"; + } + + // Assert + Assert.AreEqual("Edited", clone.First().Name); + Assert.AreSame(clone.First(), clone.First()); + Assert.AreEqual("Item1", original[0].Name); + } + + /// + /// Tests that a cloned sequence does not change when the source changes afterwards. + /// + [TestMethod] + public void Enumerable_DeepClone_ShouldNotTrackSourceChanges() + { + // Arrange + List original = + [ + new() { Id = 1, Name = "Item1" }, + new() { Id = 2, Name = "Item2" }, + ]; + + // Act + IEnumerable clone = original.DeepClone(); + original.Clear(); + + // Assert + Assert.AreEqual(2, clone.Count()); + } +#pragma warning restore CA1851 + + /// + /// Tests that deep cloning a null sequence throws ArgumentNullException. + /// + [TestMethod] + public void Enumerable_DeepClone_NullSource_ShouldThrow() + { + IEnumerable source = null!; + Assert.ThrowsExactly(() => source.DeepClone()); + } + + /// + /// Tests that deep cloning a null stack throws ArgumentNullException. + /// + [TestMethod] + public void Stack_DeepClone_NullSource_ShouldThrow() + { + Stack source = null!; + Assert.ThrowsExactly(() => source.DeepClone()); + } +} diff --git a/DeepClone.Test/SpecializedCollectionTests.cs b/DeepClone.Test/SpecializedCollectionTests.cs index 4a779d0..6679852 100644 --- a/DeepClone.Test/SpecializedCollectionTests.cs +++ b/DeepClone.Test/SpecializedCollectionTests.cs @@ -377,71 +377,6 @@ public void ConcurrentDictionary_DeepClone_ShouldCreateIndependentCopy() Assert.HasCount(2, original); Assert.IsFalse(original.ContainsKey(3), "Original should not contain key 3 added to clone"); } - -#pragma warning disable CA1851 // Enumerating the clone more than once is what these tests check - /// - /// Tests that edits to the items of a cloned sequence survive a second enumeration. - /// - [TestMethod] - public void Enumerable_DeepClone_ShouldKeepEditsAcrossEnumerations() - { - // Arrange - List original = [new() { Id = 1, Name = "Item1" }]; - - // Act - IEnumerable clone = original.DeepClone(); - foreach (SimpleObject item in clone) - { - item.Name = "Edited"; - } - - // Assert - Assert.AreEqual("Edited", clone.First().Name); - Assert.AreSame(clone.First(), clone.First()); - Assert.AreEqual("Item1", original[0].Name); - } - - /// - /// Tests that a cloned sequence does not change when the source changes afterwards. - /// - [TestMethod] - public void Enumerable_DeepClone_ShouldNotTrackSourceChanges() - { - // Arrange - List original = - [ - new() { Id = 1, Name = "Item1" }, - new() { Id = 2, Name = "Item2" }, - ]; - - // Act - IEnumerable clone = original.DeepClone(); - original.Clear(); - - // Assert - Assert.AreEqual(2, clone.Count()); - } -#pragma warning restore CA1851 - - /// - /// Tests that deep cloning a null sequence throws ArgumentNullException. - /// - [TestMethod] - public void Enumerable_DeepClone_NullSource_ShouldThrow() - { - IEnumerable source = null!; - Assert.ThrowsExactly(() => source.DeepClone()); - } - - /// - /// Tests that deep cloning a null stack throws ArgumentNullException. - /// - [TestMethod] - public void Stack_DeepClone_NullSource_ShouldThrow() - { - Stack source = null!; - Assert.ThrowsExactly(() => source.DeepClone()); - } } #pragma warning restore IDE0007