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
2 changes: 1 addition & 1 deletion TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ public static string GloballyQualified(this ISymbol typeSymbol)
{
// Handle open generic types where type arguments are type parameters
// This prevents invalid C# like List<T>, Dictionary<TKey, TValue>, T? where type parameters are undefined
if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol)
if (typeSymbol is INamedTypeSymbol { IsGenericType: true, Arity: > 0 } namedTypeSymbol)
{
// Check if this is an unbound generic type or has type parameter arguments
// Use multiple detection methods for robustness across Roslyn versions
Expand Down
21 changes: 21 additions & 0 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}

// Skip types nested inside open generic containing types
// These can't be instantiated without knowing the outer type's type arguments
if (HasOpenGenericContainingType(containingType))
{
return null;
}

var isGenericType = containingType is { IsGenericType: true, TypeParameters.Length: > 0 };
var isGenericMethod = methodSymbol is { IsGenericMethod: true };

Expand All @@ -146,6 +153,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
};
}

private static bool HasOpenGenericContainingType(INamedTypeSymbol type)
{
var current = type.ContainingType;
while (current != null)
{
if (current is { IsGenericType: true, TypeParameters.Length: > 0 })
{
return true;
}
current = current.ContainingType;
}
return false;
}

private static void GenerateInheritedTestSources(SourceProductionContext context, InheritsTestsClassMetadata? classInfo)
{
if (classInfo?.TypeSymbol == null)
Expand Down
149 changes: 149 additions & 0 deletions TUnit.Engine.Tests/UidFilterMatchingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// Comprehensive tests for UID filter matching in MetadataFilterMatcher.CouldMatchUidFilter.
/// Tests various scenarios that ensure VS Test Explorer can correctly filter tests.
/// Regression tests for GitHub issue #4656 follow-up.
/// </summary>
public class UidFilterMatchingTests(TestMode testMode) : InvokableTestBase(testMode)
{
#region Nested Classes Tests

[Test]
public async Task Filter_NestedClass_ShouldMatchOnlyNestedClass()
{
// Filter for the nested class InnerClass
// Tree node paths use just the innermost class name (Type.Name)
// Should only run tests from InnerClass, not OuterClass
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/InnerClass/InnerMethod",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
$"Expected 1 test (InnerClass.InnerMethod) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

[Test]
public async Task Filter_OuterClass_ShouldNotMatchNestedClasses()
{
// Filter for only the outer class method
// Should only run OuterClass.OuterMethod, not nested class methods
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/OuterClass/OuterMethod",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
$"Expected 1 test (OuterClass.OuterMethod) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

#endregion

#region Overlapping Names Tests

[Test]
public async Task Filter_FilterTest_ShouldNotMatchFilterTestHelper()
{
// Filter for FilterTest class
// Should NOT match FilterTestHelper or FilterTesting
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/FilterTest/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
$"Expected 1 test (FilterTest.Method1) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}. " +
"If more tests ran, substring matching may be incorrectly matching FilterTestHelper or FilterTesting."),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

[Test]
public async Task Filter_FilterTestHelper_ShouldNotMatchFilterTest()
{
// Filter for FilterTestHelper class
// Should NOT match FilterTest
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/FilterTestHelper/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
$"Expected 1 test (FilterTestHelper.Method1) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

#endregion

#region Method Name Boundary Tests

[Test]
public async Task Filter_MethodNameTest_ShouldNotMatchTestMethod()
{
// Filter for method named "Test"
// Should NOT match "TestMethod", "MyTest", or "TestingMethod"
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/MethodNameBoundaryTests/Test",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
$"Expected 1 test (MethodNameBoundaryTests.Test) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}. " +
"If more tests ran, method name boundary matching may be incorrect."),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

[Test]
public async Task Filter_AllMethodsInClass_ShouldMatchAllFour()
{
// Filter for all methods in MethodNameBoundaryTests
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/MethodNameBoundaryTests/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(4,
$"Expected 4 tests but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"),
result => result.ResultSummary.Counters.Passed.ShouldBe(4),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

#endregion

#region Original Issue Regression Tests

[Test]
public async Task OriginalIssue_ABCVC_B2_ShouldNotInclude_ABCV_B2()
{
// Original regression test from issue #4656
// Filter for ABCVC.B2 should NOT include ABCV.B2
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/ABCVC/B2",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(2,
$"Expected 2 tests (ABCVC.B2 + ABCVC.B0 dependency) but got {result.ResultSummary.Counters.Total}. " +
$"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"),
result => result.ResultSummary.Counters.Passed.ShouldBe(2),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

#endregion
}
133 changes: 112 additions & 21 deletions TUnit.Engine/Services/MetadataFilterMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Requests;
using TUnit.Core;
using TUnit.Core.Helpers;

namespace TUnit.Engine.Services;

Expand Down Expand Up @@ -214,42 +215,45 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada
{
var classMetadata = metadata.MethodMetadata.Class;
var namespaceName = classMetadata.Namespace ?? "";
var className = metadata.TestClassType.Name;
var methodName = metadata.TestMethodName;

// Handle generic types: Type`1 -> need to match Type< in the UID
var classNameForMatching = className;
var backtickIndex = className.IndexOf('`');
if (backtickIndex > 0)
{
classNameForMatching = className.Substring(0, backtickIndex);
}
// Build the full class name including nested type hierarchy (e.g., Outer+Inner)
// This matches the format used by TestIdentifierService.WriteTypeNameWithGenerics
var classNameForMatching = BuildClassNameForMatching(metadata.TestClassType);

// Build expected prefix: {Namespace}.{ClassName}. or just {ClassName}. for empty namespace
// This ensures we match the exact class in the exact namespace
// Build expected prefix: {Namespace}.{ClassName} or just {ClassName} for empty namespace
// The class name may be followed by '.', '<', or '(' depending on:
// - '.' for regular classes (e.g., MyClass.0.0.Method)
// - '<' for generic classes (e.g., MyClass<System.Int32>.0.0.Method)
// - '(' for classes with constructor parameters (e.g., MyClass(System.String).0.0.Method)
var expectedClassPrefix = string.IsNullOrEmpty(namespaceName)
? $"{classNameForMatching}."
: $"{namespaceName}.{classNameForMatching}.";

// Also handle generic class names in UIDs (e.g., Namespace.MyClass<System.Int32>.0.0...)
var expectedGenericClassPrefix = string.IsNullOrEmpty(namespaceName)
? $"{classNameForMatching}<"
: $"{namespaceName}.{classNameForMatching}<";
? classNameForMatching
: $"{namespaceName}.{classNameForMatching}";

foreach (var uid in filter.TestNodeUids)
{
var uidValue = uid.Value;

// Check for exact namespace.classname prefix to avoid matching
// same class name in different namespaces
var hasClassPrefix = uidValue.StartsWith(expectedClassPrefix, StringComparison.Ordinal) ||
uidValue.StartsWith(expectedGenericClassPrefix, StringComparison.Ordinal);

if (!hasClassPrefix)
if (!uidValue.StartsWith(expectedClassPrefix, StringComparison.Ordinal))
{
continue;
}

// Verify the character after the class name is a valid boundary: '.', '<', or '('
var indexAfterPrefix = expectedClassPrefix.Length;
if (indexAfterPrefix < uidValue.Length)
{
var charAfterPrefix = uidValue[indexAfterPrefix];
if (charAfterPrefix != '.' && charAfterPrefix != '<' && charAfterPrefix != '(')
{
// Not a valid boundary - this could be a substring match
// e.g., "ABCV" matching "ABCVC"
continue;
}
}

// Check for method name with word boundaries
// Method names are preceded by '.' and followed by '.', '<', or '('
if (!HasMethodNameMatch(uidValue, methodName))
Expand All @@ -263,6 +267,93 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada
return false;
}

/// <summary>
/// Builds the class name for matching as it appears in UIDs.
/// Handles nested types (Outer+Inner) and generic types with their type arguments.
/// This matches the format used by TestIdentifierService.WriteTypeNameWithGenerics.
/// </summary>
private static string BuildClassNameForMatching(Type type)
{
// Fast path: non-nested, non-generic types
if (type.DeclaringType == null && !type.IsGenericType)
{
return type.Name;
}

// Build the full nested type hierarchy with '+' separators
// This matches TestIdentifierService.WriteTypeNameWithGenerics
var typeHierarchy = new ValueListBuilder<string>([null, null, null, null]);
var typeVsb = new ValueStringBuilder(stackalloc char[128]);
try
{
var currentType = type;

while (currentType != null)
{
if (currentType.IsGenericType)
{
var name = currentType.Name;

var backtickIndex = name.IndexOf('`');
if (backtickIndex > 0)
{
typeVsb.Append(name.AsSpan(0, backtickIndex));
}
else
{
typeVsb.Append(name);
}

// Add the generic type arguments (same format as TestIdentifierService)
var genericArgs = currentType.GetGenericArguments();
typeVsb.Append('<');
for (var i = 0; i < genericArgs.Length; i++)
{
if (i > 0)
{
typeVsb.Append(", ");
}
typeVsb.Append(genericArgs[i].FullName ?? genericArgs[i].Name);
}
typeVsb.Append('>');

typeHierarchy.Append(typeVsb.AsSpan().ToString());
typeVsb.Length = 0;
}
else
{
typeHierarchy.Append(currentType.Name);
}

currentType = currentType.DeclaringType;
}

// Build result: reverse to get outer-to-inner order and join with '+'
var resultVsb = new ValueStringBuilder(stackalloc char[256]);
try
{
for (var i = typeHierarchy.Length - 1; i >= 0; i--)
{
if (i < typeHierarchy.Length - 1)
{
resultVsb.Append('+');
}
resultVsb.Append(typeHierarchy[i]);
}
return resultVsb.ToString();
}
finally
{
resultVsb.Dispose();
}
}
finally
{
typeHierarchy.Dispose();
typeVsb.Dispose();
}
}

private static bool HasMethodNameMatch(string uidValue, string methodName)
{
// Method name patterns with proper boundaries:
Expand Down
Loading
Loading