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
64 changes: 64 additions & 0 deletions TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR

public string? Skip { get; set; }

/// <summary>
/// Gets or sets a custom display name for this test case.
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
/// </summary>
/// <example>
/// <code>
/// [Arguments("admin", "secret", DisplayName = "Login as $arg1")]
/// </code>
/// </example>
public string? DisplayName { get; set; }

/// <summary>
/// Gets or sets categories to apply to this specific test case.
/// </summary>
/// <example>
/// <code>
/// [Arguments("value", Categories = ["smoke", "integration"])]
/// </code>
/// </example>
public string[]? Categories { get; set; }
Comment on lines +48 to +58
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new DisplayName and Categories properties are defined but not applied to the test context. The ArgumentsAttribute implements ITestRegisteredEventReceiver with an OnTestRegistered method (lines 81-90) that only handles the Skip property.

For dual-mode parity, the OnTestRegistered method needs to be updated to also apply DisplayName and Categories properties, similar to how the reflection mode handles this in TestBuilder.cs lines 874-897. Without this, the source-generated mode won't respect these properties.

The OnTestRegistered method should:

  1. Call context.SetDataSourceDisplayName(DisplayName) when DisplayName is not null/empty
  2. Add Categories to context.TestContext.Metadata.TestDetails.Categories when Categories is not null/empty

Copilot generated this review using guidance from repository custom instructions.

/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

Expand Down Expand Up @@ -65,6 +86,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context)
context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
}

if (!string.IsNullOrEmpty(DisplayName))
{
context.TestContext.SetDataSourceDisplayName(DisplayName!);
}

if (Categories is { Length: > 0 })
{
foreach (var category in Categories)
{
if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category))
{
context.TestDetails.Categories.Add(category);
}
}
}

return default;
}

Expand All @@ -76,6 +113,17 @@ public sealed class ArgumentsAttribute<T>(T value) : TypedDataSourceAttribute<T>
{
public string? Skip { get; set; }

/// <summary>
/// Gets or sets a custom display name for this test case.
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
/// </summary>
public string? DisplayName { get; set; }

/// <summary>
/// Gets or sets categories to apply to this specific test case.
/// </summary>
public string[]? Categories { get; set; }
Comment on lines +120 to +125
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new DisplayName and Categories properties are defined but not applied to the test context. The generic ArgumentsAttribute also implements ITestRegisteredEventReceiver with an OnTestRegistered method (lines 120-129) that only handles the Skip property.

For dual-mode parity, the OnTestRegistered method needs to be updated to also apply DisplayName and Categories properties, similar to how the reflection mode handles this in TestBuilder.cs lines 874-897. Without this, the source-generated mode won't respect these properties.

The OnTestRegistered method should:

  1. Call context.SetDataSourceDisplayName(DisplayName) when DisplayName is not null/empty
  2. Add Categories to context.TestContext.Metadata.TestDetails.Categories when Categories is not null/empty

Copilot generated this review using guidance from repository custom instructions.

/// <inheritdoc />
public override bool SkipIfEmpty { get; set; }

Expand All @@ -93,6 +141,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context)
context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
}

if (!string.IsNullOrEmpty(DisplayName))
{
context.TestContext.SetDataSourceDisplayName(DisplayName!);
}

if (Categories is { Length: > 0 })
{
foreach (var category in Categories)
{
if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category))
{
context.TestDetails.Categories.Add(category);
}
}
}

return default;
}

Expand Down
59 changes: 59 additions & 0 deletions TUnit.Core/Helpers/DisplayNameSubstitutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace TUnit.Core.Helpers;

/// <summary>
/// Utility for substituting parameter placeholders in display names.
/// Supports $paramName and $arg1, $arg2, etc. syntax.
/// </summary>
internal static class DisplayNameSubstitutor
{
/// <summary>
/// Substitutes parameter placeholders with actual argument values.
/// </summary>
/// <param name="displayName">The display name template with placeholders.</param>
/// <param name="parameters">The parameter metadata.</param>
/// <param name="arguments">The actual argument values.</param>
/// <param name="formatters">Optional custom formatters for argument values.</param>
/// <returns>The display name with placeholders replaced by formatted argument values.</returns>
public static string Substitute(
string displayName,
ParameterMetadata[] parameters,
object?[] arguments,
List<Func<object?, string?>>? formatters = null)
{
if (string.IsNullOrEmpty(displayName) || !displayName.Contains('$'))
{
return displayName;
}

var result = displayName;
var effectiveFormatters = formatters ?? [];

// Substitute by parameter name ($paramName)
for (var i = 0; i < parameters.Length && i < arguments.Length; i++)
{
var paramName = parameters[i].Name;
if (!string.IsNullOrEmpty(paramName))
{
var placeholder = $"${paramName}";
if (result.Contains(placeholder))
{
var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters);
result = result.Replace(placeholder, formatted);
}
}
Comment on lines +31 to +43
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The substitution logic checks for a dollar sign in the display name and performs replacements, but consider edge cases where parameter names or values themselves might contain dollar signs. While unlikely in typical scenarios, this could lead to unintended substitutions.

Consider using a more explicit placeholder syntax (e.g., {paramName} or {{paramName}}) or documenting this limitation if the current approach is intentional.

Copilot uses AI. Check for mistakes.
}

// Substitute by position ($arg1, $arg2, etc.)
for (var i = 0; i < arguments.Length; i++)
{
var placeholder = $"$arg{i + 1}";
if (result.Contains(placeholder))
{
var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters);
result = result.Replace(placeholder, formatted);
}
}

