Skip to content

Commit f64fb61

Browse files
committed
fix(sourcegen): drop covariant TActual when [GenerateAssertion] method has its own type parameters
When [GenerateAssertion] applies to a generic method with a concrete non-sealed receiver, the generator previously prepended a covariant receiver-type parameter to the produced extension. The resulting two- type-parameter signature could not bind at a call site that named the method's own type arguments explicitly, because C# does not permit partial type-argument specification. The call failed with CS1929. Suppress the receiver-type covariance when the source method declares its own type parameters. Receivers of a more-derived static type can reach the assertion via upcast. Closes #5934.
1 parent 50f6eaa commit f64fb61

8 files changed

Lines changed: 457 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
// <auto-generated/>
3+
#pragma warning disable
4+
#nullable enable
5+
6+
using System;
7+
using System.Runtime.CompilerServices;
8+
using System.Threading.Tasks;
9+
using TUnit.Assertions.Core;
10+
using TUnit.Assertions.Tests.TestData;
11+
12+
namespace TUnit.Assertions.Extensions;
13+
14+
/// <summary>
15+
/// Generated assertion for HasItem
16+
/// </summary>
17+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
18+
public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> : Assertion<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver>
19+
{
20+
private static readonly Task<AssertionResult> _passedTask = Task.FromResult(AssertionResult.Passed);
21+
22+
private readonly T _item;
23+
24+
public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> context, T item)
25+
: base(context)
26+
{
27+
_item = item;
28+
}
29+
30+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> metadata)
31+
{
32+
var value = metadata.Value;
33+
var exception = metadata.Exception;
34+
35+
if (exception != null)
36+
{
37+
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}"));
38+
}
39+
40+
if (value is null)
41+
{
42+
return Task.FromResult(AssertionResult.Failed("Actual value is null"));
43+
}
44+
45+
var result = value!.HasItem<T>(_item);
46+
return result
47+
? _passedTask
48+
: Task.FromResult(AssertionResult.Failed($"found {value}"));
49+
}
50+
51+
protected override string GetExpectation()
52+
{
53+
return $"to satisfy HasItem({_item})";
54+
}
55+
}
56+
57+
public static partial class MethodOnConcreteNonSealedReceiverExtensions
58+
{
59+
/// <summary>
60+
/// Generated extension method for HasItem
61+
/// </summary>
62+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
63+
public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> HasItem<T>(this IAssertionSource<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null)
64+
{
65+
source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})");
66+
return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T>(source.Context, item);
67+
}
68+
69+
}
70+
71+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
// <auto-generated/>
3+
#pragma warning disable
4+
#nullable enable
5+
6+
using System;
7+
using System.Runtime.CompilerServices;
8+
using System.Threading.Tasks;
9+
using TUnit.Assertions.Core;
10+
using TUnit.Assertions.Tests.TestData;
11+
12+
namespace TUnit.Assertions.Extensions;
13+
14+
/// <summary>
15+
/// Generated assertion for HasItem
16+
/// </summary>
17+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
18+
public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> : Assertion<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver>
19+
{
20+
private static readonly Task<AssertionResult> _passedTask = Task.FromResult(AssertionResult.Passed);
21+
22+
private readonly T _item;
23+
24+
public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> context, T item)
25+
: base(context)
26+
{
27+
_item = item;
28+
}
29+
30+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> metadata)
31+
{
32+
var value = metadata.Value;
33+
var exception = metadata.Exception;
34+
35+
if (exception != null)
36+
{
37+
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}"));
38+
}
39+
40+
if (value is null)
41+
{
42+
return Task.FromResult(AssertionResult.Failed("Actual value is null"));
43+
}
44+
45+
var result = value!.HasItem<T>(_item);
46+
return result
47+
? _passedTask
48+
: Task.FromResult(AssertionResult.Failed($"found {value}"));
49+
}
50+
51+
protected override string GetExpectation()
52+
{
53+
return $"to satisfy HasItem({_item})";
54+
}
55+
}
56+
57+
public static partial class MethodOnConcreteNonSealedReceiverExtensions
58+
{
59+
/// <summary>
60+
/// Generated extension method for HasItem
61+
/// </summary>
62+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
63+
public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> HasItem<T>(this IAssertionSource<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null)
64+
{
65+
source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})");
66+
return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T>(source.Context, item);
67+
}
68+
69+
}
70+
71+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
// <auto-generated/>
3+
#pragma warning disable
4+
#nullable enable
5+
6+
using System;
7+
using System.Runtime.CompilerServices;
8+
using System.Threading.Tasks;
9+
using TUnit.Assertions.Core;
10+
using TUnit.Assertions.Tests.TestData;
11+
12+
namespace TUnit.Assertions.Extensions;
13+
14+
/// <summary>
15+
/// Generated assertion for HasItem
16+
/// </summary>
17+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
18+
public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> : Assertion<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver>
19+
{
20+
private static readonly Task<AssertionResult> _passedTask = Task.FromResult(AssertionResult.Passed);
21+
22+
private readonly T _item;
23+
24+
public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> context, T item)
25+
: base(context)
26+
{
27+
_item = item;
28+
}
29+
30+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> metadata)
31+
{
32+
var value = metadata.Value;
33+
var exception = metadata.Exception;
34+
35+
if (exception != null)
36+
{
37+
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}"));
38+
}
39+
40+
if (value is null)
41+
{
42+
return Task.FromResult(AssertionResult.Failed("Actual value is null"));
43+
}
44+
45+
var result = value!.HasItem<T>(_item);
46+
return result
47+
? _passedTask
48+
: Task.FromResult(AssertionResult.Failed($"found {value}"));
49+
}
50+
51+
protected override string GetExpectation()
52+
{
53+
return $"to satisfy HasItem({_item})";
54+
}
55+
}
56+
57+
public static partial class MethodOnConcreteNonSealedReceiverExtensions
58+
{
59+
/// <summary>
60+
/// Generated extension method for HasItem
61+
/// </summary>
62+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
63+
public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> HasItem<T>(this IAssertionSource<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null)
64+
{
65+
source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})");
66+
return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T>(source.Context, item);
67+
}
68+
69+
}
70+
71+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
// <auto-generated/>
3+
#pragma warning disable
4+
#nullable enable
5+
6+
using System;
7+
using System.Runtime.CompilerServices;
8+
using System.Threading.Tasks;
9+
using TUnit.Assertions.Core;
10+
using TUnit.Assertions.Tests.TestData;
11+
12+
namespace TUnit.Assertions.Extensions;
13+
14+
/// <summary>
15+
/// Generated assertion for HasItem
16+
/// </summary>
17+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
18+
public sealed class MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> : Assertion<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver>
19+
{
20+
private static readonly Task<AssertionResult> _passedTask = Task.FromResult(AssertionResult.Passed);
21+
22+
private readonly T _item;
23+
24+
public MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion(AssertionContext<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> context, T item)
25+
: base(context)
26+
{
27+
_item = item;
28+
}
29+
30+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> metadata)
31+
{
32+
var value = metadata.Value;
33+
var exception = metadata.Exception;
34+
35+
if (exception != null)
36+
{
37+
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}"));
38+
}
39+
40+
if (value is null)
41+
{
42+
return Task.FromResult(AssertionResult.Failed("Actual value is null"));
43+
}
44+
45+
var result = value!.HasItem<T>(_item);
46+
return result
47+
? _passedTask
48+
: Task.FromResult(AssertionResult.Failed($"found {value}"));
49+
}
50+
51+
protected override string GetExpectation()
52+
{
53+
return $"to satisfy HasItem({_item})";
54+
}
55+
}
56+
57+
public static partial class MethodOnConcreteNonSealedReceiverExtensions
58+
{
59+
/// <summary>
60+
/// Generated extension method for HasItem
61+
/// </summary>
62+
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Generic type parameter is only used for property access, not instantiation")]
63+
public static MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T> HasItem<T>(this IAssertionSource<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> source, T item, [CallerArgumentExpression(nameof(item))] string? itemExpression = null)
64+
{
65+
source.Context.ExpressionBuilder.Append($".HasItem({itemExpression})");
66+
return new MethodOnConcreteNonSealedReceiver_HasItem_T_Assertion<T>(source.Context, item);
67+
}
68+
69+
}
70+
71+
]

TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,27 @@ public Task ArrayTargetType() => RunTest(
339339
await Assert.That(mainFile!).Contains("IAssertionSource<string[]>");
340340
await Assert.That(mainFile!).Contains("StringArray_ContainsMessage_String_Bool_Assertion");
341341
});
342+
343+
[Test]
344+
public Task MethodOnConcreteNonSealedReceiver() => RunTest(
345+
Path.Combine(Sourcy.Git.RootDirectory.FullName,
346+
"TUnit.Assertions.SourceGenerator.Tests",
347+
"TestData",
348+
"MethodOnConcreteNonSealedReceiver.cs"),
349+
async generatedFiles =>
350+
{
351+
await Assert.That(generatedFiles).Count().IsEqualTo(1);
352+
353+
var mainFile = generatedFiles.First();
354+
await Assert.That(mainFile).IsNotNull();
355+
356+
// The generated extension method must declare a single type parameter (T from the
357+
// source method) and target the exact receiver type. Prepending the covariant
358+
// receiver-type parameter (TActual) for this shape produces a two-type-parameter
359+
// signature that callers cannot satisfy via partial type-argument specification,
360+
// breaking call sites like .HasItem<int>(42) with CS1929.
361+
await Assert.That(mainFile).Contains("HasItem<T>(this IAssertionSource<TUnit.Assertions.Tests.TestData.MethodOnConcreteNonSealedReceiver> source");
362+
await Assert.That(mainFile).DoesNotContain("HasItem<TActual, T>");
363+
await Assert.That(mainFile).DoesNotContain("where TActual :");
364+
});
342365
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using TUnit.Assertions.Attributes;
2+
3+
namespace TUnit.Assertions.Tests.TestData;
4+
5+
/// <summary>
6+
/// Test case: generic [GenerateAssertion] method on a concrete non-sealed receiver.
7+
/// Generated extension must declare a single type parameter (T) targeting the exact
8+
/// receiver type, not a two-parameter <c>&lt;TActual, T&gt;</c> covariant shape.
9+
/// </summary>
10+
public class MethodOnConcreteNonSealedReceiver
11+
{
12+
}
13+
14+
public static partial class MethodOnConcreteNonSealedReceiverExtensions
15+
{
16+
[GenerateAssertion]
17+
public static bool HasItem<T>(this MethodOnConcreteNonSealedReceiver receiver, T item) => true;
18+
}

TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,14 @@ private static void GenerateExtensionMethod(StringBuilder sb, AssertionMethodDat
996996
var targetTypeName = data.TargetType.TypeName;
997997
var methodName = data.Method.Name;
998998
var genericParams = data.Method.GenericTypeParameters;
999-
var isCovariant = data.TargetType.IsCovariantCandidate;
999+
// Suppress receiver-type covariance when the source method has its own type
1000+
// parameters. With covariance, the extension prepends a TActual parameter so a
1001+
// more-derived static receiver can bind; but the resulting two-parameter signature
1002+
// cannot accept a call site that names the method's own type arguments explicitly
1003+
// (e.g. `.MyMethod<int>(...)`) because C# does not allow partial type-argument
1004+
// specification, so the call fails with CS1929. The dominant call shape supplies
1005+
// the method's own arguments; a more-derived static receiver can upcast.
1006+
var isCovariant = data.TargetType.IsCovariantCandidate && genericParams.Count == 0;
10001007

10011008
// Pick a covariant type param name that doesn't collide with existing generic params
10021009
var covariantParam = isCovariant ? CovarianceHelper.GetCovariantTypeParamName(genericParams) : null;

0 commit comments

Comments
 (0)