|
| 1 | +using System.Text; |
| 2 | +using FastCloner.SourceGenerator; |
| 3 | + |
| 4 | +namespace FastCloner.Tests; |
| 5 | + |
| 6 | +public class NonPublicAccessorEmitterTests |
| 7 | +{ |
| 8 | + private const string ProxyFqn = "FastCloner.SourceGenerated.__FastClonerSGBridgeProxy"; |
| 9 | + private const string GlobalProxyFqn = "global::" + ProxyFqn; |
| 10 | + |
| 11 | + private static BridgeContract MakeBridgeContract() => new BridgeContract( |
| 12 | + BridgeTypeMetadataName: "FastCloner.FastClonerSourceGeneratorBridge", |
| 13 | + ProxyTypeFullName: ProxyFqn, |
| 14 | + Methods: new EquatableArray<BridgeMethodSpec>( |
| 15 | + [ |
| 16 | + new BridgeMethodSpec("DeepCloneField", "void", |
| 17 | + new EquatableArray<string>(["object", "object", "global::System.Reflection.FieldInfo"])), |
| 18 | + new BridgeMethodSpec("CopyField", "void", |
| 19 | + new EquatableArray<string>(["object", "object", "global::System.Reflection.FieldInfo"])), |
| 20 | + new BridgeMethodSpec("DeepCloneProperty", "void", |
| 21 | + new EquatableArray<string>(["object", "object", "global::System.Reflection.PropertyInfo"])), |
| 22 | + new BridgeMethodSpec("ResolveDeclaredField", "global::System.Reflection.FieldInfo", |
| 23 | + new EquatableArray<string>(["global::System.Type", "string"])), |
| 24 | + new BridgeMethodSpec("ResolveDeclaredProperty", "global::System.Reflection.PropertyInfo", |
| 25 | + new EquatableArray<string>(["global::System.Type", "string"])), |
| 26 | + ]), |
| 27 | + IsAvailable: true); |
| 28 | + |
| 29 | + private static TypeModel MakeTypeModel(bool isStruct, string fullyQualifiedName, EquatableArray<MemberModel>? members = null) => |
| 30 | + new TypeModel( |
| 31 | + Namespace: "Test", |
| 32 | + Name: "T", |
| 33 | + FullyQualifiedName: fullyQualifiedName, |
| 34 | + IsStruct: isStruct, |
| 35 | + IsSealed: false, |
| 36 | + IsAbstract: false, |
| 37 | + IsRecord: false, |
| 38 | + HasClonableBaseClass: false, |
| 39 | + CanHaveCircularReferences: false, |
| 40 | + NeedsStateTracking: false, |
| 41 | + IsFastClonerAvailable: true, |
| 42 | + Members: members ?? EquatableArray<MemberModel>.Empty, |
| 43 | + TypeParameters: EquatableArray<string>.Empty, |
| 44 | + TypeConstraints: EquatableArray<string>.Empty, |
| 45 | + RelatedTypes: EquatableArray<TypeModel>.Empty, |
| 46 | + NestedTypes: EquatableArray<MemberModel>.Empty, |
| 47 | + DerivedTypes: EquatableArray<TypeModel>.Empty, |
| 48 | + NullabilityEnabled: true, |
| 49 | + TrustNullability: false, |
| 50 | + TargetFramework: TargetFramework.NetStandard20); |
| 51 | + |
| 52 | + private static MemberModel MakePrivateField(string name = "_privateField", string type = "int", bool isShallow = false) => |
| 53 | + new MemberModel( |
| 54 | + Name: name, |
| 55 | + TypeFullName: type, |
| 56 | + IsReadOnly: false, |
| 57 | + IsProperty: false, |
| 58 | + IsField: true, |
| 59 | + TypeKind: MemberTypeKind.Safe, |
| 60 | + ElementTypeName: null, |
| 61 | + KeyTypeName: null, |
| 62 | + ValueTypeName: null, |
| 63 | + ElementIsSafe: false, |
| 64 | + ElementHasClonableAttr: false, |
| 65 | + KeyIsSafe: false, |
| 66 | + KeyIsClonable: false, |
| 67 | + ValueIsSafe: false, |
| 68 | + ValueIsClonable: false, |
| 69 | + RequiresFastCloner: false, |
| 70 | + CollectionKind: CollectionKind.None, |
| 71 | + ConcreteTypeFullName: null, |
| 72 | + IsValueType: true, |
| 73 | + IsInitOnly: false, |
| 74 | + IsRequired: false, |
| 75 | + ArrayRank: 0, |
| 76 | + IsNullable: false, |
| 77 | + HasGetter: true, |
| 78 | + HasSetter: true, |
| 79 | + SetterIsAccessible: false, |
| 80 | + MemberBehavior: isShallow ? MemberCloneBehavior.Shallow : MemberCloneBehavior.Clone, |
| 81 | + AccessorStrategy: NonPublicAccessorStrategy.Field, |
| 82 | + GetterIsAccessible: false); |
| 83 | + |
| 84 | + private static MemberModel MakePrivateSetterProperty(string name = "PrivateSetterProp", string type = "int") => |
| 85 | + new MemberModel( |
| 86 | + Name: name, |
| 87 | + TypeFullName: type, |
| 88 | + IsReadOnly: false, |
| 89 | + IsProperty: true, |
| 90 | + IsField: false, |
| 91 | + TypeKind: MemberTypeKind.Safe, |
| 92 | + ElementTypeName: null, |
| 93 | + KeyTypeName: null, |
| 94 | + ValueTypeName: null, |
| 95 | + ElementIsSafe: false, |
| 96 | + ElementHasClonableAttr: false, |
| 97 | + KeyIsSafe: false, |
| 98 | + KeyIsClonable: false, |
| 99 | + ValueIsSafe: false, |
| 100 | + ValueIsClonable: false, |
| 101 | + RequiresFastCloner: false, |
| 102 | + CollectionKind: CollectionKind.None, |
| 103 | + ConcreteTypeFullName: null, |
| 104 | + IsValueType: true, |
| 105 | + IsInitOnly: false, |
| 106 | + IsRequired: false, |
| 107 | + ArrayRank: 0, |
| 108 | + IsNullable: false, |
| 109 | + HasGetter: true, |
| 110 | + HasSetter: true, |
| 111 | + SetterIsAccessible: false, |
| 112 | + MemberBehavior: MemberCloneBehavior.Clone, |
| 113 | + AccessorStrategy: NonPublicAccessorStrategy.SetterMethod, |
| 114 | + GetterIsAccessible: true); |
| 115 | + |
| 116 | + [Test] |
| 117 | + public async Task RuntimeBridge_StructReceiver_FieldStorage_EmitsBoxMutateUnbox() |
| 118 | + { |
| 119 | + MemberModel member = MakePrivateField(); |
| 120 | + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member])); |
| 121 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 122 | + StringBuilder sb = new StringBuilder(); |
| 123 | + |
| 124 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); |
| 125 | + |
| 126 | + string emitted = sb.ToString(); |
| 127 | + |
| 128 | + await Assert.That(emitted).Contains("object __nptarget_"); |
| 129 | + await Assert.That(emitted).Contains("= (object)result;"); |
| 130 | + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source,"); |
| 131 | + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); |
| 132 | + |
| 133 | + // The bridge call must target the box, not the local 'result'. |
| 134 | + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneField(source, result,"); |
| 135 | + } |
| 136 | + |
| 137 | + [Test] |
| 138 | + public async Task RuntimeBridge_StructReceiver_ShallowField_EmitsCopyField_ThroughBox() |
| 139 | + { |
| 140 | + MemberModel member = MakePrivateField(isShallow: true); |
| 141 | + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member])); |
| 142 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 143 | + StringBuilder sb = new StringBuilder(); |
| 144 | + |
| 145 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); |
| 146 | + |
| 147 | + string emitted = sb.ToString(); |
| 148 | + |
| 149 | + await Assert.That(emitted).Contains("object __nptarget_"); |
| 150 | + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.CopyField(source,"); |
| 151 | + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); |
| 152 | + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.CopyField(source, result,"); |
| 153 | + } |
| 154 | + |
| 155 | + [Test] |
| 156 | + public async Task RuntimeBridge_StructReceiver_PropertySetter_EmitsBoxMutateUnbox() |
| 157 | + { |
| 158 | + MemberModel member = MakePrivateSetterProperty(); |
| 159 | + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member])); |
| 160 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 161 | + StringBuilder sb = new StringBuilder(); |
| 162 | + |
| 163 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); |
| 164 | + |
| 165 | + string emitted = sb.ToString(); |
| 166 | + |
| 167 | + await Assert.That(emitted).Contains("object __nptarget_"); |
| 168 | + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneProperty(source,"); |
| 169 | + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); |
| 170 | + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneProperty(source, result,"); |
| 171 | + } |
| 172 | + |
| 173 | + [Test] |
| 174 | + public async Task RuntimeBridge_ClassReceiver_DoesNotBoxOrUnbox() |
| 175 | + { |
| 176 | + // Reference types should keep using the simple direct call -- boxing is a |
| 177 | + // no-op and the generator should not pay for an extra copy. |
| 178 | + MemberModel member = MakePrivateField(); |
| 179 | + TypeModel model = MakeTypeModel(isStruct: false, "global::Test.MyClass", new EquatableArray<MemberModel>([member])); |
| 180 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 181 | + StringBuilder sb = new StringBuilder(); |
| 182 | + |
| 183 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); |
| 184 | + |
| 185 | + string emitted = sb.ToString(); |
| 186 | + |
| 187 | + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source, result,"); |
| 188 | + await Assert.That(emitted).DoesNotContain("__nptarget_"); |
| 189 | + await Assert.That(emitted).DoesNotContain("(object)result"); |
| 190 | + } |
| 191 | + |
| 192 | + [Test] |
| 193 | + public async Task RuntimeBridge_StructReceiver_MultipleCalls_UseDistinctBoxLocals() |
| 194 | + { |
| 195 | + // Two non-public members on the same struct must use distinct local |
| 196 | + // names so the emitted method compiles. The generator uses |
| 197 | + // GetNextVariableId() for this. |
| 198 | + MemberModel a = MakePrivateField("_a"); |
| 199 | + MemberModel b = MakePrivateField("_b"); |
| 200 | + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", |
| 201 | + new EquatableArray<MemberModel>([a, b])); |
| 202 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 203 | + StringBuilder sb = new StringBuilder(); |
| 204 | + |
| 205 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, a, "result", "source", "state", " "); |
| 206 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, b, "result", "source", "state", " "); |
| 207 | + |
| 208 | + string emitted = sb.ToString(); |
| 209 | + |
| 210 | + // Find both __nptarget_<n> identifiers and verify they differ. |
| 211 | + System.Text.RegularExpressions.MatchCollection matches = |
| 212 | + System.Text.RegularExpressions.Regex.Matches(emitted, @"__nptarget_(\d+)"); |
| 213 | + HashSet<string> ids = []; |
| 214 | + foreach (System.Text.RegularExpressions.Match m in matches) |
| 215 | + { |
| 216 | + ids.Add(m.Groups[1].Value); |
| 217 | + } |
| 218 | + |
| 219 | + await Assert.That(ids.Count).IsGreaterThanOrEqualTo(2); |
| 220 | + } |
| 221 | + |
| 222 | + [Test] |
| 223 | + public async Task TfmAtLeastNet8_DoesNotUseBridge_RegardlessOfStructness() |
| 224 | + { |
| 225 | + // Sanity: on .NET 8+ the emitter uses UnsafeAccessor and never boxes. |
| 226 | + MemberModel member = MakePrivateField(); |
| 227 | + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member])) |
| 228 | + with { TargetFramework = TargetFramework.Net8 }; |
| 229 | + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); |
| 230 | + StringBuilder sb = new StringBuilder(); |
| 231 | + |
| 232 | + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); |
| 233 | + |
| 234 | + string emitted = sb.ToString(); |
| 235 | + |
| 236 | + await Assert.That(emitted).DoesNotContain(GlobalProxyFqn); |
| 237 | + await Assert.That(emitted).DoesNotContain("__nptarget_"); |
| 238 | + } |
| 239 | +} |
0 commit comments