diff --git a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs index 5c0214dde2..48e70d9a27 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs @@ -540,7 +540,7 @@ private static string GenerateCustomDataProvider(AttributeData attr) /// - /// Generates all test-related attributes for the TestMetadata.Attributes field. + /// Generates all test-related attributes for the TestMetadata.AttributesByType field as a dictionary. /// public static string GenerateTestAttributes(IMethodSymbol methodSymbol) { @@ -552,24 +552,42 @@ public static string GenerateTestAttributes(IMethodSymbol methodSymbol) if (allAttributes.Count == 0) { - return "System.Array.Empty()"; + return "new System.Collections.Generic.Dictionary>().AsReadOnly()"; } - // Generate as a single line array to avoid CS8802 parser issues + // Group attributes by type + var attributesByType = allAttributes + .GroupBy(attr => attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? "System.Attribute") + .ToList(); + using var writer = new CodeWriter("", includeHeader: false); - // Generate inline array to avoid parser issues - using (writer.BeginArrayInitializer("new System.Attribute[]", terminator: "")) + // Generate dictionary initializer + writer.Append("new System.Collections.Generic.Dictionary>()"); + writer.AppendLine(); + writer.AppendLine("{"); + writer.Indent(); + + foreach (var group in attributesByType) { + var typeString = group.Key; + var attrs = group.ToList(); + + writer.Append($"[typeof({typeString})] = new System.Attribute[] {{ "); + var attributeStrings = new List(); - foreach (var attr in allAttributes) + foreach (var attr in attrs) { - // Use unified approach for all attributes attributeStrings.Add(GenerateAttributeInstantiation(attr)); } + writer.Append(string.Join(", ", attributeStrings)); + writer.AppendLine(" },"); } + writer.Unindent(); + writer.Append("}.AsReadOnly()"); + return writer.ToString().Trim(); } diff --git a/TUnit.Core/Helpers/AttributeDictionaryHelper.cs b/TUnit.Core/Helpers/AttributeDictionaryHelper.cs new file mode 100644 index 0000000000..c0824199f9 --- /dev/null +++ b/TUnit.Core/Helpers/AttributeDictionaryHelper.cs @@ -0,0 +1,46 @@ +using System.Collections.ObjectModel; + +namespace TUnit.Core.Helpers; + +/// +/// Helper methods for working with attribute dictionaries. +/// +public static class AttributeDictionaryHelper +{ + private static readonly IReadOnlyDictionary> EmptyDictionary = + new ReadOnlyDictionary>(new Dictionary>()); + + /// + /// Converts an array of attributes to a read-only dictionary grouped by type. + /// + public static IReadOnlyDictionary> ToAttributeDictionary(this Attribute[] attributes) + { + if (attributes.Length == 0) + { + return EmptyDictionary; + } + + var result = new Dictionary>(); + + foreach (var attr in attributes) + { + var type = attr.GetType(); + if (!result.TryGetValue(type, out var list)) + { + var newList = new List { attr }; + result[type] = newList; + } + else + { + ((List)list).Add(attr); + } + } + + return new ReadOnlyDictionary>(result); + } + + /// + /// Gets an empty read-only attribute dictionary. + /// + public static IReadOnlyDictionary> Empty => EmptyDictionary; +} diff --git a/TUnit.Core/TestDetails.cs b/TUnit.Core/TestDetails.cs index 801ba823ef..e39b16704e 100644 --- a/TUnit.Core/TestDetails.cs +++ b/TUnit.Core/TestDetails.cs @@ -29,15 +29,56 @@ public class TestDetails public Dictionary> CustomProperties { get; } = new(); public Type[]? TestClassParameterTypes { get; set; } - public required IReadOnlyList Attributes { get; init; } + public required IReadOnlyDictionary> AttributesByType { get; init; } + + private readonly Lazy> _cachedAllAttributes; + + public TestDetails() + { + _cachedAllAttributes = new Lazy>(() => + { + var allAttrs = new List(); + foreach (var attrList in AttributesByType?.Values ?? []) + { + allAttrs.AddRange(attrList); + } + return allAttrs; + }); + } + + /// + /// Checks if the test has an attribute of the specified type. + /// + /// The attribute type to check for. + /// True if the test has at least one attribute of the specified type; otherwise, false. + public bool HasAttribute() where T : Attribute + => AttributesByType.ContainsKey(typeof(T)); + + /// + /// Gets all attributes of the specified type. + /// + /// The attribute type to retrieve. + /// An enumerable of attributes of the specified type. + public IEnumerable GetAttributes() where T : Attribute + => AttributesByType.TryGetValue(typeof(T), out var attrs) + ? attrs.OfType() + : Enumerable.Empty(); + + /// + /// Gets all attributes as a flattened collection. + /// Cached after first access for performance. + /// + /// All attributes associated with this test. + public IReadOnlyList GetAllAttributes() => _cachedAllAttributes.Value; + public object?[] ClassMetadataArguments => TestClassArguments; - + /// /// Resolved generic type arguments for the test method. /// Will be Type.EmptyTypes if the method is not generic. /// public Type[] MethodGenericArguments { get; set; } = Type.EmptyTypes; - + /// /// Resolved generic type arguments for the test class. /// Will be Type.EmptyTypes if the class is not generic. diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 78fa82ca94..fa69e3c536 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -876,7 +876,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM TestLineNumber = metadata.LineNumber, ReturnType = metadata.MethodMetadata.ReturnType ?? typeof(void), MethodMetadata = metadata.MethodMetadata, - Attributes = attributes, + AttributesByType = attributes.ToAttributeDictionary(), MethodGenericArguments = testData.ResolvedMethodGenericArguments, ClassGenericArguments = testData.ResolvedClassGenericArguments, Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) @@ -973,7 +973,7 @@ private async Task CreateFailedTestDetails(TestMetadata metadata, s TestLineNumber = metadata.LineNumber, ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, - Attributes = await InitializeAttributesAsync(metadata.AttributeFactory.Invoke()), + AttributesByType = (await InitializeAttributesAsync(metadata.AttributeFactory.Invoke())).ToAttributeDictionary(), Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) }; } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index ddeb311691..35bc732c91 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using EnumerableAsyncProcessor.Extensions; using TUnit.Core; +using TUnit.Core.Helpers; using TUnit.Core.Interfaces; using TUnit.Core.Services; using TUnit.Engine.Building.Interfaces; @@ -173,7 +174,7 @@ private async Task GenerateDynamicTests(TestMetadata m TestLineNumber = metadata.LineNumber, ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, - Attributes = attributes, + AttributesByType = attributes.ToAttributeDictionary(), Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) // Don't set RetryLimit here - let discovery event receivers set it }; @@ -293,7 +294,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad TestLineNumber = resolvedMetadata.LineNumber, ReturnType = typeof(Task), MethodMetadata = resolvedMetadata.MethodMetadata, - Attributes = attributes, + AttributesByType = attributes.ToAttributeDictionary(), Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout (can be overridden by TimeoutAttribute) // Don't set Timeout and RetryLimit here - let discovery event receivers set them }; @@ -367,7 +368,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada TestLineNumber = metadata.LineNumber, ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, - Attributes = [], + AttributesByType = AttributeDictionaryHelper.Empty, Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout }; @@ -419,7 +420,7 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet TestLineNumber = metadata.LineNumber, ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, - Attributes = [], + AttributesByType = AttributeDictionaryHelper.Empty, Timeout = TimeSpan.FromMinutes(30) // Default 30-minute timeout }; diff --git a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs index 9354c9525f..4866d23e2e 100644 --- a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs +++ b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs @@ -52,7 +52,7 @@ async Task CreateInstance(TestContext testContext) // Otherwise fall back to creating instance normally // Try to create instance with ClassConstructor attribute - var attributes = testContext.TestDetails.Attributes; + var attributes = testContext.TestDetails.GetAllAttributes(); var classConstructorInstance = await ClassConstructorHelper.TryCreateInstanceWithClassConstructor( attributes, TestClassType, diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index aa9b10edc8..2f5812b8b3 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -10,7 +10,7 @@ internal static class TestContextExtensions testContext.Events, ..testContext.TestDetails.TestClassArguments, testContext.TestDetails.ClassInstance, - ..testContext.TestDetails.Attributes, + ..testContext.TestDetails.GetAllAttributes(), ..testContext.TestDetails.TestMethodArguments, ..testContext.TestDetails.TestClassInjectedPropertyArguments.Select(x => x.Value), ]; diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index aa9b8f5b63..d73cb5c86e 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -183,7 +183,7 @@ private PropertyBag BuildPropertyBag(AbstractExecutableTest test) private bool IsExplicitTest(AbstractExecutableTest test) { - if (test.Context.TestDetails.Attributes.OfType().Any()) + if (test.Context.TestDetails.HasAttribute()) { return true; } diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 79352774b3..98cfa207af 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -202,7 +202,7 @@ public async Task CreateTestVariant( } var lambda = Expression.Lambda>(body, parameter); - var attributes = new List(currentContext.TestDetails.Attributes); + var attributes = new List(currentContext.TestDetails.GetAllAttributes()); var discoveryResult = new DynamicDiscoveryResult { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 5cf43438fa..3232960360 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1374,7 +1374,7 @@ namespace public class TestDetails { public TestDetails() { } - public required .<> Attributes { get; init; } + public required .<, .<>> AttributesByType { get; init; } public . Categories { get; } public [] ClassGenericArguments { get; set; } public required object ClassInstance { get; set; } @@ -1396,6 +1396,11 @@ namespace public required object?[] TestMethodArguments { get; set; } public required string TestName { get; init; } public ? Timeout { get; set; } + public .<> GetAllAttributes() { } + public . GetAttributes() + where T : { } + public bool HasAttribute() + where T : { } } public class TestDetails : .TestDetails where T : class @@ -1907,6 +1912,11 @@ namespace .Helpers public static string FormatArguments(. arguments) { } public static string GetConstantValue(.TestContext testContext, object? o) { } } + public static class AttributeDictionaryHelper + { + public static .<, .<>> Empty { get; } + public static .<, .<>> ToAttributeDictionary(this [] attributes) { } + } public static class CastHelper { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 1f014f8981..422794f37e 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1374,7 +1374,7 @@ namespace public class TestDetails { public TestDetails() { } - public required .<> Attributes { get; init; } + public required .<, .<>> AttributesByType { get; init; } public . Categories { get; } public [] ClassGenericArguments { get; set; } public required object ClassInstance { get; set; } @@ -1396,6 +1396,11 @@ namespace public required object?[] TestMethodArguments { get; set; } public required string TestName { get; init; } public ? Timeout { get; set; } + public .<> GetAllAttributes() { } + public . GetAttributes() + where T : { } + public bool HasAttribute() + where T : { } } public class TestDetails : .TestDetails where T : class @@ -1907,6 +1912,11 @@ namespace .Helpers public static string FormatArguments(. arguments) { } public static string GetConstantValue(.TestContext testContext, object? o) { } } + public static class AttributeDictionaryHelper + { + public static .<, .<>> Empty { get; } + public static .<, .<>> ToAttributeDictionary(this [] attributes) { } + } public static class CastHelper { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 7ac29524d6..e0010fbf29 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1374,7 +1374,7 @@ namespace public class TestDetails { public TestDetails() { } - public required .<> Attributes { get; init; } + public required .<, .<>> AttributesByType { get; init; } public . Categories { get; } public [] ClassGenericArguments { get; set; } public required object ClassInstance { get; set; } @@ -1396,6 +1396,11 @@ namespace public required object?[] TestMethodArguments { get; set; } public required string TestName { get; init; } public ? Timeout { get; set; } + public .<> GetAllAttributes() { } + public . GetAttributes() + where T : { } + public bool HasAttribute() + where T : { } } public class TestDetails : .TestDetails where T : class @@ -1907,6 +1912,11 @@ namespace .Helpers public static string FormatArguments(. arguments) { } public static string GetConstantValue(.TestContext testContext, object? o) { } } + public static class AttributeDictionaryHelper + { + public static .<, .<>> Empty { get; } + public static .<, .<>> ToAttributeDictionary(this [] attributes) { } + } public static class CastHelper { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index d96a686458..a58f4b47ae 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1329,7 +1329,7 @@ namespace public class TestDetails { public TestDetails() { } - public required .<> Attributes { get; init; } + public required .<, .<>> AttributesByType { get; init; } public . Categories { get; } public [] ClassGenericArguments { get; set; } public required object ClassInstance { get; set; } @@ -1350,6 +1350,11 @@ namespace public required object?[] TestMethodArguments { get; set; } public required string TestName { get; init; } public ? Timeout { get; set; } + public .<> GetAllAttributes() { } + public . GetAttributes() + where T : { } + public bool HasAttribute() + where T : { } } public class TestDetails : .TestDetails where T : class @@ -1857,6 +1862,11 @@ namespace .Helpers public static string FormatArguments(. arguments) { } public static string GetConstantValue(.TestContext testContext, object? o) { } } + public static class AttributeDictionaryHelper + { + public static .<, .<>> Empty { get; } + public static .<, .<>> ToAttributeDictionary(this [] attributes) { } + } public static class CastHelper { public static object? Cast( type, object? value) { } diff --git a/TUnit.TestProject/Bugs/1939/Tests.cs b/TUnit.TestProject/Bugs/1939/Tests.cs index e2a8c1e92f..1f81bb799d 100644 --- a/TUnit.TestProject/Bugs/1939/Tests.cs +++ b/TUnit.TestProject/Bugs/1939/Tests.cs @@ -55,7 +55,7 @@ public static async Task AssertAllDataClassesDisposed(TestSessionContext context if (!dataClass.Disposed) { var classDataSourceAttribute = - test.TestDetails.Attributes.OfType>() + test.TestDetails.GetAttributes>() .First(); throw new Exception($"Not Disposed: {classDataSourceAttribute.Shared}");