diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs index 1f13ed5ddf..28171aa5e4 100644 --- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs +++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs @@ -113,7 +113,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat // Compute Cartesian product of all parameter value sets foreach (var combination in GetCartesianProduct(parameterValueSets)) { - yield return () => Task.FromResult(combination.ToArray())!; + yield return () => Task.FromResult(combination)!; } } @@ -221,12 +221,52 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat return values; } - private readonly IEnumerable> _seed = [[]]; - - private IEnumerable> GetCartesianProduct(IEnumerable> parameterValueSets) + private static IEnumerable GetCartesianProduct(IReadOnlyList> parameterValueSets) { - // Same algorithm as Matrix - compute Cartesian product - return parameterValueSets.Aggregate(_seed, (accumulator, values) - => accumulator.SelectMany(x => values.Select(x.Append))); + 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; + } + } } } diff --git a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs index 187cc03604..57e6c2df4a 100644 --- a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs @@ -64,11 +64,25 @@ public sealed class MatrixDataSourceAttribute : UntypedDataSourceGeneratorAttrib ? dataGeneratorMetadata.TestInformation.GetCustomAttributes() : classType.GetCustomAttributesSafe()); - foreach (var row in GetMatrixValues(parameterInformation.Select(p => GetAllArguments(dataGeneratorMetadata, p)))) + var valueSets = new IReadOnlyList[parameterInformation.Length]; + for (var i = 0; i < parameterInformation.Length; i++) { - var rowArray = row.ToArray(); + valueSets[i] = GetAllArguments(dataGeneratorMetadata, parameterInformation[i]); + } - if (exclusions.Any(e => IsExcluded(e, rowArray))) + foreach (var rowArray in GetMatrixValues(valueSets)) + { + var excluded = false; + foreach (var exclusion in exclusions) + { + if (IsExcluded(exclusion, rowArray)) + { + excluded = true; + break; + } + } + + if (excluded) { continue; } @@ -109,13 +123,19 @@ private bool IsExcluded(object?[] exclusion, object?[] rowArray) return true; } - private object?[][] GetExclusions(IEnumerable attributes) + private static object?[][] GetExclusions(IEnumerable attributes) { - return attributes - .Where(x => x is MatrixExclusionAttribute) - .Cast() - .Select(x => x.Objects) - .ToArray(); + List? exclusions = null; + + foreach (var attribute in attributes) + { + if (attribute is MatrixExclusionAttribute exclusionAttribute) + { + (exclusions ??= []).Add(exclusionAttribute.Objects); + } + } + + return exclusions is null ? [] : [.. exclusions]; } private IReadOnlyList GetAllArguments(DataGeneratorMetadata dataGeneratorMetadata, @@ -226,12 +246,53 @@ 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 readonly IEnumerable> _seed = [[]]; - - private IEnumerable> GetMatrixValues(IEnumerable> elements) + private static IEnumerable GetMatrixValues(IReadOnlyList> elements) { - return elements.Aggregate(_seed, (accumulator, enumerable) - => accumulator.SelectMany(x => enumerable.Select(x.Append))); + 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; + } + } } } diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 5254d656cf..b5c5ba09de 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using TUnit.Core.Helpers; @@ -377,11 +378,26 @@ public MethodDataSourceAttribute( return types; } + private static readonly ConcurrentDictionary IsAsyncEnumerableCache = new(); + + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "The 'type' parameter is annotated with DynamicallyAccessedMemberTypes.Interfaces, so its interfaces are preserved; the closure-free factory only forwards that same Type instance.")] private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type) { - return type.GetInterfaces() - .Any(i => i.IsGenericType && - i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)); + // Cache per-type: GetInterfaces() allocates an array on every call and the + // result is invariant for a given Type. GetOrAdd with a static (closure-free) + // factory ensures concurrent callers don't each run the interface scan. + return IsAsyncEnumerableCache.GetOrAdd(type, static t => + { + foreach (var i in t.GetInterfaces()) + { + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + { + return true; + } + } + + return false; + }); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection usage is documented. AOT-safe path available via Factory property")] diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index 70d8767efc..d56b429668 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -41,10 +41,17 @@ public static DataGeneratorMetadata CreateDataGeneratorMetadata( var propertyMetadataList = new List(); var allProperties = testMetadata.MethodMetadata.Class.Properties; + // Build a name -> metadata lookup once (O(N)) so the per-data-source + // resolution below is O(1) instead of an O(N*M) FirstOrDefault scan. + var propertiesByName = new Dictionary(allProperties.Length, StringComparer.Ordinal); + foreach (var property in allProperties) + { + propertiesByName[property.Name] = property; + } + foreach (var propertyDataSource in testMetadata.PropertyDataSources) { - var matchingProperty = allProperties.FirstOrDefault(p => p.Name == propertyDataSource.PropertyName); - if (matchingProperty != null) + if (propertiesByName.TryGetValue(propertyDataSource.PropertyName, out var matchingProperty)) { propertyMetadataList.Add(matchingProperty); }