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
309 changes: 251 additions & 58 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR
// [Arguments] yields a single row, so deferring its enumeration would be pure overhead - always false.
public bool DeferEnumeration { get => false; set { } }

/// <summary>
/// True for <c>[Arguments(null)]</c>, which passes a null <c>params</c> array rather than one null
/// element. Generic type inference treats it as inferring no type, as the source generator does.
/// </summary>
internal bool IsNullValuesArray { get; }

/// <summary>
/// Initializes a new instance of the <see cref="ArgumentsAttribute"/> class with the specified test argument values.
/// </summary>
Expand All @@ -87,6 +93,7 @@ public ArgumentsAttribute(params object?[]? values)
if (values == null)
{
Values = [null];
IsNullValuesArray = true;
}
else if (values.Length == 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ internal sealed class StaticDataSourceAttribute : Attribute, IDataSourceAttribut
/// <inheritdoc />
public bool DeferEnumeration { get; set; }

/// <summary>
/// The data source whose rows this instance holds a subset of, when the engine splits a source's
/// rows across generic instantiations. The test keeps that source's attribute behaviour, such as
/// its <see cref="Interfaces.ITestRegisteredEventReceiver"/> callback.
/// </summary>
public IDataSourceAttribute? PartitionOf { get; init; }

public StaticDataSourceAttribute(params object?[][] data)
{
_data = data ?? throw new ArgumentNullException(nameof(data));
Expand Down
78 changes: 72 additions & 6 deletions src/TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ private ValueTask<IDataSourceAttribute[]> GetDataSourcesAsync(IDataSourceAttribu

foreach (var dataSource in dataSources)
{
if (PropertyInjectionCache.HasInjectableProperties(GetInjectionTarget(dataSource).GetType()))
if (PropertyInjectionCache.HasInjectableProperties(SourceToInject(dataSource).GetType()))
{
return InjectDataSourcePropertiesAsync(dataSources, cancellationToken);
}
Expand All @@ -896,12 +896,20 @@ private async ValueTask<IDataSourceAttribute[]> InjectDataSourcePropertiesAsync(
// Inject properties into data sources during discovery (IAsyncInitializer deferred to execution)
foreach (var dataSource in dataSources)
{
await _objectLifecycleService.InjectPropertiesAsync(GetInjectionTarget(dataSource), cancellationToken: cancellationToken);
await _objectLifecycleService.InjectPropertiesAsync(SourceToInject(dataSource), cancellationToken: cancellationToken);
}

return dataSources;
}

/// <summary>
/// A partition of a data source's rows holds plain values, but the source it was taken from joins
/// the test's attributes (see <see cref="ScopeRowAttributesToTest"/>), so that source receives the
/// property injection the test's other data sources do before its callbacks run.
/// </summary>
private static object SourceToInject(IDataSourceAttribute dataSource) =>
dataSource is StaticDataSourceAttribute { PartitionOf: { } partitionedSource } ? partitionedSource : GetInjectionTarget(dataSource);

/// <summary>
/// Ensures a data source is initialized before use and returns data rows.
/// This centralizes the initialization logic for all data source usage.
Expand Down Expand Up @@ -1238,10 +1246,7 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
// Use attributes from context if available, or create new ones
var attributes = testBuilderContext.InitializedAttributes ?? await InitializeAttributesAsync(metadata.GetOrCreateAttributes(), cancellationToken);

if (testBuilderContext.DataSourceAttribute != null && testBuilderContext.DataSourceAttribute is not NoDataSource)
{
attributes = [..attributes, (Attribute)testBuilderContext.DataSourceAttribute];
}
attributes = ScopeRowAttributesToTest(attributes, testBuilderContext.ClassDataSourceAttribute, testBuilderContext.DataSourceAttribute);

var testDetails = new TestDetails(attributes)
{
Expand Down Expand Up @@ -1435,6 +1440,67 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId
};
}

/// <summary>
/// Gives a test the data sources that produced it. A data source that is also an
/// <see cref="ITestRegisteredEventReceiver"/>, such as <see cref="ArgumentsAttribute"/>, carries
/// per-row settings (Skip, DisplayName, Categories), so only the producing row may stay in the
/// test's attributes: a class's attributes list every class-level row, and listing them all
/// would apply one row's settings to its siblings (#7106). The producing class and method data
/// sources are appended instead.
/// </summary>
internal static Attribute[] ScopeRowAttributesToTest(Attribute[] attributes, IDataSourceAttribute? classDataSource, IDataSourceAttribute? methodDataSource)
{
// Reflection discovery splits a class data source whose rows infer different generic type
// arguments into one partition per instantiation. The partition stands in for the original
// source's data only, so the original keeps its place here and its registration callback.
if (classDataSource is StaticDataSourceAttribute { PartitionOf: { } partitionedSource })
{
classDataSource = partitionedSource;
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

var appendClass = IsRowScopedDataSource(classDataSource);
var appendMethod = methodDataSource is not null and not NoDataSource;

var rowAttributeCount = 0;
foreach (var attribute in attributes)
{
if (IsRowScopedDataSource(attribute))
{
rowAttributeCount++;
}
}

if (rowAttributeCount == 0 && !appendClass && !appendMethod)
{
return attributes;
}

var result = new Attribute[attributes.Length - rowAttributeCount + (appendClass ? 1 : 0) + (appendMethod ? 1 : 0)];
var index = 0;
foreach (var attribute in attributes)
{
if (!IsRowScopedDataSource(attribute))
{
result[index++] = attribute;
}
}

if (appendClass)
{
result[index++] = (Attribute)classDataSource!;
}

if (appendMethod)
{
result[index] = (Attribute)methodDataSource!;
}

return result;
}

private static bool IsRowScopedDataSource(object? attribute) =>
attribute is IDataSourceAttribute and ITestRegisteredEventReceiver and Attribute;

private ValueTask<Attribute[]> InitializeAttributesAsync(Attribute[] attributes, CancellationToken cancellationToken = default)
{
foreach (var attribute in attributes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public void AddMethodRowViolations()

foreach (var argumentsAttribute in testMethod.GetCustomAttributes<ArgumentsAttribute>(inherit: false))
{
// [Arguments(null)] (a null params array) infers no type, as in the source generator
if (argumentsAttribute.IsNullValuesArray)
{
continue;
}

var typeArguments = InferClassTypeArguments(classGenericParameters, parameters, argumentsAttribute.Values);
if (typeArguments == null)
{
Expand Down
Loading
Loading