Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
230 changes: 224 additions & 6 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
private const string GeneratedNamespace = "TUnit.Generated";

private static readonly DiagnosticDescriptor GenerationErrorDescriptor = new(
"TUNIT0999",

Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / doc-snippets

Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

"Source Generation Error",
"Failed to generate test metadata for {0}.{1}: {2}",
"TUnit",
Expand Down Expand Up @@ -499,9 +499,9 @@

if (concreteInstantiations.Count == 0 && classConstraintViolations.Count == 0)
{
// No concrete types could be resolved — skip this method.
// The test won't be discovered, which matches the behavior of
// not having any data to resolve type arguments.
// No concrete types could be resolved for a generic class — skip this method. A
// generic method on a non-generic class always gets an entry: rows whose type
// arguments cannot be inferred fail instead (#7163).
return;
}
}
Expand Down Expand Up @@ -819,6 +819,12 @@
// every instantiation a class row establishes, since reflection mode runs them there too.
List<AttributeData>? uninferredMethodDataSources = null;

// A generic method on a non-generic class without [GenerateGenericTest]: every method data source
// that infers an existing instantiation's types joins it (#7163). With [GenerateGenericTest], a later
// non-[Arguments] source inferring the same types is dropped, as reflection mode does (#7092).
var joinsDuplicateMethodSources = testMethod is { IsGenericMethod: true, IsGenericType: false }
&& !testMethod.MethodAttributes.Any(static a => a.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true);

var classTypeArgCount = testMethod.IsGenericType ? testMethod.TypeSymbol.TypeParameters.Length : 0;
var methodTypeArgCount = testMethod.IsGenericMethod ? testMethod.MethodSymbol.TypeParameters.Length : 0;

Expand Down Expand Up @@ -896,7 +902,13 @@
}
}

if (specificAttr?.AttributeClass?.Name == "ArgumentsAttribute")
// A generic method on a non-generic class runs only the sources scoped to its
// instantiation, so any method data source that infers the same types as an earlier
// one (a second [MethodDataSource] declared like the first, say) joins it too.
// Otherwise its rows would be reported as uninferable (#7163). Reflection mode drops
// such a source when the method has [GenerateGenericTest], so it is dropped here too.
if (specificAttr?.AttributeClass?.Name == "ArgumentsAttribute"
|| (specificAttr is not null && !isClassRow && joinsDuplicateMethodSources))
{
existing.AddRow(specificAttr, isClassRow);
}
Expand Down Expand Up @@ -1300,9 +1312,159 @@
.Select(v => v.Message)
.ToList();

if (testMethod is { IsGenericMethod: true, IsGenericType: false })
{
AddUninferredTypeArgumentFailures(testMethod, className, results);
}

return results;
}

