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
112 changes: 42 additions & 70 deletions TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Enums;
using TUnit.Core.Extensions;
using TUnit.Core.Helpers;

namespace TUnit.Core;

Expand Down Expand Up @@ -101,23 +102,36 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat
throw new InvalidOperationException("CombinedDataSources requires test information but none is available. This may occur during static property initialization.");
}

// For each parameter, collect all possible values (individual values, not arrays)
var parameterValueSets = new List<IReadOnlyList<object?>>();
// For each parameter, collect a factory per possible value (individual values, not arrays).
// Values are materialized per combination (not once up front) so that non-shared
// reference values - e.g. [ClassDataSource] with SharedType.None - produce a fresh
// instance for every test case instead of one instance shared across the cartesian
// product, which races during parallel property injection/initialization (#6177).
var parameterValueFactorySets = new List<IReadOnlyList<Func<Task<object?>>>>();

foreach (var param in parameterInformation)
{
var parameterValues = await GetParameterValues(param, dataGeneratorMetadata);
parameterValueSets.Add(parameterValues);
var parameterValueFactories = await GetParameterValueFactories(param, dataGeneratorMetadata);
parameterValueFactorySets.Add(parameterValueFactories);
}

// Compute Cartesian product of all parameter value sets
foreach (var combination in GetCartesianProduct(parameterValueSets))
// Compute Cartesian product of all parameter value factory sets
foreach (var combination in CartesianProductHelper.GetCartesianProduct(parameterValueFactorySets))
{
yield return () => Task.FromResult(combination)!;
yield return async () =>
{
var row = new object?[combination.Length];
for (var i = 0; i < combination.Length; i++)
{
row[i] = await combination[i]();
}

return row;
};
}
}

private async Task<IReadOnlyList<object?>> GetParameterValues(ParameterMetadata parameterMetadata, DataGeneratorMetadata dataGeneratorMetadata)
private async Task<IReadOnlyList<Func<Task<object?>>>> GetParameterValueFactories(ParameterMetadata parameterMetadata, DataGeneratorMetadata dataGeneratorMetadata)
{
// Get all IDataSourceAttribute attributes on this parameter
// Prefer cached attributes from source generator for AOT compatibility
Expand Down Expand Up @@ -149,7 +163,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat
throw new InvalidOperationException($"Parameter '{parameterMetadata.Name}' has no data source attributes. All parameters must have at least one IDataSourceAttribute when using [CombinedDataSources].");
}

var allValues = new List<object?>();
var allValueFactories = new List<Func<Task<object?>>>();

// Process each data source attribute
foreach (var dataSourceAttr in dataSourceAttributes)
Expand Down Expand Up @@ -179,94 +193,52 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat
InstanceFactory = dataGeneratorMetadata.InstanceFactory
};

// Get data rows from this data source (need to await async enumerable)
var dataRows = await ProcessDataSourceAsync(dataSourceAttr, singleParamMetadata);
// Get data row factories from this data source (need to await async enumerable)
var dataRowFactories = await ProcessDataSourceAsync(dataSourceAttr, singleParamMetadata);

allValues.AddRange(dataRows);
allValueFactories.AddRange(dataRowFactories);
}

if (allValues.Count == 0)
if (allValueFactories.Count == 0)
{
throw new InvalidOperationException($"Parameter '{parameterMetadata.Name}' data sources produced no values.");
}

return allValues;
return allValueFactories;
}

private static async Task<List<object?>> ProcessDataSourceAsync(IDataSourceAttribute dataSourceAttr, DataGeneratorMetadata metadata)
private static async Task<List<Func<Task<object?>>>> ProcessDataSourceAsync(IDataSourceAttribute dataSourceAttr, DataGeneratorMetadata metadata)
{
var values = new List<object?>();
var valueFactories = new List<Func<Task<object?>>>();

// Special handling for ArgumentsAttribute when used on parameters with CombinedDataSources
// ArgumentsAttribute yields ONE row containing ALL values, but for CombinedDataSources we need
// each value to be treated as a separate option for this parameter
if (dataSourceAttr is ArgumentsAttribute argsAttr)
{
// Each value in Arguments should be a separate option for this parameter
values.AddRange(argsAttr.Values);
foreach (var value in argsAttr.Values)
{
valueFactories.Add(() => Task.FromResult(value));
}
}
else
{
await foreach (var dataRowFunc in dataSourceAttr.GetDataRowsAsync(metadata))
{
var dataRow = await dataRowFunc();
if (dataRow != null && dataRow.Length > 0)
// Defer invocation: the row factory runs once per combination that uses it,
// so each test case gets its own value (fresh instance for non-shared sources).
valueFactories.Add(async () =>
{
var dataRow = await dataRowFunc();

// Each data row should have exactly one element for this parameter
values.Add(dataRow[0]);
}
return dataRow is { Length: > 0 } ? dataRow[0] : null;
});
}
}

return values;
return valueFactories;
}

private static IEnumerable<object?[]> GetCartesianProduct(IReadOnlyList<IReadOnlyList<object?>> parameterValueSets)
{
var dimensionCount = parameterValueSets.Count;

// Any empty dimension makes the product empty (matches the previous
// Aggregate/SelectMany behaviour where SelectMany over [] yields nothing).
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
if (parameterValueSets[dimension].Count == 0)
{
yield break;
}
}

