diff --git a/Directory.Packages.props b/Directory.Packages.props index e1aea90142..6552ed4c44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,8 @@ + + diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt index 23de6dc4a0..f985714752 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt @@ -21,7 +21,7 @@ namespace TUnit.Mocks.Generated { return __result; } - return base.GetById(id); + return base.GetById(id); } public override void Save(T entity) @@ -30,7 +30,7 @@ namespace TUnit.Mocks.Generated { return; } - base.Save(entity); + base.Save(entity); } public override TResult Transform(TInput input) @@ -39,7 +39,7 @@ namespace TUnit.Mocks.Generated { return __result; } - return base.Transform(input); + return base.Transform(input); } public override global::System.Collections.Generic.IEnumerable GetAll() diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index c617603e18..b156f96e66 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -569,7 +569,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"base.{method.Name}({argPassList});"); + writer.AppendLine($"base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsVoid && method.IsAsync) { @@ -589,7 +589,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsAsync) { @@ -619,7 +619,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsRefStructReturn) { @@ -638,7 +638,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsReturnTypeStaticAbstractInterface) { @@ -650,7 +650,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return __result;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else { @@ -662,7 +662,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return __result;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } } diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 465a211023..873c4e50a7 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -41,8 +41,14 @@ public static string Build(MockTypeModel model) { bool firstMember = true; + // Pre-compute which methods need their `out` parameters kept in the extension + // signature to avoid CS0111 collisions. A method needs disambiguation when + // some other method on the model shares the same name AND the same + // matchable-parameter signature (i.e. parameters excluding out). + var methodsWithDisambiguation = ApplyOutDisambiguation(model.Methods); + // Methods - foreach (var method in model.Methods) + foreach (var method in methodsWithDisambiguation) { if (!firstMember) writer.AppendLine(); firstMember = false; @@ -84,6 +90,20 @@ public static string Build(MockTypeModel model) return writer.ToString(); } + private static void EmitOutParamDefaults(CodeWriter writer, MockMemberModel method) + { + if (!method.KeepOutParamsInExtensionSignature) return; + // Out params are assigned `default!` because the extension method never actually invokes + // the mocked method — it only *configures* a setup. The out value is never observed by + // caller code: this setup-configuration call returns a MockMethodCall, not the mocked + // result. For reference types this suppresses the CS8625 nullable warning on an unused + // assignment that exists solely to satisfy the `out` contract. + foreach (var op in method.Parameters.Where(p => p.Direction == ParameterDirection.Out)) + { + writer.AppendLine($"{op.Name} = default!;"); + } + } + private static bool ShouldGenerateTypedWrapper(MockMemberModel method, bool hasEvents) { if (method.IsGenericMethod) return false; @@ -577,6 +597,54 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth } } + /// + /// Returns the input methods, with + /// set to true on any method whose generated extension method would otherwise collide with + /// another overload. Methods are grouped by (name, type-arity, matchable-parameter signature); + /// any group with more than one entry causes its out-bearing members to be flagged. + /// The matchable-parameter signature includes parameter direction (ref/in/by-value) so that + /// overloads differing only by direction (e.g. Foo(int) vs Foo(ref int)) are not + /// treated as collisions. + /// + private static IEnumerable ApplyOutDisambiguation(EquatableArray methods) + { + var flagged = new HashSet(); + var byKey = new Dictionary>(System.StringComparer.Ordinal); + foreach (var m in methods) + { + var matchable = string.Join(",", m.Parameters + .Where(p => p.Direction != ParameterDirection.Out) + .Select(p => $"{p.Direction}:{p.FullyQualifiedType}")); + var typeArity = m.TypeParameters.Length; + var key = $"{m.Name}`{typeArity}({matchable})"; + if (!byKey.TryGetValue(key, out var list)) + { + list = new List(); + byKey[key] = list; + } + list.Add(m); + } + foreach (var group in byKey.Values) + { + if (group.Count < 2) continue; + foreach (var m in group) + { + if (m.Parameters.Any(p => p.Direction == ParameterDirection.Out)) + { + flagged.Add(m.MemberId); + } + } + } + + if (flagged.Count == 0) + { + return methods; + } + return methods.Select(m => flagged.Contains(m.MemberId) + ? m with { KeepOutParamsInExtensionSignature = true } + : m); + } + private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) GetReturnTypeInfo( MockMemberModel method, MockTypeModel model, string safeName) { @@ -633,6 +701,8 @@ private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { + EmitOutParamDefaults(writer, method); + // Build matchers array var matchableParams = includeRefStructArgs ? method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList() @@ -717,7 +787,16 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me for (int i = 0; i < method.Parameters.Length; i++) { var p = method.Parameters[i]; - if (p.Direction == ParameterDirection.Out) continue; + if (p.Direction == ParameterDirection.Out) + { + // Keep out params only when needed to disambiguate colliding overloads. + // Callers of the disambiguated overload must write `out _` at the call site. + if (method.KeepOutParamsInExtensionSignature) + { + paramParts.Add($"out {p.FullyQualifiedType} {p.Name}"); + } + continue; + } if (funcIndices.Contains(i)) { @@ -757,6 +836,8 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { + EmitOutParamDefaults(writer, method); + // Convert Func params to Arg via implicit conversion foreach (var idx in funcIndices.OrderBy(i => i)) { @@ -876,7 +957,19 @@ private static string GetArgParameterList(MockMemberModel method, bool includeRe var parts = new List(); foreach (var p in method.Parameters) { - if (p.Direction == ParameterDirection.Out) continue; + if (p.Direction == ParameterDirection.Out) + { + // Normally out params are omitted from the extension signature so callers + // don't have to write `out _`. But when another overload of this method has + // the same matchable-parameter signature (e.g. GenerateSasUri(perms, expires) + // vs GenerateSasUri(perms, expires, out string)), we MUST keep the out param + // in the signature, otherwise CS0111 fires on the generated extensions. + if (method.KeepOutParamsInExtensionSignature) + { + parts.Add($"out {p.FullyQualifiedType} {p.Name}"); + } + continue; + } if (p.IsRefStruct) { if (includeRefStructArgs) diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index e123ef3fe6..78a38556ce 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -45,6 +45,19 @@ internal sealed record MockMemberModel : IEquatable /// public bool IsReturnTypeStaticAbstractInterface { get; init; } + /// + /// True when this method's out parameters must be kept in the generated extension + /// method signature to avoid CS0111 collisions with a sibling overload that shares the same + /// matchable-parameter signature (e.g. BlobClient.GenerateSasUri(perms, expires) vs + /// GenerateSasUri(perms, expires, out string stringToSign)). When true, callers must + /// pass out _ at the call site for the disambiguated overload. + /// Computed transiently inside by examining + /// the full method set; never set on models flowing through the incremental pipeline. + /// Excluded from / because + /// it is a derived per-build flag, not part of model identity. + /// + public bool KeepOutParamsInExtensionSignature { get; init; } + /// /// For methods returning ReadOnlySpan<T> or Span<T>, the fully qualified element type. /// Null for non-span return types. Used to support configurable span return values via array conversion. diff --git a/TUnit.Mocks.Tests/Issue5434Tests.cs b/TUnit.Mocks.Tests/Issue5434Tests.cs new file mode 100644 index 0000000000..0c60ce255d --- /dev/null +++ b/TUnit.Mocks.Tests/Issue5434Tests.cs @@ -0,0 +1,49 @@ +using Azure.Data.Tables; +using Azure.Storage.Blobs; +using Azure.Storage.Sas; + +namespace TUnit.Mocks.Tests; + +// Reproduction and regression tests for https://github.com/thomhurst/TUnit/issues/5434 +// BlobClient: CS0111 duplicate GenerateSasUri / GenerateUserDelegationSasUri members in generated extensions. +// TableClient: CS0411 type inference failures for generic methods (GetEntity, GetEntityAsync, +// GetEntityIfExists, GetEntityIfExistsAsync, Query, QueryAsync) in generated impl factory. +public class Issue5434Tests +{ + [Test] + public void Can_Mock_BlobClient() + { + var mock = Mock.Of(MockBehavior.Strict); + _ = mock.Object; + } + + [Test] + public void Can_Mock_TableClient() + { + var mock = Mock.Of(MockBehavior.Strict); + _ = mock.Object; + } + + // Exercises the disambiguated overload that keeps `out string stringToSign` in its + // signature to distinguish it from GenerateSasUri(perms, expires). This call would not + // compile if `keepOutParams` disambiguation regressed. + [Test] + public void Can_Configure_BlobClient_GenerateSasUri_OutOverload() + { + var mock = Mock.Of(MockBehavior.Loose); + _ = mock.GenerateSasUri(Arg.Any(), Arg.Any(), out _); + } + + // Exercises the generic-return-type override path. This would not compile if + // the base.GetEntity(...) call in the generated override was missing the type argument. + [Test] + public void Can_Configure_TableClient_GetEntity_Generic() + { + var mock = Mock.Of(MockBehavior.Loose); + _ = mock.GetEntity( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } +} diff --git a/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj b/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj index e16011c09f..2b7f98ea61 100644 --- a/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj +++ b/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj @@ -9,6 +9,8 @@ + +