/// <summary>
/// Reports the data sources of a generic test method whose rows no instantiation runs, because
/// their declared type does not determine the method's type arguments (an <c>object</c> or
/// <c>object[]</c> row, say). Reflection mode infers the type arguments from each row's run-time
/// values, which source-generated mode cannot do. Rather than dropping the rows silently, each of
/// them fails with a message that says how to fix it. A method with no data source at all fails
/// once, as it does in reflection mode (#7163).
/// </summary>
private static void AddUninferredTypeArgumentFailures(TestMethodMetadata testMethod, string className, List<ConcreteInstantiation> results)
{
List<AttributeData>? uncovered = null;
var hasDataSource = false;

foreach (var attribute in testMethod.MethodAttributes)
{
// [GenerateGenericTest] lists the type arguments, so its types run every data source
// (or fail their constraints) and no data source has to infer them.
if (attribute.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true)
{
return;
}
}

foreach (var attribute in testMethod.MethodAttributes)
{
if (!DataSourceAttributeHelper.IsDataSourceAttribute(attribute.AttributeClass))
{
continue;
}

hasDataSource = true;
if (!IsRunByAnyInstantiation(testMethod, results, attribute))
{
(uncovered ??= []).Add(attribute);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}
}

if (hasDataSource && uncovered is null)
{
return;
}

if (!hasDataSource && results.Count > 0)
{
return;
}

var methodName = testMethod.MethodSymbol.Name;
var violations = new List<MethodConstraintViolation>();
if (uncovered is null)
{
violations.Add(new MethodConstraintViolation(
$"Could not infer the type arguments of generic test method '{methodName}': it has no data source to infer them from. List the type arguments with [GenerateGenericTest].",
RowSource: null,
AllMethodDataSources: false));
}
else
{
foreach (var attribute in uncovered)
{
violations.Add(new MethodConstraintViolation(
$"Could not infer the type arguments of generic test method '{methodName}' from data source [{DescribeDataSource(attribute)}]. "
+ "Source-generated mode infers type arguments at compile time from the data source's declared type, not from the run-time type of its values, and that declared type does not determine them. "
+ "Declare the data source with a type that does (for example IEnumerable<Func<int>> rather than IEnumerable<object>), or list the type arguments with [GenerateGenericTest].",
RowSource: attribute,
AllMethodDataSources: false));
}
}

// All failures of one concrete class share one entry, as constraint violations do, so each
// failing row gets its own data source index and so its own test id.
var existing = results.FirstOrDefault(r => r.MethodConstraintViolations != null && r.ConcreteClassName == className);
if (existing != null)
{
existing.MethodConstraintViolations!.AddRange(violations);
return;
}

results.Add(new ConcreteInstantiation
{
ConcreteClassName = className,
TypeArguments = [],
ClassTypeArgs = [],
MethodTypeArgs = [],
TestName = methodName,
MethodName = methodName,
MethodConstraintViolations = violations,
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

static string DescribeDataSource(AttributeData attribute)
{
if (attribute.ApplicationSyntaxReference?.GetSyntax() is { } syntax)
{
return syntax.ToString();
}

var name = attribute.AttributeClass?.Name ?? "DataSource";
return name.EndsWith("Attribute", StringComparison.Ordinal) ? name.Substring(0, name.Length - "Attribute".Length) : name;
}
}

/// <summary>
/// Whether the rows of <paramref name="dataSource"/> run, or are reported, on at least one of the
/// instantiations of a generic method on a non-generic class. This mirrors the data source
/// selection in <see cref="EmitConcreteDataSources"/>.
/// </summary>
private static bool IsRunByAnyInstantiation(TestMethodMetadata testMethod, List<ConcreteInstantiation> results, AttributeData dataSource)
{
if (results.Count == 0)
{
return false;
}

// When the parameters do not use the type arguments, every instantiation runs every data source.
if (!MethodParametersReferenceTypeParameters(testMethod))
{
return true;
}

foreach (var instantiation in results)
{
if (instantiation.MethodConstraintViolations is { } violations)
{
foreach (var violation in violations)
{
if (violation.AllMethodDataSources
|| (violation.RowSource is { } rowSource && AreSameAttribute(rowSource, dataSource)))
{
return true;
}
}

continue;
}

// [GenerateGenericTest] and IInfersType<T> instantiations run every method data source.
if (instantiation.SpecificArgumentsAttribute is null || instantiation.IsSpecificMethodDataSource(dataSource))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
return true;
}
}

return false;
}

/// <summary>
/// Extracts type arguments from a GenerateGenericTestAttribute's constructor arguments.
/// The attribute takes params Type[] in its constructor.
Expand Down Expand Up @@ -3838,7 +4000,7 @@

if (!methodSymbol.ReturnsVoid)
{
writer.AppendLine($"returnType: typeof({methodSymbol.ReturnType.GloballyQualified()}),");
writer.AppendLine($"returnType: typeof({ReturnTypeForTypeOf(methodSymbol.ReturnType)}),");
}

if (methodSymbol.TypeParameters.Length > 0)
Expand All @@ -3858,12 +4020,68 @@
return writer.ToString();
}

/// <summary>
/// The type to name in <c>typeof(...)</c> for a test method's return type. The metadata is emitted
/// outside the method and its class, so a return type that uses a type parameter cannot name it.
/// <see cref="TypeExtensions.GloballyQualified"/> opens a generic type whose arguments are type
/// parameters (<c>Task&lt;T&gt;</c> becomes <c>Task&lt;&gt;</c>), but not one that nests them
/// (<c>Task&lt;T[]&gt;</c>) or takes them from a containing type (<c>Outer&lt;T&gt;.Inner</c>), and a
/// bare <c>T</c> or <c>T[]</c> has no open form. Those become the open generic type and
/// <c>object</c>, as parameter metadata does.
/// </summary>
private static string ReturnTypeForTypeOf(ITypeSymbol returnType)
{
if (!ReferencesTypeParameter(returnType))
{
return returnType.GloballyQualified();
}

if (returnType is INamedTypeSymbol namedType)
{
return OpenGenericTypeOf(namedType);
}

return "object";

// Unlike CodeGenerationHelpers.ContainsTypeParameter, this also looks at containing types:
// Outer<T>.Inner has no type arguments of its own, but still mentions T.
static bool ReferencesTypeParameter(ITypeSymbol type) => type switch
{
ITypeParameterSymbol => true,
IArrayTypeSymbol arrayType => ReferencesTypeParameter(arrayType.ElementType),
INamedTypeSymbol named => named.TypeArguments.Any(ReferencesTypeParameter)
|| (named.ContainingType is { } containingType && ReferencesTypeParameter(containingType)),
_ => false,
};

// The unbound form of the type and every type that contains it, such as
// global::Outer<>.Inner<,>. C# only accepts an unbound generic name in typeof when every
// generic level is unbound. Roslyn displays Nullable<T> as `T?`, so the name is built from
// the definition's parts rather than from its display string.
static string OpenGenericTypeOf(INamedTypeSymbol type)
{
var definition = type.OriginalDefinition;
var name = definition.Arity > 0
? $"{definition.Name}<{new string(',', definition.Arity - 1)}>"
: definition.Name;

if (definition.ContainingType is { } containingType)
{
return $"{OpenGenericTypeOf(containingType)}.{name}";
}

return definition.ContainingNamespace is { IsGlobalNamespace: false } containingNamespace
? $"{containingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}"
: $"global::{name}";
}
}

