diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f98568f8e4..016cbb603a8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,6 +41,7 @@ + diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 7db6053a6c0..6bdb8917c61 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -731,7 +731,7 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m Constraints = tp.GetGenericConstraints(), HasReferenceTypeConstraint = tp.HasReferenceTypeConstraint, HasValueTypeConstraint = tp.HasValueTypeConstraint, - HasAnnotatedNullableUsage = tp.IsUnconstrainedWithNullableUsage(method) + HasAnnotatedNullableUsage = tp.NeedsDefaultConstraintForNullableUsage(method) }).ToImmutableArray() ), ExplicitInterfaceName = explicitInterfaceName, diff --git a/src/TUnit.Mocks.SourceGenerator/Extensions/MethodSymbolExtensions.cs b/src/TUnit.Mocks.SourceGenerator/Extensions/MethodSymbolExtensions.cs index 3b39d756508..1ef284ba654 100644 --- a/src/TUnit.Mocks.SourceGenerator/Extensions/MethodSymbolExtensions.cs +++ b/src/TUnit.Mocks.SourceGenerator/Extensions/MethodSymbolExtensions.cs @@ -54,9 +54,13 @@ public static string GetGenericConstraints(this ITypeParameterSymbol typeParam) return string.Join(", ", constraints); } - public static bool IsUnconstrainedWithNullableUsage(this ITypeParameterSymbol typeParam, IMethodSymbol method) + public static bool NeedsDefaultConstraintForNullableUsage(this ITypeParameterSymbol typeParam, IMethodSymbol method) { - if (!typeParam.IsUnconstrained()) + // On overrides and explicit interface implementations, T? is parsed as Nullable + // unless T is known to be a reference/value type or a 'where T : default' clause is + // present. Interface-only constraints (e.g. Kiota's 'where T : IParsable') leave T + // unknown, so they need 'default' too — not just fully unconstrained parameters (#6456). + if (typeParam.IsReferenceType || typeParam.IsValueType) { return false; } diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/KiotaMockTests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/KiotaMockTests.cs new file mode 100644 index 00000000000..7ac7abb5899 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/KiotaMockTests.cs @@ -0,0 +1,62 @@ +using Microsoft.CodeAnalysis; +using Microsoft.Kiota.Abstractions; + +namespace TUnit.Mocks.SourceGenerator.Tests; + +/// +/// Regression tests for #6456: mocking Microsoft.Kiota.Abstractions.IRequestAdapter, whose +/// generic methods carry an interface constraint (where ModelType : IParsable). Explicit +/// interface implementations inherit constraints, but T? in them is parsed as Nullable<T> +/// unless a 'where T : default' clause is emitted — which is required whenever T is not known +/// to be a reference or value type, not just when T is fully unconstrained. +/// +public class KiotaMockTests : SnapshotTestBase +{ + private const string Source = """ + using Microsoft.Kiota.Abstractions; + using TUnit.Mocks; + + [assembly: GenerateMock(typeof(IRequestAdapter))] + """; + + private static IEnumerable KiotaReferences() => + [MetadataReference.CreateFromFile(typeof(IRequestAdapter).Assembly.Location)]; + + [Test] + public void KiotaIRequestAdapter_ConstrainedGenericMethod_EmitsDefaultConstraint() + { + var output = string.Join("\n", RunGenerator(Source, KiotaReferences())); + + // SendAsync has 'where ModelType : IParsable' and returns Task; + // its explicit implementation must carry 'where ModelType : default'. + AssertContains(output, + "global::Microsoft.Kiota.Abstractions.IRequestAdapter.SendAsync"); + AssertContains(output, "where ModelType : default"); + } + + [Test] + public void KiotaIRequestAdapter_GeneratedMock_HasNoConstraintErrors() + { + var errors = GetGeneratedCompilationErrors(Source, KiotaReferences()); + + // The exact errors reported in #6456. Other diagnostics (e.g. CS1520/CS1513 from C# 14 + // extension() blocks that the test-pinned Roslyn cannot parse) are infra limitations + // and intentionally not asserted here — see SnapshotTestBase. + foreach (var id in (string[])["CS0539", "CS0535", "CS0453", "CS0314"]) + { + var match = errors.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.Ordinal)); + if (match is not null) + { + throw new InvalidOperationException($"Generated code produced {id}: {match}"); + } + } + } + + private static void AssertContains(string text, string expected) + { + if (!text.Contains(expected, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Expected generated output to contain: {expected}"); + } + } +} diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj b/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj index 281d5117d2b..affa8e16d20 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/TUnit.Mocks.Tests/Issue6456Tests.cs b/tests/TUnit.Mocks.Tests/Issue6456Tests.cs new file mode 100644 index 00000000000..c34f8d039d8 --- /dev/null +++ b/tests/TUnit.Mocks.Tests/Issue6456Tests.cs @@ -0,0 +1,51 @@ +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using TUnit.Mocks; + +namespace TUnit.Mocks.Tests; + +/// +/// Regression tests for #6456: Microsoft.Kiota.Abstractions.IRequestAdapter could not be mocked +/// because its generic methods carry 'where ModelType : IParsable'. The generated explicit +/// interface implementations omitted the 'where ModelType : default' clause, so the compiler +/// parsed Task<ModelType?> as Task<Nullable<ModelType>> (CS0453/CS0539/CS0535/CS0314). +/// +public class Issue6456Tests +{ + private sealed class TestModel : IParsable + { + public IDictionary> GetFieldDeserializers() => + new Dictionary>(); + + public void Serialize(ISerializationWriter writer) + { + } + } + + [Test] + public async Task SendNoContentAsync_Can_Be_Mocked_And_Verified() + { + var mock = IRequestAdapter.Mock(); + var requestInfo = new RequestInformation(); + + await mock.Object.SendNoContentAsync(requestInfo); + + mock.SendNoContentAsync(Any(), Any(), Any()).WasCalled(Times.Once); + } + + [Test] + public async Task SendAsync_With_Constrained_Generic_Returns_Configured_Value() + { + var mock = IRequestAdapter.Mock(); + var expected = new TestModel(); + mock.SendAsync(Any(), Any(), Any(), Any()).Returns(expected); + + var result = await mock.Object.SendAsync( + new RequestInformation(), + static _ => new TestModel(), + errorMapping: null, + CancellationToken.None); + + await Assert.That(result).IsSameReferenceAs(expected); + } +} diff --git a/tests/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj b/tests/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj index edac9eae952..77a851d7b3b 100644 --- a/tests/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj +++ b/tests/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj @@ -16,6 +16,7 @@ +