// Odometer-style Cartesian product: the last dimension varies fastest,
// matching the previous Aggregate/SelectMany ordering exactly.
var indices = new int[dimensionCount];

while (true)
{
var row = new object?[dimensionCount];
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
row[dimension] = parameterValueSets[dimension][indices[dimension]];
}

yield return row;

// Advance the odometer from the rightmost dimension.
var position = dimensionCount - 1;
while (position >= 0)
{
if (++indices[position] < parameterValueSets[position].Count)
{
break;
}

indices[position] = 0;
position--;
}

// All dimensions wrapped back to zero: enumeration is complete.
if (position < 0)
{
yield break;
}
}
}
}
52 changes: 2 additions & 50 deletions TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Enums;
using TUnit.Core.Extensions;
using TUnit.Core.Helpers;

namespace TUnit.Core;

Expand Down Expand Up @@ -70,7 +71,7 @@ public sealed class MatrixDataSourceAttribute : UntypedDataSourceGeneratorAttrib
valueSets[i] = GetAllArguments(dataGeneratorMetadata, parameterInformation[i]);
}

foreach (var rowArray in GetMatrixValues(valueSets))
foreach (var rowArray in CartesianProductHelper.GetCartesianProduct(valueSets))
{
var excluded = false;
foreach (var exclusion in exclusions)
Expand Down Expand Up @@ -246,53 +247,4 @@ private bool IsExcluded(object?[] exclusion, object?[] rowArray)
throw new ArgumentNullException($"No MatrixAttribute found for parameter '{sourceGeneratedParameterInformation.Name}' and the parameter type '{resolvedType.Name}' cannot be auto-generated. Only bool and enum types support auto-generation.");
}

private static IEnumerable<object?[]> GetMatrixValues(IReadOnlyList<IReadOnlyList<object?>> elements)
{
var dimensionCount = elements.Count;

// Any empty dimension makes the product empty (matches the previous
// Aggregate/SelectMany behaviour where SelectMany over [] yields nothing).
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
if (elements[dimension].Count == 0)
{
yield break;
}
}

// Odometer-style Cartesian product: the last dimension varies fastest,
// matching the previous Aggregate/SelectMany ordering exactly.
var indices = new int[dimensionCount];

while (true)
{
var row = new object?[dimensionCount];
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
row[dimension] = elements[dimension][indices[dimension]];
}

yield return row;

// Advance the odometer from the rightmost dimension.
var position = dimensionCount - 1;
while (position >= 0)
{
if (++indices[position] < elements[position].Count)
{
break;
}

indices[position] = 0;
position--;
}

// All dimensions wrapped back to zero: enumeration is complete.
if (position < 0)
{
yield break;
}
}
}

}
61 changes: 61 additions & 0 deletions TUnit.Core/Helpers/CartesianProductHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace TUnit.Core.Helpers;

/// <summary>
/// Computes Cartesian products for data source expansion
/// (e.g. <see cref="MatrixDataSourceAttribute"/> and <see cref="CombinedDataSourcesAttribute"/>).
/// </summary>
internal static class CartesianProductHelper
{
/// <summary>
/// Computes the Cartesian product of the given sets.
/// The last dimension varies fastest, matching Aggregate/SelectMany ordering.
/// </summary>
public static IEnumerable<T[]> GetCartesianProduct<T>(IReadOnlyList<IReadOnlyList<T>> sets)
{
var dimensionCount = sets.Count;

// Any empty dimension makes the product empty (matches the previous
// Aggregate/SelectMany behaviour where SelectMany over [] yields nothing).
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
if (sets[dimension].Count == 0)
{
yield break;
}
}

// Odometer-style Cartesian product: the last dimension varies fastest,
// matching the previous Aggregate/SelectMany ordering exactly.
var indices = new int[dimensionCount];

while (true)
{
var row = new T[dimensionCount];
for (var dimension = 0; dimension < dimensionCount; dimension++)
{
row[dimension] = sets[dimension][indices[dimension]];
}

yield return row;

// Advance the odometer from the rightmost dimension.
var position = dimensionCount - 1;
while (position >= 0)
{
if (++indices[position] < sets[position].Count)
{
break;
}

indices[position] = 0;
position--;
}

// All dimensions wrapped back to zero: enumeration is complete.
if (position < 0)
{
yield break;
}
}
}
}
17 changes: 17 additions & 0 deletions TUnit.TestProject/CombinedDataSourceTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

Expand Down Expand Up @@ -622,6 +623,22 @@ public async Task CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyn
await Assert.That(address.IsValidated).IsTrue();
}

public class NonSharedInstance;

private static readonly ConcurrentDictionary<NonSharedInstance, byte> SeenNonSharedInstances = new();

[Test]
[CombinedDataSources]
public async Task CombinedDataSource_NonSharedClassDataSource_CreatesDistinctInstancePerTestCase(
[Arguments(1, 2)] int x,
[ClassDataSource<NonSharedInstance>] NonSharedInstance instance)
{
// ClassDataSource defaults to SharedType.None - each test case must get its OWN instance.
// Sharing one instance across the cartesian combinations causes a property-injection /
// initialization race during parallel test registration (#6177).
await Assert.That(SeenNonSharedInstances.TryAdd(instance, 0)).IsTrue();
}

[Test]
[CombinedDataSources]
public async Task CombinedDataSource_ComplexScenario_MultipleParametersWithMixedFeatures(
Expand Down
Loading