diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet10_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet10_0.verified.txt new file mode 100644 index 0000000000..cb8f1c660e --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet10_0.verified.txt @@ -0,0 +1,71 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for HasItem +/// +[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] +public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly T _item; + + public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext context, T item) + : base(context) + { + _item = item; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (value is null) + { + return Task.FromResult(AssertionResult.Failed("Actual value is null")); + } + + var result = value!.HasItem(_item); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy HasItem({_item})"; + } +} + +public static partial class MethodOnConcreteNonSealedReceiverExtensions +{ + /// + /// Generated extension method for HasItem + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] + public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion HasItem(this IAssertionSource source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null) + { + source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})"); + return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(source.Context, item); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet8_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet8_0.verified.txt new file mode 100644 index 0000000000..cb8f1c660e --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet8_0.verified.txt @@ -0,0 +1,71 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for HasItem +/// +[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] +public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly T _item; + + public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext context, T item) + : base(context) + { + _item = item; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (value is null) + { + return Task.FromResult(AssertionResult.Failed("Actual value is null")); + } + + var result = value!.HasItem(_item); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy HasItem({_item})"; + } +} + +public static partial class MethodOnConcreteNonSealedReceiverExtensions +{ + /// + /// Generated extension method for HasItem + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] + public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion HasItem(this IAssertionSource source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null) + { + source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})"); + return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(source.Context, item); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet9_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet9_0.verified.txt new file mode 100644 index 0000000000..cb8f1c660e --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.DotNet9_0.verified.txt @@ -0,0 +1,71 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for HasItem +/// +[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] +public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly T _item; + + public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext context, T item) + : base(context) + { + _item = item; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (value is null) + { + return Task.FromResult(AssertionResult.Failed("Actual value is null")); + } + + var result = value!.HasItem(_item); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy HasItem({_item})"; + } +} + +public static partial class MethodOnConcreteNonSealedReceiverExtensions +{ + /// + /// Generated extension method for HasItem + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] + public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion HasItem(this IAssertionSource source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null) + { + source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})"); + return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(source.Context, item); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.Net4_7.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.Net4_7.verified.txt new file mode 100644 index 0000000000..cb8f1c660e --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.MethodOnConcreteNonSealedReceiver.Net4_7.verified.txt @@ -0,0 +1,71 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for HasItem +/// +[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] +public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly T _item; + + public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext context, T item) + : base(context) + { + _item = item; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (value is null) + { + return Task.FromResult(AssertionResult.Failed("Actual value is null")); + } + + var result = value!.HasItem(_item); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy HasItem({_item})"; + } +} + +public static partial class MethodOnConcreteNonSealedReceiverExtensions +{ + /// + /// Generated extension method for HasItem + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")] + public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion HasItem(this IAssertionSource source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null) + { + source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})"); + return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(source.Context, item); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs index 47bd63421d..b8a3a2df43 100644 --- a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs @@ -395,4 +395,27 @@ await Assert.That(mainFile).Contains( // that a content-only assertion cannot see. await CompileChecker.AssertNoErrors(generatedFiles); }); + + [Test] + public Task MethodOnConcreteNonSealedReceiver() => RunTest( + Path.Combine(Sourcy.Git.RootDirectory.FullName, + "TUnit.Assertions.SourceGenerator.Tests", + "TestData", + "MethodOnConcreteNonSealedReceiver.cs"), + async generatedFiles => + { + await Assert.That(generatedFiles).Count().IsEqualTo(1); + + var mainFile = generatedFiles.First(); + await Assert.That(mainFile).IsNotNull(); + + // The generated extension method must declare a single type parameter (T from the + // source method) and target the exact receiver type. Prepending the covariant + // receiver-type parameter (TActual) for this shape produces a two-type-parameter + // signature that callers cannot satisfy via partial type-argument specification, + // breaking call sites like .HasItem(42) with CS1929. + await Assert.That(mainFile).Contains("HasItem(this IAssertionSource source"); + await Assert.That(mainFile).DoesNotContain("HasItem"); + await Assert.That(mainFile).DoesNotContain("where TActual :"); + }); } diff --git a/TUnit.Assertions.SourceGenerator.Tests/TestData/MethodOnConcreteNonSealedReceiver.cs b/TUnit.Assertions.SourceGenerator.Tests/TestData/MethodOnConcreteNonSealedReceiver.cs new file mode 100644 index 0000000000..dd5d8d548e --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/TestData/MethodOnConcreteNonSealedReceiver.cs @@ -0,0 +1,18 @@ +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Tests.TestData; + +/// +/// Test case: generic [GenerateAssertion] method on a concrete non-sealed receiver. +/// Generated extension must declare a single type parameter (T) targeting the exact +/// receiver type, not a two-parameter <TActual, T> covariant shape. +/// +public class MethodOnConcreteNonSealedReceiver +{ +} + +public static partial class MethodOnConcreteNonSealedReceiverExtensions +{ + [GenerateAssertion] + public static bool HasItem(this MethodOnConcreteNonSealedReceiver receiver, T item) => true; +} diff --git a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs index 114d1010a8..4fac7f299b 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs @@ -996,7 +996,14 @@ private static void GenerateExtensionMethod(StringBuilder sb, AssertionMethodDat var targetTypeName = data.TargetType.TypeName; var methodName = data.Method.Name; var genericParams = data.Method.GenericTypeParameters; - var isCovariant = data.TargetType.IsCovariantCandidate; + // Suppress receiver-type covariance when the source method has its own type + // parameters. With covariance, the extension prepends a TActual parameter so a + // more-derived static receiver can bind; but the resulting two-parameter signature + // cannot accept a call site that names the method's own type arguments explicitly + // (e.g. `.MyMethod(...)`) because C# does not allow partial type-argument + // specification, so the call fails with CS1929. The dominant call shape supplies + // the method's own arguments; a more-derived static receiver can upcast. + var isCovariant = data.TargetType.IsCovariantCandidate && genericParams.Count == 0; // Pick a covariant type param name that doesn't collide with existing generic params var covariantParam = isCovariant ? CovarianceHelper.GetCovariantTypeParamName(genericParams) : null; diff --git a/TUnit.Assertions.Tests/GenerateAssertionGenericMethodOnNonSealedReceiverTests.cs b/TUnit.Assertions.Tests/GenerateAssertionGenericMethodOnNonSealedReceiverTests.cs new file mode 100644 index 0000000000..84bad32931 --- /dev/null +++ b/TUnit.Assertions.Tests/GenerateAssertionGenericMethodOnNonSealedReceiverTests.cs @@ -0,0 +1,124 @@ +using System.Threading.Tasks; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Tests; + +/// +/// Regression coverage for [GenerateAssertion] on a generic source method whose receiver is a +/// concrete non-sealed reference type. The generator previously prepended a covariant +/// receiver-type parameter, producing a two-type-parameter extension that no call site +/// supplying one explicit type argument (e.g. .HasItem<int>(42)) could bind to, +/// because C# does not permit partial type-argument specification. Control cases isolate the +/// other variables (sealed receiver, non-generic source, full inference); additional cases +/// cover multi-parameter generics, interface receivers, async-result returns, constrained +/// type parameters, and the documented upcast workaround for a more-derived static receiver. +/// +public class GenerateAssertionGenericMethodOnNonSealedReceiverTests +{ + [Test] + public async Task GenericMethod_OnNonSealedReceiver_WithExplicitTypeArg() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasItem(42); + } + + [Test] + public async Task GenericMethod_OnSealedReceiver_WithExplicitTypeArg() + { + var container = new SealedContainer(); + await Assert.That(container).HasItemSealed(42); + } + + [Test] + public async Task NonGenericMethod_OnNonSealedReceiver() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasInt(42); + } + + [Test] + public async Task GenericMethod_OnNonSealedReceiver_WithFullInference() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasItem(42); + } + + [Test] + public async Task GenericMethod_WithMultipleTypeParameters_WithExplicitTypeArgs() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasPair(42, "x"); + } + + [Test] + public async Task GenericMethod_OnInterfaceReceiver_WithExplicitTypeArg() + { + IContainerInterface container = new NonSealedContainer(); + await Assert.That(container).HasInterfaceItem(42); + } + + [Test] + public async Task GenericMethod_WithConstraint_OnNonSealedReceiver_WithExplicitTypeArg() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasParsable("42"); + } + + [Test] + public async Task GenericMethod_AsyncResult_OnNonSealedReceiver_WithExplicitTypeArg() + { + var container = new NonSealedContainer(); + await Assert.That(container).HasItemAsync(42); + } + + [Test] + public async Task GenericMethod_DerivedStaticReceiver_UpcastWorkaround() + { + var derived = new DerivedNonSealedContainer(); + await Assert.That((NonSealedContainer)derived).HasItem(42); + } +} + +public class NonSealedContainer : IContainerInterface +{ +} + +public class DerivedNonSealedContainer : NonSealedContainer +{ +} + +public sealed class SealedContainer +{ +} + +public interface IContainerInterface +{ +} + +public static partial class GenerateAssertionGenericMethodOnNonSealedReceiverTestExtensions +{ + [GenerateAssertion] + public static bool HasItem(this NonSealedContainer container, T item) => true; + + [GenerateAssertion] + public static bool HasItemSealed(this SealedContainer container, T item) => true; + + [GenerateAssertion] + public static bool HasInt(this NonSealedContainer container, int item) => true; + + [GenerateAssertion] + public static bool HasPair(this NonSealedContainer container, TFirst first, TSecond second) => true; + + [GenerateAssertion] + public static bool HasInterfaceItem(this IContainerInterface container, T item) => true; + + [GenerateAssertion] + public static bool HasParsable(this NonSealedContainer container, string text) + where T : IParsable + => T.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out _); + + [GenerateAssertion] + public static Task HasItemAsync(this NonSealedContainer container, T item) + => Task.FromResult(AssertionResult.Passed); +}