return result;
}
}
90 changes: 90 additions & 0 deletions TUnit.Core/Helpers/TestDataRowUnwrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Diagnostics.CodeAnalysis;

namespace TUnit.Core.Helpers;

/// <summary>
/// Utility for detecting and unwrapping <see cref="TestDataRow{T}"/> instances.
/// </summary>
internal static class TestDataRowUnwrapper
{
private static readonly Type TestDataRowGenericType = typeof(TestDataRow<>);

/// <summary>
/// Checks if the value is a <see cref="TestDataRow{T}"/> and extracts metadata and data.
/// </summary>
/// <param name="value">The value to check.</param>
/// <param name="data">The extracted data if unwrapped, otherwise the original value.</param>
/// <param name="metadata">The extracted metadata if unwrapped, otherwise null.</param>
/// <returns>True if the value was a TestDataRow and was unwrapped.</returns>
public static bool TryUnwrap(object? value, out object? data, [NotNullWhen(true)] out TestDataRowMetadata? metadata)
{
if (value is null)
{
data = null;
metadata = null;
return false;
}

// Use interface-based access for AOT compatibility (avoids reflection)
if (value is ITestDataRow testDataRow)
{
data = testDataRow.GetData();
metadata = new TestDataRowMetadata(testDataRow.DisplayName, testDataRow.Skip, testDataRow.Categories);
return true;
}

data = value;
metadata = null;
return false;
}

/// <summary>
/// Checks if a type is a <see cref="TestDataRow{T}"/>.
/// </summary>
public static bool IsTestDataRowType(Type? type)
{
return type is not null && type.IsGenericType && type.GetGenericTypeDefinition() == TestDataRowGenericType;
}

/// <summary>
/// Gets the inner data type from a <see cref="TestDataRow{T}"/> type.
/// </summary>
public static Type? GetInnerDataType(Type testDataRowType)
{
if (!IsTestDataRowType(testDataRowType))
{
return null;
}

return testDataRowType.GetGenericArguments()[0];
}

/// <summary>
/// Unwraps an array of values, extracting TestDataRow metadata from single-element arrays.
/// </summary>
/// <param name="values">The array of values to unwrap.</param>
/// <returns>A tuple of the unwrapped data array and any extracted metadata.</returns>
public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values)
{
if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata))
{
// Single TestDataRow<T> - unwrap it
// If the inner data is already an array, use it directly
if (data is object?[] dataArray)
{
return (dataArray, metadata);
}

// Check if the data is a tuple that should be expanded
if (DataSourceHelpers.IsTuple(data))
{
return (data.ToObjectArray(), metadata);
}

// Otherwise wrap the single value in an array
return ([data], metadata);
}

