Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context.Map<System.Exception>(static x => (System.Exception?)x), predicate);
}

/// <summary>
/// Extension method for ConcreteReceiverWithExtraGenericAssertion.
/// </summary>
public static ConcreteReceiverWithExtraGenericAssertion<T> ConcreteReceiverWithExtraGenericMatches<T>(this IAssertionSource<System.Exception> source, System.Func<T, bool> predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null)
{
source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches(");
source.Context.ExpressionBuilder.Append(predicateExpression);
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context, predicate);
}
}

]
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context.Map<System.Exception>(static x => (System.Exception?)x), predicate);
}

/// <summary>
/// Extension method for ConcreteReceiverWithExtraGenericAssertion.
/// </summary>
public static ConcreteReceiverWithExtraGenericAssertion<T> ConcreteReceiverWithExtraGenericMatches<T>(this IAssertionSource<System.Exception> source, System.Func<T, bool> predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null)
{
source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches(");
source.Context.ExpressionBuilder.Append(predicateExpression);
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context, predicate);
}
}

]
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context.Map<System.Exception>(static x => (System.Exception?)x), predicate);
}

/// <summary>
/// Extension method for ConcreteReceiverWithExtraGenericAssertion.
/// </summary>
public static ConcreteReceiverWithExtraGenericAssertion<T> ConcreteReceiverWithExtraGenericMatches<T>(this IAssertionSource<System.Exception> source, System.Func<T, bool> predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null)
{
source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches(");
source.Context.ExpressionBuilder.Append(predicateExpression);
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context, predicate);
}
}

]
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context.Map<System.Exception>(static x => (System.Exception?)x), predicate);
}

