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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ public static class SourceInformationWriter
{
public static void GenerateClassInformation(ICodeWriter sourceCodeWriter, Compilation compilation, INamedTypeSymbol namedTypeSymbol)
{
var parent = namedTypeSymbol.ContainingType;
var parentExpression = parent != null ? MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(parent, null, sourceCodeWriter.IndentLevel) : null;
var parentExpression = GenerateParentClassMetadataExpression(namedTypeSymbol, sourceCodeWriter.IndentLevel);
var classMetadata = MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(namedTypeSymbol, parentExpression, sourceCodeWriter.IndentLevel);

// Handle multi-line class metadata similar to method metadata
Expand Down Expand Up @@ -98,4 +97,20 @@ public static void GenerateParameterInformation(ICodeWriter sourceCodeWriter,
MetadataGenerationHelper.WriteParameterMetadataGeneric(sourceCodeWriter, parameter);
sourceCodeWriter.Append(",");
}

/// <summary>
/// Recursively generates parent ClassMetadata expression for nested types.
/// Returns null if the type has no containing type.
/// </summary>
private static string? GenerateParentClassMetadataExpression(INamedTypeSymbol typeSymbol, int indentLevel)
{
var parent = typeSymbol.ContainingType;
if (parent == null)
{
return null;
}

var grandparentExpression = GenerateParentClassMetadataExpression(parent, indentLevel);
return MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(parent, grandparentExpression, indentLevel);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1934,7 +1934,7 @@ private static void GenerateEnumerateTestDescriptors(CodeWriter writer, TestMeth
{
var methodName = testMethod.MethodSymbol.Name;
var namespaceName = testMethod.TypeSymbol.ContainingNamespace?.ToDisplayString() ?? "";
var simpleClassName = testMethod.TypeSymbol.Name;
var simpleClassName = testMethod.TypeSymbol.GetNestedClassName();
var fullyQualifiedName = string.IsNullOrEmpty(namespaceName)
? $"{simpleClassName}.{methodName}"
: $"{namespaceName}.{simpleClassName}.{methodName}";
Expand Down
20 changes: 19 additions & 1 deletion TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static void WriteMethodMetadata(ICodeWriter writer, IMethodSymbol methodS
WriteParameterMetadataArrayForMethod(writer, methodSymbol);
writer.AppendLine(",");
writer.Append("Class = ");
WriteClassMetadataGetOrAdd(writer, namedTypeSymbol);
WriteClassMetadataGetOrAddWithParent(writer, namedTypeSymbol);

// Manually restore indent level
writer.SetIndentLevel(currentIndent);
Expand Down Expand Up @@ -147,6 +147,24 @@ private static void WriteClassMetadataGetOrAdd(ICodeWriter writer, INamedTypeSym
writer.Append("})");
}

/// <summary>
/// Writes ClassMetadata with recursive parent generation for nested types
/// </summary>
private static void WriteClassMetadataGetOrAddWithParent(ICodeWriter writer, INamedTypeSymbol typeSymbol)
{
if (typeSymbol.ContainingType != null)
{
// Generate the parent expression as a string, then pass it
var parentWriter = new CodeWriter("", includeHeader: false).SetIndentLevel(writer.IndentLevel);
WriteClassMetadataGetOrAddWithParent(parentWriter, typeSymbol.ContainingType);
WriteClassMetadataGetOrAdd(writer, typeSymbol, parentWriter.ToString());
}
else
{
WriteClassMetadataGetOrAdd(writer, typeSymbol);
}
}

/// <summary>
/// Generates code for creating a ClassMetadata instance with GetOrAdd pattern
/// </summary>
Expand Down
49 changes: 47 additions & 2 deletions TUnit.Core/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,29 @@ public static class TestContextExtensions
{
public static string GetClassTypeName(this TestContext context)
{
var type = context.Metadata.TestDetails.ClassType;
var parameters = context.Metadata.TestDetails.MethodMetadata.Class.Parameters;

var nestedPrefix = GetNestedTypePrefix(type);

if (parameters.Length == 0)
{
return context.Metadata.TestDetails.ClassType.Name;
return nestedPrefix != null
? $"{nestedPrefix}+{type.Name}"
: type.Name;
}

var args = context.Metadata.TestDetails.TestClassArguments;
var sb = StringBuilderPool.Get();
try
{
sb.Append(context.Metadata.TestDetails.ClassType.Name);
if (nestedPrefix != null)
{
sb.Append(nestedPrefix);
sb.Append('+');
}

sb.Append(type.Name);
sb.Append('(');

for (var i = 0; i < args.Length; i++)
Expand All @@ -40,6 +51,40 @@ public static string GetClassTypeName(this TestContext context)
}
}

/// <summary>
/// Gets the nested type prefix (outer class names joined by '+') for a type, or null if not nested.
/// For example, for OuterClass+MiddleClass+InnerClass, returns "OuterClass+MiddleClass".
/// </summary>
internal static string? GetNestedTypePrefix(Type type)
{
if (type.DeclaringType == null)
{
return null;
}

// Walk the declaring type chain and build the hierarchy
var hierarchy = new List<string>();
var current = type.DeclaringType;
while (current != null)
{
hierarchy.Add(current.Name);
current = current.DeclaringType;
}

hierarchy.Reverse();
return string.Join("+", hierarchy);
}

/// <summary>
/// Gets the full nested type name with '+' separator (matching .NET Type.FullName convention for nested types).
/// For example: OuterClass+InnerClass
/// </summary>
internal static string GetNestedTypeName(Type type)
{
var prefix = GetNestedTypePrefix(type);
return prefix != null ? $"{prefix}+{type.Name}" : type.Name;
}

#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Dynamic test metadata creation uses reflection")]
#endif
Expand Down
46 changes: 46 additions & 0 deletions TUnit.Engine.Tests/NestedClassFilteringTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

public class NestedClassFilteringTests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Filter_NestedClass_ByFullNestedName()
{
await RunTestsWithFilter(
"/*/*/NestedTestClassTests+NestedClass/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

[Test]
public async Task Filter_NestedClass_SpecificMethod()
{
await RunTestsWithFilter(
"/*/*/NestedTestClassTests+NestedClass/Inner",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}

[Test]
public async Task Filter_OuterClass_StillWorks()
{
await RunTestsWithFilter(
"/*/*/NestedTestClassTests/Outer",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}
}
6 changes: 3 additions & 3 deletions TUnit.Engine.Tests/UidFilterMatchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ public class UidFilterMatchingTests(TestMode testMode) : InvokableTestBase(testM
[Test]
public async Task Filter_NestedClass_ShouldMatchOnlyNestedClass()
{
// Filter for the nested class InnerClass
// Tree node paths use just the innermost class name (Type.Name)
// Filter for the nested class InnerClass using full nested path
// Tree node paths now use OuterClass+InnerClass format for nested types
// Should only run tests from InnerClass, not OuterClass
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._4656/InnerClass/InnerMethod",
"/*/TUnit.TestProject.Bugs._4656/OuterClass+InnerClass/InnerMethod",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1,
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Building/ReflectionMetadataBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn
}),
Parameters = constructorParameters,
Properties = [],
Parent = null
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
};
});
}
Expand Down
33 changes: 19 additions & 14 deletions TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -837,20 +837,7 @@ private static MethodMetadata CreateMethodMetadata(
{
Name = method.Name,
Type = type,
Class = new ClassMetadata
{
Name = type.Name,
Type = type,
TypeInfo = new ConcreteType(type),
Namespace = type.Namespace ?? string.Empty,
Assembly = new AssemblyMetadata
{
Name = type.Assembly.GetName().Name ?? "Unknown"
},
Parameters = [],
Properties = [],
Parent = null
},
Class = CreateClassMetadata(type),
Parameters = method.GetParameters().Select(p => new ParameterMetadata(p.ParameterType)
{
Name = p.Name ?? string.Empty,
Expand All @@ -865,6 +852,24 @@ private static MethodMetadata CreateMethodMetadata(
};
}

private static ClassMetadata CreateClassMetadata(Type type)
{
return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata
{
Name = type.Name,
Type = type,
TypeInfo = new ConcreteType(type),
Namespace = type.Namespace ?? string.Empty,
Assembly = AssemblyMetadata.GetOrAdd(type.Assembly.GetName().Name ?? "Unknown", () => new AssemblyMetadata
{
Name = type.Assembly.GetName().Name ?? "Unknown"
}),
Parameters = [],
Properties = [],
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
});
}

private static Func<object, TestContext, CancellationToken, ValueTask> CreateInstanceHookDelegate(Type type, MethodInfo method)
{
return async (instance, context, cancellationToken) =>
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Discovery/ReflectionInstanceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ private static ClassMetadata CreateClassMetadata(Type type)
}),
Properties = [],
Parameters = [],
Parent = null
Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null
});
}

Expand Down
3 changes: 2 additions & 1 deletion TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2497,10 +2497,11 @@ public IEnumerable<TestDescriptor> EnumerateDescriptors()
var capturedType = type;
var capturedMethod = method;

var nestedClassName = TUnit.Core.Extensions.TestContextExtensions.GetNestedTypeName(type);
yield return new TestDescriptor
{
TestId = $"{type.FullName}.{method.Name}",
ClassName = type.Name,
ClassName = nestedClassName,
MethodName = method.Name,
FullyQualifiedName = $"{type.FullName}.{method.Name}",
FilePath = ExtractFilePath(method) ?? "Unknown",
Expand Down
21 changes: 16 additions & 5 deletions TUnit.Engine/Services/MetadataFilterMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,15 @@ public bool CouldTypeMatch(Type type)
// Check class name hint
if (ClassName != null)
{
// Handle generic types (e.g., MyClass`1)
if (type.Name != ClassName && !type.Name.StartsWith(ClassName + "`"))
// The filter ClassName may be "OuterClass+InnerClass" or just "InnerClass"
// type.Name is just the innermost name
var nestedName = TUnit.Core.Extensions.TestContextExtensions.GetNestedTypeName(type);
if (nestedName != ClassName
&& !nestedName.StartsWith(ClassName + "`")
&& !nestedName.EndsWith("+" + ClassName)
&& !nestedName.StartsWith(ClassName + "+")
&& type.Name != ClassName
&& !type.Name.StartsWith(ClassName + "`"))
{
return false;
}
Expand All @@ -91,8 +98,12 @@ public bool CouldDescriptorMatch(TestDescriptor descriptor)
// Check class name hint
if (ClassName != null)
{
// Handle generic types (e.g., MyClass`1)
if (descriptor.ClassName != ClassName && !descriptor.ClassName.StartsWith(ClassName + "`"))
// The filter ClassName may be "OuterClass+InnerClass" or just "InnerClass"
// The descriptor ClassName now includes nested hierarchy (e.g., "OuterClass+InnerClass")
if (descriptor.ClassName != ClassName
&& !descriptor.ClassName.StartsWith(ClassName + "`")
&& !descriptor.ClassName.EndsWith("+" + ClassName)
&& !descriptor.ClassName.StartsWith(ClassName + "+"))
{
return false;
}
Expand Down Expand Up @@ -425,7 +436,7 @@ private static string BuildPathFromMetadata(TestMetadata metadata)
var classMetadata = metadata.MethodMetadata.Class;
var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*";
var namespaceName = classMetadata.Namespace ?? "*";
var className = classMetadata.Name;
var className = TestFilterService.GetNestedClassName(classMetadata);
var methodName = metadata.TestMethodName;

return $"/{assemblyName}/{namespaceName}/{className}/{methodName}";
Expand Down
25 changes: 24 additions & 1 deletion TUnit.Engine/Services/TestFilterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ private string BuildPath(AbstractExecutableTest test)
var classMetadata = test.Context.Metadata.TestDetails.MethodMetadata.Class;
var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*";
var namespaceName = classMetadata.Namespace ?? "*";
var classTypeName = classMetadata.Name;
var classTypeName = GetNestedClassName(classMetadata);

var path = $"/{assemblyName}/{namespaceName}/{classTypeName}/{metadata.TestMethodName}";

Expand Down Expand Up @@ -243,4 +243,27 @@ private IReadOnlyCollection<AbstractExecutableTest> FilterOutExplicitTests(IRead

return filteredTests;
}

/// <summary>
/// Builds the nested class name from ClassMetadata by walking the Parent chain.
/// Returns names joined with '+' (e.g., "OuterClass+InnerClass").
/// </summary>
internal static string GetNestedClassName(ClassMetadata classMetadata)
{
if (classMetadata.Parent == null)
{
return classMetadata.Name;
}

var hierarchy = new List<string>();
var current = classMetadata;
while (current != null)
{
hierarchy.Add(current.Name);
current = current.Parent;
}

hierarchy.Reverse();
return string.Join("+", hierarchy);
}
}
20 changes: 20 additions & 0 deletions TUnit.TestProject/NestedTestClassTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject;

[EngineTest(ExpectedResult.Pass)]
public class NestedTestClassTests
{
[Test]
public void Outer()
{
}

public class NestedClass
{
[Test]
public void Inner()
{
}
}
}
Loading