return (values, null);
}
}
13 changes: 12 additions & 1 deletion TUnit.Core/TestContext.Metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public partial class TestContext
{
internal string GetDisplayName()
{
if(!string.IsNullOrEmpty(CustomDisplayName))
if (!string.IsNullOrEmpty(CustomDisplayName))
{
return CustomDisplayName!;
}
Expand All @@ -17,6 +17,17 @@ internal string GetDisplayName()
return _cachedDisplayName;
}

// Check for data source display name (from TestDataRow or ArgumentsAttribute.DisplayName)
if (!string.IsNullOrEmpty(DataSourceDisplayName))
{
_cachedDisplayName = DisplayNameSubstitutor.Substitute(
DataSourceDisplayName!,
TestDetails.MethodMetadata.Parameters,
TestDetails.TestMethodArguments,
ArgumentDisplayFormatters);
return _cachedDisplayName;
}

if (TestDetails.TestMethodArguments.Length == 0)
{
_cachedDisplayName = TestDetails.TestName;
Expand Down
15 changes: 15 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ public static string WorkingDirectory

internal string? CustomDisplayName { get; set; }

/// <summary>
/// Display name provided by the data source (from TestDataRow or ArgumentsAttribute.DisplayName).
/// This takes precedence over the default generated display name but can be overridden by CustomDisplayName.
/// Supports $paramName substitution.
/// </summary>
internal string? DataSourceDisplayName { get; private set; }

/// <summary>
/// Sets the display name from the data source (TestDataRow or ArgumentsAttribute.DisplayName).
/// </summary>
internal void SetDataSourceDisplayName(string displayName)
{
DataSourceDisplayName = displayName;
}


internal TestDetails TestDetails { get; set; } = null!;

Expand Down
10 changes: 10 additions & 0 deletions TUnit.Core/TestDataCombination.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ public class TestDataCombination

public string? DisplayName { get; init; }

/// <summary>
/// Skip reason from the data source. When set, this test combination will be skipped.
/// </summary>
public string? Skip { get; init; }

/// <summary>
/// Categories from the data source to apply to this specific test combination.
/// </summary>
public string[]? Categories { get; init; }
Comment on lines +38 to +46
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TestDataCombination class now has Skip and Categories properties, but there's no corresponding logic in the source generator to populate these properties when creating TestDataCombination instances from ArgumentsAttribute metadata.

For dual-mode parity, the source generator needs to extract these properties from ArgumentsAttribute when generating test combinations. The reflection mode handles this via DataSourceMetadataExtractor.ExtractFromAttribute and TestBuilder.BuildTestAsync (lines 874-897), but the source-generated code path needs equivalent logic.

Copilot generated this review using guidance from repository custom instructions.

public int RepeatIndex { get; init; } = 0;

public Dictionary<string, Type>? ResolvedGenericTypes { get; init; }
Expand Down
50 changes: 50 additions & 0 deletions TUnit.Core/TestDataRow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace TUnit.Core;

/// <summary>
/// Internal interface for accessing TestDataRow properties without reflection.
/// This enables AOT compatibility by avoiding dynamic property access.
/// </summary>
internal interface ITestDataRow
{
object? GetData();
string? DisplayName { get; }
string? Skip { get; }
string[]? Categories { get; }
}

/// <summary>
/// Wraps test data with optional metadata for customizing test execution.
/// Use this when returning data from method/class data sources to specify
/// per-row display names, skip reasons, or categories.
/// </summary>
/// <typeparam name="T">The type of the test data.</typeparam>
/// <param name="Data">The actual test data to be passed to the test method.</param>
/// <param name="DisplayName">
/// Optional custom display name for the test case.
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
/// </param>
/// <param name="Skip">
/// Optional skip reason. When set, the test case will be skipped with this message.
/// </param>
/// <param name="Categories">
/// Optional categories to apply to this specific test case.
/// </param>
/// <example>
/// <code>
/// public static IEnumerable&lt;TestDataRow&lt;(string Username, string Password)&gt;&gt; GetLoginData()
/// {
/// yield return new(("admin", "secret123"), DisplayName: "Admin login");
/// yield return new(("guest", "guest"), DisplayName: "Guest login");
/// yield return new(("", ""), DisplayName: "Empty credentials", Skip: "Not implemented yet");
/// }
/// </code>
/// </example>
public record TestDataRow<T>(
T Data,
string? DisplayName = null,
string? Skip = null,
string[]? Categories = null
) : ITestDataRow
{
object? ITestDataRow.GetData() => Data;
}
36 changes: 36 additions & 0 deletions TUnit.Core/TestDataRowMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace TUnit.Core;

/// <summary>
/// Metadata extracted from a <see cref="TestDataRow{T}"/> wrapper or data source attributes.
/// </summary>
/// <param name="DisplayName">Custom display name for the test case.</param>
/// <param name="Skip">Skip reason - test will be skipped if set.</param>
/// <param name="Categories">Categories to apply to the test case.</param>
internal record TestDataRowMetadata(
string? DisplayName,
string? Skip,
string[]? Categories
)
{
/// <summary>
/// Returns true if any metadata property is set.
/// </summary>
public bool HasMetadata => DisplayName is not null || Skip is not null || Categories is { Length: > 0 };

/// <summary>
/// Merges this metadata with another, preferring non-null values from this instance.
/// </summary>
public TestDataRowMetadata MergeWith(TestDataRowMetadata? other)
{
if (other is null)
{
return this;
}

return new TestDataRowMetadata(
DisplayName ?? other.DisplayName,
Skip ?? other.Skip,
Categories ?? other.Categories
);
}
}
Loading
Loading