From 00f85e6e624138580cd0dc2f27303e8cb383a1d6 Mon Sep 17 00:00:00 2001 From: Markus Jarderot Date: Thu, 4 Jun 2026 17:13:02 +0200 Subject: [PATCH 1/3] Add IncludeSubtypes to enable subtype cloning for non-abstract classes Introduce IncludeSubtypes property to FastClonerClonableAttribute, allowing non-abstract base classes to opt into runtime subtype dispatch for FastDeepClone. Update code generation and type modeling to support this feature. Add documentation and tests to demonstrate and verify the new behavior. --- README.md | 18 ++++ .../FastClonerClonableAttribute.cs | 14 +++ .../CloneCodeGenerator.cs | 50 +++++++--- .../DerivedTypeCollector.cs | 10 +- .../ImplicitTypeAnalyzer.cs | 2 +- src/FastCloner.SourceGenerator/TypeModel.cs | 1 + .../TypeModelFactory.cs | 25 ++++- src/FastCloner.Tests/AbstractClassTests.cs | 94 +++++++++++++++++++ 8 files changed, 193 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b8b8746..7830669 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,24 @@ Animal pet = new Dog { Name = "Buddy", Breed = "Labrador" }; Animal clone = pet.FastDeepClone(); // Returns a cloned Dog ``` +For non-abstract base classes, you can opt into the same runtime subtype dispatch behavior: + +```cs +[FastClonerClonable(IncludeSubtypes = true)] +public class Device +{ + public string Name { get; set; } +} + +public class Phone : Device +{ + public string OS { get; set; } +} + +Device device = new Phone { Name = "Pixel", OS = "Android" }; +Device clone = device.FastDeepClone(); // Returns a cloned Phone +``` + ### Explicitly Including Types When a type is only used dynamically (not visible at compile time), use `[FastClonerInclude]` to ensure the generator creates cloning code for it: diff --git a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs index 569fd3a..a3ac3e8 100644 --- a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs +++ b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs @@ -9,6 +9,20 @@ namespace FastCloner.SourceGenerator.Shared; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public class FastClonerClonableAttribute : Attribute { + /// + /// Gets or sets whether subtype dispatch should be generated for this type. + /// When true, generated clone code dispatches by runtime type and clones known subtypes similarly to abstract roots. + /// This is ignored for abstract types, which always have subtype dispatch. + /// This is ignored for structs, which cannot have subtypes. + /// + /// + /// + /// [FastClonerClonable(IncludeSubtypes = true)] + /// public class BaseType { } + /// + /// + public bool IncludeSubtypes { get; set; } + /// /// Initializes a new instance of the FastClonerClonableAttribute. /// diff --git a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs index 09cee05..66a2eb4 100644 --- a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs +++ b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs @@ -208,7 +208,9 @@ private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName return; } - if (_context.Model.IsAbstract) + bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes); + + if (needsSubtypeDispatcher) { sb.AppendLine(" return InternalFastDeepClone(source, null);"); } @@ -274,11 +276,19 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam sb.AppendLine(" if (source == null) return null;"); } - if (_context.Model.IsAbstract) + bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes); + if (needsSubtypeDispatcher) { - WriteAbstractTypeDispatcher(typeName); + WriteSubtypeDispatcher(typeName, fullTypeName, includeRootTypeGuard: !_context.Model.IsAbstract); + if (_context.Model.IsAbstract) + { + sb.AppendLine(" }"); + sb.AppendLine(); + return; + } } - else if (_context.NeedsStateTracking) + + if (_context.NeedsStateTracking) { _context.NeedsStateClass = true; sb.AppendLine(" var localState = state ?? new FcGeneratedCloneState();"); @@ -309,7 +319,7 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam sb.AppendLine(); } - private void WriteAbstractTypeDispatcher(string typeName) + private void WriteSubtypeDispatcher(string typeName, string fullTypeName, bool includeRootTypeGuard) { StringBuilder sb = _context.Source; EquatableArray derivedTypes = _context.Model.DerivedTypes; @@ -319,6 +329,14 @@ private void WriteAbstractTypeDispatcher(string typeName) sb.AppendLine(" var runtimeType = source.GetType();"); sb.AppendLine(); + string indent = " "; + if (includeRootTypeGuard) + { + sb.AppendLine($" if (runtimeType != typeof({fullTypeName}))"); + sb.AppendLine(" {"); + indent = " "; + } + foreach (TypeModel? derivedType in derivedTypes) { string derivedTypeName = derivedType.FullyQualifiedName; @@ -331,29 +349,35 @@ private void WriteAbstractTypeDispatcher(string typeName) extensionClassName = $"{derivedType.Name}FastDeepCloneExtensions"; } - sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))"); - sb.AppendLine($" return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);"); + sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))"); + sb.AppendLine($"{indent} return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);"); } else { string helperName = $"Clone{GetSafeTypeName(derivedType.Name)}"; _context.RegisterDerivedTypeHelper(derivedType, helperName); - sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))"); - sb.AppendLine($" return ({typeName}){helperName}(({derivedTypeName})source, state);"); + sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))"); + sb.AppendLine($"{indent} return ({typeName}){helperName}(({derivedTypeName})source, state);"); } sb.AppendLine(); } if (_context.IsFastClonerAvailable) { - sb.AppendLine($" return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;"); + sb.AppendLine($"{indent}return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;"); } else { - sb.AppendLine($" throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +"); - sb.AppendLine(" \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +"); - sb.AppendLine(" \"or install the FastCloner NuGet package for runtime fallback.\");"); + sb.AppendLine($"{indent}throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +"); + sb.AppendLine($"{indent} \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +"); + sb.AppendLine($"{indent} \"or install the FastCloner NuGet package for runtime fallback.\");"); + } + + if (includeRootTypeGuard) + { + sb.AppendLine(" }"); + sb.AppendLine(); } } diff --git a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs index 6856d44..66dd3bc 100644 --- a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs +++ b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs @@ -137,11 +137,11 @@ private static TypeModel CreateMinimalTypeModel( RelatedTypes: EquatableArray.Empty, NestedTypes: EquatableArray.Empty, DerivedTypes: EquatableArray.Empty, - nullabilityEnabled, - trustNullability, + NullabilityEnabled: nullabilityEnabled, + TrustNullability: trustNullability, PreserveIdentity: null, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework); } @@ -332,10 +332,10 @@ private static TypeModel CreateMinimalTypeModel( trustNullability, preserveIdentity, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework, - new EquatableArray(circRefLog.ToArray())); + CircularAnalysisLog: new EquatableArray(circRefLog.ToArray())); } private static bool? GetPreserveIdentityFromType(INamedTypeSymbol symbol) diff --git a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs index 1d87917..22a9102 100644 --- a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs +++ b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs @@ -204,7 +204,7 @@ bool TryHandleComponent(ITypeSymbol componentType, out MemberModel? componentMem trustNullability, PreserveIdentity: null, IsRefLikeType: false, - hasParameterlessConstructor, + HasParameterlessConstructor: hasParameterlessConstructor, CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null, TargetFramework: targetFramework); diff --git a/src/FastCloner.SourceGenerator/TypeModel.cs b/src/FastCloner.SourceGenerator/TypeModel.cs index 48db8b3..97de2c9 100644 --- a/src/FastCloner.SourceGenerator/TypeModel.cs +++ b/src/FastCloner.SourceGenerator/TypeModel.cs @@ -31,5 +31,6 @@ internal sealed record TypeModel( bool IsRefLikeType = false, // Whether the type is a ref struct (cannot be boxed/used as generic) bool HasParameterlessConstructor = true, // Whether the type has a public parameterless constructor (defaults to true for safety) bool CodeAnalysisAvailable = false, // Whether System.Diagnostics.CodeAnalysis attributes are available + bool IncludeSubtypes = false, // Whether subtype dispatch should be generated for this type TargetFramework TargetFramework = TargetFramework.NetStandard20, // Detected target framework for TFM-specific optimizations EquatableArray CircularAnalysisLog = default) : IEquatable; diff --git a/src/FastCloner.SourceGenerator/TypeModelFactory.cs b/src/FastCloner.SourceGenerator/TypeModelFactory.cs index ee9065c..3e94d49 100644 --- a/src/FastCloner.SourceGenerator/TypeModelFactory.cs +++ b/src/FastCloner.SourceGenerator/TypeModelFactory.cs @@ -23,6 +23,7 @@ public static bool TryCreate( .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerSimulateNoRuntimeAttribute"); bool trustNullability = symbol.GetAttributes() .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerTrustNullabilityAttribute"); + bool includeSubtypes = GetIncludeSubtypesFromType(symbol); bool? preserveIdentity = GetPreserveIdentityFromType(symbol); bool codeAnalysisAvailable = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null; @@ -211,12 +212,12 @@ public static bool TryCreate( bool isRefLikeType = TypeAnalyzer.IsRefStructType(symbol); EquatableArray derivedTypes = EquatableArray.Empty; - if (symbol.IsAbstract) + if (symbol.IsAbstract || includeSubtypes) { List derivedTypesList = DerivedTypeCollector.Collect(symbol, compilation, nullabilityEnabled, targetFramework); derivedTypes = new EquatableArray(derivedTypesList.ToArray()); - if (derivedTypesList.Count == 0 && !isFastClonerAvailable) + if (symbol.IsAbstract && derivedTypesList.Count == 0 && !isFastClonerAvailable) { error = Diagnostic.Create( new DiagnosticDescriptor( @@ -257,6 +258,7 @@ public static bool TryCreate( isRefLikeType, hasParameterlessConstructor, codeAnalysisAvailable, + includeSubtypes, targetFramework, new EquatableArray(circRefLog.ToArray())); @@ -283,4 +285,23 @@ public static bool TryCreate( } return null; } + + private static bool GetIncludeSubtypesFromType(INamedTypeSymbol symbol) + { + foreach (AttributeData attr in symbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != "FastCloner.SourceGenerator.Shared.FastClonerClonableAttribute") + continue; + + foreach (KeyValuePair namedArg in attr.NamedArguments) + { + if (namedArg is { Key: "IncludeSubtypes", Value.Value: bool includeSubtypes }) + return includeSubtypes; + } + + return false; + } + + return false; + } } diff --git a/src/FastCloner.Tests/AbstractClassTests.cs b/src/FastCloner.Tests/AbstractClassTests.cs index 3cffb85..bfabb6e 100644 --- a/src/FastCloner.Tests/AbstractClassTests.cs +++ b/src/FastCloner.Tests/AbstractClassTests.cs @@ -152,6 +152,32 @@ public class Address #endregion + #region Test Classes - Concrete Base with IncludeSubtypes + + [FastClonerClonable(IncludeSubtypes = true)] + public class Device + { + public string? Name { get; set; } + } + + public class Phone : Device + { + public string? OperatingSystem { get; set; } + } + + [FastClonerClonable] + public class PlainDevice + { + public string? Name { get; set; } + } + + public class PlainPhone : PlainDevice + { + public string? OperatingSystem { get; set; } + } + + #endregion + #region Tests - Basic Abstract Class Cloning [Test] @@ -212,6 +238,74 @@ public async Task Abstract_Cat_Should_Clone_Correctly() await Assert.That(clonedCat.IsIndoor).IsTrue(); } + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithIncludeSubtypes_Should_Dispatch_To_Derived_Cloner() + { + // Arrange + Phone phone = new Phone + { + Name = "MyPhone", + OperatingSystem = "Android" + }; + + // Act - Clone via concrete base type + Device device = phone; + Device? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(phone); + + Phone clonedPhone = (Phone)clone!; + await Assert.That(clonedPhone.Name).IsEqualTo("MyPhone"); + await Assert.That(clonedPhone.OperatingSystem).IsEqualTo("Android"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithoutIncludeSubtypes_Should_Keep_Default_Behavior() + { + // Arrange + PlainPhone phone = new PlainPhone + { + Name = "LegacyPhone", + OperatingSystem = "Symbian" + }; + + // Act - Clone via concrete base type without IncludeSubtypes + PlainDevice device = phone; + PlainDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(phone); + await Assert.That(clone is PlainPhone).IsFalse(); + await Assert.That(clone!.Name).IsEqualTo("LegacyPhone"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task ConcreteBase_WithIncludeSubtypes_Should_Clone_Device_Instance() + { + // Arrange + Device device = new Device + { + Name = "BaseDevice" + }; + + // Act + Device? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(device); + await Assert.That(clone!.Name).IsEqualTo("BaseDevice"); + } + #endregion #region Tests - Abstract with Collections From 2ba00e47e48f546b0dba87c6eda899c8db4f66f8 Mon Sep 17 00:00:00 2001 From: Markus Jarderot Date: Thu, 4 Jun 2026 17:52:02 +0200 Subject: [PATCH 2/3] Add test case for included subtype together with IncludeSubtypes --- src/FastCloner.Tests/AbstractClassTests.cs | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/FastCloner.Tests/AbstractClassTests.cs b/src/FastCloner.Tests/AbstractClassTests.cs index bfabb6e..e068f4c 100644 --- a/src/FastCloner.Tests/AbstractClassTests.cs +++ b/src/FastCloner.Tests/AbstractClassTests.cs @@ -20,6 +20,12 @@ public class ExternalBird : AbstractClassTests.Pet public double Wingspan { get; set; } public bool CanFly { get; set; } } + +public class ExternalWatch : AbstractClassTests.IncludedDevice +{ + public string? FirmwareVersion { get; set; } +} + [SourceGeneratorCompatible] public class AbstractClassTests { @@ -176,6 +182,14 @@ public class PlainPhone : PlainDevice public string? OperatingSystem { get; set; } } + [FastClonerClonable(IncludeSubtypes = true)] + [FastClonerDisableAutoDiscovery] + [FastClonerInclude(typeof(ExternalWatch))] + public class IncludedDevice + { + public string? Name { get; set; } + } + #endregion #region Tests - Basic Abstract Class Cloning @@ -597,6 +611,31 @@ public async Task FastClonerInclude_Multiple_Types_Independence() await Assert.That(((ExternalBird)clones[2]!).CanFly).IsTrue(); } + [Test] + [SourceGeneratorCompatible] + public async Task IncludeSubtypes_WithFastClonerInclude_ExternalSubtype_Should_Clone() + { + // Arrange + ExternalWatch watch = new ExternalWatch + { + Name = "Pixel Watch", + FirmwareVersion = "1.2.3" + }; + + // Act - clone via concrete base with IncludeSubtypes=true + IncludedDevice device = watch; + IncludedDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(watch); + + ExternalWatch clonedWatch = (ExternalWatch)clone!; + await Assert.That(clonedWatch.Name).IsEqualTo("Pixel Watch"); + await Assert.That(clonedWatch.FirmwareVersion).IsEqualTo("1.2.3"); + } + #endregion #region Tests - Null Handling From 7b261a3f121b5639ed320872059223f550fe721d Mon Sep 17 00:00:00 2001 From: Markus Jarderot Date: Thu, 4 Jun 2026 18:51:11 +0200 Subject: [PATCH 3/3] Clarify and enforce IncludeSubtypes behavior for sealed/struct --- .../FastClonerClonableAttribute.cs | 1 + .../TypeModelFactory.cs | 7 +-- src/FastCloner.Tests/AbstractClassTests.cs | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs index a3ac3e8..431858b 100644 --- a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs +++ b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs @@ -14,6 +14,7 @@ public class FastClonerClonableAttribute : Attribute /// When true, generated clone code dispatches by runtime type and clones known subtypes similarly to abstract roots. /// This is ignored for abstract types, which always have subtype dispatch. /// This is ignored for structs, which cannot have subtypes. + /// This is ignored for sealed classes, which cannot be subclassed. /// /// /// diff --git a/src/FastCloner.SourceGenerator/TypeModelFactory.cs b/src/FastCloner.SourceGenerator/TypeModelFactory.cs index 3e94d49..0d2ec48 100644 --- a/src/FastCloner.SourceGenerator/TypeModelFactory.cs +++ b/src/FastCloner.SourceGenerator/TypeModelFactory.cs @@ -23,7 +23,8 @@ public static bool TryCreate( .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerSimulateNoRuntimeAttribute"); bool trustNullability = symbol.GetAttributes() .Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerTrustNullabilityAttribute"); - bool includeSubtypes = GetIncludeSubtypesFromType(symbol); + bool requestedIncludeSubtypes = GetIncludeSubtypesFromType(symbol); + bool effectiveIncludeSubtypes = requestedIncludeSubtypes && !symbol.IsValueType && !symbol.IsSealed; bool? preserveIdentity = GetPreserveIdentityFromType(symbol); bool codeAnalysisAvailable = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null; @@ -212,7 +213,7 @@ public static bool TryCreate( bool isRefLikeType = TypeAnalyzer.IsRefStructType(symbol); EquatableArray derivedTypes = EquatableArray.Empty; - if (symbol.IsAbstract || includeSubtypes) + if (symbol.IsAbstract || effectiveIncludeSubtypes) { List derivedTypesList = DerivedTypeCollector.Collect(symbol, compilation, nullabilityEnabled, targetFramework); derivedTypes = new EquatableArray(derivedTypesList.ToArray()); @@ -258,7 +259,7 @@ public static bool TryCreate( isRefLikeType, hasParameterlessConstructor, codeAnalysisAvailable, - includeSubtypes, + effectiveIncludeSubtypes, targetFramework, new EquatableArray(circRefLog.ToArray())); diff --git a/src/FastCloner.Tests/AbstractClassTests.cs b/src/FastCloner.Tests/AbstractClassTests.cs index e068f4c..3cceffe 100644 --- a/src/FastCloner.Tests/AbstractClassTests.cs +++ b/src/FastCloner.Tests/AbstractClassTests.cs @@ -99,6 +99,18 @@ public abstract class Shape public string? Name { get; set; } } + [FastClonerClonable(IncludeSubtypes = true)] + public sealed class SealedDevice + { + public string? Name { get; set; } + } + + [FastClonerClonable(IncludeSubtypes = true)] + public struct StructDevice + { + public int Id { get; set; } + } + public abstract class Polygon : Shape { public int NumberOfSides { get; set; } @@ -320,6 +332,43 @@ public async Task ConcreteBase_WithIncludeSubtypes_Should_Clone_Device_Instance( await Assert.That(clone!.Name).IsEqualTo("BaseDevice"); } + [Test] + [SourceGeneratorCompatible] + public async Task SealedClass_WithIncludeSubtypes_Should_Clone_Normally() + { + // Arrange + SealedDevice device = new SealedDevice + { + Name = "Sealed" + }; + + // Act + SealedDevice? clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone).IsNotNull(); + await Assert.That(clone).IsTypeOf(); + await Assert.That(clone).IsNotSameReferenceAs(device); + await Assert.That(clone!.Name).IsEqualTo("Sealed"); + } + + [Test] + [SourceGeneratorCompatible] + public async Task Struct_WithIncludeSubtypes_Should_Clone_Normally() + { + // Arrange + StructDevice device = new StructDevice + { + Id = 42 + }; + + // Act + StructDevice clone = device.FastDeepClone(); + + // Assert + await Assert.That(clone.Id).IsEqualTo(42); + } + #endregion #region Tests - Abstract with Collections