/// <summary>
/// Extension method for ConcreteReceiverWithExtraGenericAssertion.
/// </summary>
public static ConcreteReceiverWithExtraGenericAssertion<T> ConcreteReceiverWithExtraGenericMatches<T>(this IAssertionSource<System.Exception> source, System.Func<T, bool> predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null)
{
source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches(");
source.Context.ExpressionBuilder.Append(predicateExpression);
source.Context.ExpressionBuilder.Append(")");
return new ConcreteReceiverWithExtraGenericAssertion<T>(source.Context, predicate);
}
}

]
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ public Task ConcreteReceiverWithExtraGeneric() => RunTest(
// for this fixture's compilation context.
await Assert.That(extensionFile!).DoesNotContain("><");

// Issue #5922: for a covariance-candidate receiver that also declares its own generic
// parameter, the generator emits BOTH the covariant overload (keeps subclass binding)
// and an inference-friendly pinned-receiver overload that omits the covariant TActual,
// so the common exact-type call site only needs the class's own type argument.
// Covariant overload: <TActual, T> receiver IAssertionSource<TActual>.
await Assert.That(extensionFile!).Contains("ConcreteReceiverWithExtraGenericMatches<TActual, T>(this IAssertionSource<TActual> source");
// Pinned overload: <T> only, receiver pinned to the concrete IAssertionSource<System.Exception>.
await Assert.That(extensionFile!).Contains("ConcreteReceiverWithExtraGenericMatches<T>(this IAssertionSource<System.Exception> source");

// Compile-clean gate: parse + compile the generated source through Roslyn and
// assert no error-severity diagnostic. Catches the entire class of emit-syntax
// bugs (mis-paired brackets, wrong default rendering, adjacent generic blocks)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,27 @@ private static void GenerateExtensionMethods(SourceProductionContext context, As
sourceBuilder.AppendLine($"public static partial class {extensionClassName}");
sourceBuilder.AppendLine("{");

// When the receiver type is a covariance candidate AND the assertion class declares its
// own generic parameter(s), the covariant method signature is <TActual, T...>. Callers
// that name the class's own arguments (e.g. a non-inferable lambda type) must then also
// spell out the covariant TActual, because C# forbids partial type-argument specification.
// To restore inference for the common exact-receiver call site, additionally emit a
// pinned-receiver overload (IAssertionSource<TConcrete>) that omits TActual. The covariant
// overload is kept so a more-derived static receiver can still bind. See issue #5922.
var receiverType = data.AssertionBaseType.TypeArguments[0];
var hasOwnGenerics = data.ClassSymbol.IsGenericType && data.ClassSymbol.TypeParameters.Length > 0;
var needsPinnedOverload = CovarianceHelper.IsCovariantCandidate(receiverType) && hasOwnGenerics;

// Emit the covariant method plus, when warranted, the pinned-receiver overload.
void EmitMethod(IMethodSymbol constructor, bool negated)
{
GenerateExtensionMethod(sourceBuilder, data, constructor, negated, isNullableOverload: false);
if (needsPinnedOverload)
{
GenerateExtensionMethod(sourceBuilder, data, constructor, negated, isNullableOverload: false, pinnedReceiver: true);
}
}

// Generate extension methods for each constructor
foreach (var constructor in data.Constructors)
{
Expand All @@ -173,12 +194,12 @@ private static void GenerateExtensionMethods(SourceProductionContext context, As
}

// Generate positive assertion method
GenerateExtensionMethod(sourceBuilder, data, constructor, negated: false, isNullableOverload: false);
EmitMethod(constructor, negated: false);

// Generate negated assertion method if requested
if (!string.IsNullOrEmpty(data.NegatedMethodName))
{
GenerateExtensionMethod(sourceBuilder, data, constructor, negated: true, isNullableOverload: false);
EmitMethod(constructor, negated: true);
}
}

Expand Down Expand Up @@ -214,7 +235,8 @@ private static void GenerateExtensionMethod(
AssertionExtensionData data,
IMethodSymbol constructor,
bool negated,
bool isNullableOverload)
bool isNullableOverload,
bool pinnedReceiver = false)
{
var methodName = negated ? data.NegatedMethodName : data.MethodName;
var assertionType = data.ClassSymbol;
Expand Down Expand Up @@ -354,7 +376,9 @@ private static void GenerateExtensionMethod(
string? genericTypeParam = null;
string? genericConstraint = null;

var isCovariantCandidate = !isNullableOverload
// pinnedReceiver suppresses covariance so the receiver is pinned to the concrete type
// (IAssertionSource<TConcrete>), dropping the TActual parameter for inference-friendly call sites.
var isCovariantCandidate = !isNullableOverload && !pinnedReceiver
&& CovarianceHelper.IsCovariantCandidate(typeParam);
var typeParamDisplay = typeParam.ToDisplayString();

Expand Down
92 changes: 92 additions & 0 deletions TUnit.Assertions.Tests/CovariantGenericExtensionInferenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Core;

namespace TUnit.Assertions.Tests;

/// <summary>
/// Issue #5922: an <see cref="AssertionExtensionAttribute"/> class that declares its own generic
/// parameter over a concrete, non-sealed (covariance-candidate) receiver gets TWO generated
/// overloads: a covariant <c>&lt;TActual, T&gt;</c> one (so a more-derived static receiver can
/// bind) and an inference-friendly pinned <c>&lt;T&gt;</c> one whose receiver is the concrete type.
///
/// These tests are primarily a COMPILE-TIME guard: the call sites below only compile if overload
/// resolution selects the right method without ambiguity (no CS0121). That is exactly what the
/// generator's own snapshot/compile-clean tests cannot prove, because they have no call sites.
/// </summary>
public class CovariantGenericExtensionInferenceTests
{
public sealed class Payload
{
public bool Ok { get; init; }
}

[Test]
public async Task Pinned_Overload_Exact_Receiver_Only_Needs_Own_Type_Argument()
{
var ex = new Exception("boom");

// Receiver is exactly Exception: the pinned overload binds, so only the class's own
// type argument (Payload) is named — the redundant <Exception> is NOT required.
await Assert.That(ex).MatchesPayload<Payload>(p => p.Ok);
}

[Test]
public async Task Covariant_Overload_Still_Binds_For_Subclass_Receiver()
{
var ex = new ArgumentException("bad arg");

// Receiver is a subclass (ArgumentException). The pinned IAssertionSource<Exception>
// overload is not applicable (the interface is invariant), so the covariant overload
// binds — both type arguments are named because C# forbids partial specification.
await Assert.That(ex).MatchesPayload<ArgumentException, Payload>(p => p.Ok);
}

[Test]
public async Task Both_Overloads_Coexist_Without_Ambiguity_When_Type_Arg_Is_Inferable()
{
var ex = new Exception("boom");

// Here the class's own type arg is inferable from the value argument, so NO type
// arguments are written. Both overloads are applicable for an exact Exception receiver;
// this only compiles because C#'s "more specific parameter types" rule prefers the
// pinned IAssertionSource<Exception> receiver over the covariant IAssertionSource<TActual>.
await Assert.That(ex).HasTag(42);
}
}

/// <summary>Issue #5922 fixture: own generic parameter is NOT inferable (lambda predicate).</summary>
[AssertionExtension("MatchesPayload")]
public class InferencePayloadMatchesAssertion<T> : Assertion<Exception>
{
private readonly Func<T, bool> _predicate;

public InferencePayloadMatchesAssertion(AssertionContext<Exception> context, Func<T, bool> predicate)
: base(context)
{
_predicate = predicate;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<Exception> metadata)
=> Task.FromResult(AssertionResult.Passed);

protected override string GetExpectation() => "to match the payload predicate";
}

/// <summary>Issue #5922 fixture: own generic parameter IS inferable (value argument), which
/// exercises the case where both overloads are simultaneously applicable.</summary>
[AssertionExtension("HasTag")]
public class InferenceTaggedExceptionAssertion<T> : Assertion<Exception>
{
private readonly T _tag;

public InferenceTaggedExceptionAssertion(AssertionContext<Exception> context, T tag)
: base(context)
{
_tag = tag;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<Exception> metadata)
=> Task.FromResult(AssertionResult.Passed);

protected override string GetExpectation() => $"to be tagged with {_tag}";
}
Loading