From 2e25e67b0c3e8db7bfc9b5917e46fe8941a9a092 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 20:09:44 +0100 Subject: [PATCH 1/3] Convert collection expressions to array initializers for property assignments in FullyQualifiedWithGlobalPrefixRewriter --- .../FullyQualifiedWithGlobalPrefixRewriter.cs | 52 ++++++++-- .../Bugs/Issue2504CollectionExpressionTest.cs | 97 +++++++++++++++++++ .../Bugs/Issue2504CompilationTest.cs | 38 ++++++++ 3 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs create mode 100644 TUnit.TestProject/Bugs/Issue2504CompilationTest.cs diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/FullyQualifiedWithGlobalPrefixRewriter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/FullyQualifiedWithGlobalPrefixRewriter.cs index 8d598f932b..6a22b60ad2 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/FullyQualifiedWithGlobalPrefixRewriter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Helpers/FullyQualifiedWithGlobalPrefixRewriter.cs @@ -176,20 +176,58 @@ public override SyntaxNode VisitTypeOfExpression(TypeOfExpressionSyntax node) #if ROSLYN4_7_OR_GREATER public override SyntaxNode? VisitCollectionExpression(CollectionExpressionSyntax node) { - // For collection expressions, visit each element and ensure proper type conversion - var rewrittenElements = node.Elements.Select(element => + // Convert collection expressions to array initializers for property assignments + // Collection expressions like [1, 2, 3] need to be converted to new object[] { 1, 2, 3 } + // when used in property initializers to avoid compilation errors + + // Get the type info from the semantic model if available + var typeInfo = semanticModel.GetTypeInfo(node); + var elementType = "object"; + + if (typeInfo.ConvertedType is IArrayTypeSymbol arrayTypeSymbol) + { + elementType = arrayTypeSymbol.ElementType.GloballyQualified(); + } + else if (typeInfo.Type is IArrayTypeSymbol arrayTypeSymbol2) + { + elementType = arrayTypeSymbol2.ElementType.GloballyQualified(); + } + + // Visit and rewrite each element + var rewrittenElements = new List(); + foreach (var element in node.Elements) { if (element is ExpressionElementSyntax expressionElement) { var rewrittenExpression = Visit(expressionElement.Expression); - return SyntaxFactory.ExpressionElement((ExpressionSyntax)rewrittenExpression); + rewrittenElements.Add((ExpressionSyntax)rewrittenExpression); } - return element; - }).ToList(); - - return SyntaxFactory.CollectionExpression( + } + + // Create an array creation expression instead of a collection expression + // This ensures compatibility with property initializers + var arrayTypeSyntax = SyntaxFactory.ArrayType( + SyntaxFactory.ParseTypeName(elementType), + SyntaxFactory.SingletonList( + SyntaxFactory.ArrayRankSpecifier( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.OmittedArraySizeExpression() + ) + ) + ) + ); + + var initializer = SyntaxFactory.InitializerExpression( + SyntaxKind.ArrayInitializerExpression, SyntaxFactory.SeparatedList(rewrittenElements) ); + + // Create the array creation expression with proper spacing + return SyntaxFactory.ArrayCreationExpression( + SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Whitespace(" ")), + arrayTypeSyntax, + initializer + ); } #endif } diff --git a/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs b/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs new file mode 100644 index 0000000000..0dc7072f56 --- /dev/null +++ b/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs; + +/// +/// Test for issue #2504 - Collection expression syntax in MethodDataSource Arguments +/// +[EngineTest(ExpectedResult.Pass)] +public class Issue2504CollectionExpressionTest +{ + private static readonly ConcurrentBag ExecutedTests = []; + + [Test] + [MethodDataSource(nameof(GetDataWithSingleIntParam), Arguments = [5])] // Collection expression syntax + [MethodDataSource(nameof(GetDataWithSingleIntParam), Arguments = new object[] { 10 })] // Traditional array syntax + public async Task TestWithSingleArgument(int value) + { + ExecutedTests.Add($"SingleParam:{value}"); + await Assert.That(value).IsIn(10, 20); // 5*2=10, 10*2=20 + } + + [Test] + [MethodDataSource(nameof(GetDataWithMultipleParams), Arguments = [10, "test"])] // Collection expression syntax + public async Task TestWithMultipleArguments(int number, string text) + { + ExecutedTests.Add($"MultiParam:{number}-{text}"); + await Assert.That(number).IsEqualTo(15); + await Assert.That(text).IsEqualTo("test_modified"); + } + + [Test] + [MethodDataSource(nameof(GetDataWithArrayParam), Arguments = [new int[] { 4, 5 }])] // Collection expression with array element + public async Task TestWithArrayArgument(int value) + { + ExecutedTests.Add($"ArrayParam:{value}"); + await Assert.That(value).IsIn(4, 5); + } + + public static IEnumerable GetDataWithSingleIntParam(int multiplier) + { + // Return test data based on the multiplier + yield return multiplier * 2; + } + + public static IEnumerable GetDataWithMultipleParams(int baseNumber, string baseText) + { + // Return modified values + yield return [baseNumber + 5, baseText + "_modified"]; + } + + public static IEnumerable GetDataWithArrayParam(int[] values) + { + // Return each value from the array as test data + foreach (var value in values) + { + yield return [value]; + } + } + + [After(Assembly)] + public static async Task VerifyTestsExecuted() + { + var executedTests = ExecutedTests.ToList(); + + // Skip verification if no tests were executed (e.g., filtered run) + if (executedTests.Count == 0) + { + return; + } + + // Should have 5 test instances total: + // - 1 from first MethodDataSource with [5] + // - 1 from second MethodDataSource with { 10 } + // - 1 from TestWithMultipleArguments + // - 2 from TestWithArrayArgument (array has 2 elements) + await Assert.That(executedTests.Count).IsEqualTo(5); + + // Verify we have the expected values + var expected = new[] + { + "SingleParam:10", // 5 * 2 + "SingleParam:20", // 10 * 2 (this should be 20, not 15!) + "MultiParam:15-test_modified", + "ArrayParam:4", + "ArrayParam:5" + }; + + foreach (var expectedTest in expected) + { + await Assert.That(executedTests).Contains(expectedTest); + } + + // Clear for next run + ExecutedTests.Clear(); + } +} \ No newline at end of file diff --git a/TUnit.TestProject/Bugs/Issue2504CompilationTest.cs b/TUnit.TestProject/Bugs/Issue2504CompilationTest.cs new file mode 100644 index 0000000000..462229a6d6 --- /dev/null +++ b/TUnit.TestProject/Bugs/Issue2504CompilationTest.cs @@ -0,0 +1,38 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs; + +/// +/// Test for issue #2504 - Compilation issue with collection expression syntax in MethodDataSource Arguments. +/// This test verifies that both collection expression syntax and traditional array syntax compile correctly. +/// +[EngineTest(ExpectedResult.Pass)] +public class Issue2504CompilationTest +{ + [Test] + [MethodDataSource(nameof(GetData), Arguments = [5])] // Collection expression syntax - previously caused compilation error + [MethodDataSource(nameof(GetData), Arguments = new object[] { 10 })] // Traditional array syntax + public async Task TestWithCollectionExpressionSyntax(int value) + { + // Test should receive 10 (5 * 2) and 20 (10 * 2) + await Assert.That(value).IsIn(10, 20); + } + + [Test] + [MethodDataSource(nameof(GetDataWithMultipleParams), Arguments = ["hello", 42])] // Collection expression with mixed types + public async Task TestWithMultipleArgumentsCollectionExpression(string text, int number) + { + await Assert.That(text).IsEqualTo("hello_modified"); + await Assert.That(number).IsEqualTo(84); // 42 * 2 + } + + public static int GetData(int input) + { + return input * 2; + } + + public static IEnumerable GetDataWithMultipleParams(string baseText, int baseNumber) + { + yield return [baseText + "_modified", baseNumber * 2]; + } +} \ No newline at end of file From 1546d902cb09908e93e8d744f21dea4cf2935426 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:10:57 +0100 Subject: [PATCH 2/3] Update MethodDataSource attributes to use object initializers for Arguments and Shared properties --- .../AsyncMethodDataSourceDrivenTests.Test.verified.txt | 2 +- .../MethodDataSourceDrivenTests.Test.verified.txt | 6 +++--- .../MultipleClassDataSourceDrivenTests.Test.verified.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/TUnit.Core.SourceGenerator.Tests/AsyncMethodDataSourceDrivenTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/AsyncMethodDataSourceDrivenTests.Test.verified.txt index 15894ea974..ae51206ba2 100644 --- a/TUnit.Core.SourceGenerator.Tests/AsyncMethodDataSourceDrivenTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/AsyncMethodDataSourceDrivenTests.Test.verified.txt @@ -566,7 +566,7 @@ internal sealed class AsyncMethodDataSourceDrivenTests_AsyncMethodDataSource_Wit [ new global::TUnit.Core.TestAttribute(), new global::TUnit.Core.MethodDataSourceAttribute("AsyncDataMethodWithArgs") -{Arguments = [5],}, +{Arguments = new object[]{5},}, new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) ], DataSources = new global::TUnit.Core.IDataSourceAttribute[] diff --git a/TUnit.Core.SourceGenerator.Tests/MethodDataSourceDrivenTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/MethodDataSourceDrivenTests.Test.verified.txt index 92fdcd8836..98b2757653 100644 --- a/TUnit.Core.SourceGenerator.Tests/MethodDataSourceDrivenTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/MethodDataSourceDrivenTests.Test.verified.txt @@ -400,7 +400,7 @@ internal sealed class MethodDataSourceDrivenTests_DataSource_Method3_TestSource_ [ new global::TUnit.Core.TestAttribute(), new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") -{Arguments = [5],}, +{Arguments = new object[]{5},}, new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") {Arguments = new object[] { 5 },}, new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) @@ -545,11 +545,11 @@ internal sealed class MethodDataSourceDrivenTests_DataSource_Method4_TestSource_ [ new global::TUnit.Core.TestAttribute(), new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") -{Arguments = ["Hello World!",5,true],}, +{Arguments = new object[]{"Hello World!",5,true},}, new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") {Arguments = new object[] { "Hello World!", 6, true },}, new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") -{Arguments = ["Hello World!",7,true],}, +{Arguments = new object[]{"Hello World!",7,true},}, new global::TUnit.Core.MethodDataSourceAttribute("SomeMethod") {Arguments = new object[] { "Hello World!", 8, true },}, new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) diff --git a/TUnit.Core.SourceGenerator.Tests/MultipleClassDataSourceDrivenTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/MultipleClassDataSourceDrivenTests.Test.verified.txt index 2904cb34ee..34113cc507 100644 --- a/TUnit.Core.SourceGenerator.Tests/MultipleClassDataSourceDrivenTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/MultipleClassDataSourceDrivenTests.Test.verified.txt @@ -20,7 +20,7 @@ internal sealed class MultipleClassDataSourceDrivenTests_Test1_TestSource_GUID : new global::TUnit.Core.TestAttribute(), new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass), new global::TUnit.Core.ClassDataSourceAttribute() -{Shared = [global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None],} +{Shared = new global::TUnit.Core.SharedType[]{global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None},} ], DataSources = global::System.Array.Empty(), ClassDataSources = new global::TUnit.Core.IDataSourceAttribute[] @@ -154,7 +154,7 @@ internal sealed class MultipleClassDataSourceDrivenTests_Test2_TestSource_GUID : new global::TUnit.Core.TestAttribute(), new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass), new global::TUnit.Core.ClassDataSourceAttribute() -{Shared = [global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None],} +{Shared = new global::TUnit.Core.SharedType[]{global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None,global::TUnit.Core.SharedType.None},} ], DataSources = global::System.Array.Empty(), ClassDataSources = new global::TUnit.Core.IDataSourceAttribute[] From 874f9fde37b0ce5e476a9d974d0e292ce5ba60ba Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:51:55 +0100 Subject: [PATCH 3/3] Add ConcurrentHashSet implementation and update EventReceiverOrchestrator to use it for tracking initialized objects --- TUnit.Engine/ConcurrentHashSet.cs | 122 ++++++++++++++++++ .../Services/EventReceiverOrchestrator.cs | 16 +-- .../Bugs/Issue2504CollectionExpressionTest.cs | 4 +- 3 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 TUnit.Engine/ConcurrentHashSet.cs diff --git a/TUnit.Engine/ConcurrentHashSet.cs b/TUnit.Engine/ConcurrentHashSet.cs new file mode 100644 index 0000000000..85df4becb8 --- /dev/null +++ b/TUnit.Engine/ConcurrentHashSet.cs @@ -0,0 +1,122 @@ +namespace TUnit.Engine; + +internal class ConcurrentHashSet +{ + private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion); + private readonly HashSet _hashSet = []; + + #region Implementation of ICollection ...ish + + public bool Add(T item) + { + _lock.EnterWriteLock(); + + try + { + return _hashSet.Add(item); + } + finally + { + if (_lock.IsWriteLockHeld) + { + _lock.ExitWriteLock(); + } + } + } + + public void Clear() + { + _lock.EnterWriteLock(); + + try + { + _hashSet.Clear(); + } + finally + { + if (_lock.IsWriteLockHeld) + { + _lock.ExitWriteLock(); + } + } + } + + public bool Contains(T item) + { + _lock.EnterReadLock(); + + try + { + return _hashSet.Contains(item); + } + finally + { + if (_lock.IsReadLockHeld) + { + _lock.ExitReadLock(); + } + } + } + + public bool Remove(T item) + { + _lock.EnterWriteLock(); + + try + { + return _hashSet.Remove(item); + } + finally + { + if (_lock.IsWriteLockHeld) + { + _lock.ExitWriteLock(); + } + } + } + + public int Count + { + get + { + _lock.EnterReadLock(); + + try + { + return _hashSet.Count; + } + finally + { + if (_lock.IsReadLockHeld) + { + _lock.ExitReadLock(); + } + } + } + } + + #endregion + + #region Dispose + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _lock.Dispose(); + } + } + + ~ConcurrentHashSet() + { + Dispose(false); + } + + #endregion +} diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index c7dde5dc8e..79dfecbd35 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -26,12 +26,12 @@ internal sealed class EventReceiverOrchestrator : IDisposable private readonly ThreadSafeDictionary _assemblyTestCounts = new(); private readonly ThreadSafeDictionary _classTestCounts = new(); private int _sessionTestCount; - + // Track which objects have already been initialized to avoid duplicates - private readonly HashSet _initializedObjects = new(); - + private readonly ConcurrentHashSet _initializedObjects = new(); + // Track registered First event receiver types to avoid duplicate registrations - private readonly HashSet _registeredFirstEventReceiverTypes = new(); + private readonly ConcurrentHashSet _registeredFirstEventReceiverTypes = new(); public EventReceiverOrchestrator(TUnitFrameworkLogger logger) { @@ -45,19 +45,19 @@ public async ValueTask InitializeAllEligibleObjectsAsync(TestContext context, Ca // Only initialize and register objects that haven't been processed yet var newObjects = new List(); var objectsToRegister = new List(); - + foreach (var obj in eligibleObjects) { if (_initializedObjects.Add(obj)) // Add returns false if already present { newObjects.Add(obj); - + // For First event receivers, only register one instance per type var objType = obj.GetType(); bool isFirstEventReceiver = obj is IFirstTestInTestSessionEventReceiver || obj is IFirstTestInAssemblyEventReceiver || obj is IFirstTestInClassEventReceiver; - + if (isFirstEventReceiver) { if (_registeredFirstEventReceiverTypes.Add(objType)) @@ -80,7 +80,7 @@ obj is IFirstTestInAssemblyEventReceiver || // Register only the objects that should be registered _registry.RegisterReceivers(objectsToRegister); } - + if (newObjects.Count > 0) { // Initialize all new objects (even if not registered) diff --git a/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs b/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs index 0dc7072f56..6623279309 100644 --- a/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs +++ b/TUnit.TestProject/Bugs/Issue2504CollectionExpressionTest.cs @@ -49,12 +49,12 @@ public static IEnumerable GetDataWithMultipleParams(int baseNumber, st yield return [baseNumber + 5, baseText + "_modified"]; } - public static IEnumerable GetDataWithArrayParam(int[] values) + public static IEnumerable GetDataWithArrayParam(int[] values) { // Return each value from the array as test data foreach (var value in values) { - yield return [value]; + yield return value; } }