/// <summary>
/// Generates a MethodMetadataFactory.Create(...) call expression referencing __classType and __classMetadata.
/// </summary>
private static string GenerateMethodMetadataFactoryCall(IMethodSymbol methodSymbol)
{
var returnType = methodSymbol.ReturnType.GloballyQualified();
var returnType = ReturnTypeForTypeOf(methodSymbol.ReturnType);
var sb = new System.Text.StringBuilder();
sb.Append($"global::TUnit.Core.MethodMetadataFactory.Create(\"{methodSymbol.Name}\", __classType, typeof({returnType}), __classMetadata");

Expand Down Expand Up @@ -6514,7 +6732,7 @@

// Map tuple elements to method parameters to infer types
var tupleElements = tupleType.TupleElements;
for (var i = 0; i < testMethodParams.Length && i < tupleElements.Length; i++)

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / doc-snippets

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / doc-snippets

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Dereference of a possibly null reference.

Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Dereference of a possibly null reference.
{
var paramType = testMethodParams[i].Type;
var tupleElementType = tupleElements[i].Type;
Expand Down
65 changes: 65 additions & 0 deletions tests/TUnit.Core.SourceGenerator.Tests/Bugs/7163/Tests7163.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace TUnit.Core.SourceGenerator.Tests;

/// <summary>
/// A generic test method whose type arguments its data source's declared type cannot determine must
/// be emitted as an entry whose rows fail with an explanation, instead of being dropped (#7163).
/// </summary>
internal class Tests7163 : TestsBase
{
[Test]
public Task Test() => RunTest(Path.Combine(
Git.TestsDirectory.FullName,
"TUnit.TestProject",
"Bugs",
"_7163",
"UninferableGenericMethodTests.cs"),
async generatedFiles =>
{
const string cannotInfer = "Could not infer the type arguments of generic test method 'Generic' from data source [MethodDataSource(nameof(Rows))]";

string SourceFor(string className) => generatedFiles.Single(f =>
f.Contains($"typeof(global::TUnit.TestProject.Bugs._7163.{className})"));

// Uninferable rows: the data source is wrapped so each of its rows fails.
foreach (var className in new[] { "ObjectRowsTests", "ObjectArrayRowsTests" })
{
var source = SourceFor(className);
await Assert.That(source).Contains($"global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"{cannotInfer}");
await Assert.That(source).DoesNotContain("instance.Generic<");
}

// The inferable row still runs, beside the failing source.
var partly = SourceFor("PartlyInferableTests");
await Assert.That(partly).Contains("instance.Generic<int>(");
await Assert.That(partly).Contains($"global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"{cannotInfer}");

// [GenerateGenericTest] lists the type arguments, so nothing is reported.
var generateGeneric = SourceFor("GenerateGenericTestWithObjectRowsTests");
await Assert.That(generateGeneric).Contains("instance.Generic<int>(");
await Assert.That(generateGeneric).DoesNotContain("GenericConstraintViolationDataSource");

// No data source: a single failing row.
var noDataSource = SourceFor("NoDataSourceTests");
await Assert.That(noDataSource).Contains("global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"Could not infer the type arguments of generic test method 'Generic': it has no data source to infer them from.");

// Data sources that infer the same type arguments as an earlier one join its instantiation.
foreach (var className in new[] { "ArgumentsAndDataSourceWithSameTypesTests", "TwoDataSourcesWithSameTypesTests" })
{
var source = SourceFor(className);
await Assert.That(source).Contains("instance.Generic<int>(");
await Assert.That(source).DoesNotContain("GenericConstraintViolationDataSource");
}

// A return type that nests T is named by its open generic type, since T does not exist there.
var nestedReturnType = SourceFor("NestedGenericReturnTypeTests");
await Assert.That(nestedReturnType).Contains("typeof(global::System.Threading.Tasks.Task<>)");
await Assert.That(nestedReturnType).DoesNotContain("Task<T[]>");

var nullableReturnType = SourceFor("NullableGenericReturnTypeTests");
await Assert.That(nullableReturnType).Contains("typeof(global::System.Nullable<>)");
await Assert.That(nullableReturnType).DoesNotContain("T?<>");

var containingReturnType = SourceFor("ContainingGenericReturnTypeTests");
await Assert.That(containingReturnType).Contains("typeof(global::TUnit.TestProject.Bugs._7163.ContainingGenericReturnTypeTests.Outer<>.Inner)");
});
}
Loading
Loading