11using System . Collections . Concurrent ;
2+ using System . Collections . Frozen ;
23using System . Reflection ;
34using System . Runtime . CompilerServices ;
45using System . Threading . Channels ;
@@ -98,15 +99,16 @@ public override bool Equals(object? obj)
9899 [ Test ]
99100 public async Task FastClonerStableHashAttribute_Marks_Type_As_Stable ( )
100101 {
101- await Assert . That ( global :: FastCloner . Code . FastClonerSafeTypes . HasStableHashSemantics ( typeof ( ProbeUnfriendlyButStableKey ) ) )
102+ await Assert . That ( Code . FastClonerSafeTypes . HasStableHashSemantics ( typeof ( ProbeUnfriendlyButStableKey ) ) )
102103 . IsTrue ( )
103104 . Because ( "[FastClonerStableHash] must short-circuit the probe and declare stable semantics, " +
104105 "even when GetHashCode would throw on default-state instances." ) ;
105-
106- // Unchanged behavior for the attribute-less twin: probe throws on null Tag, conservative rebuild.
107- await Assert . That ( global ::FastCloner . Code . FastClonerSafeTypes . HasStableHashSemantics ( typeof ( ProbeUnfriendlyKeyNoAttribute ) ) )
108- . IsFalse ( )
109- . Because ( "Without the opt-in, a probe that NREs on default state must fall back to rebuild." ) ;
106+
107+ await Assert . That ( Code . FastClonerSafeTypes . HasStableHashSemantics ( typeof ( ProbeUnfriendlyKeyNoAttribute ) ) )
108+ . IsTrue ( )
109+ . Because ( "The smarter probe substitutes string.Empty for stable-hash reference fields, " +
110+ "so an override of Tag.ToUpperInvariant().GetHashCode() no longer NREs and is " +
111+ "correctly classified as stable even without the [FastClonerStableHash] opt-in." ) ;
110112 }
111113
112114 [ Test ]
@@ -128,6 +130,45 @@ public async Task FastClonerStableHashAttribute_Allows_FastPath_With_Correct_Loo
128130 await Assert . That ( clone . Contains ( new ProbeUnfriendlyButStableKey { Tag = "alpha" } ) ) . IsTrue ( )
129131 . Because ( "Hash is value-based on Tag (case-insensitive) and survives the clone unchanged." ) ;
130132 }
133+
134+ private sealed class IdentityNode
135+ {
136+ public string Tag { get ; set ; } = "" ;
137+ }
138+
139+ private struct IdentityDelegatingStruct : IEquatable < IdentityDelegatingStruct >
140+ {
141+ public IdentityNode ? Node ;
142+
143+ public override int GetHashCode ( ) => Node is null ? 0 : Node . GetHashCode ( ) ;
144+
145+ public bool Equals ( IdentityDelegatingStruct other ) => ReferenceEquals ( Node , other . Node ) ;
146+
147+ public override bool Equals ( object ? obj )
148+ => obj is IdentityDelegatingStruct other && Equals ( other ) ;
149+ }
150+
151+ [ Test ]
152+ public async Task HashSet_Of_Struct_With_IdentityHashed_Reference_Field_Should_Be_Lookupable_After_Clone ( )
153+ {
154+ IdentityNode node = new IdentityNode { Tag = "n" } ;
155+ IdentityDelegatingStruct entry = new IdentityDelegatingStruct { Node = node } ;
156+
157+ HashSet < IdentityDelegatingStruct > original = [ entry ] ;
158+ HashSet < IdentityDelegatingStruct > clone = original . DeepClone ( ) ;
159+
160+ await Assert . That ( clone ) . IsNotSameReferenceAs ( original ) ;
161+ await Assert . That ( clone . Count ) . IsEqualTo ( 1 ) ;
162+
163+ IdentityDelegatingStruct cloneEntry = clone . Single ( ) ;
164+ await Assert . That ( cloneEntry . Node ) . IsNotSameReferenceAs ( node )
165+ . Because ( "Reference field inside the struct must be deep-cloned to a fresh instance." ) ;
166+
167+ await Assert . That ( clone . Contains ( cloneEntry ) ) . IsTrue ( )
168+ . Because ( "FastCloner treats every value type as having stable hash semantics, but this struct's " +
169+ "hash delegates to an identity-hashed reference field that gets a new identity on clone, " +
170+ "so the bucket-stored hash no longer matches the element's actual hash and lookup misses." ) ;
171+ }
131172
132173 [ Test ]
133174 public async Task BufferBlock_Should_Be_Deep_Cloned_Independently ( )
@@ -349,6 +390,47 @@ public async Task ReaderWriterLockSlim_Should_Be_Deep_Cloned_Independently()
349390 // Ignore cleanup errors
350391 }
351392 }
393+
394+ [ Test ]
395+ public async Task FrozenSet_Should_Be_Cloned_Without_StackOverflow ( )
396+ {
397+ FrozenSet < string > original = new [ ] { "alpha" , "beta" , "gamma" } . ToFrozenSet ( ) ;
398+
399+ FrozenSet < string > clone = original . DeepClone ( ) ;
400+
401+ await Assert . That ( clone . Count ) . IsEqualTo ( 3 )
402+ . Because ( "All elements must survive the clone." ) ;
403+ await Assert . That ( clone . Contains ( "alpha" ) ) . IsTrue ( ) ;
404+ await Assert . That ( clone . Contains ( "beta" ) ) . IsTrue ( ) ;
405+ await Assert . That ( clone . Contains ( "gamma" ) ) . IsTrue ( ) ;
406+ await Assert . That ( clone . Contains ( "delta" ) ) . IsFalse ( ) ;
407+ }
408+
409+ private sealed class StructWrappedSelfReferenceContainer
410+ {
411+ public List < int > Payload { get ; set ; } = [ 1 , 2 , 3 ] ;
412+ public StructBackRef BackRef ;
413+ }
414+
415+ private struct StructBackRef
416+ {
417+ public StructWrappedSelfReferenceContainer ? Owner ;
418+ }
419+
420+ [ Test ]
421+ public async Task Container_With_StructMediated_SelfReference_Should_Clone_Without_Overflow ( )
422+ {
423+ StructWrappedSelfReferenceContainer original = new StructWrappedSelfReferenceContainer ( ) ;
424+ original . BackRef = new StructBackRef { Owner = original } ;
425+ await Assert . That ( original . BackRef . Owner ) . IsSameReferenceAs ( original ) ;
426+
427+ StructWrappedSelfReferenceContainer clone = original . DeepClone ( ) ;
428+
429+ await Assert . That ( clone ) . IsNotSameReferenceAs ( original ) ;
430+ await Assert . That ( clone . Payload ) . IsEquivalentTo ( new [ ] { 1 , 2 , 3 } ) ;
431+ await Assert . That ( clone . BackRef . Owner ) . IsSameReferenceAs ( clone )
432+ . Because ( "The cycle must be rebound to the clone, not left dangling at the original." ) ;
433+ }
352434
353435 [ Test ]
354436 public async Task Task_Should_Not_Be_Deep_Cloned ( )
0 commit comments